@rafinery/cli 0.8.15 → 0.10.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 (71) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/bin/rafa.mjs +11 -0
  3. package/blueprint/.claude/agents/atlas.md +21 -3
  4. package/blueprint/.claude/agents/bloom.md +2 -2
  5. package/blueprint/.claude/agents/prism.md +42 -3
  6. package/blueprint/.claude/agents/sage.md +2 -1
  7. package/blueprint/.claude/commands/rafa.md +24 -13
  8. package/blueprint/.claude/rafa/contract.md +6 -0
  9. package/blueprint/.claude/rafa/hooks/brain-commit.mjs +62 -16
  10. package/blueprint/.claude/rafa/hooks/session-start.mjs +66 -0
  11. package/blueprint/.claude/skills/rafa-build/SKILL.md +111 -20
  12. package/blueprint/.claude/skills/rafa-improve/SKILL.md +22 -0
  13. package/blueprint/.claude/skills/rafa-leverage/SKILL.md +6 -1
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +36 -1
  15. package/blueprint/.claude/skills/rafa-review/SKILL.md +16 -2
  16. package/blueprint/.claude/skills/rafa-sage/SKILL.md +21 -4
  17. package/blueprint/.claude/skills/rafa-scan/SKILL.md +4 -1
  18. package/lib/brain-repo.mjs +1 -1
  19. package/lib/checkpoint.mjs +76 -10
  20. package/lib/distiller/doctrine.mjs +5 -16
  21. package/lib/doctor.mjs +19 -1
  22. package/lib/facts.mjs +130 -0
  23. package/lib/gate/compile.mjs +12 -0
  24. package/lib/gate/verify-citations.mjs +78 -1
  25. package/lib/hydrate.mjs +45 -0
  26. package/lib/init.mjs +9 -1
  27. package/lib/leverage/engine.mjs +32 -3
  28. package/lib/leverage.mjs +11 -2
  29. package/lib/loop-cache.mjs +67 -0
  30. package/lib/mcp-client.mjs +12 -0
  31. package/lib/push.mjs +9 -1
  32. package/lib/reflex.mjs +5 -1
  33. package/lib/releases.mjs +87 -18
  34. package/lib/review.mjs +32 -6
  35. package/lib/session-facts.mjs +108 -0
  36. package/lib/skill-deps.mjs +377 -0
  37. package/lib/update.mjs +9 -0
  38. package/package.json +3 -2
  39. package/skills-bundle/frontend-design/LICENSE.txt +177 -0
  40. package/skills-bundle/frontend-design/SKILL.md +55 -0
  41. package/{LICENSE → skills-bundle/grill-me/LICENSE} +6 -6
  42. package/skills-bundle/grill-me/SKILL.md +7 -0
  43. package/skills-bundle/grill-me/agents/openai.yaml +5 -0
  44. package/skills-bundle/grilling/SKILL.md +53 -0
  45. package/skills-bundle/improve-codebase-architecture/HTML-REPORT.md +123 -0
  46. package/skills-bundle/improve-codebase-architecture/LICENSE +21 -0
  47. package/skills-bundle/improve-codebase-architecture/SKILL.md +71 -0
  48. package/skills-bundle/improve-codebase-architecture/agents/openai.yaml +5 -0
  49. package/skills-bundle/requesting-code-review/LICENSE +21 -0
  50. package/skills-bundle/requesting-code-review/SKILL.md +95 -0
  51. package/skills-bundle/requesting-code-review/code-reviewer.md +172 -0
  52. package/skills-bundle/skills-manifest.json +72 -0
  53. package/skills-bundle/tdd/LICENSE +21 -0
  54. package/skills-bundle/tdd/SKILL.md +36 -0
  55. package/skills-bundle/tdd/agents/openai.yaml +3 -0
  56. package/skills-bundle/tdd/mocking.md +59 -0
  57. package/skills-bundle/tdd/tests.md +77 -0
  58. package/skills-bundle/vercel-composition-patterns/AGENTS.md +946 -0
  59. package/skills-bundle/vercel-composition-patterns/README.md +60 -0
  60. package/skills-bundle/vercel-composition-patterns/SKILL.md +89 -0
  61. package/skills-bundle/vercel-composition-patterns/metadata.json +11 -0
  62. package/skills-bundle/vercel-composition-patterns/rules/_sections.md +29 -0
  63. package/skills-bundle/vercel-composition-patterns/rules/_template.md +24 -0
  64. package/skills-bundle/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md +100 -0
  65. package/skills-bundle/vercel-composition-patterns/rules/architecture-compound-components.md +112 -0
  66. package/skills-bundle/vercel-composition-patterns/rules/patterns-children-over-render-props.md +87 -0
  67. package/skills-bundle/vercel-composition-patterns/rules/patterns-explicit-variants.md +100 -0
  68. package/skills-bundle/vercel-composition-patterns/rules/react19-no-forwardref.md +42 -0
  69. package/skills-bundle/vercel-composition-patterns/rules/state-context-interface.md +191 -0
  70. package/skills-bundle/vercel-composition-patterns/rules/state-decouple-implementation.md +113 -0
  71. package/skills-bundle/vercel-composition-patterns/rules/state-lift-state.md +125 -0
package/lib/facts.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // rafa facts — the session-facts surface (wave 5.4, memoized verification).
2
+ //
3
+ // rafa facts list facts + staleness (the conductor's read)
4
+ // rafa facts --json machine read (spawn-prompt slices)
5
+ // rafa facts add --claim="…" --command="…" --exit=0 [--depends=a,b] [--tool=…]
6
+ // bank ONE verified fact (stamps HEAD sha)
7
+ // rafa facts invalidate <SF-n> drop a fact deliberately
8
+ // rafa facts prune --stale drop every mechanically-stale fact
9
+ //
10
+ // A fact is never an assumption: `add` REQUIRES the command + exit code that
11
+ // proved it, and staleness is mechanical (dependsOn ∩ git diff since the
12
+ // verifying sha, working tree included). Subagents read first and cite
13
+ // "SF-<n> unchanged" instead of re-deriving — prism's bounded exception.
14
+
15
+ import { execSync } from "node:child_process";
16
+ import {
17
+ factsPath,
18
+ nextFactId,
19
+ readFacts,
20
+ staleFacts,
21
+ validateFact,
22
+ writeFacts,
23
+ } from "./session-facts.mjs";
24
+
25
+ const die = (m) => {
26
+ console.error(`✗ ${m}`);
27
+ process.exit(1);
28
+ };
29
+
30
+ export default async function facts(args = []) {
31
+ const ROOT = process.cwd();
32
+ const arg = (k) => {
33
+ const m = args.find((a) => a.startsWith(`--${k}=`));
34
+ return m ? m.slice(k.length + 3) : undefined;
35
+ };
36
+ const [sub, subArg] = args.filter((a) => !a.startsWith("-"));
37
+
38
+ if (sub === "add") {
39
+ const claim = arg("claim");
40
+ const command = arg("command");
41
+ const exit = arg("exit");
42
+ if (!claim || !command || exit === undefined)
43
+ die('usage: rafa facts add --claim="…" --command="…" --exit=0 [--depends=a,b]');
44
+ let sha = "";
45
+ try {
46
+ sha = execSync("git rev-parse HEAD", {
47
+ cwd: ROOT,
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "ignore"],
50
+ }).trim();
51
+ } catch {
52
+ die("not a git repo — session facts anchor to a verifying sha");
53
+ }
54
+ const doc = readFacts(ROOT);
55
+ const fact = {
56
+ id: nextFactId(doc),
57
+ claim,
58
+ evidence: { command, exitCode: Number(exit) },
59
+ verifiedAtSha: sha,
60
+ dependsOn: (arg("depends") ?? "").split(",").map((s) => s.trim()).filter(Boolean),
61
+ at: new Date().toISOString(),
62
+ };
63
+ const bad = validateFact(fact);
64
+ if (bad) die(bad);
65
+ doc.facts.push(fact);
66
+ writeFacts(ROOT, doc);
67
+ console.log(
68
+ `✓ ${fact.id} banked — "${claim}"\n` +
69
+ ` evidence: \`${command}\` → exit ${fact.evidence.exitCode} @ ${sha.slice(0, 7)}` +
70
+ (fact.dependsOn.length
71
+ ? `\n citable while untouched: ${fact.dependsOn.join(", ")}` +
72
+ (fact.dependsOn.every((p) => !p.endsWith("/"))
73
+ ? "\n note: invalidation is NOT transitive — if this fact spans a subsystem, declare the" +
74
+ "\n enclosing directory (e.g. --depends=src/) so indirect changes stale it. Breadth is the conservative move."
75
+ : "")
76
+ : "\n no dependsOn — environment-shaped; invalidate by hand when the env changes"),
77
+ );
78
+ return;
79
+ }
80
+
81
+ if (sub === "invalidate") {
82
+ if (!subArg) die("usage: rafa facts invalidate <SF-n>");
83
+ const doc = readFacts(ROOT);
84
+ const before = doc.facts.length;
85
+ doc.facts = doc.facts.filter((f) => f.id !== subArg);
86
+ if (doc.facts.length === before) die(`no fact ${subArg} (see \`rafa facts\`)`);
87
+ writeFacts(ROOT, doc);
88
+ console.log(`✓ ${subArg} invalidated`);
89
+ return;
90
+ }
91
+
92
+ if (sub === "prune") {
93
+ if (!args.includes("--stale")) die("usage: rafa facts prune --stale");
94
+ const doc = readFacts(ROOT);
95
+ const stale = new Set(staleFacts(ROOT, doc).map((s) => s.id));
96
+ const before = doc.facts.length;
97
+ doc.facts = doc.facts.filter((f) => !stale.has(f.id));
98
+ writeFacts(ROOT, doc);
99
+ console.log(`✓ pruned ${before - doc.facts.length} stale fact(s) · ${doc.facts.length} remain`);
100
+ return;
101
+ }
102
+
103
+ // Default: list.
104
+ const doc = readFacts(ROOT);
105
+ if (args.includes("--json")) {
106
+ console.log(JSON.stringify({ ...doc, stale: staleFacts(ROOT, doc) }, null, 2));
107
+ return;
108
+ }
109
+ if (doc.facts.length === 0) {
110
+ console.log(
111
+ "no session facts banked — `rafa facts add` after verifying something expensive\n" +
112
+ ` (${factsPath(ROOT).replace(ROOT + "/", "")} · read-first for every spawned agent)`,
113
+ );
114
+ return;
115
+ }
116
+ const stale = new Map(staleFacts(ROOT, doc).map((s) => [s.id, s.reason]));
117
+ for (const f of doc.facts) {
118
+ const s = stale.get(f.id);
119
+ console.log(
120
+ `${s ? "!" : "✓"} ${f.id} — ${f.claim}\n` +
121
+ ` \`${f.evidence.command}\` → exit ${f.evidence.exitCode} @ ${f.verifiedAtSha.slice(0, 7)}` +
122
+ (s ? `\n STALE: ${s}` : ""),
123
+ );
124
+ }
125
+ const staleCount = stale.size;
126
+ console.log(
127
+ `${doc.facts.length} fact(s)` +
128
+ (staleCount ? ` · ${staleCount} STALE — re-verify or \`rafa facts prune --stale\`` : " · all fresh"),
129
+ );
130
+ }
@@ -579,6 +579,15 @@ export function runCompile(argv = []) {
579
579
  !(Number.isInteger(data.estimate) && data.estimate >= 0)
580
580
  )
581
581
  fail(path, "estimate", "optional · non-negative int (points)");
582
+ if (
583
+ data.validation_tier !== undefined &&
584
+ !["light", "standard", "full"].includes(data.validation_tier)
585
+ )
586
+ fail(
587
+ path,
588
+ "validation_tier",
589
+ "optional · light|standard|full — bounds prism re-derivation depth ONLY (the Done-check gate never relaxes)",
590
+ );
582
591
  if (data.external !== undefined) {
583
592
  const ex = data.external;
584
593
  if (
@@ -618,6 +627,9 @@ export function runCompile(argv = []) {
618
627
  : {}),
619
628
  ...(Number.isInteger(data.priority) ? { priority: data.priority } : {}),
620
629
  ...(Number.isInteger(data.estimate) ? { estimate: data.estimate } : {}),
630
+ ...(["light", "standard", "full"].includes(data.validation_tier)
631
+ ? { validationTier: data.validation_tier }
632
+ : {}),
621
633
  ...(typeof data.branch === "string" && data.branch !== ""
622
634
  ? { branch: data.branch }
623
635
  : {}),
@@ -119,6 +119,31 @@ function gitLsGlob(glob, cwd = process.cwd()) {
119
119
  }
120
120
  }
121
121
 
122
+ // EXPORTED — moved here from distiller/doctrine.mjs (wave 5.3; doctrine
123
+ // re-exports it) so the gate's `--fix` can use it without an import cycle
124
+ // (doctrine imports citeResolves/gitGrep FROM this file). Rewrites frontmatter
125
+ // `cites:` DSL locators so a re-grounded citation points at where the token
126
+ // NOW lives. Token-anchored when the cite carries one — two cites sharing
127
+ // file:line with different tokens must not clobber each other; falls back to
128
+ // the locator-only match for callers that pass no token.
129
+ export function rewriteCites(content, regroundings) {
130
+ let out = content;
131
+ for (const r of regroundings ?? []) {
132
+ const fromLoc = r.cite.loc; // file:line[-end]
133
+ const toLoc = r.to.loc; // file:line
134
+ const esc = fromLoc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
135
+ if (r.cite.token !== undefined) {
136
+ const escTok = String(r.cite.token).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ const re = new RegExp(`(-\\s+)${esc}(\\s*::\\s*${escTok}\\s*)$`, "gm");
138
+ out = out.replace(re, `$1${toLoc}$2`);
139
+ } else {
140
+ const re = new RegExp(`(-\\s+)${esc}(\\s*::\\s*)`, "g");
141
+ out = out.replace(re, `$1${toLoc}$2`);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
122
147
  // EXPORTED (refinery-arc-p1-distiller): the doctrine validates a candidate's
123
148
  // citations against merged main through the exact same predicate the gate uses,
124
149
  // so "resolves for the checker" and "resolves for arbitration" can never drift.
@@ -160,6 +185,14 @@ export function runVerifyCitations(argv = []) {
160
185
  const arg = (k, d) => { const m = argv.find((a) => a.startsWith(`--${k}=`)); return m ? m.slice(k.length + 3) : d; };
161
186
  const ROOT = arg("root", ".rafa/brain"); // --root=.rafa/improve for the improve ledger
162
187
  const NOTE_DIRS = arg("dirs", "rules,playbooks").split(",").filter(Boolean); // --dirs=improvements
188
+ // --fix (wave 5.3): heal DRIFTED line-cites in place — a failing cite whose
189
+ // token still lives at exactly ONE grep site gets its locator rewritten, then
190
+ // the whole verification re-runs clean. gone (0 hits) and ambiguous (>1)
191
+ // stay untouched and keep failing — the checker never guesses.
192
+ const FIX = argv.includes("--fix");
193
+ const fixDrifted = []; // { notePath, cite: {loc, token}, to: {loc} }
194
+ const fixGone = []; // { note, loc, token }
195
+ const fixAmbiguous = []; // { note, loc, token, hits }
163
196
 
164
197
  // --selftest: prove each gate's logic on throwaway fixtures (no brain/repo pollution).
165
198
  // prism runs this as its mutation probe — re-running the checker is not proof it still
@@ -264,7 +297,22 @@ export function runVerifyCitations(argv = []) {
264
297
  // RESOLUTION
265
298
  for (const ct of cites) {
266
299
  const { ok, reason } = citeResolves(ct.file, ct.start, ct.end, ct.token);
267
- resolution.push({ note: rel, loc: `${ct.file}:${ct.start}${ct.end !== ct.start ? "-" + ct.end : ""}`, token: ct.token, ok, reason });
300
+ const loc = `${ct.file}:${ct.start}${ct.end !== ct.start ? "-" + ct.end : ""}`;
301
+ resolution.push({ note: rel, loc, token: ct.token, ok, reason });
302
+ if (!ok && FIX) {
303
+ // Classify with the gate's OWN primitives — deliberately NOT
304
+ // arbitrateCite, which takes hits[0] blindly; the gate never guesses.
305
+ const hits = gitGrep(ct.token);
306
+ if (hits.length === 1)
307
+ fixDrifted.push({
308
+ notePath: note,
309
+ cite: { loc, token: ct.token },
310
+ to: { loc: `${hits[0].file}:${hits[0].line}` },
311
+ ranged: ct.end !== ct.start,
312
+ });
313
+ else if (hits.length === 0) fixGone.push({ note: rel, loc, token: ct.token });
314
+ else fixAmbiguous.push({ note: rel, loc, token: ct.token, hits: hits.length });
315
+ }
268
316
  }
269
317
 
270
318
  // COMPLETENESS
@@ -355,6 +403,35 @@ export function runVerifyCitations(argv = []) {
355
403
  }
356
404
  }
357
405
 
406
+ // ── --fix apply + re-run (wave 5.3) ────────────────────────────────────────
407
+ if (FIX && (fixDrifted.length || fixGone.length || fixAmbiguous.length)) {
408
+ const byNote = new Map();
409
+ for (const f of fixDrifted) {
410
+ if (!byNote.has(f.notePath)) byNote.set(f.notePath, []);
411
+ byNote.get(f.notePath).push(f);
412
+ }
413
+ for (const [notePath, fixes] of byNote) {
414
+ const before = readFileSync(notePath, "utf8");
415
+ const after = rewriteCites(before, fixes);
416
+ if (after !== before) writeFileSync(notePath, after);
417
+ }
418
+ for (const f of fixDrifted)
419
+ console.log(
420
+ ` ✓ fixed drifted ${f.cite.loc} → ${f.to.loc} :: ${f.cite.token}` +
421
+ (f.ranged ? " (range collapsed to the found line)" : ""),
422
+ );
423
+ for (const g of fixGone)
424
+ console.log(` ✗ gone ${g.loc} :: ${g.token} — token nowhere in the tree, not fixable [${g.note}]`);
425
+ for (const a2 of fixAmbiguous)
426
+ console.log(` · ambiguous ${a2.loc} :: ${a2.token} — ${a2.hits} grep sites, never guessed [${a2.note}]`);
427
+ console.log(
428
+ `--fix: ${fixDrifted.length} drifted healed · ${fixGone.length} gone · ${fixAmbiguous.length} ambiguous (untouched)`,
429
+ );
430
+ // Re-run WITHOUT --fix so the report + exit code reflect the healed state
431
+ // (gone/ambiguous cites still fail — honestly).
432
+ return runVerifyCitations(argv.filter((a2) => a2 !== "--fix"));
433
+ }
434
+
358
435
  const rFail = resolution.filter((r) => !r.ok);
359
436
  const cFail = completeness.filter((r) => !r.ok);
360
437
  const pFail = policy.filter((r) => !r.ok);
package/lib/hydrate.mjs CHANGED
@@ -22,6 +22,7 @@ import { execSync } from "node:child_process";
22
22
  import { mkdirSync, writeFileSync } from "node:fs";
23
23
  import { dirname, join } from "node:path";
24
24
  import { callTool } from "./mcp-client.mjs";
25
+ import { citeResolves, gitGrep } from "./gate/verify-citations.mjs";
25
26
  import {
26
27
  hashOf,
27
28
  materializeImprovement,
@@ -35,6 +36,42 @@ const die = (m) => {
35
36
  process.exit(1);
36
37
  };
37
38
 
39
+ // Wave 5.3 — hydrate-time citation re-basing. Served cites were authored
40
+ // against the brain's base sha; the LOCAL checkout may be newer. Before
41
+ // materializing, re-ground each {file, line, token} with the gate's own
42
+ // primitives: resolves → keep; exactly ONE grep hit → re-cite there;
43
+ // gone/ambiguous → keep the original + a ⚠ (the push gate still catches it).
44
+ // Fail-soft by construction: any error (not a git repo, missing file) keeps
45
+ // the served original untouched. Exported for tests.
46
+ export function rebaseCites(cites, cwd = process.cwd()) {
47
+ const out = [];
48
+ const notes = [];
49
+ for (const c of cites ?? []) {
50
+ try {
51
+ const [startRaw, endRaw] = String(c.line).split("-");
52
+ const start = Number(startRaw);
53
+ const end = Number(endRaw ?? startRaw);
54
+ if (!Number.isFinite(start) || citeResolves(join(cwd, c.file), start, end, c.token).ok) {
55
+ out.push(c);
56
+ continue;
57
+ }
58
+ const hits = gitGrep(c.token, cwd);
59
+ if (hits.length === 1) {
60
+ out.push({ ...c, file: hits[0].file, line: hits[0].line });
61
+ notes.push(`✓ re-based ${c.file}:${c.line} → ${hits[0].file}:${hits[0].line} :: ${c.token}`);
62
+ } else {
63
+ out.push(c);
64
+ notes.push(
65
+ `⚠ ${c.file}:${c.line} did not re-base (${hits.length === 0 ? "gone" : `ambiguous — ${hits.length} sites`}) — served line may be stale`,
66
+ );
67
+ }
68
+ } catch {
69
+ out.push(c);
70
+ }
71
+ }
72
+ return { cites: out, notes };
73
+ }
74
+
38
75
  export default async function hydrate(args = []) {
39
76
  const ROOT = process.cwd();
40
77
 
@@ -94,6 +131,14 @@ export default async function hydrate(args = []) {
94
131
  die(e instanceof Error ? e.message : String(e));
95
132
  }
96
133
  const brainForSha = payload.envelope?.brainForSha ?? null;
134
+ // Wave 5.3 — arrive pre-healed: re-base served cites against THIS checkout
135
+ // before the file is written (drifted-but-unique tokens re-cite; gone/
136
+ // ambiguous keep the original + a ⚠ so nothing is ever guessed).
137
+ if (Array.isArray(payload.cites) && payload.cites.length > 0) {
138
+ const { cites, notes } = rebaseCites(payload.cites, ROOT);
139
+ payload.cites = cites;
140
+ for (const n of notes) console.log(` ${n}`);
141
+ }
97
142
  const rel =
98
143
  kind === "improvement"
99
144
  ? materializeImprovement(ROOT, payload, brainForSha)
package/lib/init.mjs CHANGED
@@ -131,6 +131,13 @@ export default async function init(args) {
131
131
  for (const u of r.updated) console.log(` ✓ repo MCPs → ${u}`);
132
132
  }
133
133
 
134
+ // Wave 6 — offer the declared skill dependencies (consent-gated, verified,
135
+ // stamped; declined remembered). .agents/skills is COMMITTED dev toolbox.
136
+ {
137
+ const { installSkillDeps } = await import("./skill-deps.mjs");
138
+ await installSkillDeps(TARGET, { args });
139
+ }
140
+
134
141
  // Keep the brain out of the code repo's diffs; keep the per-dev MCP key out of git.
135
142
  const ensureIgnored = (line, label) => {
136
143
  const gi = join(TARGET, ".gitignore");
@@ -200,7 +207,8 @@ export default async function init(args) {
200
207
 
201
208
  console.log(
202
209
  `\n✓ Provisioned.\n\nNext steps:\n` +
203
- " 1. Commit the vendored files (agents, /rafa command, skills, contract, rafa.json)\n" +
210
+ " 1. Commit the vendored files (agents, /rafa command, skills, contract, rafa.json,\n" +
211
+ " and .agents/skills/ if you installed the skill dependencies)\n" +
204
212
  " — protected-main teams: via a normal MR. The brain remote lives in rafa.json,\n" +
205
213
  " so any teammate's clone bootstraps with npx rafa pull (no init needed).\n" +
206
214
  " 2. Restart Claude Code in this repo (reload the /rafa command + agents + skills" +
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readFileSync, readdirSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { adapters } from "./adapters/index.mjs";
9
+ import { listAgentSkills, neededSkills, skillsStamp } from "../skill-deps.mjs";
9
10
 
10
11
  // Merge dependencies from the cwd package.json and any apps/*/package.json (monorepo).
11
12
  function readDeps(cwd) {
@@ -66,17 +67,45 @@ export function repoContext(cwd) {
66
67
  envFiles,
67
68
  envGitignored: envFiles.length === 0 || envIgnored(cwd),
68
69
  mcp: readMcp(cwd),
70
+ // The cross-harness toolbox (wave 6): `.agents/skills/` is harness-NEUTRAL
71
+ // (Claude Code · Codex · Cursor read the same SKILL.md convention; the
72
+ // vendored deps ship Codex manifests alongside). Every adapter sees the
73
+ // same inventory — never a per-toolchain re-read.
74
+ agentSkills: listAgentSkills(cwd),
75
+ skillDeps: {
76
+ needed: neededSkills(cwd).map((d) => ({ name: d.name, why: d.why })),
77
+ ...skillsStamp(cwd),
78
+ },
69
79
  };
70
80
  }
71
81
 
72
82
  const RANK = { P1: 0, P2: 1, P3: 2 };
73
83
 
84
+ // Engine-level tips — rafa's OWN dependency system, harness-neutral by
85
+ // definition (the fix is a rafa command, whatever the host toolchain).
86
+ function engineTips(ctx) {
87
+ const tips = [];
88
+ const missing = ctx.skillDeps?.needed ?? [];
89
+ if (missing.length) {
90
+ tips.push({
91
+ id: "skill-deps-missing",
92
+ priority: "P2",
93
+ title: `${missing.length} declared skill dependenc${missing.length === 1 ? "y" : "ies"} not installed`,
94
+ detail: missing.map((d) => `${d.name} — ${d.why}`).join(" · "),
95
+ fix: "run `rafa update --skills` (consent-gated; declined is remembered)",
96
+ tool: "rafa",
97
+ });
98
+ }
99
+ return tips;
100
+ }
101
+
74
102
  // Run every detected adapter, tag tips with their tool, and rank by priority.
75
103
  export function advise(cwd) {
76
104
  const ctx = repoContext(cwd);
77
105
  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));
106
+ const tips = [
107
+ ...engineTips(ctx),
108
+ ...active.flatMap((a) => a.recommend(cwd, ctx).map((t) => ({ ...t, tool: a.name }))),
109
+ ].sort((x, y) => (RANK[x.priority] ?? 9) - (RANK[y.priority] ?? 9));
81
110
  return { ctx, detected: active.map((a) => a.name), tips };
82
111
  }
package/lib/leverage.mjs CHANGED
@@ -7,16 +7,25 @@ import { adapters, PLANNED } from "./leverage/adapters/index.mjs";
7
7
 
8
8
  export default async function leverage(args = []) {
9
9
  const cwd = process.cwd();
10
- const { detected, tips } = advise(cwd);
10
+ const { ctx, detected, tips } = advise(cwd);
11
11
 
12
12
  // --json: the DETERMINISTIC toolbox inventory (ratified 2026-07-12) — what
13
13
  // the conductor loads at bootstrap and atlas consults during scan's toolbox
14
14
  // pass. Machine truth: only what is actually installed, never inferred.
15
+ // `agentSkills` (wave 6) is the HARNESS-NEUTRAL `.agents/skills/` inventory
16
+ // — top-level, not under any one toolchain: Claude Code, Codex, and Cursor
17
+ // all read the same skill convention.
15
18
  if (args.includes("--json")) {
16
19
  const inventory = {};
17
20
  for (const a of adapters)
18
21
  if (a.detect(cwd)) inventory[a.id] = a.inspect(cwd);
19
- console.log(JSON.stringify({ detected, inventory, tips }, null, 2));
22
+ console.log(
23
+ JSON.stringify(
24
+ { detected, agentSkills: ctx.agentSkills, skillDeps: ctx.skillDeps, inventory, tips },
25
+ null,
26
+ 2,
27
+ ),
28
+ );
20
29
  return;
21
30
  }
22
31
 
@@ -0,0 +1,67 @@
1
+ // The checkpoint-written loop-event cache + the sage-due comparator (wave 5.5).
2
+ //
3
+ // Sage's trigger never fired organically: it was a discretionary end-of-session
4
+ // SOP step (the same disease the capture engine cured — "capture never fired,
5
+ // authoring was discretionary SOP"). The bridge makes DETECTION mechanical:
6
+ // `rafa checkpoint` already makes one best-effort get_loop_events call — it now
7
+ // caches recent event SHAPES here (.rafa/loop-events-tail.json — category/at
8
+ // only, subjects excluded: the smallest honest cache), and the SessionStart
9
+ // digest compares the cache against the learnings ledger LOCALLY, printing
10
+ // "sage due" with ZERO added platform calls. Front-of-session, where reminders
11
+ // get obeyed — only the spawn stays with the conductor.
12
+
13
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ export const LOOP_CACHE_REL = "loop-events-tail.json";
17
+ export const loopCachePath = (cwd) => join(cwd, ".rafa", LOOP_CACHE_REL);
18
+
19
+ export function readLoopCache(cwd = process.cwd()) {
20
+ try {
21
+ const doc = JSON.parse(readFileSync(loopCachePath(cwd), "utf8"));
22
+ if (doc?.schemaVersion !== 1 || !Array.isArray(doc.events)) return null;
23
+ return doc;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function writeLoopCache(cwd, { fetchedAt, events }) {
30
+ const rows = (events ?? [])
31
+ .map((e) => ({ category: e.category, at: e.at }))
32
+ .filter((e) => typeof e.category === "string" && Number.isFinite(e.at));
33
+ const categories = {};
34
+ for (const e of rows) categories[e.category] = (categories[e.category] ?? 0) + 1;
35
+ const doc = {
36
+ schemaVersion: 1,
37
+ fetchedAt,
38
+ newestAt: rows.reduce((m, e) => Math.max(m, e.at), 0) || null,
39
+ count: rows.length,
40
+ categories,
41
+ events: rows,
42
+ };
43
+ mkdirSync(join(cwd, ".rafa"), { recursive: true });
44
+ writeFileSync(loopCachePath(cwd), JSON.stringify(doc, null, 2) + "\n");
45
+ return doc;
46
+ }
47
+
48
+ // The deterministic trigger condition from the rafa-sage SOP: fire only when
49
+ // the count of loop events NEWER than the learnings ledger's newest entry
50
+ // reaches the threshold (≥10 — under that is noise, not a pattern). No ledger
51
+ // yet (ledgerNewestAt == null) = sage never ran → every cached event counts.
52
+ export function sageDue({ events, ledgerNewestAt, threshold = 10 }) {
53
+ const rows = events ?? [];
54
+ const fresh =
55
+ ledgerNewestAt == null ? rows : rows.filter((e) => e.at > ledgerNewestAt);
56
+ return { due: fresh.length >= threshold, count: fresh.length, threshold };
57
+ }
58
+
59
+ // Newest learnings-pass timestamp, parsed LOCALLY from the committed ledger
60
+ // (`- Generated: YYYY-MM-DD …`). Returns epoch ms or null (no ledger = never ran).
61
+ export function ledgerNewestAt(ledgerContent) {
62
+ if (typeof ledgerContent !== "string") return null;
63
+ const m = ledgerContent.match(/^-\s*Generated:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}(?:[T ][0-9:.Z+-]+)?)/m);
64
+ if (!m) return null;
65
+ const t = Date.parse(m[1]);
66
+ return Number.isFinite(t) ? t : null;
67
+ }
@@ -14,6 +14,18 @@ import { existsSync, readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { credentialsPath } from "./mcp-config.mjs";
16
16
  import { readStamp } from "./stamp.mjs";
17
+ import { CLI_VERSION } from "./releases.mjs";
18
+
19
+ // The CLI's own actor envelope for state-plane writes (wave 5: REQUIRED on every
20
+ // loop event — strict, no legacy path). model is the explicit "mechanical"
21
+ // sentinel: no LLM ruled here, and the split stays grep-able for sage.
22
+ export function cliActorMeta() {
23
+ return {
24
+ model: "mechanical",
25
+ agent: `cli@${CLI_VERSION}`,
26
+ runner: process.env.CI ? "ci" : "session",
27
+ };
28
+ }
17
29
 
18
30
  const readJson = (file) => {
19
31
  try {
package/lib/push.mjs CHANGED
@@ -128,11 +128,19 @@ export default async function push(args = []) {
128
128
  // a push; an unprovisioned repo just skips.
129
129
  const emitGate = async (subject, ok) => {
130
130
  try {
131
- const { callTool } = await import("./mcp-client.mjs");
131
+ const { callTool, cliActorMeta } = await import("./mcp-client.mjs");
132
132
  await callTool(ROOT, "report_loop_event", {
133
133
  category: "gate-result",
134
134
  outcome: ok ? "exit0" : "failed",
135
135
  subject,
136
+ // Wave 5 trust plane: the gate ACTUALLY ran here, so the event carries
137
+ // method:"live" + the real exit code, stamped by the mechanical actor.
138
+ actorMeta: cliActorMeta(),
139
+ verification: { method: "live", tool: subject, evidence: ok ? "exit 0" : "exit 1" },
140
+ // Idempotent per (gate, code sha, outcome): re-pushing an identical tree
141
+ // re-emits the same key and the platform dedupes; a fail→pass transition
142
+ // at the same sha is two distinct facts and both land.
143
+ dedupeKey: `${subject}·${codeSha}·${ok ? "exit0" : "failed"}`,
136
144
  });
137
145
  } catch {
138
146
  /* platform unreachable / unprovisioned — the local exit code is still the truth */
package/lib/reflex.mjs CHANGED
@@ -60,13 +60,17 @@ export default async function reflex(args = []) {
60
60
  // IS the outcome moment, so the event rides it mechanically — shape only
61
61
  // (the correction id, never its text). Best-effort, never blocks.
62
62
  try {
63
- const { callTool } = await import("./mcp-client.mjs");
63
+ const { callTool, cliActorMeta } = await import("./mcp-client.mjs");
64
64
  const outcome =
65
65
  verdict === "banked" ? "durable" : verdict === "refuted" ? "refuted" : "session-only";
66
66
  await callTool(process.cwd(), "report_loop_event", {
67
67
  category: "reflex-outcome",
68
68
  outcome,
69
69
  subject: id,
70
+ // Wave 5: actor envelope required on every emit; a consume is mechanical.
71
+ // One consume per correction id — dedupeKey makes the emit idempotent.
72
+ actorMeta: cliActorMeta(),
73
+ dedupeKey: `reflex·${id}`,
70
74
  });
71
75
  } catch {
72
76
  /* unprovisioned repo / platform unreachable — the queue marker is the local truth */