@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.
- package/CHANGELOG.md +130 -0
- package/bin/rafa.mjs +11 -0
- package/blueprint/.claude/agents/atlas.md +21 -3
- package/blueprint/.claude/agents/bloom.md +2 -2
- package/blueprint/.claude/agents/prism.md +42 -3
- package/blueprint/.claude/agents/sage.md +1 -1
- package/blueprint/.claude/commands/rafa.md +24 -13
- package/blueprint/.claude/rafa/contract.md +23 -0
- package/blueprint/.claude/rafa/hooks/brain-commit.mjs +4 -0
- package/blueprint/.claude/rafa/hooks/session-start.mjs +66 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +111 -20
- package/blueprint/.claude/skills/rafa-commit/SKILL.md +22 -0
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +37 -4
- package/blueprint/.claude/skills/rafa-improve/SKILL.md +22 -0
- package/blueprint/.claude/skills/rafa-leverage/SKILL.md +6 -1
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +36 -1
- package/blueprint/.claude/skills/rafa-review/SKILL.md +16 -2
- package/blueprint/.claude/skills/rafa-sage/SKILL.md +8 -2
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +4 -1
- package/lib/brain-repo.mjs +1 -1
- package/lib/checkpoint.mjs +26 -15
- package/lib/distill.mjs +56 -3
- package/lib/distiller/doctrine.mjs +5 -16
- package/lib/distiller/schema-ladder.mjs +186 -0
- package/lib/doctor.mjs +19 -1
- package/lib/facts.mjs +130 -0
- package/lib/gate/compile.mjs +12 -0
- package/lib/gate/verify-citations.mjs +78 -1
- package/lib/hydrate.mjs +45 -0
- package/lib/init.mjs +9 -1
- package/lib/leverage/engine.mjs +32 -3
- package/lib/leverage.mjs +11 -2
- package/lib/loop-cache.mjs +67 -0
- package/lib/mcp-client.mjs +19 -0
- package/lib/push.mjs +18 -4
- package/lib/reflex.mjs +5 -1
- package/lib/releases.mjs +78 -0
- package/lib/review.mjs +32 -6
- package/lib/session-facts.mjs +108 -0
- package/lib/skill-deps.mjs +377 -0
- package/lib/stamp.mjs +24 -0
- package/lib/update.mjs +9 -0
- package/package.json +3 -2
- package/skills-bundle/frontend-design/LICENSE.txt +177 -0
- package/skills-bundle/frontend-design/SKILL.md +55 -0
- package/{LICENSE → skills-bundle/grill-me/LICENSE} +6 -6
- package/skills-bundle/grill-me/SKILL.md +7 -0
- package/skills-bundle/grill-me/agents/openai.yaml +5 -0
- package/skills-bundle/grilling/SKILL.md +53 -0
- package/skills-bundle/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/skills-bundle/improve-codebase-architecture/LICENSE +21 -0
- package/skills-bundle/improve-codebase-architecture/SKILL.md +71 -0
- package/skills-bundle/improve-codebase-architecture/agents/openai.yaml +5 -0
- package/skills-bundle/requesting-code-review/LICENSE +21 -0
- package/skills-bundle/requesting-code-review/SKILL.md +95 -0
- package/skills-bundle/requesting-code-review/code-reviewer.md +172 -0
- package/skills-bundle/skills-manifest.json +72 -0
- package/skills-bundle/tdd/LICENSE +21 -0
- package/skills-bundle/tdd/SKILL.md +36 -0
- package/skills-bundle/tdd/agents/openai.yaml +3 -0
- package/skills-bundle/tdd/mocking.md +59 -0
- package/skills-bundle/tdd/tests.md +77 -0
- package/skills-bundle/vercel-composition-patterns/AGENTS.md +946 -0
- package/skills-bundle/vercel-composition-patterns/README.md +60 -0
- package/skills-bundle/vercel-composition-patterns/SKILL.md +89 -0
- package/skills-bundle/vercel-composition-patterns/metadata.json +11 -0
- package/skills-bundle/vercel-composition-patterns/rules/_sections.md +29 -0
- package/skills-bundle/vercel-composition-patterns/rules/_template.md +24 -0
- package/skills-bundle/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md +100 -0
- package/skills-bundle/vercel-composition-patterns/rules/architecture-compound-components.md +112 -0
- package/skills-bundle/vercel-composition-patterns/rules/patterns-children-over-render-props.md +87 -0
- package/skills-bundle/vercel-composition-patterns/rules/patterns-explicit-variants.md +100 -0
- package/skills-bundle/vercel-composition-patterns/rules/react19-no-forwardref.md +42 -0
- package/skills-bundle/vercel-composition-patterns/rules/state-context-interface.md +191 -0
- package/skills-bundle/vercel-composition-patterns/rules/state-decouple-implementation.md +113 -0
- 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
|
+
}
|
package/lib/mcp-client.mjs
CHANGED
|
@@ -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 =
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
+
}
|