@rafinery/cli 0.4.1 → 0.6.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/bin/rafa.mjs +30 -5
  3. package/blueprint/.claude/agents/atlas.md +10 -5
  4. package/blueprint/.claude/agents/compass.md +3 -1
  5. package/blueprint/.claude/commands/rafa.md +103 -12
  6. package/blueprint/.claude/rafa/contract.md +111 -47
  7. package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
  8. package/blueprint/.claude/rafa/hooks/pre-push +24 -0
  9. package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
  10. package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
  11. package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
  12. package/blueprint/.claude/skills/rafa-build/SKILL.md +17 -5
  13. package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
  15. package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
  16. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  17. package/lib/blueprint.mjs +9 -1
  18. package/lib/brain-repo.mjs +10 -4
  19. package/lib/checkpoint.mjs +7 -0
  20. package/lib/ci-setup.mjs +2 -0
  21. package/lib/claude-config.mjs +113 -0
  22. package/lib/dirty.mjs +114 -0
  23. package/lib/distill.mjs +47 -2
  24. package/lib/gate/compile.mjs +145 -32
  25. package/lib/gate/verify-citations.mjs +214 -23
  26. package/lib/githook.mjs +54 -0
  27. package/lib/init.mjs +45 -0
  28. package/lib/leverage.mjs +13 -2
  29. package/lib/migrations/index.mjs +57 -2
  30. package/lib/pull.mjs +7 -0
  31. package/lib/push.mjs +21 -0
  32. package/lib/reflex.mjs +76 -0
  33. package/lib/releases.mjs +66 -0
  34. package/lib/status.mjs +152 -0
  35. package/lib/update.mjs +20 -0
  36. package/package.json +2 -2
@@ -46,8 +46,11 @@ doesn't validate, compile fails.
46
46
  | plan | `plans/**/*.md` | **structured** | §7 | plan/build |
47
47
  | log | `brain/log.md` | **verbatim** | — (prose trail) | conductor |
48
48
  | citation-check | `**/citation-check.md` | **generated** | — (by `rafa verify-citations`) | tool |
49
+ | citation-check record | `**/citation-check.json` | **generated** | `{ checkerVersion, at, pass, gates, warns }` (by `rafa verify-citations`; compile folds it into `manifest.citations`) | tool |
49
50
  | active pointer | `active.md` | **generated** | — (`# <plan-id>` or "No active plan"; compile emits it as `activePlanId`, §7) | conductor |
50
51
  | contract copy | `contract.md` | **generated** | — (stamped copy of `.claude/rafa/contract.md`, written by `rafa push`) | tool |
52
+ | dirty queue | `dirty.jsonl` | **generated** (local state — transport-excluded, NEVER pushed/ingested) | one `{f, t}` JSON line per code edit (by the PostToolUse sensor hook; read by `rafa dirty` + the SessionStart digest) | tool |
53
+ | reflex queue | `reflex.jsonl` | **generated** (local state — transport-excluded, NEVER pushed/ingested; transcript pointers are LOCAL, raw transcripts never ship) | `{id, p, t, tp}` per detected correction + append-only `{id, done, verdict, at}` markers (by the UserPromptSubmit sensor; read by `rafa reflex` + the digest) | tool |
51
54
  | agent | `.claude/agents/*.md` (code repo) | **structured** (local gate — NOT in manifest) | §10 | rafa |
52
55
 
53
56
  The `agent` type is the one structured type outside `.rafa/`: the shipped agent cards
@@ -146,6 +149,12 @@ JSON. This is the exact shape the platform binds to.
146
149
  ]
147
150
  },
148
151
 
152
+ "citations": { // from brain/citation-check.json — or null
153
+ "checkerVersion": 2, // the gate level this brain passed
154
+ "pass": true, // that run's verdict (recorded, never assumed)
155
+ "at": "2026-07-12T10:00:00Z" // when the checker last ran
156
+ }, // null = no recorded checker run rode this push
157
+
149
158
  "notes": [
150
159
  {
151
160
  "id": "agent-name-contract",
@@ -204,6 +213,14 @@ title: The agent name is a cross-process contract # required
204
213
  summary: Only "research_agent" is fully wired end to end # required
205
214
  links: [copilotkit-runtime-route-convention] # optional · default []
206
215
  failure: silent # optional · silent | loud
216
+ anchor: research_agent # optional · checker gate B2: EVERY code hit of this
217
+ # token must be a cited site (`anchor: none` = explicit
218
+ # exemption for composition/ordering contracts)
219
+ absent: legacy_agent_name # optional · checker gate B3: this token must appear
220
+ # NOWHERE in code (docs/.md excluded) — declare one for
221
+ # every claim that depends on something NOT existing;
222
+ # the checker re-greps it every run, so the claim can
223
+ # never silently go stale. Repeatable (one per line).
207
224
  cites: # required · ≥ 1
208
225
  - src/lib/model-selector-provider.tsx:42 :: research_agent
209
226
  - agents/python/main.py:20 :: research_agent
@@ -211,6 +228,12 @@ cites: # required · ≥ 1
211
228
  (prose body — rendered verbatim, never parsed)
212
229
  ```
213
230
 
231
+ `anchor:`/`absent:` are **checker declarations** (consumed by `rafa verify-citations`
232
+ gates B2/B3, not emitted into the manifest). A note whose title/summary reads as an
233
+ absence claim without declaring `absent:` is listed as a checker WARN — prism's
234
+ worklist, never a gate failure (a heuristic that fails the gate would be an assumed
235
+ value).
236
+
214
237
  ---
215
238
 
216
239
  ## 3. Improvement files — `improve/improvements/*.md`
@@ -282,69 +305,109 @@ domain found (compile emits it as `[{ domain, status }]` in the manifest). Statu
282
305
  ---
283
306
  schemaVersion: 1
284
307
  domains: { design-system: mapped, components: mapped, api: thin, external-integrations: empty }
308
+ inventory: # optional · declared surface inventories, re-counted by
309
+ - route-pages :: apps/web/app/**/page.tsx :: 23 # the checker every run
310
+ - api-routes :: apps/web/app/api/**/route.ts :: 2
285
311
  ---
286
312
  (per-criterion PASS/FAIL narrative in the body)
287
313
  ```
288
314
 
315
+ `inventory:` entries are `<name> :: <glob> :: <count>` — the checker recomputes each
316
+ count via `git ls-files ':(glob)<glob>'` and FAILS on drift (coverage claiming a
317
+ surface inventory the repo has outgrown). atlas declares one per framework surface it
318
+ maps (route pages, API routes, workflows — whatever the repo's shape makes load-bearing);
319
+ compile validates the grammar, the checker owns the truth.
320
+
289
321
  ---
290
322
 
291
- ## 7. Plan files — `plans/**/*.md` (+ the plans channel)
323
+ ## 7. Plan files — `plans/**/*.md` (+ the plans channel) — **v2: the work-item tree**
292
324
 
293
- Each child owns exactly one file (so merges never conflict; parent progress is
294
- derived by counting).
325
+ Plans are a TREE of work items (plans data version 2, ratified 2026-07-12): one
326
+ **epic** (the root) → **tasks** → **subtasks**. Three ranks, deliberately matching
327
+ the ceiling of Linear (Project→Issue→sub-issue), Jira (Epic→Task→Sub-task), and
328
+ Asana (Project→Task→Subtask) so tracker sync is lossless. Vocabulary is
329
+ vendor-blended: field names below are the majority vendor names wherever one exists.
330
+ Each item owns exactly one file (merges never conflict; progress is derived).
295
331
 
296
332
  ```yaml
297
333
  ---
298
334
  schemaVersion: 1
299
- id: multitenant-c1-schema # required · == filename stem · GLOBALLY unique across plans/**
300
- plan: multitenant # required · the parent plan id
301
- parent: multitenant # required · parent plan id (child), or null (root)
302
- kind: parent | child # required
303
- title: # required
304
- status: todo | in-progress | done | blocked | superseded | abandoned # required (child); parent may omit
305
- branch: feat/mt-c1 # optional · the branch this executes on
335
+ id: payments-c3-webhooks # required · == filename stem · GLOBALLY unique across plans/**
336
+ plan: payments # required · the ROOT (epic) id
337
+ parent: payments-c3 # required · the parent ITEM id (any rank above), or null (epic)
338
+ kind: epic | task | subtask # required · epic = root only · task's parent = the epic · subtask's parent = a task
339
+ title: Handle provider webhooks # required · WHAT (Linear title · Jira summary · Asana name)
340
+ description: >- # optional · WHY + context syncs 1:1 with vendor description/notes
341
+ reconciliation must be automatic; manual matching doesn't scale
342
+ approach: verify HMAC → enqueue → idempotent apply # optional · HOW, one line — rafa value-add
343
+ status: todo | in-progress | done | superseded | abandoned # required (task/subtask); epic may omit
344
+ assignee: rohik # optional · WHO (free string pre-orgs; unanimous vendor word)
345
+ blocked_by: [payments-c2] # optional · item ids this waits on — a dependency IS a blocker
346
+ blocked_reason: "vendor sandbox access" # optional · external blocks (no dependency id to point at)
347
+ priority: 2 # optional · 0 none · 1 urgent · 2 high · 3 medium · 4 low (Linear scale)
348
+ estimate: 3 # optional · points (Linear estimate / Jira story points)
349
+ branch: feat/webhooks # optional · ↔ Linear branchName
306
350
  baseSha: abc123 # optional · commit it was cut from
351
+ domains: [payments] # optional · EPIC only · the blast radius — the brain link
352
+ external: { provider: jira, key: PAY-142 } # optional · tracker identity — READ-ONLY to sessions
307
353
  ---
308
- (prose body — for a child it MUST contain a `## Done-check` section: the expected
309
- outcome prism validates execution against. Compile never parses bodies; a missing
310
- Done-check is rejected by prism's PLAN validation, not by compile.)
354
+ (prose body — a LEAF item (a task with no subtasks, or a subtask) MUST carry a
355
+ `## Done-check`; `## Log` is the execution journal; `## Decisions` mirrors the
356
+ structured decision records (below). Compile never parses bodies.)
311
357
  ```
312
358
 
313
359
  Rules (all compile-enforced unless noted):
314
360
 
315
- - **Global id uniqueness.** `plans/**` is nested, so stems could collide; a duplicate
316
- plan id anywhere under `plans/` is a compile error. Convention: prefix children
317
- with the plan id (`<plan>-c1-….md`).
318
- - **`kind: parent`** `parent` MUST be `null` and `plan` MUST equal `id`.
319
- - **`kind: child`** `plan` MUST resolve to a `kind: parent` file in the same
320
- compile, and `parent` MUST equal `plan` (flat parent→children in v1). A dangling
321
- child is a loud error; a parent with zero children is valid (a plan just created).
322
- - **Progress is never stored.** A `progress` key in plan frontmatter is a compile
323
- error. Parent progress is `count(children where status == done) / count(children)`
324
- derived at read time, everywhere (compile, platform, MCP).
325
- - **Statuses.** `done` exists only on prism PASS (the execution gate). The two
326
- TERMINAL statuses close work honestly without pretending `done`:
327
- `superseded` (a newer plan replaces it) · `abandoned` (deliberately dropped).
328
- `blocked` is waiting, not terminal. Statuses are set in sessions; the platform
329
- board renders them read-only.
330
- - **`active.md` `activePlanId`.** Compile reads the generated pointer: a first line
331
- `# <plan-id>` must resolve to a `kind: parent` plan (else compile error);
332
- `# No active plan` or a missing `active.md` `activePlanId: null` (documented
333
- default the pointer is generated, absence means "none", never a guess of one).
334
- The PLATFORM pointer is set separately through `set_active_plan` (below).
335
-
336
- **Transport the plans channel (0.4.0; plans never ride the brain manifest):**
337
-
338
- - **Authoring**: plans are drafted as the files above (compile-validated
339
- determinism holds; `.rafa/plans/` is the working copy).
340
- - **Push on approval**: the dev's approval IS the trigger — `push_plan` sends the
341
- whole plan (parent + children + bodies) to the platform; `set_active_plan`
342
- points the repo's active pointer at it. Build cadence re-pushes statuses +
343
- `## Log` journals (`push_plan` upsert / `update_plan_status`).
344
- - **Pull-based work**: `list_plans` returns NAMES ONLY (id · title · status ·
345
- progress); **"load plan X"** = `get_plan` (bodies included) materialized back
346
- into `.rafa/plans/` by the session. Nothing plan-shaped is hydrated until
347
- explicitly loaded.
361
+ - **Global id uniqueness** across `plans/**` (duplicate = error). Convention: prefix
362
+ descendants with the epic id.
363
+ - **Tree shape.** Exactly ONE `kind: epic` per plan; `epic` → `parent: null`, `plan == id`.
364
+ A task's `parent` is the epic; a subtask's `parent` is a task. Every item's `plan`
365
+ is the root epic id. Dangling parents and rank violations are loud errors.
366
+ - **`blocked` is DERIVED, never stored.** An item is blocked when it has an
367
+ unresolved `blocked_by` (a listed item not yet `done`) or a `blocked_reason`.
368
+ `status: blocked` is a compile ERROR (removed in v2 `rafa migrate` converts).
369
+ No vendor stores blocked as a state either (Linear: relations · Jira: links +
370
+ Flagged · Asana: dependencies) deriving it keeps sync lossless.
371
+ - **`blocked_by` ids resolve within the plan and are acyclic.**
372
+ - **Progress is never stored.** A `progress` key is a compile error. Progress =
373
+ `count(done LEAVES) / count(leaves)` per subtree derived everywhere.
374
+ - **Statuses.** `done` exists only on prism PASS (the execution gate). Terminal:
375
+ `superseded` (replaced by a newer plan) · `abandoned` (deliberately dropped) —
376
+ mapping losslessly to Linear `canceled` / Jira resolution "Duplicate"/"Won't do".
377
+ Statuses are set in sessions; the platform board renders them read-only.
378
+ - **`external` is read-only to sessions** the sync layer owns it. When a provider
379
+ closes a ticket, the item shows DUAL status (provider's + ours); `done` is never
380
+ imported (K6: done is earned, not synced).
381
+ - **`active.md` → `activePlanId`.** First line `# <plan-id>` must resolve to a
382
+ `kind: epic`; `# No active plan` / missing file null.
383
+
384
+ **Decisions first-class deliberation records (channel-borne, never frontmatter):**
385
+ the frontmatter grammar is flat and bodies are never parsed, so structured decisions
386
+ live on the PLATFORM, pushed via `log_decision` at checkpoint moments:
387
+ `{ item, at, actor, context, options[], decision, rationale }` *actor* is who
388
+ decided (the dev for steering; the agent for proposals it made under standing
389
+ consent). Text fidelity: **paraphrase + short verbatim quotes only where the exact
390
+ wording carries the decision** (transcripts never land in shared stores). Sessions
391
+ mirror each record into the item body's `## Decisions` for human reading. On tracker
392
+ sync, decisions flow OUT as prefixed comments (Jira/Linear comments, Asana stories);
393
+ imported comments are never scraped into decisions.
394
+
395
+ **Transport — the plans channel (plans never ride the brain manifest):**
396
+
397
+ - **Authoring**: files above, compile-validated; `.rafa/plans/` is the working copy.
398
+ - **Push on approval**: approval IS the trigger — `push_plan` sends the whole tree
399
+ (`items[]`, bodies included); `set_active_plan` points the pointer. Build cadence
400
+ re-pushes statuses + `## Log` (`push_plan` / `update_plan_status`) and logs
401
+ decisions (`log_decision`).
402
+ - **Pull-based work**: `list_plans` = NAMES ONLY; **"load plan X"** = `get_plan`
403
+ (bodies + decisions) materialized back into `.rafa/plans/` by the session.
404
+ - **The brain link**: the epic's `domains:` rides the channel; the plan detail page
405
+ renders the related knowledge beside the tree.
406
+ - **Tracker sync (phased)**: `external` identity now → import (a ticket is
407
+ purpose/what, NOT an executable plan — it still flows through plan drafting +
408
+ prism before build) → two-way sync under a field-ownership matrix (provider owns
409
+ ticket-native fields; rafa owns execution fields; conflicts surface, never
410
+ auto-resolve).
348
411
 
349
412
  ---
350
413
 
@@ -435,7 +498,8 @@ state**, marked by `envelope.plane: "state"`:
435
498
  | `get_working_set` | branch working set | hydration (a session starts with its branch's working set), the branch view, distillation collect; `needs-adjudication` rows carry the incoming copy in `pending` |
436
499
  | `resolve_working_file` | branch working set | `distilled` / `refuted` (+ cited note) at merge-to-main distillation; `keep-current` for adjudication decisions; CAS on status — a racing distill fails loudly |
437
500
  | `fold_working_set` | branch working set | branch→parent-branch MECHANICAL fold (no LLM, no prism — rigor only at main): absent → re-keyed · identical → merged · divergent → `needs-adjudication` on the parent row |
438
- | `push_plan` / `list_plans` / `update_plan_status` / `set_active_plan` | **plans** — (repo) work objects | the dedicated push-on-approval channel (§7): approval pushes the whole plan (bodies stored); `list_plans` = names only; statuses push back from sessions; the active pointer is explicit, never inferred |
501
+ | `push_plan` / `list_plans` / `update_plan_status` / `set_active_plan` / `log_decision` | **plans** — (repo) work objects | the push-on-approval channel (§7 v2): approval pushes the whole work-item TREE (items[], bodies stored); `list_plans` = names only; statuses push back (5-value enum — blocked is derived); `log_decision` records the deliberation trail (paraphrase + pivotal quotes); the active pointer is explicit, never inferred |
502
+ | `report_improvement_status` | **events only** — no store | a LIVE session signal (bloom's fixed-in-passing during build). The improvement ledger row is untouched — it has ONE writer, the ingest (K1); the platform overlays the report as pending-reconciliation until the next brain push confirms it |
439
503
 
440
504
  State tools work with or without an ingested brain (a working set can exist —
441
505
  and a plan can be pushed — before the first scan) and are exempt from the
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // rafa hook · PostToolUse (Edit|Write|MultiEdit|NotebookEdit) — the dirty-marker.
3
+ //
4
+ // M5 capture engine, sensor #1: every code edit is recorded the moment it happens
5
+ // (monotonic, event-driven — never at session end, because there is no session end).
6
+ // Appends {f, t} to .rafa/dirty.jsonl; the SessionStart digest and `rafa dirty`
7
+ // resolve entries to the brain notes that cite those files (the codeRefs plane).
8
+ //
9
+ // Hard rules: NEVER block the session (exit 0 on every path, all errors swallowed),
10
+ // no network, no LLM, O(append). Honors RAFA_HOOKS_DISABLED=1 (headless workers /
11
+ // CI distillation set it — the recursion guard).
12
+
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join, relative, isAbsolute, sep } from "node:path";
15
+
16
+ try {
17
+ if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
18
+
19
+ let input = "";
20
+ try {
21
+ input = readFileSync(0, "utf8"); // stdin: the hook event JSON
22
+ } catch {
23
+ process.exit(0);
24
+ }
25
+ let evt = null;
26
+ try {
27
+ evt = JSON.parse(input);
28
+ } catch {
29
+ process.exit(0);
30
+ }
31
+
32
+ const root = process.env.CLAUDE_PROJECT_DIR || evt?.cwd || process.cwd();
33
+ if (!existsSync(join(root, "rafa.json"))) process.exit(0); // not a rafa-provisioned repo
34
+
35
+ const fp = evt?.tool_input?.file_path || evt?.tool_input?.notebook_path;
36
+ if (!fp || typeof fp !== "string") process.exit(0);
37
+
38
+ const rel = isAbsolute(fp) ? relative(root, fp) : fp;
39
+ // Outside the repo, or not code-plane: knowledge/state/tooling edits don't dirty the brain.
40
+ if (rel.startsWith("..") || isAbsolute(rel)) process.exit(0);
41
+ const top = rel.split(sep)[0];
42
+ if ([".rafa", ".claude", ".git", "node_modules"].includes(top) || rel === "rafa.json") process.exit(0);
43
+
44
+ const rafaDir = join(root, ".rafa");
45
+ mkdirSync(rafaDir, { recursive: true });
46
+
47
+ // Transport exclusion, belt-and-braces: the queue is session-local state and must
48
+ // never ride a brain push (`rafa push` commits .rafa/ wholesale). ensureBrainRepo
49
+ // writes this too — this covers the window before any pull/push ran here.
50
+ const gi = join(rafaDir, ".gitignore");
51
+ const body = existsSync(gi) ? readFileSync(gi, "utf8") : "";
52
+ if (!/^dirty\.jsonl$/m.test(body))
53
+ writeFileSync(gi, body + (body === "" || body.endsWith("\n") ? "" : "\n") + "dirty.jsonl\n");
54
+
55
+ appendFileSync(
56
+ join(rafaDir, "dirty.jsonl"),
57
+ JSON.stringify({ f: rel.split(sep).join("/"), t: new Date().toISOString() }) + "\n",
58
+ );
59
+ } catch {
60
+ /* a sensor must never take down the session */
61
+ }
62
+ process.exit(0);
@@ -0,0 +1,24 @@
1
+ #!/bin/sh
2
+ # rafa pre-push hook v1 (installed by @rafinery/cli init/pull/update — safe to
3
+ # delete; it will be offered again, never forced over a foreign hook).
4
+ #
5
+ # M5 capture engine, sensor #3: `git push` of the CODE branch is a ratified
6
+ # natural checkpoint boundary — so the branch WORKING SET (edited/new brain
7
+ # files) syncs to the platform deterministically here, not only when a session
8
+ # remembers its SOP. Non-blocking by design: a checkpoint problem must never
9
+ # stop a code push (exit 0 on every path; conflicts land as .theirs.md copies
10
+ # for the next session to decide — printed, never auto-resolved).
11
+
12
+ [ "$RAFA_HOOKS_DISABLED" = "1" ] && exit 0
13
+ [ -f "rafa.json" ] || exit 0
14
+
15
+ echo "rafa · checkpoint (pre-push boundary) …"
16
+ npx -y @rafinery/cli checkpoint || {
17
+ code=$?
18
+ if [ "$code" = "2" ]; then
19
+ echo "rafa · checkpoint CONFLICT — a teammate's newer copy landed as *.theirs.md; decide in your next session (merge/adopt/keep, re-run rafa checkpoint). Push continues."
20
+ else
21
+ echo "rafa · checkpoint skipped (exit $code) — push continues; run 'rafa checkpoint' manually when convenient."
22
+ fi
23
+ }
24
+ exit 0
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // rafa hook · SessionStart (startup|resume|clear) — the state digest.
3
+ //
4
+ // M5 capture engine, sensor #2: the session starts KNOWING instead of assuming —
5
+ // staleness (dirty code files → the brain notes citing them, via the local manifest
6
+ // or the platform's get_code_context), pending checkpoint conflicts (*.theirs.md),
7
+ // and the active plan. State, never activity (no-gossip principle: nothing here
8
+ // narrates what other devs did — only what THIS working copy's brain plane looks like).
9
+ //
10
+ // Everything printed to stdout lands in Claude's context. Silence = nothing to
11
+ // report (the honest digest). Budget: local reads + ONE optional platform call
12
+ // (get_code_context batch, ≤ 2s, fail-soft to local counts). Never blocks a
13
+ // session: every path exits 0. Honors RAFA_HOOKS_DISABLED=1.
14
+
15
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const DIRTY_FILES_ALARM = 15; // ≥ → recommend /rafa scan --brain-only (the fire-alarm rung)
20
+ const CITED_NOTES_ALARM = 10;
21
+ const MCP_BUDGET_MS = 2000;
22
+ const MCP_MAX_FILES = 6;
23
+
24
+ function readJson(file) {
25
+ try {
26
+ return JSON.parse(readFileSync(file, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function dirtyEntries(rafaDir) {
33
+ const f = join(rafaDir, "dirty.jsonl");
34
+ if (!existsSync(f)) return [];
35
+ const files = new Map(); // path → latest touch
36
+ for (const line of readFileSync(f, "utf8").split("\n")) {
37
+ if (!line.trim()) continue;
38
+ try {
39
+ const e = JSON.parse(line);
40
+ if (e && typeof e.f === "string") files.set(e.f, e.t ?? "");
41
+ } catch {
42
+ /* a torn line never breaks the digest */
43
+ }
44
+ }
45
+ return [...files.keys()];
46
+ }
47
+
48
+ // file → citing note ids, from the LOCAL manifest (a full pull / prior compile).
49
+ function citersFromManifest(rafaDir, files) {
50
+ const m = readJson(join(rafaDir, "manifest.json"));
51
+ if (!m || !Array.isArray(m.notes)) return null;
52
+ const want = new Set(files);
53
+ const byNote = new Map();
54
+ for (const group of [m.notes, Array.isArray(m.improvements) ? m.improvements : []]) {
55
+ for (const n of group) {
56
+ const hits = (n.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file);
57
+ if (hits.length) byNote.set(n.id, [...new Set(hits)]);
58
+ }
59
+ }
60
+ return byNote;
61
+ }
62
+
63
+ // Fallback: the platform's inverted cite graph (get_code_context), fail-soft.
64
+ async function citersFromPlatform(root, files) {
65
+ const stamp = readJson(join(root, "rafa.json"));
66
+ const repoId = stamp?.repoId;
67
+ if (!repoId) return null;
68
+ let key = process.env.RAFA_MCP_KEY || null;
69
+ let url = null;
70
+ const local = readJson(join(root, ".claude", "settings.local.json"));
71
+ if (!key && local?.env?.RAFA_MCP_KEY) key = local.env.RAFA_MCP_KEY;
72
+ const creds = readJson(join(homedir(), ".config", "rafinery", "credentials.json"));
73
+ const entry = creds?.repos?.[repoId];
74
+ if (!key && entry?.key) key = entry.key;
75
+ if (entry?.mcpUrl) url = entry.mcpUrl;
76
+ if (!key || !url || typeof fetch !== "function") return null;
77
+
78
+ const byNote = new Map();
79
+ const ctrl = new AbortController();
80
+ const timer = setTimeout(() => ctrl.abort(), MCP_BUDGET_MS);
81
+ try {
82
+ await Promise.all(
83
+ files.slice(0, MCP_MAX_FILES).map(async (path, i) => {
84
+ const res = await fetch(url, {
85
+ method: "POST",
86
+ signal: ctrl.signal,
87
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
88
+ body: JSON.stringify({
89
+ jsonrpc: "2.0",
90
+ id: i + 1,
91
+ method: "tools/call",
92
+ params: { name: "get_code_context", arguments: { path } },
93
+ }),
94
+ });
95
+ if (!res.ok) return;
96
+ const rpc = await res.json();
97
+ const text = rpc?.result?.content?.[0]?.text;
98
+ const env = typeof text === "string" ? JSON.parse(text) : text;
99
+ for (const ref of [...(env?.notes ?? []), ...(env?.improvements ?? [])]) {
100
+ const id = ref?.id;
101
+ if (!id) continue;
102
+ if (!byNote.has(id)) byNote.set(id, []);
103
+ byNote.get(id).push(path);
104
+ }
105
+ }),
106
+ );
107
+ } catch {
108
+ return null; // offline / slow / shape drift → local counts still serve
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ return byNote.size ? byNote : null;
113
+ }
114
+
115
+ // Unprocessed corrections (the reflex queue) — monotonic; an abandoned session
116
+ // loses nothing because the next session starts by seeing these.
117
+ function pendingCorrections(rafaDir) {
118
+ const f = join(rafaDir, "reflex.jsonl");
119
+ if (!existsSync(f)) return [];
120
+ const out = [];
121
+ for (const line of readFileSync(f, "utf8").split("\n")) {
122
+ if (!line.trim()) continue;
123
+ try {
124
+ const e = JSON.parse(line);
125
+ if (e && e.id && !e.done) out.push(e);
126
+ } catch {
127
+ /* torn line never breaks the digest */
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function conflictCopies(rafaDir) {
134
+ const out = [];
135
+ const walk = (dir, depth) => {
136
+ if (depth > 4 || !existsSync(dir)) return;
137
+ for (const e of readdirSync(dir)) {
138
+ if (e === ".git") continue;
139
+ const p = join(dir, e);
140
+ try {
141
+ if (statSync(p).isDirectory()) walk(p, depth + 1);
142
+ else if (e.endsWith(".theirs.md")) out.push(p.slice(rafaDir.length + 1));
143
+ } catch {
144
+ /* races are fine */
145
+ }
146
+ }
147
+ };
148
+ walk(rafaDir, 0);
149
+ return out;
150
+ }
151
+
152
+ try {
153
+ if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
154
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
155
+ if (!existsSync(join(root, "rafa.json"))) process.exit(0);
156
+ const rafaDir = join(root, ".rafa");
157
+
158
+ const lines = [];
159
+
160
+ const dirty = dirtyEntries(rafaDir);
161
+ if (dirty.length) {
162
+ let byNote = citersFromManifest(rafaDir, dirty);
163
+ if (byNote === null || byNote.size === 0) byNote = (await citersFromPlatform(root, dirty)) ?? byNote;
164
+ const noteIds = byNote ? [...byNote.keys()] : [];
165
+ const shown = noteIds.slice(0, 8).join(", ") + (noteIds.length > 8 ? ` (+${noteIds.length - 8} more)` : "");
166
+ lines.push(
167
+ `[rafa · staleness] ${dirty.length} code file(s) edited since the last brain reconcile` +
168
+ (noteIds.length
169
+ ? ` → ${noteIds.length} brain note(s) cite them: ${shown}.`
170
+ : byNote === null
171
+ ? " (citing notes unresolved here — run `rafa dirty` for the mapping)."
172
+ : " — no brain note cites them (nothing to refresh)."),
173
+ );
174
+ if (noteIds.length || byNote === null)
175
+ lines.push(
176
+ dirty.length >= DIRTY_FILES_ALARM || noteIds.length >= CITED_NOTES_ALARM
177
+ ? `[rafa · staleness] drift is past the threshold (${dirty.length} files / ${noteIds.length} notes) — recommend a brain refresh from main (\`/rafa scan --brain-only\`) at the next natural boundary.`
178
+ : `[rafa · staleness] at the next natural boundary, OFFER a scoped refresh of just the citing notes (atlas re-derives them, gates run as usual, then checkpoint). After the refresh: \`rafa dirty --consume\`.`,
179
+ );
180
+ }
181
+
182
+ const corrections = pendingCorrections(rafaDir);
183
+ if (corrections.length)
184
+ lines.push(
185
+ `[rafa · reflex] ${corrections.length} unprocessed correction(s) from earlier sessions: ` +
186
+ corrections
187
+ .slice(0, 3)
188
+ .map((c) => `${c.id} ("${c.p.slice(0, 60)}${c.p.length > 60 ? "…" : ""}")`)
189
+ .join(" · ") +
190
+ (corrections.length > 3 ? " …" : "") +
191
+ ` — fold into the bootstrap digest: bank the durable ones through the gates (see \`rafa reflex\`), consume each with its verdict.`,
192
+ );
193
+
194
+ const conflicts = conflictCopies(rafaDir);
195
+ if (conflicts.length)
196
+ lines.push(
197
+ `[rafa · conflicts] ${conflicts.length} checkpoint conflict cop${conflicts.length === 1 ? "y" : "ies"} await a decision (never auto-resolved): ${conflicts.slice(0, 5).join(", ")}${conflicts.length > 5 ? " …" : ""} — read each .theirs.md, merge/adopt/keep, re-run \`rafa checkpoint\`.`,
198
+ );
199
+
200
+ let activePlanId = null;
201
+ const activePath = join(rafaDir, "active.md");
202
+ if (existsSync(activePath)) {
203
+ const first = readFileSync(activePath, "utf8").split("\n")[0].trim();
204
+ if (first.startsWith("#") && first !== "# No active plan") {
205
+ activePlanId = first.replace(/^#\s*/, "");
206
+ lines.push(`[rafa · plan] active: ${activePlanId} (materialized under .rafa/plans/).`);
207
+ }
208
+ }
209
+
210
+ // Suggested next — ONE deterministic recommendation, ranked by consequence:
211
+ // a teammate blocked by a conflict > an unbanked correction > resuming the
212
+ // active plan > staleness repair. Guidance is front-loaded here (and ambient
213
+ // in the statusline) — never interleaved mid-flow (offer etiquette).
214
+ {
215
+ let next = null;
216
+ if (conflicts.length)
217
+ next = `resolve the checkpoint conflict${conflicts.length > 1 ? "s" : ""} (${conflicts.length} .theirs.md) — a teammate's copy is waiting on your decision`;
218
+ else if (corrections.length)
219
+ next = `work the ${corrections.length} unprocessed correction${corrections.length > 1 ? "s" : ""} (bank the durable ones — \`rafa reflex\`)`;
220
+ else if (activePlanId) next = `resume plan ${activePlanId}`;
221
+ else if (dirty.length) next = `refresh the notes your edits staled (offer the scoped refresh)`;
222
+ if (next) lines.push(`[rafa · next] suggested next: ${next}. Fold into the bootstrap digest — one question, never serial.`);
223
+ }
224
+
225
+ if (lines.length) process.stdout.write(lines.join("\n") + "\n");
226
+ } catch {
227
+ /* the digest is a courtesy — never a crash */
228
+ }
229
+ process.exit(0);