@rafinery/cli 0.1.0 → 0.2.1

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 ADDED
@@ -0,0 +1,49 @@
1
+ # @rafinery/cli — changelog
2
+
3
+ The machine-readable version of this lives in [`lib/releases.mjs`](lib/releases.mjs) — the
4
+ CLI reads it to tell a client, on `rafa update`, exactly what an upgrade requires (nothing,
5
+ a re-scan, or a `rafa migrate`).
6
+
7
+ ## 0.2.1
8
+
9
+ **Adopt with:** `npx @rafinery/cli@latest update` (or `/rafa update` in Claude Code) — no re-scan,
10
+ no migration. Recommended: `/rafa leverage` to tune your toolbox.
11
+
12
+ - **Intelligence where it belongs.** `leverage` and `migrate` are now `/rafa` commands: the CLI
13
+ *detects* (deterministic), the LLM *acts* — `/rafa leverage` edits your config exactly (merges
14
+ settings, wires the MCP, scaffolds a skill, on approval); `/rafa migrate` rewrites plans to a new
15
+ schema preserving meaning, then compile-gates them.
16
+ - **Two-layer update — one job each, no double work.**
17
+ - `rafa update` (CLI) = the **blueprint side**: pulls the latest blueprint into your repo and runs
18
+ the blueprint/mechanical migrations. It prints the **release notes** since your version and
19
+ **exactly what the brain needs** next (computed from the contract/plans schema deltas).
20
+ - `/rafa update` (Claude Code) = the **brain side**: runs the migrations that need intelligence
21
+ (re-scan a stale brain, rewrite plans to a new shape). It does **not** re-run the CLI.
22
+ - `/rafa` redirects the terminal-only `compile` instead of erroring on it.
23
+
24
+ ## 0.2.0
25
+
26
+ **Adopt with:** `npx @rafinery/cli@latest update` — no re-scan, no migration.
27
+ Your brain data is still valid (contract v1, plans v1 unchanged).
28
+
29
+ - **Non-destructive vendoring.** `init`/`update` never clobber tuned files: owned files
30
+ (agents, capabilities) are preserved — a changed one is written as `<file>.new` for you to
31
+ merge — while tools + contract move in lockstep. Required permissions are **merged** into
32
+ `.claude/settings.json` (union into `permissions.allow`; every other key preserved; a file
33
+ we can't parse is left untouched).
34
+ - **Secrets guardrail.** The scan reads env var **names from source only** — never opens
35
+ `.env*` or captures a value.
36
+ - **`rafa leverage`.** Inspects your agent toolbox (permissions, skills, MCP, config) and
37
+ prints prioritized, advisory tips. Modular behind a `ToolchainAdapter` seam (Claude Code
38
+ today; Codex/Cursor slot in later).
39
+ - **Version-aware updates.** `init`/`update` notify you when a newer rafinery is published;
40
+ `update` shows what changed since your version and whether a re-scan or migration is needed.
41
+ - **`rafa migrate`.** Brings structured files (plans, config) up to the schema the installed
42
+ CLI ships. Idempotent; a no-op until a release changes a shape — the seam is in place.
43
+ - A `rafa.json` version stamp records the contract/plans versions this repo was provisioned
44
+ at (the anchor `update`/`migrate` diff against).
45
+
46
+ ## 0.1.0
47
+
48
+ - Deterministic contract + `manifest.json` ingest.
49
+ - `@rafinery/cli` — `init` (vendor blueprint + wire brain remote), `update`, `compile`, `push`.
package/bin/rafa.mjs CHANGED
@@ -5,6 +5,8 @@
5
5
  // rafa update re-sync the blueprint into this repo (shadcn-style)
6
6
  // rafa compile run the contract gate → .rafa/manifest.json
7
7
  // rafa push compile + push .rafa/ to the brain remote
8
+ // rafa leverage inspect your agent toolbox → proactive tips
9
+ // rafa migrate bring structured files (plans/config) up to the current schema
8
10
  //
9
11
  // The LLM work (scan / improve / plan) runs inside Claude Code via /rafa.
10
12
 
@@ -15,7 +17,7 @@ import { fileURLToPath } from "node:url";
15
17
  const HERE = dirname(fileURLToPath(import.meta.url));
16
18
  const pkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
17
19
 
18
- const COMMANDS = ["init", "update", "compile", "push"];
20
+ const COMMANDS = ["init", "update", "compile", "push", "leverage", "migrate"];
19
21
  const [cmd, ...rest] = process.argv.slice(2);
20
22
 
21
23
  function help() {
@@ -29,6 +31,10 @@ Commands:
29
31
  update Re-sync the blueprint into this repo (you own your copy; opt-in updates).
30
32
  compile Validate the brain against the contract → .rafa/manifest.json.
31
33
  push Compile, then push .rafa/ to your brain remote.
34
+ leverage Inspect your agent toolbox (permissions, skills, MCP) and print
35
+ prioritized tips for getting more out of it. Advisory — changes nothing.
36
+ migrate Bring this repo's structured files (plans, config) up to the schema
37
+ the installed CLI ships. Idempotent; review the diff after.
32
38
 
33
39
  Then, inside Claude Code: /rafa scan · /rafa improve · /rafa plan
34
40
 
@@ -72,6 +72,10 @@ Never act cold; never over-load.
72
72
  - **Capture the trace, not the tool.** Knowledge is tool-agnostic — record the decision, the
73
73
  cited code, and the *why*, never which (possibly personal) skill produced it. Committed
74
74
  `.claude/` is org config (mappable); the dev's personal `~/.claude/` is private (never indexed).
75
+ - **Secrets are off-limits.** Record env var **names and where they're read** (from source only);
76
+ **never open `.env`/`.env.*` or any secret store, and never read or copy a value.** A key's
77
+ *name* is a contract; its *value* is a secret. If a note genuinely needs a value, stop and ask
78
+ the dev — don't harvest it. (Enforced in [scan](../../.rafa/capabilities/scan.md) step 4.)
75
79
  - **Orchestrate, don't bury.** At work-time, ride the host + the dev's existing skills/tools/MCP
76
80
  and recommend the best-fit one; never reinvent a capability that already exists. rafa conducts
77
81
  what's present.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: rafa — init/scan (know + improve) · improve · push. Usage: /rafa <init|scan|improve|push> [--brain-only]
2
+ description: rafa — init · scan · improve · push · leverage · migrate · update. Usage: /rafa <init|scan|improve|push|leverage|migrate|update> [--brain-only]
3
3
  ---
4
4
 
5
5
  You are the **conductor** of the rafa agent loop, running in the **main session**.
@@ -56,8 +56,57 @@ high-leverage P0/P1s — don't nag; the dev triages.**
56
56
  `node .rafa/bin/rafa-push.mjs` — commit `.rafa/` and push to `origin` (the brain repo) with the
57
57
  dev's own git auth. Use after a scan, or to re-sync a brain that changed.
58
58
 
59
+ ## `leverage` — tune the dev's toolbox (rafa's 3rd mission; intelligence + exact edits)
60
+ Detection is cheap and deterministic; **acting needs intelligence — that's why this is a
61
+ `/rafa` command, not just the `rafa leverage` CLI.** The CLI *reports*; you *fix*.
62
+ 1. **Inspect** the committed toolbox: `.claude/settings.json`, `.mcp.json`, `.claude/skills/`,
63
+ `.claude/commands/`, and the stack (`package.json` / dirs). (`rafa leverage` gives the fast
64
+ deterministic signal; here you reason over the real files.)
65
+ 2. **Reason** per [`.rafa/capabilities/leverage.md`](../../.rafa/capabilities/leverage.md): what's
66
+ missing / misconfigured / unused — permission gaps, an MCP the stack needs but isn't wired, a
67
+ repeatable flow worth capturing as a skill, the best-fit *existing* skill for THIS repo. P1→P3.
68
+ 3. **Act — on the dev's approval, edit exactly.** Merge the precise permission entries, write the
69
+ correct `.mcp.json` server block, scaffold the skill. **Merge, never clobber** the dev's config;
70
+ show the diff. Propose, don't force. Never open `.env*` or read a secret value (scan's guardrail).
71
+
72
+ ## `migrate` — bring structured files to the current schema (the intelligent half)
73
+ Two channels. **Mechanical** changes (field rename, path move) run deterministically via the
74
+ terminal `rafa migrate`. **Semantic** changes — a plan's *shape* changed and each file must be
75
+ understood to be rewritten correctly — need intelligence, and that's this command.
76
+ 1. Read the target schema in [`.rafa/contract.md`](../../.rafa/contract.md) (§6/§7 for plans) and
77
+ the `from` versions in `rafa.json`.
78
+ 2. For each affected file under `.rafa/plans/` (and any other structured dir), **rewrite it to
79
+ conform** — preserve meaning, remap fields, fill new required fields sensibly.
80
+ 3. Run `node .rafa/bin/rafa-compile.mjs` until it **exits 0** (validate-and-correct loop), surface
81
+ the diff for approval, then offer `rafa push` if this is a connected repo.
82
+
83
+ ## `update` — the BRAIN-side of an upgrade (the CLI already did the blueprint side)
84
+ **Two layers, two actors — don't re-run the CLI here.**
85
+ - The terminal **`npx @rafinery/cli@latest update`** already pulled the latest blueprint and ran
86
+ the **blueprint-side** (mechanical/structural) migrations. It also *reported* what the brain needs.
87
+ - **`/rafa update` (this command)** does the **brain-side** migrations — the part that needs
88
+ intelligence — against the blueprint the CLI just installed. It does **not** pull the blueprint
89
+ or re-run the CLI.
90
+ 1. **Find the gap.** The brain/plans conform to a *data* version; the blueprint just moved to a
91
+ newer one. Compare `.rafa/manifest.json`'s `schemaVersion` (and `rafa.json`'s `contract`/`plans`)
92
+ to the current `.rafa/contract.md`. That gap is exactly what the CLI update flagged. (If the CLI
93
+ update hasn't run yet, tell the dev to run `npx @rafinery/cli@latest update` first, then return.)
94
+ 2. **Migrate the brain side:**
95
+ - **Contract bumped → the brain is a stale cache:** re-scan (`## scan`) to regenerate it, or for
96
+ a targeted change, intelligently rewrite the affected notes to the new schema and re-validate.
97
+ - **Plans shape changed:** rewrite each plan per `## migrate` (preserve meaning), compile to exit 0.
98
+ 3. **Record + summarize.** Run `node .rafa/bin/rafa-compile.mjs` to exit 0, then advance the data
99
+ version in `rafa.json` (`contract`/`plans` → the versions you migrated to). Summarize what changed;
100
+ on the dev's approval, `rafa push` if this is a connected repo.
101
+ Never hand-edit around a migration or discard tuned files.
102
+
59
103
  ## (no argument, or unrecognized)
60
- Print usage and stop: `/rafa init` · `/rafa scan [--brain-only]` · `/rafa improve` · `/rafa push`.
104
+ `/rafa` runs the in-editor passes: `init` · `scan [--brain-only]` · `improve` · `push` ·
105
+ `leverage` · `migrate` · `update`.
106
+
107
+ `compile` is a **terminal** command on the `rafa` CLI (`@rafinery/cli`) — not a slash command.
108
+ If the dev typed it, don't error out; point them to the shell (`rafa compile`) and stop. For
109
+ anything else unrecognized, print this usage line and stop.
61
110
 
62
111
  ---
63
112
  Token discipline: glob/grep/AST before reading; scoped reads; deterministic extraction
@@ -0,0 +1,77 @@
1
+ # leverage — get more out of the toolbox you already have (capability #3)
2
+
3
+ rafa's third mission: **make the dev's agent toolchain pull its full weight.** rafa already
4
+ reads your *code* to build a brain; leverage turns the same lens on your *toolbox* — the
5
+ config layer (permissions, skills, MCP servers, hooks, commands) that decides how effective
6
+ the AI engineer actually is. It surfaces what's missing, misconfigured, or unused, and points
7
+ at the fix. This is platform DNA: **leverage what's present; never reinvent it.**
8
+
9
+ **Two layers — detection is deterministic, action needs intelligence:**
10
+ - **`rafa leverage`** (CLI) — the cheap **detector**. Reads the repo, ranks, and *reports*; it
11
+ **changes nothing**. Good for a notify/CI signal, and as structured input to the LLM.
12
+ - **`/rafa leverage`** (in Claude Code) — the **actor**. rafa reasons over the real config and,
13
+ on the dev's approval, **applies the fix exactly** — merges the precise permission entries,
14
+ writes the correct `.mcp.json` block, scaffolds the skill. The CLI can't do this; only the
15
+ intelligence can edit correctly. The detector finds the gap; the LLM closes it.
16
+
17
+ ---
18
+
19
+ ## Prime directives
20
+
21
+ 1. **Leverage, don't replace** — always prefer the dev's existing capability (a skill, an MCP
22
+ server, a host feature) over hand-rolling one. The best tip points at something already
23
+ installed but unwired.
24
+ 2. **Signal, not noise** — emit a tip only from a clear, checkable signal (a missing permission,
25
+ an un-ignored `.env`, a stack with no matching MCP). A false or obvious tip mutes the whole
26
+ advisor. Few, relevant, dismissible.
27
+ 3. **Leverage-ranked** — `P1` security/correctness → `P2` friction/leverage → `P3` nice-to-have.
28
+ Lead with impact × ease. Never dump an unranked wall.
29
+ 4. **Approval-gated, never authoritarian** — the dev owns their setup. `rafa leverage` (CLI) only
30
+ proposes. `/rafa leverage` *applies*, but **only on the dev's approval and always as a merge** —
31
+ show the diff, never clobber, propose don't force.
32
+ 5. **Tool-agnostic by construction** — the advice engine knows nothing about any specific tool;
33
+ all tool knowledge lives in an **adapter**. This is what makes "leverage what's available"
34
+ true for Codex, Cursor, or any other toolchain, not just Claude Code.
35
+
36
+ ---
37
+
38
+ ## Architecture — the ToolchainAdapter seam
39
+
40
+ The engine ([`lib/leverage/engine.mjs`](../../packages/cli/lib/leverage/engine.mjs)) gathers
41
+ light, tool-agnostic repo signals (`repoContext`: stack deps, `.env` files + whether they're
42
+ ignored, declared MCP servers), then runs every **detected** adapter and ranks the tips.
43
+
44
+ Each adapter implements one contract ([`lib/leverage/adapters/index.mjs`](../../packages/cli/lib/leverage/adapters/index.mjs)):
45
+
46
+ | Member | Shape | Job |
47
+ |---|---|---|
48
+ | `id` / `name` | string | identity + human label |
49
+ | `detect(cwd)` | → boolean | is this toolchain present in the repo? |
50
+ | `inspect(cwd)` | → Capabilities | normalized view: `{ permissions, skills, agents, commands, … }` |
51
+ | `recommend(cwd, ctx)` | → Tip[] | proactive tips `{ id, priority, title, detail, fix }` |
52
+
53
+ **Implemented:** `claude-code` — reads `.claude/settings.json`, `skills/`, `agents/`,
54
+ `commands/`, and `.mcp.json`. **Declared, not yet built:** `codex`, `cursor` (the `PLANNED`
55
+ list) — the seam is real, the adapters are added when a user actually runs on that toolchain.
56
+ *Don't build an adapter ahead of the phase that consumes it.*
57
+
58
+ ### What the Claude Code adapter checks today
59
+ - **Secrets** — `.env*` present but not gitignored → `P1` (protect the keys before a scan or a commit).
60
+ - **Permissions** — `.rafa/bin` vendored but its tools aren't allow-listed / no `settings.json`
61
+ → `P2`, fix = `rafa init`/`update` (which *merge*, never clobber — see the CLI).
62
+ - **MCP** — a stack with no matching server wired (e.g. Convex repo, no `convex` MCP) → `P2`.
63
+ - **Skills** — no project skills captured → `P3`.
64
+
65
+ Adding a check = one entry in an adapter's `recommend`. Adding a toolchain = one new adapter
66
+ behind the same contract. The engine and the `rafa leverage` command never change.
67
+
68
+ ---
69
+
70
+ ## Boundaries
71
+
72
+ - **Committed config only.** Read the repo's `.claude/` (org config). The dev's personal
73
+ `~/.claude/` is private — never inspected. (Mirrors [scan](scan.md)'s privacy line.)
74
+ - **Secrets are off-limits.** Detect that an `.env` is unprotected; **never open it or read a
75
+ value.** Same hard line as the scan guardrail.
76
+ - **Read-only.** leverage itself mutates nothing. The only writes are `rafa init`/`update`
77
+ merging permissions, run explicitly by the dev.
@@ -138,8 +138,13 @@ retrieval index. Bodies read like a senior engineer explaining that one concept
138
138
  data-op shape, naming).
139
139
  **One `convention` note minimum per domain** — this guarantees breadth. For the
140
140
  **external-integrations** domain, produce an **env-and-integrations** note: every
141
- required env var and the `file:line` where each is read (so "what keys does this repo
142
- need, and where?" is answerable at work-time). A domain marked `mapped` must have a note.
141
+ required env var **name** and the `file:line` in **source** where each is read (so "what keys
142
+ does this repo need, and where?" is answerable at work-time). A domain marked `mapped` must
143
+ have a note.
144
+ **Secrets guardrail (hard):** record env var **names only, read from code**. **Never open
145
+ `.env`, `.env.*`, or any secret file, and never read, log, or copy a value** — a name is a
146
+ contract, a value is a secret. If a note truly needs a value, **stop and ask the dev**; never
147
+ harvest it. (`.env*` is excluded from every read/grep in this step.)
143
148
  **Flag exceptions as non-exemplars.** When a convention has a grandfathered/legacy file
144
149
  that *violates* it — often the **most salient** example — the note MUST name that file and
145
150
  mark it *"exception, not the model — don't pattern-match it."* Salience gets copied; an
@@ -150,8 +155,8 @@ retrieval index. Bodies read like a senior engineer explaining that one concept
150
155
 
151
156
  5. **Seam detection** [grep + LLM judge] — anchor tokens (agent/graph names, tool names,
152
157
  env vars, route strings, exported state/types, table names). For each, run
153
- `git grep -n <token>` repo-wide and record **every** hit as a site (site list == grep
154
- hit list; never infer or sample). Any token in ≥2 boundary-crossing files = a
158
+ `git grep -n <token> -- ':!.env*'` repo-wide (env files excluded see the secrets guardrail
159
+ above) and record **every** hit as a site (site list == grep hit list; never infer or sample). Any token in ≥2 boundary-crossing files = a
155
160
  `contract`. Judge *what must stay in sync* and *does it fail silently*. State-shape
156
161
  contracts count too (e.g. a frontend `AgentState` type that must match a backend graph
157
162
  state). **Composition/ordering contracts count too** — provider nesting, middleware
package/lib/blueprint.mjs CHANGED
@@ -7,25 +7,31 @@ import {
7
7
  existsSync,
8
8
  mkdirSync,
9
9
  cpSync,
10
+ readFileSync,
10
11
  readdirSync,
11
12
  statSync,
12
13
  } from "node:fs";
13
14
  import { dirname, join } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
 
16
- // Files a client owns + tunes (agents, SOPs). Tools + contract move in lockstep.
17
+ // Two classes of blueprint file:
18
+ // - owned → the client tunes these (agents, SOPs). We NEVER clobber a modified copy;
19
+ // a changed file is written alongside as `<file>.new` so the dev merges by hand.
20
+ // - lockstep → tools + contract. These move with the CLI version, so they overwrite.
17
21
  export const BLUEPRINT = {
18
- files: [
22
+ owned: [
19
23
  ".claude/agents/atlas.md",
20
24
  ".claude/agents/prism.md",
21
25
  ".claude/agents/bloom.md",
22
26
  ".claude/commands/rafa.md",
27
+ ],
28
+ ownedDirs: [".rafa/capabilities"],
29
+ lockstep: [
23
30
  ".rafa/contract.md",
24
31
  ".rafa/bin/verify-citations.mjs",
25
32
  ".rafa/bin/rafa-compile.mjs",
26
33
  ".rafa/bin/rafa-push.mjs",
27
34
  ],
28
- dirs: [".rafa/capabilities"],
29
35
  };
30
36
 
31
37
  const HERE = dirname(fileURLToPath(import.meta.url)); // …/packages/cli/lib
@@ -60,27 +66,70 @@ export function repoRoot() {
60
66
  return root;
61
67
  }
62
68
 
63
- // Copy every blueprint file/dir from `from` into `to`. Returns the relative paths written.
64
- export function copyBlueprint(from, to) {
65
- const written = [];
66
- for (const rel of BLUEPRINT.files) {
67
- const src = join(from, rel);
68
- if (!existsSync(src)) continue;
69
- const dst = join(to, rel);
70
- mkdirSync(dirname(dst), { recursive: true });
71
- cpSync(src, dst);
72
- written.push(rel);
69
+ // Expand a blueprint dir into its flat list of relative file paths.
70
+ function filesUnder(from, relDir) {
71
+ const out = [];
72
+ const abs = join(from, relDir);
73
+ if (!existsSync(abs)) return out;
74
+ for (const e of readdirSync(abs)) {
75
+ const p = join(abs, e);
76
+ if (statSync(p).isDirectory()) out.push(...filesUnder(from, join(relDir, e)));
77
+ else out.push(join(relDir, e));
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // The resolved owned/lockstep file lists (dirs expanded), for a given source root.
83
+ export function blueprintFiles(from) {
84
+ const owned = [
85
+ ...BLUEPRINT.owned,
86
+ ...BLUEPRINT.ownedDirs.flatMap((d) => filesUnder(from, d)),
87
+ ];
88
+ return { owned, lockstep: [...BLUEPRINT.lockstep] };
89
+ }
90
+
91
+ function sameBytes(a, b) {
92
+ try {
93
+ return readFileSync(a).equals(readFileSync(b));
94
+ } catch {
95
+ return false;
73
96
  }
74
- for (const rel of BLUEPRINT.dirs) {
97
+ }
98
+
99
+ // Copy the blueprint from `from` into `to`.
100
+ // - mode "vendor" (default): non-destructive. lockstep files overwrite; owned files are
101
+ // created if absent, left untouched if identical, and preserved (writing `<file>.new`)
102
+ // if the dev has changed them — we never clobber tuning.
103
+ // - mode "bundle": force-copy everything (building the package tarball into a scratch dir).
104
+ // Returns { created, updated, unchanged, preserved } — arrays of relative paths.
105
+ export function copyBlueprint(from, to, { mode = "vendor" } = {}) {
106
+ const { owned, lockstep } = blueprintFiles(from);
107
+ const report = { created: [], updated: [], unchanged: [], preserved: [] };
108
+
109
+ const put = (rel, isOwned) => {
75
110
  const src = join(from, rel);
76
- if (!existsSync(src)) continue;
111
+ if (!existsSync(src)) return;
77
112
  const dst = join(to, rel);
78
- mkdirSync(dst, { recursive: true });
79
- cpSync(src, dst, { recursive: true });
80
- // list the .md files copied, for a useful summary
81
- for (const e of readdirSync(src)) {
82
- if (statSync(join(src, e)).isFile()) written.push(join(rel, e));
113
+ mkdirSync(dirname(dst), { recursive: true });
114
+ if (!existsSync(dst)) {
115
+ cpSync(src, dst);
116
+ report.created.push(rel);
117
+ return;
83
118
  }
84
- }
85
- return written;
119
+ if (sameBytes(src, dst)) {
120
+ report.unchanged.push(rel);
121
+ return;
122
+ }
123
+ if (mode === "bundle" || !isOwned) {
124
+ cpSync(src, dst); // lockstep, or a scratch bundle: safe to overwrite
125
+ report.updated.push(rel);
126
+ } else {
127
+ cpSync(src, `${dst}.new`); // owned + modified: keep the dev's copy, offer .new
128
+ report.preserved.push(rel);
129
+ }
130
+ };
131
+
132
+ for (const rel of lockstep) put(rel, false);
133
+ for (const rel of owned) put(rel, true);
134
+ return report;
86
135
  }
@@ -0,0 +1,57 @@
1
+ // Non-destructive reads/writes of the host's Claude Code config. rafa augments the
2
+ // dev's toolbox; it must never clear or overwrite what's already there.
3
+
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ // The permissions rafa's installed tooling needs to run without a prompt each time.
8
+ // Kept minimal + specific to what `rafa init` actually vendors into `.rafa/bin`.
9
+ export const RAFA_PERMISSIONS = [
10
+ "Read(.rafa/**)",
11
+ "Bash(node .rafa/bin/rafa-compile.mjs:*)",
12
+ "Bash(node .rafa/bin/rafa-push.mjs:*)",
13
+ "Bash(node .rafa/bin/verify-citations.mjs:*)",
14
+ ];
15
+
16
+ export function settingsPath(targetDir) {
17
+ return join(targetDir, ".claude", "settings.json");
18
+ }
19
+
20
+ // Read + parse .claude/settings.json. Returns null if absent, `{ error }` if it exists
21
+ // but can't be parsed (so callers can refuse to touch a file they can't understand).
22
+ export function readClaudeSettings(targetDir) {
23
+ const file = settingsPath(targetDir);
24
+ if (!existsSync(file)) return null;
25
+ try {
26
+ return JSON.parse(readFileSync(file, "utf8"));
27
+ } catch (e) {
28
+ return { error: e instanceof Error ? e.message : String(e) };
29
+ }
30
+ }
31
+
32
+ // Merge rafa's required permissions into .claude/settings.json WITHOUT clobbering the
33
+ // dev's config — union into `permissions.allow`, preserve every other key untouched.
34
+ // Never overwrites a file it can't parse. Returns:
35
+ // { added: [...] } entries newly written (empty = already present)
36
+ // { skipped: "reason" } we declined to touch the file (e.g. unparseable)
37
+ export function mergeClaudeSettings(targetDir, perms = RAFA_PERMISSIONS) {
38
+ const file = settingsPath(targetDir);
39
+ const existing = readClaudeSettings(targetDir);
40
+ if (existing && existing.error) {
41
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
42
+ }
43
+
44
+ const settings = existing ?? {};
45
+ const permissions = { ...(settings.permissions ?? {}) };
46
+ const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
47
+ const have = new Set(allow);
48
+ const added = perms.filter((p) => !have.has(p));
49
+
50
+ if (added.length === 0 && existing) return { added: [] };
51
+
52
+ permissions.allow = [...allow, ...added];
53
+ const next = { ...settings, permissions };
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
56
+ return { added };
57
+ }
package/lib/init.mjs CHANGED
@@ -10,6 +10,32 @@ import {
10
10
  import { execSync } from "node:child_process";
11
11
  import { join } from "node:path";
12
12
  import { blueprintSource, copyBlueprint } from "./blueprint.mjs";
13
+ import { mergeClaudeSettings } from "./claude-config.mjs";
14
+ import { stampCurrent } from "./stamp.mjs";
15
+ import { notifyIfBehind } from "./version-check.mjs";
16
+
17
+ // One-line summary of a copyBlueprint report; notes preserved (un-clobbered) files.
18
+ export function reportBlueprint(r) {
19
+ const n = (a) => a.length;
20
+ console.log(
21
+ ` ✓ blueprint: ${n(r.created)} new · ${n(r.updated)} updated · ` +
22
+ `${n(r.unchanged)} unchanged · ${n(r.preserved)} preserved`,
23
+ );
24
+ for (const rel of r.preserved)
25
+ console.log(` • kept your ${rel} — latest written to ${rel}.new (merge by hand)`);
26
+ }
27
+
28
+ // Union rafa's required permissions into .claude/settings.json without clobbering it.
29
+ export function reportSettings(target) {
30
+ const res = mergeClaudeSettings(target);
31
+ if (res.skipped) {
32
+ console.log(` ! settings untouched — ${res.skipped}`);
33
+ } else if (res.added.length) {
34
+ console.log(` ✓ merged ${res.added.length} permission(s) into .claude/settings.json`);
35
+ } else {
36
+ console.log(" ✓ .claude/settings.json already grants rafa's tools");
37
+ }
38
+ }
13
39
 
14
40
  const die = (m) => {
15
41
  console.error(`✗ ${m}`);
@@ -42,11 +68,13 @@ export default async function init(args) {
42
68
  console.log("rafa init — vendoring the blueprint (no setup URL)");
43
69
  }
44
70
 
45
- // Vendor the blueprint into .claude/ + .rafa/ (you own these copies).
46
- const written = copyBlueprint(blueprintSource(), TARGET);
71
+ // Vendor the blueprint into .claude/ + .rafa/ (you own these copies) — non-destructive:
72
+ // owned files (agents, capabilities) are never clobbered, tools/contract move in lockstep.
73
+ reportBlueprint(copyBlueprint(blueprintSource(), TARGET));
47
74
  if (!existsSync(join(TARGET, ".rafa", "active.md")))
48
75
  writeFileSync(join(TARGET, ".rafa", "active.md"), "# No active plan\n");
49
- console.log(` ✓ vendored ${written.length} blueprint files .claude/ + .rafa/`);
76
+ // Grant rafa's vendored tools their permissions (merge, never overwrite).
77
+ reportSettings(TARGET);
50
78
 
51
79
  // Keep the brain out of the code repo's diffs.
52
80
  const gi = join(TARGET, ".gitignore");
@@ -56,21 +84,22 @@ export default async function init(args) {
56
84
  console.log(" ✓ .rafa/ → .gitignore");
57
85
  }
58
86
 
59
- if (p) {
60
- writeFileSync(
61
- join(TARGET, "rafa.json"),
62
- JSON.stringify(
63
- {
87
+ // Stamp the versions this repo was provisioned at — the compat anchor `update`/`migrate`
88
+ // diff against. Carries platform identity too when provisioned via a setup URL.
89
+ stampCurrent(
90
+ TARGET,
91
+ p
92
+ ? {
64
93
  name: p.repoName,
65
94
  repoId: p.repoId,
66
95
  codeRepo: p.codeRepoUrl ?? null,
67
96
  brainRepo: p.brainRepoUrl,
68
97
  provisionedFor: p.owner ?? null,
69
- },
70
- null,
71
- 2,
72
- ) + "\n",
73
- );
98
+ }
99
+ : {},
100
+ );
101
+
102
+ if (p) {
74
103
  const rafaDir = join(TARGET, ".rafa");
75
104
  if (!existsSync(join(rafaDir, ".git"))) run("git init -b main", rafaDir);
76
105
  try {
@@ -88,4 +117,6 @@ export default async function init(args) {
88
117
  " 2. Run /rafa scan — build + validate the brain, then rafa push to sync it.\n" +
89
118
  " 3. Open the repo on the rafinery platform — its Overview shows the brain once pushed.",
90
119
  );
120
+
121
+ await notifyIfBehind();
91
122
  }
@@ -0,0 +1,103 @@
1
+ // Claude Code toolchain adapter. Normalizes the host's `.claude/` + `.mcp.json` config
2
+ // and emits proactive tips for getting more out of it. Implements the ToolchainAdapter
3
+ // contract in ../index.mjs.
4
+
5
+ import { existsSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { readClaudeSettings, RAFA_PERMISSIONS } from "../../claude-config.mjs";
8
+
9
+ const P1 = "P1", P2 = "P2", P3 = "P3";
10
+
11
+ function count(dir) {
12
+ return existsSync(dir) ? readdirSync(dir).length : 0;
13
+ }
14
+
15
+ const adapter = {
16
+ id: "claude-code",
17
+ name: "Claude Code",
18
+
19
+ // Present if the repo carries Claude Code config.
20
+ detect(cwd) {
21
+ return existsSync(join(cwd, ".claude")) || existsSync(join(cwd, ".mcp.json"));
22
+ },
23
+
24
+ // Normalized capability view — the shape every adapter reports in.
25
+ inspect(cwd) {
26
+ const settings = readClaudeSettings(cwd);
27
+ const ok = settings && !settings.error;
28
+ return {
29
+ permissions: ok ? (settings.permissions?.allow ?? []) : [],
30
+ settingsState: !settings ? "missing" : settings.error ? "unparseable" : "ok",
31
+ skills: count(join(cwd, ".claude", "skills")),
32
+ agents: count(join(cwd, ".claude", "agents")),
33
+ commands: count(join(cwd, ".claude", "commands")),
34
+ };
35
+ },
36
+
37
+ // Proactive tips, from clear signals only (no noise).
38
+ recommend(cwd, ctx) {
39
+ const tips = [];
40
+ const cap = this.inspect(cwd);
41
+
42
+ // Secrets: .env present but not ignored — leverage the toolbox to protect them.
43
+ if (ctx.envFiles.length && !ctx.envGitignored) {
44
+ tips.push({
45
+ id: "env-not-ignored",
46
+ priority: P1,
47
+ title: "`.env` files aren't gitignored",
48
+ detail: `Found ${ctx.envFiles.join(", ")} untracked-by-ignore — secrets can be committed or read into a scan.`,
49
+ fix: "add `.env*` to .gitignore (keep `!.env.example`)",
50
+ });
51
+ }
52
+
53
+ // rafa's own tools should run without a prompt each time.
54
+ if (existsSync(join(cwd, ".rafa", "bin"))) {
55
+ if (cap.settingsState === "missing") {
56
+ tips.push({
57
+ id: "no-settings",
58
+ priority: P2,
59
+ title: "No `.claude/settings.json` — every rafa tool call prompts",
60
+ detail: "rafa vendored gates into .rafa/bin but nothing grants them.",
61
+ fix: "run `rafa init` (or `rafa update`) — it merges the permissions in",
62
+ });
63
+ } else if (cap.settingsState === "ok") {
64
+ const missing = RAFA_PERMISSIONS.filter((p) => !cap.permissions.includes(p));
65
+ if (missing.length) {
66
+ tips.push({
67
+ id: "perms-missing",
68
+ priority: P2,
69
+ title: `${missing.length} rafa tool permission(s) not allow-listed`,
70
+ detail: "the vendored gates will prompt until granted.",
71
+ fix: "run `rafa update` — it unions them into settings.json without clobbering yours",
72
+ });
73
+ }
74
+ }
75
+ }
76
+
77
+ // MCP leverage: a Convex repo with no Convex MCP server wired.
78
+ if (ctx.stack.convex && !ctx.mcp.servers.has("convex")) {
79
+ tips.push({
80
+ id: "mcp-convex",
81
+ priority: P2,
82
+ title: "Convex in the stack, but no Convex MCP server",
83
+ detail: "wiring it gives agents schema-aware queries + function specs instead of guessing.",
84
+ fix: "add a `convex` entry to .mcp.json",
85
+ });
86
+ }
87
+
88
+ // Skills: package repeatable procedures so agents reuse them.
89
+ if (cap.skills === 0) {
90
+ tips.push({
91
+ id: "no-skills",
92
+ priority: P3,
93
+ title: "No project skills in `.claude/skills`",
94
+ detail: "recurring procedures (deploy, scaffold, review) reused across sessions belong here.",
95
+ fix: "capture one repeatable flow as a skill",
96
+ });
97
+ }
98
+
99
+ return tips;
100
+ },
101
+ };
102
+
103
+ export default adapter;
@@ -0,0 +1,23 @@
1
+ // Toolchain adapter registry. Every adapter implements the same contract so the
2
+ // leverage engine stays tool-agnostic:
3
+ //
4
+ // id string
5
+ // name string human label
6
+ // detect (cwd) => boolean is this toolchain present in the repo?
7
+ // inspect (cwd) => Capabilities normalized view { permissions, skills, ... }
8
+ // recommend(cwd, ctx) => Tip[] proactive tips { id, priority, title, detail, fix }
9
+ //
10
+ // This is the seam that makes "leverage what's available" true beyond Claude Code:
11
+ // Codex, Cursor, and any other agent toolchain slot in here behind the same interface.
12
+ // They're declared, not yet built — we add each adapter when a user actually runs on it
13
+ // (don't build ahead of the phase that consumes it).
14
+
15
+ import claudeCode from "./claude-code.mjs";
16
+
17
+ export const adapters = [claudeCode];
18
+
19
+ // Declared-but-unimplemented toolchains — the shape they'll fill.
20
+ export const PLANNED = [
21
+ { id: "codex", name: "Codex" },
22
+ { id: "cursor", name: "Cursor" },
23
+ ];
@@ -0,0 +1,82 @@
1
+ // The leverage advisor — tool-agnostic. It reads light signals about the repo, runs
2
+ // every detected toolchain adapter, and returns prioritized tips for getting more out
3
+ // of the dev's existing setup. The adapters (Claude Code today; Codex/Cursor behind the
4
+ // same seam) do the tool-specific work; this engine only orchestrates + ranks.
5
+
6
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { adapters } from "./adapters/index.mjs";
9
+
10
+ // Merge dependencies from the cwd package.json and any apps/*/package.json (monorepo).
11
+ function readDeps(cwd) {
12
+ const deps = new Set();
13
+ const add = (file) => {
14
+ if (!existsSync(file)) return;
15
+ try {
16
+ const p = JSON.parse(readFileSync(file, "utf8"));
17
+ for (const k of Object.keys({ ...p.dependencies, ...p.devDependencies })) deps.add(k);
18
+ } catch {
19
+ /* ignore unparseable package.json */
20
+ }
21
+ };
22
+ add(join(cwd, "package.json"));
23
+ const appsDir = join(cwd, "apps");
24
+ if (existsSync(appsDir)) {
25
+ for (const app of readdirSync(appsDir)) add(join(appsDir, app, "package.json"));
26
+ }
27
+ return deps;
28
+ }
29
+
30
+ // Env files present in the repo root (excluding the committed example).
31
+ function listEnvFiles(cwd) {
32
+ return readdirSync(cwd).filter((f) => /^\.env(\.|$)/.test(f) && f !== ".env.example");
33
+ }
34
+
35
+ // Does .gitignore actually cover .env files?
36
+ function envIgnored(cwd) {
37
+ const gi = join(cwd, ".gitignore");
38
+ if (!existsSync(gi)) return false;
39
+ return /^\s*\.env/m.test(readFileSync(gi, "utf8"));
40
+ }
41
+
42
+ // Server names declared in .mcp.json.
43
+ function readMcp(cwd) {
44
+ const file = join(cwd, ".mcp.json");
45
+ if (!existsSync(file)) return { present: false, servers: new Set() };
46
+ try {
47
+ const j = JSON.parse(readFileSync(file, "utf8"));
48
+ return { present: true, servers: new Set(Object.keys(j.mcpServers ?? j.servers ?? {})) };
49
+ } catch {
50
+ return { present: true, servers: new Set() };
51
+ }
52
+ }
53
+
54
+ // Light, tool-agnostic view of the repo so adapters can tailor tips to the real stack.
55
+ export function repoContext(cwd) {
56
+ const deps = readDeps(cwd);
57
+ const has = (n) => deps.has(n);
58
+ const envFiles = listEnvFiles(cwd);
59
+ return {
60
+ stack: {
61
+ next: has("next"),
62
+ convex: has("convex") || existsSync(join(cwd, "convex")) || existsSync(join(cwd, "apps", "web", "convex")),
63
+ langgraph: has("@langchain/langgraph"),
64
+ clerk: [...deps].some((d) => d.startsWith("@clerk/")),
65
+ },
66
+ envFiles,
67
+ envGitignored: envFiles.length === 0 || envIgnored(cwd),
68
+ mcp: readMcp(cwd),
69
+ };
70
+ }
71
+
72
+ const RANK = { P1: 0, P2: 1, P3: 2 };
73
+
74
+ // Run every detected adapter, tag tips with their tool, and rank by priority.
75
+ export function advise(cwd) {
76
+ const ctx = repoContext(cwd);
77
+ const active = adapters.filter((a) => a.detect(cwd));
78
+ const tips = active
79
+ .flatMap((a) => a.recommend(cwd, ctx).map((t) => ({ ...t, tool: a.name })))
80
+ .sort((x, y) => (RANK[x.priority] ?? 9) - (RANK[y.priority] ?? 9));
81
+ return { ctx, detected: active.map((a) => a.name), tips };
82
+ }
@@ -0,0 +1,38 @@
1
+ // rafa leverage — inspect the dev's agent toolbox (permissions, skills, MCP, config)
2
+ // and print prioritized tips for getting more out of what's already installed. Advisory
3
+ // only: it reads, ranks, and reports — it changes nothing. Run from your repo root.
4
+
5
+ import { advise } from "./leverage/engine.mjs";
6
+ import { PLANNED } from "./leverage/adapters/index.mjs";
7
+
8
+ export default async function leverage() {
9
+ const cwd = process.cwd();
10
+ const { detected, tips } = advise(cwd);
11
+
12
+ if (detected.length === 0) {
13
+ console.log(
14
+ "No supported agent toolchain detected here.\n" +
15
+ ` Supported now: Claude Code. Planned: ${PLANNED.map((p) => p.name).join(", ")}.`,
16
+ );
17
+ return;
18
+ }
19
+
20
+ console.log(`rafa leverage — inspecting your toolbox (${detected.join(", ")})\n`);
21
+
22
+ if (tips.length === 0) {
23
+ console.log("✓ Nothing to tune — your toolbox is set up well.");
24
+ return;
25
+ }
26
+
27
+ for (const t of tips) {
28
+ console.log(` [${t.priority}] ${t.title} · ${t.tool}`);
29
+ console.log(` ${t.detail}`);
30
+ if (t.fix) console.log(` fix: ${t.fix}`);
31
+ console.log("");
32
+ }
33
+ console.log(
34
+ `${tips.length} suggestion(s), most impactful first. This is the detector — it changed nothing.\n` +
35
+ "To have rafa apply these exactly (merge settings, wire the MCP, scaffold a skill), run\n" +
36
+ " /rafa leverage in Claude Code — it edits with your approval.",
37
+ );
38
+ }
@@ -0,0 +1,53 @@
1
+ // rafa migrate — bring a repo's structured files (plans, config) up to the schema the
2
+ // installed CLI ships. Reads the version stamp to know where the repo is coming FROM,
3
+ // runs the applicable migrations idempotently, and re-stamps. Advisory + reversible:
4
+ // it only rewrites files you can review in `git diff` (and re-run safely).
5
+
6
+ import { CURRENT, releaseOf } from "./releases.mjs";
7
+ import { stampedVersions, stampCurrent } from "./stamp.mjs";
8
+ import { pendingMigrations } from "./migrations/index.mjs";
9
+
10
+ export default async function migrate() {
11
+ const cwd = process.cwd();
12
+ const from = stampedVersions(cwd);
13
+ const pending = pendingMigrations(from, CURRENT);
14
+
15
+ if (pending.length === 0) {
16
+ console.log(
17
+ `✓ Nothing mechanical to migrate — plans v${from.plans}, contract v${from.contract} ` +
18
+ `already match ${CURRENT.version}.`,
19
+ );
20
+ if (CURRENT.plans > from.plans || CURRENT.contract > from.contract) {
21
+ console.log(
22
+ " ↳ but the schema version advanced. If a plan/brain SHAPE changed and files need\n" +
23
+ " rewriting, that's a semantic migration — run /rafa migrate in Claude Code.",
24
+ );
25
+ }
26
+ // Still advance the stamp so the recorded CLI version reflects reality.
27
+ stampCurrent(cwd);
28
+ return;
29
+ }
30
+
31
+ console.log(`rafa migrate — applying ${pending.length} migration(s) → ${CURRENT.version}\n`);
32
+ for (const m of pending) {
33
+ console.log(` • ${m.id} [${m.kind}] — ${m.summary}`);
34
+ let r;
35
+ try {
36
+ r = m.run(cwd);
37
+ } catch (e) {
38
+ console.error(` ✗ failed: ${e instanceof Error ? e.message : e}`);
39
+ console.error(" Stamp left unchanged — fix the error and re-run `rafa migrate`.");
40
+ process.exit(1);
41
+ }
42
+ console.log(` ✓ ${r.changed.length} file(s) updated`);
43
+ for (const n of r.notes) console.log(` ${n}`);
44
+ }
45
+
46
+ stampCurrent(cwd);
47
+ const rescan = releaseOf(CURRENT.version)?.requires === "rescan";
48
+ console.log(
49
+ `\n✓ Migrated to ${CURRENT.version}. Review the diff` +
50
+ (rescan ? ", then run `/rafa scan` (the brain contract changed)" : "") +
51
+ ", then `rafa push` if this is a connected repo.",
52
+ );
53
+ }
@@ -0,0 +1,38 @@
1
+ // Migration registry. When a release changes the SHAPE of a structured file (a plan's
2
+ // frontmatter, the contract, vendored config), it adds a migration here so clients move
3
+ // forward with one `rafa migrate` instead of hand-editing. Migrations are idempotent and
4
+ // run in order; each transforms the client's on-disk files from one schema to the next.
5
+ //
6
+ // Contract:
7
+ // id string stable identifier, shown in the log
8
+ // kind "plans" | "contract" | "config"
9
+ // mode "mechanical" | "assisted" default "mechanical"
10
+ // summary string one line: what it changes + why
11
+ // appliesTo(from, to) => boolean from = stampedVersions(cwd), to = CURRENT
12
+ // run(cwd) => { changed: string[], notes: string[] } idempotent; report what it touched
13
+ //
14
+ // mode distinguishes the two migration channels:
15
+ // "mechanical" — a deterministic transform (field rename, path move, add-default). `run` does
16
+ // it here in the CLI, no LLM needed.
17
+ // "assisted" — the shape changed and each file must be UNDERSTOOD to be rewritten. The CLI
18
+ // can't do this correctly; `run` is a no-op that flags it, and `/rafa migrate`
19
+ // (the LLM) rewrites the files against the contract. Intelligence lives there.
20
+ //
21
+ // Empty by design until a release actually changes a shape — the SEAM is what matters,
22
+ // so the first breaking change ships its migration here and `rafa migrate` just works.
23
+ // Example (kept as a reference, not registered):
24
+ //
25
+ // const plans_v1_to_v2 = {
26
+ // id: "plans-add-owner-field",
27
+ // kind: "plans",
28
+ // summary: "plan frontmatter gains a required `owner` field (default: repo owner).",
29
+ // appliesTo: (from, to) => from.plans < 2 && to.plans >= 2,
30
+ // run: (cwd) => backfillPlanField(cwd, "owner", ownerOf(cwd)),
31
+ // };
32
+
33
+ export const MIGRATIONS = [];
34
+
35
+ // The migrations that apply to a given move (stamped `from` → shipped `to`), in order.
36
+ export function pendingMigrations(from, to) {
37
+ return MIGRATIONS.filter((m) => m.appliesTo(from, to));
38
+ }
@@ -0,0 +1,78 @@
1
+ // The release ledger — the CLI's machine-readable source of truth for "what does moving
2
+ // to version X require?" Every published release adds one entry here (in lockstep with
3
+ // package.json). It declares the contract + plans schema versions that release ships and
4
+ // the strongest action a client must take to adopt it:
5
+ //
6
+ // requires: "update" | "migrate" | "rescan" the strongest structural action
7
+ // ("update" → just re-sync files; "migrate"/"rescan" are also computed
8
+ // deterministically from the contract/plans version deltas below).
9
+ // recommends: string[] soft follow-ups this release suggests (e.g. ["leverage"]) — shown
10
+ // by `update` as recommended, not required.
11
+ //
12
+ // `update` reads this to tell the client — transparently — what an upgrade means for them:
13
+ // the release notes since their version, and exactly which actions the upgrade needs.
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ export const RELEASES = [
20
+ {
21
+ version: "0.1.0",
22
+ contract: 1,
23
+ plans: 1,
24
+ requires: "update",
25
+ summary:
26
+ "Deterministic contract + manifest ingest; @rafinery/cli (init/update/compile/push).",
27
+ },
28
+ {
29
+ version: "0.2.0",
30
+ contract: 1,
31
+ plans: 1,
32
+ requires: "update",
33
+ summary:
34
+ "Non-destructive vendoring (merge settings, never clobber tuned agents); secrets " +
35
+ "guardrail in scan; `rafa leverage` toolbox advisor; version-aware updates + `rafa migrate`.",
36
+ },
37
+ {
38
+ version: "0.2.1",
39
+ contract: 1,
40
+ plans: 1,
41
+ requires: "update",
42
+ recommends: ["leverage"],
43
+ summary:
44
+ "leverage & migrate are now intelligent `/rafa` commands — the LLM applies toolbox fixes " +
45
+ "and rewrites plans exactly. Two-layer update: `rafa update` (CLI) does the blueprint side + " +
46
+ "blueprint migrations and reports what the brain needs; new `/rafa update` does the brain-side " +
47
+ "migrations (re-scan / rewrite). `rafa update` now prints release notes + required actions.",
48
+ },
49
+ ];
50
+
51
+ // The release this CLI build ships (last entry).
52
+ export const CURRENT = RELEASES[RELEASES.length - 1];
53
+
54
+ // The installed CLI version, read from package.json (the single numeric source of truth).
55
+ const HERE = dirname(fileURLToPath(import.meta.url)); // …/packages/cli/lib
56
+ export const CLI_VERSION = JSON.parse(
57
+ readFileSync(join(HERE, "..", "package.json"), "utf8"),
58
+ ).version;
59
+
60
+ // Compare two x.y.z strings → -1 | 0 | 1.
61
+ export function cmpSemver(a, b) {
62
+ const pa = String(a).split(".").map(Number);
63
+ const pb = String(b).split(".").map(Number);
64
+ for (let i = 0; i < 3; i++) {
65
+ const d = (pa[i] || 0) - (pb[i] || 0);
66
+ if (d) return d < 0 ? -1 : 1;
67
+ }
68
+ return 0;
69
+ }
70
+
71
+ export function releaseOf(version) {
72
+ return RELEASES.find((r) => r.version === version) ?? null;
73
+ }
74
+
75
+ // Releases strictly newer than `fromVersion`, oldest-first — the changelog to show on update.
76
+ export function releasesAfter(fromVersion) {
77
+ return RELEASES.filter((r) => cmpSemver(r.version, fromVersion) > 0);
78
+ }
package/lib/stamp.mjs ADDED
@@ -0,0 +1,63 @@
1
+ // The version stamp — `rafa.json` at the repo root records what versions this repo was
2
+ // last provisioned/updated at. It is the compat anchor: `update` and `migrate` diff the
3
+ // stamp against what the running CLI ships to know what changed and what to migrate FROM.
4
+ // (Also holds platform identity fields when the repo was provisioned via a setup URL.)
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { CURRENT, CLI_VERSION, RELEASES } from "./releases.mjs";
9
+
10
+ export function stampPath(cwd) {
11
+ return join(cwd, "rafa.json");
12
+ }
13
+
14
+ export function readStamp(cwd) {
15
+ const f = stampPath(cwd);
16
+ if (!existsSync(f)) return {};
17
+ try {
18
+ return JSON.parse(readFileSync(f, "utf8"));
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ // Merge a patch into rafa.json without dropping existing keys (platform fields survive
25
+ // a version bump; the `rafa` version block merges field-by-field).
26
+ export function writeStamp(cwd, patch = {}) {
27
+ const cur = readStamp(cwd);
28
+ const next = {
29
+ ...cur,
30
+ ...patch,
31
+ rafa: { ...(cur.rafa ?? {}), ...(patch.rafa ?? {}) },
32
+ };
33
+ writeFileSync(stampPath(cwd), JSON.stringify(next, null, 2) + "\n");
34
+ return next;
35
+ }
36
+
37
+ // The versions this repo was last stamped at. Un-stamped repos predate the stamp (added in
38
+ // 0.2.0), so default to the FIRST release's schema — never raise a false migration alarm.
39
+ export function stampedVersions(cwd) {
40
+ const base = RELEASES[0];
41
+ const s = readStamp(cwd).rafa ?? {};
42
+ return {
43
+ cli: s.cli ?? base.version,
44
+ contract: s.contract ?? base.contract,
45
+ plans: s.plans ?? base.plans,
46
+ };
47
+ }
48
+
49
+ // Re-stamp to the versions this CLI build ships (called after init, or after update when NO
50
+ // brain-side migration remains — i.e. blueprint AND data are current together).
51
+ export function stampCurrent(cwd, extra = {}) {
52
+ return writeStamp(cwd, {
53
+ ...extra,
54
+ rafa: { cli: CLI_VERSION, contract: CURRENT.contract, plans: CURRENT.plans },
55
+ });
56
+ }
57
+
58
+ // Advance ONLY the blueprint/CLI version — the data (brain/plans) version is left where it is,
59
+ // so `/rafa update` can still see the gap and run the brain-side migrations. Use after
60
+ // `rafa update` when brain-side work remains.
61
+ export function stampCli(cwd) {
62
+ return writeStamp(cwd, { rafa: { cli: CLI_VERSION } });
63
+ }
package/lib/update.mjs CHANGED
@@ -1,10 +1,18 @@
1
- // rafa update — re-sync the blueprint into this repo. shadcn-style: you own your
2
- // copies, so this is an opt-in refresh (run it to pull the latest agents,
3
- // capabilities, contract, and gates after upgrading the CLI).
1
+ // rafa update — re-sync the blueprint into this repo. shadcn-style: you own your copies,
2
+ // so this is non-destructive tools + contract move to the latest, but your tuned
3
+ // agents/capabilities are never clobbered (a changed one is written as `<file>.new`).
4
+ //
5
+ // It also makes the upgrade TRANSPARENT: it shows what changed since your last version,
6
+ // and whether the change needs a re-scan (brain contract) or a `rafa migrate` (plan shape).
4
7
 
5
8
  import { existsSync } from "node:fs";
6
9
  import { join } from "node:path";
7
10
  import { blueprintSource, copyBlueprint } from "./blueprint.mjs";
11
+ import { reportBlueprint, reportSettings } from "./init.mjs";
12
+ import { CURRENT, CLI_VERSION, releasesAfter } from "./releases.mjs";
13
+ import { stampedVersions, stampCurrent, stampCli } from "./stamp.mjs";
14
+ import { pendingMigrations } from "./migrations/index.mjs";
15
+ import { notifyIfBehind } from "./version-check.mjs";
8
16
 
9
17
  export default async function update() {
10
18
  const TARGET = process.cwd();
@@ -12,11 +20,64 @@ export default async function update() {
12
20
  console.error("✗ This repo isn't provisioned yet — run `rafa init` first.");
13
21
  process.exit(1);
14
22
  }
15
- const written = copyBlueprint(blueprintSource(), TARGET);
16
- console.log(`✓ rafa update synced ${written.length} blueprint files:`);
17
- for (const f of written) console.log(` ${f}`);
23
+
24
+ // Where this repo is coming from (before we re-stamp).
25
+ const from = stampedVersions(TARGET);
26
+
27
+ await notifyIfBehind();
28
+
29
+ console.log("rafa update — syncing the blueprint (non-destructive)");
30
+ reportBlueprint(copyBlueprint(blueprintSource(), TARGET));
31
+ reportSettings(TARGET);
32
+
33
+ // ── Blueprint-side migrations: mechanical/deterministic, run here in the CLI. ──
34
+ // (Anything needing intelligence is left for the brain-side pass — /rafa update.)
35
+ const pending = pendingMigrations(from, CURRENT);
36
+ const mechanical = pending.filter((m) => (m.mode ?? "mechanical") !== "assisted");
37
+ for (const m of mechanical) {
38
+ try {
39
+ const r = m.run(TARGET);
40
+ console.log(` ✓ migrated ${m.id}: ${r.changed.length} file(s)`);
41
+ } catch (e) {
42
+ console.log(` ! migration ${m.id} failed: ${e instanceof Error ? e.message : e}`);
43
+ }
44
+ }
45
+
46
+ // ── Release notes: what changed since the client's version. ──
47
+ const news = releasesAfter(from.cli);
48
+ if (news.length) {
49
+ console.log(`\n Release notes (${from.cli} → ${CLI_VERSION}):`);
50
+ for (const r of news) console.log(` ${r.version} — ${r.summary}`);
51
+ }
52
+
53
+ // ── Brain-side follow-ups: the CLI can't do these — they need intelligence. Route to /rafa update. ──
54
+ // The gap is measured against the DATA version (from.contract/plans), which only advances when the
55
+ // brain/plans are actually regenerated — not by this blueprint sync.
56
+ const brain = [];
57
+ if (CURRENT.contract > from.contract) {
58
+ brain.push(
59
+ `Re-scan the brain (contract v${from.contract} → v${CURRENT.contract}; the brain is a stale cache)`,
60
+ );
61
+ }
62
+ if (CURRENT.plans > from.plans || pending.some((m) => m.mode === "assisted")) {
63
+ brain.push(`Rewrite plans to the new shape (plans v${from.plans} → v${CURRENT.plans})`);
64
+ }
65
+
66
+ console.log("");
67
+ if (brain.length) {
68
+ console.log(" Brain-side follow-ups — run /rafa update in Claude Code (needs intelligence):");
69
+ for (const b of brain) console.log(` • ${b}`);
70
+ // Blueprint is current, but the data still lags — keep the gap visible for /rafa update.
71
+ stampCli(TARGET);
72
+ } else {
73
+ console.log(" ✓ No brain migration needed — your brain data is still valid.");
74
+ stampCurrent(TARGET);
75
+ }
76
+
77
+ const recs = new Set(news.flatMap((r) => r.recommends ?? []));
78
+ if (recs.has("leverage")) console.log(" ↳ recommended: /rafa leverage (tune your toolbox).");
79
+
18
80
  console.log(
19
- "\n Your brain data (.rafa/brain, .rafa/improve) was untouched. " +
20
- "Restart Claude Code to reload the agents.",
81
+ "\n Owned files you tuned were preserved (review any `.new`). Restart Claude Code to reload the agents.",
21
82
  );
22
83
  }
@@ -0,0 +1,33 @@
1
+ // Version awareness — tell the client, gently, when a newer rafinery is published. Fail
2
+ // SILENT: a network hiccup or offline machine must never break `init`/`update`.
3
+
4
+ import { cmpSemver, CLI_VERSION } from "./releases.mjs";
5
+
6
+ const REGISTRY = "https://registry.npmjs.org/@rafinery/cli/latest";
7
+
8
+ // The latest published version on npm, or null if it can't be determined quickly.
9
+ export async function latestOnNpm(timeoutMs = 2500) {
10
+ try {
11
+ const ctrl = new AbortController();
12
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
13
+ const res = await fetch(REGISTRY, { signal: ctrl.signal });
14
+ clearTimeout(t);
15
+ if (!res.ok) return null;
16
+ const j = await res.json();
17
+ return typeof j.version === "string" ? j.version : null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ // Print an upgrade nudge iff a strictly-newer version is published. Returns the latest
24
+ // version (or null) so callers can decide whether to say more.
25
+ export async function notifyIfBehind(installed = CLI_VERSION) {
26
+ const latest = await latestOnNpm();
27
+ if (!latest || cmpSemver(latest, installed) <= 0) return latest;
28
+ console.log(
29
+ `\n ⬆ rafinery ${latest} is available (you're on ${installed}).\n` +
30
+ ` Update: npx @rafinery/cli@latest update (fetches the latest CLI + vendors it into your repo)\n`,
31
+ );
32
+ return latest;
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
- "blueprint"
12
+ "blueprint",
13
+ "CHANGELOG.md"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=18"