@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.
- package/CHANGELOG.md +144 -0
- package/bin/rafa.mjs +30 -5
- package/blueprint/.claude/agents/atlas.md +10 -5
- package/blueprint/.claude/agents/compass.md +3 -1
- package/blueprint/.claude/commands/rafa.md +103 -12
- package/blueprint/.claude/rafa/contract.md +111 -47
- package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
- package/blueprint/.claude/rafa/hooks/pre-push +24 -0
- package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
- package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
- package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +17 -5
- package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/blueprint.mjs +9 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/checkpoint.mjs +7 -0
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +113 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +47 -2
- package/lib/gate/compile.mjs +145 -32
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +45 -0
- package/lib/leverage.mjs +13 -2
- package/lib/migrations/index.mjs +57 -2
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +66 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +20 -0
- 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
|
-
|
|
294
|
-
|
|
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:
|
|
300
|
-
plan:
|
|
301
|
-
parent:
|
|
302
|
-
kind:
|
|
303
|
-
title:
|
|
304
|
-
|
|
305
|
-
|
|
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 —
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
-
|
|
326
|
-
|
|
327
|
-
`
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
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);
|