@rafinery/cli 0.8.16 → 0.11.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 (76) hide show
  1. package/CHANGELOG.md +130 -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 +1 -1
  7. package/blueprint/.claude/commands/rafa.md +24 -13
  8. package/blueprint/.claude/rafa/contract.md +23 -0
  9. package/blueprint/.claude/rafa/hooks/brain-commit.mjs +4 -0
  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-commit/SKILL.md +22 -0
  13. package/blueprint/.claude/skills/rafa-distill/SKILL.md +37 -4
  14. package/blueprint/.claude/skills/rafa-improve/SKILL.md +22 -0
  15. package/blueprint/.claude/skills/rafa-leverage/SKILL.md +6 -1
  16. package/blueprint/.claude/skills/rafa-plan/SKILL.md +36 -1
  17. package/blueprint/.claude/skills/rafa-review/SKILL.md +16 -2
  18. package/blueprint/.claude/skills/rafa-sage/SKILL.md +8 -2
  19. package/blueprint/.claude/skills/rafa-scan/SKILL.md +4 -1
  20. package/lib/brain-repo.mjs +1 -1
  21. package/lib/checkpoint.mjs +26 -15
  22. package/lib/distill.mjs +56 -3
  23. package/lib/distiller/doctrine.mjs +5 -16
  24. package/lib/distiller/schema-ladder.mjs +186 -0
  25. package/lib/doctor.mjs +19 -1
  26. package/lib/facts.mjs +130 -0
  27. package/lib/gate/compile.mjs +12 -0
  28. package/lib/gate/verify-citations.mjs +78 -1
  29. package/lib/hydrate.mjs +45 -0
  30. package/lib/init.mjs +9 -1
  31. package/lib/leverage/engine.mjs +32 -3
  32. package/lib/leverage.mjs +11 -2
  33. package/lib/loop-cache.mjs +67 -0
  34. package/lib/mcp-client.mjs +19 -0
  35. package/lib/push.mjs +18 -4
  36. package/lib/reflex.mjs +5 -1
  37. package/lib/releases.mjs +78 -0
  38. package/lib/review.mjs +32 -6
  39. package/lib/session-facts.mjs +108 -0
  40. package/lib/skill-deps.mjs +377 -0
  41. package/lib/stamp.mjs +24 -0
  42. package/lib/update.mjs +9 -0
  43. package/package.json +3 -2
  44. package/skills-bundle/frontend-design/LICENSE.txt +177 -0
  45. package/skills-bundle/frontend-design/SKILL.md +55 -0
  46. package/{LICENSE → skills-bundle/grill-me/LICENSE} +6 -6
  47. package/skills-bundle/grill-me/SKILL.md +7 -0
  48. package/skills-bundle/grill-me/agents/openai.yaml +5 -0
  49. package/skills-bundle/grilling/SKILL.md +53 -0
  50. package/skills-bundle/improve-codebase-architecture/HTML-REPORT.md +123 -0
  51. package/skills-bundle/improve-codebase-architecture/LICENSE +21 -0
  52. package/skills-bundle/improve-codebase-architecture/SKILL.md +71 -0
  53. package/skills-bundle/improve-codebase-architecture/agents/openai.yaml +5 -0
  54. package/skills-bundle/requesting-code-review/LICENSE +21 -0
  55. package/skills-bundle/requesting-code-review/SKILL.md +95 -0
  56. package/skills-bundle/requesting-code-review/code-reviewer.md +172 -0
  57. package/skills-bundle/skills-manifest.json +72 -0
  58. package/skills-bundle/tdd/LICENSE +21 -0
  59. package/skills-bundle/tdd/SKILL.md +36 -0
  60. package/skills-bundle/tdd/agents/openai.yaml +3 -0
  61. package/skills-bundle/tdd/mocking.md +59 -0
  62. package/skills-bundle/tdd/tests.md +77 -0
  63. package/skills-bundle/vercel-composition-patterns/AGENTS.md +946 -0
  64. package/skills-bundle/vercel-composition-patterns/README.md +60 -0
  65. package/skills-bundle/vercel-composition-patterns/SKILL.md +89 -0
  66. package/skills-bundle/vercel-composition-patterns/metadata.json +11 -0
  67. package/skills-bundle/vercel-composition-patterns/rules/_sections.md +29 -0
  68. package/skills-bundle/vercel-composition-patterns/rules/_template.md +24 -0
  69. package/skills-bundle/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md +100 -0
  70. package/skills-bundle/vercel-composition-patterns/rules/architecture-compound-components.md +112 -0
  71. package/skills-bundle/vercel-composition-patterns/rules/patterns-children-over-render-props.md +87 -0
  72. package/skills-bundle/vercel-composition-patterns/rules/patterns-explicit-variants.md +100 -0
  73. package/skills-bundle/vercel-composition-patterns/rules/react19-no-forwardref.md +42 -0
  74. package/skills-bundle/vercel-composition-patterns/rules/state-context-interface.md +191 -0
  75. package/skills-bundle/vercel-composition-patterns/rules/state-decouple-implementation.md +113 -0
  76. package/skills-bundle/vercel-composition-patterns/rules/state-lift-state.md +125 -0
@@ -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,25 @@ 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. The
22
+ // runner honors RAFA_ACTOR_RUNNER first (the platform's sandbox distiller and
23
+ // other tiers export it — sandbox|ci|session), else CI env, else session.
24
+ export function cliActorMeta() {
25
+ const declared = process.env.RAFA_ACTOR_RUNNER;
26
+ return {
27
+ model: "mechanical",
28
+ agent: `cli@${CLI_VERSION}`,
29
+ runner: ["sandbox", "ci", "session"].includes(declared)
30
+ ? declared
31
+ : process.env.CI
32
+ ? "ci"
33
+ : "session",
34
+ };
35
+ }
17
36
 
18
37
  const readJson = (file) => {
19
38
  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 */
@@ -181,6 +189,11 @@ export default async function push(args = []) {
181
189
  // on it.
182
190
  const verbArg = args.find((a) => a.startsWith("--verb="));
183
191
  const verb = verbArg ? verbArg.slice("--verb=".length) : "update";
192
+ // Custom subject (owner 2026-07-26: reconciliation commits are DESCRIPTIVE):
193
+ // --message="…" replaces the generic manifest-derived subject; the brain-for
194
+ // trailer machinery below is untouched — provenance consumers keep their key.
195
+ const msgArg = args.find((a) => a.startsWith("--message="));
196
+ const customSubject = msgArg ? msgArg.slice("--message=".length).trim().replace(/"/g, "'") : null;
184
197
  const subjectParts = [];
185
198
  try {
186
199
  const m = JSON.parse(readFileSync(join(rafaDir, "manifest.json"), "utf8"));
@@ -206,9 +219,10 @@ export default async function push(args = []) {
206
219
  } catch {
207
220
  /* stat is garnish */
208
221
  }
209
- const subject = `brain(${verb}): ${
210
- subjectParts.length ? subjectParts.join(" · ") : "state sync"
211
- } · brain-for: ${codeSha}`.replace(/"/g, "'");
222
+ const subject = (
223
+ customSubject ??
224
+ `brain(${verb}): ${subjectParts.length ? subjectParts.join(" · ") : "state sync"} · brain-for: ${codeSha}`
225
+ ).replace(/"/g, "'");
212
226
  try {
213
227
  inRafa(
214
228
  `git commit -m "${subject}" -m "brain-for: ${codeSha}${stagedStat ? ` · ${stagedStat}` : ""} · cli: ${CLI_VERSION}"`,
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 */
package/lib/releases.mjs CHANGED
@@ -331,6 +331,84 @@ export const RELEASES = [
331
331
  "review.json (the review gate's scored-ranges artifact) joins the " +
332
332
  "local-state exclusion — machine state, never brain knowledge.",
333
333
  },
334
+ {
335
+ version: "0.9.0",
336
+ contract: 1,
337
+ plans: 2,
338
+ requires: "update",
339
+ summary:
340
+ "WAVE 5 — trust plane + transport economics (the QA-dogfood fixes). " +
341
+ "Loop events are STRICT (no legacy path): required actor envelope " +
342
+ "{model, agent, runner} + required verification {method: static|live, " +
343
+ "tool, evidence} on verdict/gate categories + idempotent dedupeKey + " +
344
+ "tier — pre-0.9.0 emits reject loudly (owner: old CLIs discarded). " +
345
+ "One beat per task boundary: patch_plan_item (field-level item patch) " +
346
+ "+ checkpoint_task (patch + loop event + decisions, ONE transaction). " +
347
+ "verify-citations --fix heals drifted cites (unique-grep only — never " +
348
+ "guesses) and hydrate re-bases served cites on arrival. NEW `rafa " +
349
+ "facts`: the session-facts plane (memoized verification, sha-anchored " +
350
+ "invalidation). The SessionStart digest computes 'sage due' from a " +
351
+ "checkpoint-written loop-event cache — sage's trigger is mechanical. " +
352
+ "Plan tasks gain validation_tier (light|standard|full — prism depth " +
353
+ "only; the Done-check never relaxes). Cards/SOPs re-vendored: prism " +
354
+ "0.8.0 (tool authenticity · structured return · bounded invariant " +
355
+ "citation) · atlas 3.9.0 · sage 0.4.0 · conductor 2.1.0.",
356
+ },
357
+ {
358
+ version: "0.10.0",
359
+ contract: 1,
360
+ plans: 2,
361
+ requires: "update",
362
+ summary:
363
+ "WAVE 6 — the capability arc. NEW: consent-gated SKILL DEPENDENCIES — " +
364
+ "this release declares 7 third-party skills (tdd · frontend-design · " +
365
+ "vercel-composition-patterns · requesting-code-review · " +
366
+ "improve-codebase-architecture · grill-me · grilling), and `rafa " +
367
+ "update`/`init` offers them ONCE with per-skill reasons, installs " +
368
+ "package-manager style into the HARNESS-NEUTRAL .agents/skills/ " +
369
+ "(Claude Code · Codex · Cursor read the same convention), verifies " +
370
+ "(frontmatter · hashes · licenses), and stamps rafa.json. Declined is " +
371
+ "remembered (re-offer: --skills); --yes for CI; a declined set NEVER " +
372
+ "breaks anything (F1: cards never point at .agents). Agent upgrades " +
373
+ "riding it: TDD-DEFAULT builds (atlas 4.0.0 — the Done-check is a " +
374
+ "failing test first at a dev-approved seam; red→green evidence; prism " +
375
+ "verifies tier-scaled), the UI design method (frontend-design invoked, " +
376
+ "brain conventions win), the plan-gate GRILL pass (tier-bounded " +
377
+ "adversarial interview; vacuous Done-checks caught), severity-labeled " +
378
+ "findings (Critical/Important/Minor — Minor never flips a verdict; " +
379
+ "prism 0.9.0), and bloom's architecture method (deletion test · " +
380
+ "Confidence-lined candidates; bloom 0.9.0). Conductor 2.2.0: the " +
381
+ "toolbox recall surface includes .agents/skills; `rafa leverage " +
382
+ "--json` carries the harness-neutral agentSkills + skillDeps blocks; " +
383
+ "doctor gains a capabilities section.",
384
+ },
385
+ {
386
+ version: "0.11.0",
387
+ contract: 1,
388
+ plans: 2,
389
+ requires: "update",
390
+ summary:
391
+ "SCHEMA-AWARE RECONCILIATION (owner's 4-case doctrine): distillation " +
392
+ "resolves the version lattice BEFORE judging — target below source → " +
393
+ "full target rewrite onto the newer schema (rides the run's gates + " +
394
+ "push); source below target → incoming candidates lift before judging; " +
395
+ "equal-but-old → the merged target rewrites onto the runner's latest; " +
396
+ "equal-and-current → plain diff (today). Either side newer than the " +
397
+ "runner aborts LOUDLY (update the runner; knowledge is never " +
398
+ "downgraded); transforms are registered per step and a missing step " +
399
+ "fails loudly — the ladder never guesses a schema. Also in this " +
400
+ "release (missed 0.10.0's publish): distill-refutation emits carry the " +
401
+ "required actor envelope + dedupeKey (a re-run counts once); " +
402
+ "cliActorMeta honors RAFA_ACTOR_RUNNER (sandbox|ci|session); dependsOn " +
403
+ "invalidation documented NOT transitive with breadth-as-conservative " +
404
+ "taught at facts add/help/prism's card (pinned by test). DESCRIPTIVE " +
405
+ "COMMITS: reconciliation pushes compose reconcile(<branch>): N banked " +
406
+ "· M refuted · K adjudication (rafa push --message= override); the " +
407
+ "commit contract requires an agent-readable body on dev commits. The " +
408
+ "configurable prod branch reaches the CLI: trunkBranchOf (stamp → " +
409
+ "origin/HEAD → main) drives the checkpoint trunk guard + CI distill " +
410
+ "target.",
411
+ },
334
412
  ];
335
413
 
336
414
  // The release this CLI build ships (last entry).
package/lib/review.mjs CHANGED
@@ -117,6 +117,37 @@ export function relevanceOf(note, diffTokens) {
117
117
  return { score, engage: score >= 3 ? "deep" : score >= 1 ? "triage" : "skip" };
118
118
  }
119
119
 
120
+ // Summary counters, deduped by id (wave 5 T7): get_code_context runs per
121
+ // changed file, so a note citing N changed files lands in N per-file rows —
122
+ // counting it N times inflated every summary number. Dedupe is DISPLAY-COUNT
123
+ // only: the per-file sections (and review.json's files[]) stay per-file — a
124
+ // note governing two files still prints under both. A note keeps its STRONGEST
125
+ // classification: direct beats file-hit, engage beats skip.
126
+ export function summarizeReview(out) {
127
+ const dedupe = (rows) => {
128
+ const seen = new Map();
129
+ for (const r of rows) if (!seen.has(r.id)) seen.set(r.id, r);
130
+ return [...seen.values()];
131
+ };
132
+ const allNotes = out.flatMap((f) => f.notes);
133
+ const direct = dedupe(allNotes.filter((r) => r.hit === "direct"));
134
+ const directIds = new Set(direct.map((r) => r.id));
135
+ const fileHits = dedupe(allNotes.filter((r) => r.hit === "file")).filter(
136
+ (r) => !directIds.has(r.id),
137
+ );
138
+ const engaged = dedupe(allNotes.filter((r) => r.engage !== "skip"));
139
+ const engagedIds = new Set(engaged.map((r) => r.id));
140
+ const skipped = dedupe(allNotes).filter((r) => !engagedIds.has(r.id));
141
+ return {
142
+ direct,
143
+ fileHits,
144
+ imps: dedupe(out.flatMap((f) => f.improvements)),
145
+ stale: dedupe(allNotes.filter((r) => r.staleCites)),
146
+ engaged,
147
+ skipped,
148
+ };
149
+ }
150
+
120
151
  export default async function review(args = []) {
121
152
  const ROOT = process.cwd();
122
153
  if (!existsSync(join(ROOT, "rafa.json"))) {
@@ -208,12 +239,7 @@ export default async function review(args = []) {
208
239
  console.log(JSON.stringify(report, null, 2));
209
240
  return;
210
241
  }
211
- const direct = out.flatMap((f) => f.notes.filter((r) => r.hit === "direct"));
212
- const fileHits = out.flatMap((f) => f.notes.filter((r) => r.hit === "file"));
213
- const imps = out.flatMap((f) => f.improvements);
214
- const stale = out.flatMap((f) => f.notes.filter((r) => r.staleCites));
215
- const engaged = out.flatMap((f) => f.notes.filter((r) => r.engage !== "skip"));
216
- const skipped = out.flatMap((f) => f.notes.filter((r) => r.engage === "skip"));
242
+ const { direct, fileHits, imps, stale, engaged, skipped } = summarizeReview(out);
217
243
  console.log(
218
244
  `✓ review pre-pass vs ${base.slice(0, 7)}: ${files.length} changed file(s)` +
219
245
  (dropped > 0 ? ` (${dropped} beyond the ${MAX_FILES}-file bound NOT reviewed)` : ""),
@@ -0,0 +1,108 @@
1
+ // The session-facts plane (wave 5.4 — memoized verification).
2
+ //
3
+ // QA-session finding: six subagents independently re-derived the same
4
+ // environment facts ("no node_modules, workspace:* deps") in one build. The fix
5
+ // is NOT a trust-me cache — that would be the assumed-values disease. Every
6
+ // fact carries its VERIFICATION EVIDENCE (the command + exit code that proved
7
+ // it) and a MECHANICAL invalidation rule: the fact is citable only while
8
+ // `git diff <verifiedAtSha>` (against the working tree, uncommitted edits
9
+ // count) leaves its `dependsOn` paths untouched. Memoization with
10
+ // invalidation — the same move as brain = f(code@sha), at session scale.
11
+ //
12
+ // File: .rafa/session-facts.json — machine/session state, excluded from brain
13
+ // pushes (LOCAL_STATE in brain-commit.mjs + IGNORES in brain-repo.mjs; a
14
+ // defensive self-ignore rides every write). Conductor writes via `rafa facts`;
15
+ // subagents READ FIRST and cite "SF-<n> unchanged" instead of re-deriving.
16
+
17
+ import { execSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ export const FACTS_REL = "session-facts.json";
22
+ export const factsPath = (cwd) => join(cwd, ".rafa", FACTS_REL);
23
+
24
+ const EMPTY = () => ({ schemaVersion: 1, facts: [] });
25
+
26
+ export function readFacts(cwd = process.cwd()) {
27
+ try {
28
+ const doc = JSON.parse(readFileSync(factsPath(cwd), "utf8"));
29
+ if (doc?.schemaVersion !== 1 || !Array.isArray(doc.facts)) return EMPTY();
30
+ return doc;
31
+ } catch {
32
+ return EMPTY();
33
+ }
34
+ }
35
+
36
+ // A malformed fact is rejected loudly at write time — never stored, never
37
+ // silently dropped at read time. Returns an error string or null.
38
+ export function validateFact(f) {
39
+ if (!f || typeof f !== "object") return "fact must be an object";
40
+ if (typeof f.id !== "string" || !/^SF-\d+$/.test(f.id)) return "id must be SF-<n>";
41
+ if (typeof f.claim !== "string" || f.claim.trim() === "") return "claim is required";
42
+ if (!f.evidence || typeof f.evidence.command !== "string" || f.evidence.command.trim() === "")
43
+ return "evidence.command is required — a fact without the command that proved it is an assumed value";
44
+ if (!Number.isInteger(f.evidence.exitCode)) return "evidence.exitCode must be an integer";
45
+ if (typeof f.verifiedAtSha !== "string" || f.verifiedAtSha.trim() === "")
46
+ return "verifiedAtSha is required";
47
+ if (!Array.isArray(f.dependsOn)) return "dependsOn must be an array of paths (may be empty)";
48
+ return null;
49
+ }
50
+
51
+ export function writeFacts(cwd, doc) {
52
+ const dir = join(cwd, ".rafa");
53
+ mkdirSync(dir, { recursive: true });
54
+ writeFileSync(factsPath(cwd), JSON.stringify(doc, null, 2) + "\n");
55
+ // Defensive self-ignore (post-tool.mjs pattern): the facts file must never
56
+ // ride a brain push even if this repo predates the wave-5 ignore lists.
57
+ try {
58
+ const gi = join(dir, ".gitignore");
59
+ const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
60
+ const have = new Set(cur.split("\n").map((l) => l.trim()).filter(Boolean));
61
+ if (!have.has(FACTS_REL))
62
+ writeFileSync(gi, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + FACTS_REL + "\n");
63
+ } catch {
64
+ /* ignore-file hygiene is best-effort — the canonical lists also carry it */
65
+ }
66
+ }
67
+
68
+ const short = (sha) => String(sha).slice(0, 7);
69
+
70
+ // The mechanical invalidation rule. A fact goes stale when:
71
+ // - any dependsOn path changed since verifiedAtSha (working tree INCLUDED —
72
+ // an uncommitted edit invalidates just as hard), or
73
+ // - verifiedAtSha is unknown to this checkout (rebase/gc) → fail-honest.
74
+ // Empty dependsOn = environment-shaped fact, never auto-stale (invalidate by hand).
75
+ export function staleFacts(cwd = process.cwd(), doc = null) {
76
+ const d = doc ?? readFacts(cwd);
77
+ const out = [];
78
+ for (const f of d.facts) {
79
+ try {
80
+ const changed = execSync(`git diff --name-only ${f.verifiedAtSha}`, {
81
+ cwd,
82
+ encoding: "utf8",
83
+ stdio: ["ignore", "pipe", "ignore"],
84
+ })
85
+ .split("\n")
86
+ .filter(Boolean);
87
+ const hit = (f.dependsOn ?? []).filter((p) =>
88
+ changed.some((c) => c === p || c.startsWith(p.endsWith("/") ? p : p + "/")),
89
+ );
90
+ if (hit.length)
91
+ out.push({ id: f.id, reason: `dependsOn touched since ${short(f.verifiedAtSha)}: ${hit.join(", ")}` });
92
+ } catch {
93
+ out.push({
94
+ id: f.id,
95
+ reason: `verifiedAtSha ${short(f.verifiedAtSha)} unknown to this checkout — re-verify or drop`,
96
+ });
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ export function nextFactId(doc) {
103
+ const max = doc.facts.reduce((m, f) => {
104
+ const n = Number(String(f.id).replace(/^SF-/, ""));
105
+ return Number.isFinite(n) && n > m ? n : m;
106
+ }, 0);
107
+ return `SF-${max + 1}`;
108
+ }