@codewithjuber/forgekit 0.8.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 (140) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.codex-plugin/plugin.json +29 -0
  4. package/.mcp.json +8 -0
  5. package/ARCHITECTURE.md +314 -0
  6. package/CHANGELOG.md +467 -0
  7. package/LICENSE +21 -0
  8. package/ONBOARDING.md +180 -0
  9. package/README.md +286 -0
  10. package/bin/claude-init.sh +90 -0
  11. package/bin/claude-taste.sh +33 -0
  12. package/bin/learn-consolidate.sh +51 -0
  13. package/brand.json +15 -0
  14. package/global/CLAUDE.md +31 -0
  15. package/global/crew/frontend-verifier.md +36 -0
  16. package/global/crew/independent-reviewer.md +29 -0
  17. package/global/crew/scout.md +24 -0
  18. package/global/crew/verifier.md +28 -0
  19. package/global/guards/_guardlib.sh +36 -0
  20. package/global/guards/cortex.sh +14 -0
  21. package/global/guards/cost-budget.sh +41 -0
  22. package/global/guards/doom-loop.sh +25 -0
  23. package/global/guards/format-on-edit.sh +32 -0
  24. package/global/guards/lean-guard.sh +20 -0
  25. package/global/guards/protect-paths.sh +45 -0
  26. package/global/guards/recall-load.sh +22 -0
  27. package/global/guards/secret-redact.sh +18 -0
  28. package/global/guards/session-learner.sh +72 -0
  29. package/global/recall/MEMORY.md +7 -0
  30. package/global/rules/self-correction.md +17 -0
  31. package/global/rules/stack-notes.md +24 -0
  32. package/global/rules/tech-currency.md +19 -0
  33. package/global/settings.template.json +183 -0
  34. package/global/statusline.sh +51 -0
  35. package/global/taste/brutalist.json +9 -0
  36. package/global/taste/brutalist.md +19 -0
  37. package/global/taste/corporate.json +9 -0
  38. package/global/taste/corporate.md +19 -0
  39. package/global/taste/editorial.json +9 -0
  40. package/global/taste/editorial.md +19 -0
  41. package/global/taste/minimalist.json +9 -0
  42. package/global/taste/minimalist.md +20 -0
  43. package/global/taste/playful.json +9 -0
  44. package/global/taste/playful.md +19 -0
  45. package/global/tools/atlas/SKILL.md +27 -0
  46. package/global/tools/code-modernization/SKILL.md +275 -0
  47. package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
  48. package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
  49. package/global/tools/code-modernization/references/research-protocol.md +42 -0
  50. package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
  51. package/global/tools/cognitive-substrate/SKILL.md +56 -0
  52. package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
  53. package/global/tools/cost-guard/SKILL.md +50 -0
  54. package/global/tools/design-md/SKILL.md +54 -0
  55. package/global/tools/dev-radar/SKILL.md +56 -0
  56. package/global/tools/explore-plan-code/SKILL.md +24 -0
  57. package/global/tools/lean/SKILL.md +41 -0
  58. package/global/tools/recall/SKILL.md +31 -0
  59. package/global/tools/reuse-first/SKILL.md +64 -0
  60. package/global/tools/self-improve/SKILL.md +44 -0
  61. package/global/tools/taste/SKILL.md +26 -0
  62. package/global/tools/tech-selector/SKILL.md +35 -0
  63. package/global/tools/ui-workflow/SKILL.md +44 -0
  64. package/hooks/hooks.json +107 -0
  65. package/install.sh +88 -0
  66. package/package.json +93 -0
  67. package/public/index.html +45 -0
  68. package/scripts/build-pages.mjs +180 -0
  69. package/scripts/bump.mjs +322 -0
  70. package/skills/cognitive-substrate/SKILL.md +56 -0
  71. package/skills/cognitive-substrate/references/capability-map.md +17 -0
  72. package/source/mcp.json +10 -0
  73. package/source/rules.json +106 -0
  74. package/source/substrate.json +41 -0
  75. package/src/adjudicate.js +84 -0
  76. package/src/anchor.js +210 -0
  77. package/src/atlas.js +487 -0
  78. package/src/brain.js +84 -0
  79. package/src/brand.js +25 -0
  80. package/src/cli.js +1509 -0
  81. package/src/context.js +273 -0
  82. package/src/cortex.js +251 -0
  83. package/src/cortex_distill.js +55 -0
  84. package/src/cortex_features.js +81 -0
  85. package/src/cortex_hook.js +197 -0
  86. package/src/cortex_hook_main.js +139 -0
  87. package/src/cortex_mcp.js +352 -0
  88. package/src/cost_report.js +271 -0
  89. package/src/dash.html +396 -0
  90. package/src/dash.js +220 -0
  91. package/src/diagnose.js +0 -0
  92. package/src/doctor.js +315 -0
  93. package/src/embed.js +244 -0
  94. package/src/emit/_shared.js +39 -0
  95. package/src/emit/aider.js +22 -0
  96. package/src/emit/claude.js +44 -0
  97. package/src/emit/codex.js +17 -0
  98. package/src/emit/continue.js +28 -0
  99. package/src/emit/copilot.js +12 -0
  100. package/src/emit/cursor.js +23 -0
  101. package/src/emit/gemini.js +40 -0
  102. package/src/emit/mcp.js +94 -0
  103. package/src/emit/windsurf.js +22 -0
  104. package/src/emit/zed.js +34 -0
  105. package/src/eval.js +47 -0
  106. package/src/extract.js +82 -0
  107. package/src/harden.js +44 -0
  108. package/src/imagine.js +301 -0
  109. package/src/init.js +178 -0
  110. package/src/lean.js +149 -0
  111. package/src/ledger.js +475 -0
  112. package/src/ledger_bridge.js +279 -0
  113. package/src/ledger_read.js +152 -0
  114. package/src/ledger_store.js +360 -0
  115. package/src/lessons.js +185 -0
  116. package/src/lessons_store.js +137 -0
  117. package/src/metrics.js +54 -0
  118. package/src/model_tiers.js +17 -0
  119. package/src/model_tiers.json +39 -0
  120. package/src/predictor.js +143 -0
  121. package/src/preflight.js +410 -0
  122. package/src/providers.js +320 -0
  123. package/src/recall.js +103 -0
  124. package/src/reuse.js +0 -0
  125. package/src/route.js +323 -0
  126. package/src/scope.js +122 -0
  127. package/src/skillgate.js +89 -0
  128. package/src/speclock.js +64 -0
  129. package/src/substrate.js +492 -0
  130. package/src/sync.js +132 -0
  131. package/src/taste.js +55 -0
  132. package/src/uicheck.js +96 -0
  133. package/src/uifingerprint.js +861 -0
  134. package/src/uivisual.js +334 -0
  135. package/src/util.js +71 -0
  136. package/src/verify.js +117 -0
  137. package/templates/project-layer/.claude/settings.json +22 -0
  138. package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
  139. package/templates/project-layer/AGENTS.md +28 -0
  140. package/templates/project-layer/CLAUDE.md +40 -0
package/src/lean.js ADDED
@@ -0,0 +1,149 @@
1
+ // forge lean — M5 anti-over-engineering, made measurable. The paper flags φ(y) − φ*(x) > 0: the
2
+ // solution's footprint beyond the task's minimal sufficient footprint. The shipped substrate only
3
+ // had three keyword regexes; this measures the ACTUAL footprint from the working diff — files
4
+ // touched, lines added, and NEW abstractions introduced — against what the task NAMED, and flags
5
+ // the excess. Deterministic, git/diff-based, zero-dep. Advisory (never blocks); tests always win.
6
+ import { execFileSync } from "node:child_process";
7
+ import { referencedEntities } from "./preflight.js";
8
+
9
+ // A new top-level definition introduced on an added (+) diff line — the over-abstraction signal.
10
+ const NEW_DEF_RES = [
11
+ /^\+\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/,
12
+ /^\+\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
13
+ /^\+\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/,
14
+ /^\+\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/,
15
+ // const/let/var bound to a function value (a real new abstraction, not a scalar)
16
+ /^\+\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/,
17
+ /^\+\s*def\s+([A-Za-z_]\w*)/,
18
+ /^\+\s*class\s+([A-Za-z_]\w*)/,
19
+ ];
20
+
21
+ /** Pure: a unified diff → the actual footprint {files, linesAdded, newSymbols}. */
22
+ export function parseDiffFootprint(diff) {
23
+ const files = new Set();
24
+ const newSymbols = [];
25
+ let linesAdded = 0;
26
+ for (const line of String(diff).split("\n")) {
27
+ if (line.startsWith("+++ ")) {
28
+ const m = line.match(/^\+\+\+ (?:b\/)?(.+)$/);
29
+ if (m && m[1] !== "/dev/null") files.add(m[1].trim());
30
+ continue;
31
+ }
32
+ if (line.startsWith("+") && !line.startsWith("+++")) {
33
+ linesAdded += 1;
34
+ for (const re of NEW_DEF_RES) {
35
+ const m = line.match(re);
36
+ if (m) {
37
+ newSymbols.push(m[1]);
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ }
43
+ return { files: [...files], linesAdded, newSymbols };
44
+ }
45
+
46
+ /**
47
+ * Pure: compare the actual footprint against what the task asked for and flag the excess.
48
+ * @param {string} task
49
+ * @param {{files:string[], linesAdded:number, newSymbols:string[]}} actual
50
+ */
51
+ export function assessFootprint(task, actual, { maxLinesForShortTask = 120 } = {}) {
52
+ const { symbols: named, files: namedFiles } = referencedEntities(task);
53
+ const namedSymbols = new Set(named.map((s) => s.toLowerCase()));
54
+ const words = String(task).trim().split(/\s+/).filter(Boolean).length;
55
+ const warnings = [];
56
+
57
+ // Abstractions the task never named — the core φ(y) − φ*(x) signal.
58
+ const unrequested = [...new Set(actual.newSymbols)].filter(
59
+ (s) => !namedSymbols.has(s.toLowerCase()),
60
+ );
61
+ if (unrequested.length >= 3) {
62
+ warnings.push(
63
+ `${unrequested.length} new abstractions the task didn't ask for (${unrequested.slice(0, 5).join(", ")}) — is each one necessary, or is this over-built?`,
64
+ );
65
+ }
66
+
67
+ // A short ask that produced a large diff.
68
+ if (words <= 12 && actual.linesAdded > maxLinesForShortTask) {
69
+ warnings.push(
70
+ `${actual.linesAdded} lines added for a ${words}-word task — confirm the scope matches the request.`,
71
+ );
72
+ }
73
+
74
+ // Touched far more files than the task named.
75
+ if (namedFiles.length) {
76
+ const extra = actual.files.filter(
77
+ (f) => !namedFiles.some((nf) => f.endsWith(nf) || nf.endsWith(f)),
78
+ );
79
+ if (extra.length > Math.max(2, namedFiles.length * 2)) {
80
+ warnings.push(
81
+ `Touched ${actual.files.length} files but the task named ${namedFiles.length} — ${extra.length} are beyond the stated scope.`,
82
+ );
83
+ }
84
+ }
85
+
86
+ return {
87
+ warnings,
88
+ footprint: {
89
+ files: actual.files.length,
90
+ linesAdded: actual.linesAdded,
91
+ newAbstractions: [...new Set(actual.newSymbols)],
92
+ unrequestedAbstractions: unrequested,
93
+ },
94
+ };
95
+ }
96
+
97
+ function gitDiff(root, base) {
98
+ try {
99
+ const staged = execFileSync("git", ["diff", "--unified=0", base], {
100
+ cwd: root,
101
+ encoding: "utf8",
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ });
104
+ return (
105
+ staged ||
106
+ execFileSync("git", ["diff", "--unified=0", "--cached"], {
107
+ cwd: root,
108
+ encoding: "utf8",
109
+ stdio: ["ignore", "pipe", "ignore"],
110
+ })
111
+ );
112
+ } catch (err) {
113
+ if (process.env.FORGE_DEBUG === "1")
114
+ process.stderr.write(`forge lean gitDiff: ${err?.message ?? err}\n`);
115
+ return "";
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Repo wrapper: measure the working-tree footprint against a task. `diff` injectable for tests.
121
+ * @param {string} root
122
+ * @param {string} task
123
+ * @param {object} [opts]
124
+ * @param {string} [opts.base]
125
+ * @param {string} [opts.diff]
126
+ */
127
+ export function leanRepo(root, task, { base = "HEAD", diff } = {}) {
128
+ const d = diff ?? gitDiff(root, base);
129
+ return {
130
+ ...assessFootprint(String(task || ""), parseDiffFootprint(d)),
131
+ hasDiff: Boolean(d.trim()),
132
+ };
133
+ }
134
+
135
+ export function renderLean(r) {
136
+ const lines = ["Forge lean — scope minimality (M5)", ""];
137
+ if (!r.hasDiff) return `${lines.join("\n")} no diff vs HEAD yet — nothing to measure.`;
138
+ const f = r.footprint;
139
+ lines.push(
140
+ ` footprint: ${f.files} file(s), +${f.linesAdded} line(s), ${f.newAbstractions.length} new abstraction(s)`,
141
+ );
142
+ if (r.warnings.length) {
143
+ lines.push("", " possible over-engineering:");
144
+ for (const w of r.warnings) lines.push(` - ${w}`);
145
+ } else {
146
+ lines.push("", " ✓ footprint looks proportionate to the task.");
147
+ }
148
+ return lines.join("\n");
149
+ }
package/src/ledger.js ADDED
@@ -0,0 +1,475 @@
1
+ // forge ledger — the Proof-Carrying Memory (PCM) core. PURE logic only (no fs — see
2
+ // ledger_store.js): canonical claims, content-addressed ids, evidence outcomes, a
3
+ // decayed Beta-posterior confidence, Eq.-3 retrieval scoring, MinHash similarity, and
4
+ // the semilattice merge that makes team memory conflict-free by construction.
5
+ // Spec: docs/plans/substrate-v2/01-pcm-protocol.md (ADR-0006).
6
+ //
7
+ // Design invariants (shared with lessons.js, now protocol law for every stored thing):
8
+ // - Confidence is EARNED from independent oracles (tests, CI, human accept/revert),
9
+ // never from the model's self-assessment; retrieval/injection is never confirmation.
10
+ // val() takes oracle weights from the ORACLES table, NEVER from the stored record —
11
+ // a forged/corrupted evidence line cannot buy confidence it isn't entitled to.
12
+ // - A claim's persisted bytes are a pure function of (kind, body, scope): anything
13
+ // author- or time-varying (provenance, evidence, tombstones) lives in append-only
14
+ // logs. That is what makes every file either byte-identical across teammates or
15
+ // union-mergeable — the join-semilattice property is structural, not aspirational.
16
+ // - Unreviewed claims decay toward the PRIOR (0.5, uncertainty), not toward false.
17
+ import { contentHash } from "./util.js";
18
+
19
+ // Anything matching this is refused at mint — store a pointer to where the secret
20
+ // lives, never the value. Moved here from recall.js (which re-exports it) so NO claim
21
+ // kind can persist a credential. See recall.js history for the precision rationale:
22
+ // known credential formats, plus a secret-ish key ASSIGNED to a value (a bare English
23
+ // mention like "implement password hashing" must NOT be refused).
24
+ export const SECRET_RE =
25
+ /(-----BEGIN |\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}|\b[\w-]*(?:api[_-]?key|secret|passwd|password|token)[\w-]*["']?\s*[:=]\s*["']?\S)/i;
26
+
27
+ export const KINDS = [
28
+ "lesson", // a corrected behavior (cortex)
29
+ "fact", // a durable project fact (recall)
30
+ "artifact", // verified generated code (reuse cache, P3)
31
+ "edge", // a verified dependency edge (atlas overlay, P5)
32
+ "fingerprint", // a design-token vector (UI gate, P6)
33
+ "diagnosis", // a doom-loop root cause (P5)
34
+ "decision", // a ratified team decision (hikma layer)
35
+ "summary", // a compressed context span (P4)
36
+ "outcome", // a raw oracle result (ilm layer)
37
+ ];
38
+
39
+ /**
40
+ * Oracle taxonomy — who may move confidence, and how much. `w` = prior reliability;
41
+ * `family` powers the cross-family gate (a lone behavioral signal never moves a claim
42
+ * on its own — same rule as lessons.js scoreMistake). The two `bridge: true` entries
43
+ * exist only for the P1 migration seam (cortex episodes, legacy imports) and carry a
44
+ * deliberately conservative weight — Stop-hook revert detection is regex-based and
45
+ * routinely matches innocent `git restore`s, so it must NOT ride the full-weight
46
+ * human.revert oracle (that one is reserved for explicit, unambiguous human signals).
47
+ */
48
+ export const ORACLES = {
49
+ "human.revert": { w: 1.0, family: "human" },
50
+ "human.accept": { w: 0.9, family: "human" },
51
+ "test.run": { w: 0.8, family: "outcome" },
52
+ "ci.run": { w: 0.8, family: "outcome" },
53
+ typecheck: { w: 0.6, family: "outcome" },
54
+ "graph.reval": { w: 0.5, family: "structural" },
55
+ behavioral: { w: 0.3, family: "behavioral" },
56
+ "cortex.episode": { w: 0.5, family: "outcome", bridge: true },
57
+ "legacy.import": { w: 0.5, family: "outcome", bridge: true },
58
+ };
59
+
60
+ /** One source of truth for scope weighting — lessons.js re-exports this. */
61
+ export const SCOPE_WEIGHT = { symbol: 1.0, dir: 0.8, repo: 0.6, global: 0.4 };
62
+
63
+ /** Retrieval weights for Eq. 3 (a=relevance, b=recency, g=validity) — calibrated in P8. */
64
+ export const EQ3_WEIGHTS = { a: 0.55, b: 0.15, g: 0.3 };
65
+
66
+ export const DEFAULT_HALF_LIFE_DAYS = 45;
67
+ /** Below this val a claim is dormant: kept for audit, never retrieved. The trusted
68
+ * band starts at the mirror threshold (1 − DORMANT_VAL) — stats uses both. */
69
+ export const DORMANT_VAL = 0.35;
70
+
71
+ /**
72
+ * Deterministic canonical JSON: lexicographically sorted keys, no insignificant
73
+ * whitespace, NFC-normalized strings, no undefined/function values (dropped, as in
74
+ * JSON.stringify). The canonical BYTES are what gets hashed and stored — id stability
75
+ * under re-serialization is a protocol guarantee.
76
+ * @param {*} value
77
+ * @returns {string}
78
+ */
79
+ export function canonicalize(value) {
80
+ if (value === null || typeof value === "number" || typeof value === "boolean")
81
+ return JSON.stringify(value);
82
+ if (typeof value === "string") return JSON.stringify(value.normalize("NFC"));
83
+ if (Array.isArray(value))
84
+ return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`;
85
+ if (typeof value === "object") {
86
+ const keys = Object.keys(value)
87
+ .filter((k) => value[k] !== undefined && typeof value[k] !== "function")
88
+ .sort();
89
+ return `{${keys.map((k) => `${JSON.stringify(k.normalize("NFC"))}:${canonicalize(value[k])}`).join(",")}}`;
90
+ }
91
+ return "null"; // undefined / function at the top level
92
+ }
93
+
94
+ /** Content address over (kind, body, scope) ONLY — provenance and evidence excluded, so
95
+ * two teammates who independently learn the same thing mint the SAME id and their
96
+ * evidence merges instead of duplicating. */
97
+ export function claimId(kind, body, scope = {}) {
98
+ return contentHash(canonicalize({ body, kind, scope }));
99
+ }
100
+
101
+ /** Stamp a record with its content hash (the dedupe key in every append-only log). */
102
+ export function sealRecord(record) {
103
+ return { ...record, h: contentHash(canonicalize(record)) };
104
+ }
105
+
106
+ /**
107
+ * Mint a claim. Refuses secrets and unknown kinds ({ok:false, reason} — same contract
108
+ * as recall.add / lessons_store.save so callers keep one error shape). The body/scope
109
+ * are normalized through JSON first (Dates → ISO strings, Maps/Sets → {}), so a
110
+ * non-JSON value can never make two different bodies collide on one address.
111
+ * `provenance` rides on the in-memory claim but is NEVER part of the id or the claim
112
+ * file bytes — the store appends it to a per-claim log instead.
113
+ * @param {{kind:string, body:object, scope?:object, provenance?:object, t?:number}} f
114
+ * `t` is the mint day (epoch days) — passed in, never read from the clock here.
115
+ * @returns {{ok:true, claim:any}|{ok:false, reason:string}}
116
+ */
117
+ export function mintClaim({ kind, body, scope = {}, provenance = {}, t = 0 }) {
118
+ if (!KINDS.includes(kind)) return { ok: false, reason: `unknown claim kind: ${kind}` };
119
+ if (body === null || typeof body !== "object")
120
+ return { ok: false, reason: "claim body must be an object" };
121
+ const nBody = JSON.parse(JSON.stringify(body));
122
+ const nScope = JSON.parse(JSON.stringify(scope));
123
+ const canon = canonicalize({ body: nBody, kind, scope: nScope });
124
+ if (SECRET_RE.test(canon))
125
+ return {
126
+ ok: false,
127
+ reason: "refused: looks like a secret/credential — store a pointer, not the value",
128
+ };
129
+ return {
130
+ ok: true,
131
+ claim: {
132
+ v: 1,
133
+ id: claimId(kind, nBody, nScope),
134
+ kind,
135
+ body: nBody,
136
+ scope: nScope,
137
+ provenance: sealRecord({ ...provenance, t }),
138
+ evidence: [],
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Build an evidence outcome. Evidence without a verifiable ref (commit SHA, test-run
145
+ * id, episode id, CI URL) is rejected — "the model said so" is not evidence. The
146
+ * oracle's table weight is recorded for audit, but val() re-reads the table.
147
+ * @param {{oracle:string, result:"confirm"|"contradict", ref:string, author?:string, t?:number}} f
148
+ * @returns {{ok:true, outcome:any}|{ok:false, reason:string}}
149
+ */
150
+ export function outcomeRecord({ oracle, result, ref, author = "", t = 0 }) {
151
+ const o = ORACLES[oracle];
152
+ if (!o) return { ok: false, reason: `unknown oracle: ${oracle}` };
153
+ if (result !== "confirm" && result !== "contradict")
154
+ return { ok: false, reason: `result must be confirm|contradict, got: ${result}` };
155
+ if (!ref || typeof ref !== "string")
156
+ return { ok: false, reason: "evidence requires a verifiable ref" };
157
+ return { ok: true, outcome: sealRecord({ author, oracle, ref, result, t, w: o.w }) };
158
+ }
159
+
160
+ /** An evidence record val() will count: known oracle, valid result, a ref, a hash. */
161
+ export function validOutcome(e) {
162
+ return Boolean(
163
+ e && ORACLES[e.oracle] && (e.result === "confirm" || e.result === "contradict") && e.ref && e.h,
164
+ );
165
+ }
166
+
167
+ // Weight comes from the ORACLES table — a stored `w` is audit metadata, never trusted
168
+ // (a hand-edited or forged log line must not be able to buy extra confidence).
169
+ const decayed = (outcome, nowDay, halfLife) =>
170
+ ORACLES[outcome.oracle].w * 0.5 ** (Math.max(0, nowDay - (outcome.t ?? 0)) / halfLife);
171
+
172
+ /**
173
+ * Validity — the paper's `val` term as a time-decayed Beta posterior mean with a
174
+ * Beta(1,1) prior: (1 + Σ confirms·w·λ^Δt/T) / (2 + Σ all·w·λ^Δt/T).
175
+ * Fresh claim → 0.5. Unreviewed evidence decays, pulling val back toward 0.5
176
+ * (uncertainty), never toward 0 — review (new evidence) is what restores weight.
177
+ * Records that fail validOutcome (unknown oracle, malformed) are IGNORED, not
178
+ * trusted. Optional `trust` (author → u, see authorTrust) scales each record by its
179
+ * appender's earned reliability. Pure function of (evidence set, trust map) ⇒
180
+ * identical after any merge order.
181
+ * @param {any} claim
182
+ * @param {number} [nowDay]
183
+ * @param {{halfLife?: number, trust?: Record<string, number>}} [opts]
184
+ */
185
+ export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS, trust } = {}) {
186
+ let confirms = 0;
187
+ let all = 0;
188
+ for (const e of claim.evidence ?? []) {
189
+ if (!validOutcome(e)) continue;
190
+ const d = decayed(e, nowDay, halfLife) * (trust?.[e.author ?? ""] ?? 1);
191
+ all += d;
192
+ if (e.result === "confirm") confirms += d;
193
+ }
194
+ return (1 + confirms) / (2 + all);
195
+ }
196
+
197
+ /**
198
+ * Per-author trust u(author) ∈ [0.5, 1] — the historical confirm rate of the claims
199
+ * an author MINTED (docs/plans/substrate-v2/02-team-memory.md §3): authors whose
200
+ * claims keep being contradicted by oracles contribute less evidence weight going
201
+ * forward. Smoothed so a no-history author starts at 1.0 (never punish the new
202
+ * teammate) and floored at 0.5 (never silence anyone).
203
+ * u(a) = max(0.5, (confirms_a + s) / (confirms_a + contradictions_a + s)), s = 2
204
+ * Counts are oracle-weighted, and an author's own evidence on their own claims is
205
+ * EXCLUDED — self-confirmation must not raise one's trust (C12 discipline).
206
+ * @returns {Record<string, number>} author → u
207
+ */
208
+ export function authorTrust(claims) {
209
+ const tally = new Map(); // author → {c, m}
210
+ for (const claim of claims) {
211
+ const author = claim.provenance?.author ?? "";
212
+ if (!author) continue;
213
+ const t = tally.get(author) ?? { c: 0, m: 0 };
214
+ for (const e of claim.evidence ?? []) {
215
+ if (!validOutcome(e) || (e.author ?? "") === author) continue;
216
+ const w = ORACLES[e.oracle].w;
217
+ if (e.result === "confirm") t.c += w;
218
+ else t.m += w;
219
+ }
220
+ tally.set(author, t);
221
+ }
222
+ /** @type {Record<string, number>} */
223
+ const out = {};
224
+ for (const [a, { c, m }] of tally) out[a] = Math.max(0.5, (c + 2) / (c + m + 2));
225
+ return out;
226
+ }
227
+
228
+ /** Recency — λ^(Δt/T) since the last evidence (or mint, if none). */
229
+ export function rec(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) {
230
+ const last = Math.max(claim.provenance?.t ?? 0, ...(claim.evidence ?? []).map((e) => e.t ?? 0));
231
+ return 0.5 ** (Math.max(0, nowDay - last) / halfLife);
232
+ }
233
+
234
+ /** Dormant claims are kept for audit but never retrieved. */
235
+ export function isDormant(claim, nowDay = 0) {
236
+ return val(claim, nowDay) < DORMANT_VAL;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // MinHash similarity — the dependency-free `rel` term. k independent-ish hash
241
+ // functions via affine reseeding of one FNV-1a base hash; Jaccard is estimated by the
242
+ // fraction of matching sketch positions (unbiased, SE ≈ sqrt(J(1-J)/k) ≈ 0.044 at
243
+ // k=128). Candidate pairing at scale uses LSH banding (P3); here pairwise is fine.
244
+ // ---------------------------------------------------------------------------
245
+
246
+ export const SKETCH_K = 128;
247
+
248
+ const fnv1a = (s) => {
249
+ let h = 0x811c9dc5;
250
+ for (let i = 0; i < s.length; i++) {
251
+ h ^= s.charCodeAt(i);
252
+ h = Math.imul(h, 0x01000193) >>> 0;
253
+ }
254
+ return h;
255
+ };
256
+
257
+ // Fixed odd multipliers/offsets derived from a splitmix-style constant — deterministic
258
+ // across runs and platforms (no Math.random, ever: ids and sketches must be stable).
259
+ const SEEDS = Array.from({ length: SKETCH_K }, (_, i) => ({
260
+ a: (Math.imul(i + 1, 0x9e3779b1) | 1) >>> 0,
261
+ b: Math.imul(i + 1, 0x85ebca6b) >>> 0,
262
+ }));
263
+
264
+ const normalizeText = (text) =>
265
+ String(text)
266
+ .toLowerCase()
267
+ .split(/[^a-z0-9]+/)
268
+ .filter(Boolean);
269
+
270
+ /** n-token shingle set of normalized text (short texts fall back to single tokens). */
271
+ export function shingles(text, n = 4) {
272
+ const toks = normalizeText(text);
273
+ if (toks.length < n) return new Set(toks.length ? [toks.join(" ")] : []);
274
+ const out = new Set();
275
+ for (let i = 0; i + n <= toks.length; i++) out.add(toks.slice(i, i + n).join(" "));
276
+ return out;
277
+ }
278
+
279
+ /** MinHash sketch of a text: k per-seed minima over the shingle hashes. */
280
+ export function sketch(text, k = SKETCH_K) {
281
+ const sh = shingles(text);
282
+ const mins = new Array(k).fill(0xffffffff);
283
+ for (const s of sh) {
284
+ const h = fnv1a(s);
285
+ for (let i = 0; i < k; i++) {
286
+ const v = (Math.imul(h, SEEDS[i].a) + SEEDS[i].b) >>> 0;
287
+ if (v < mins[i]) mins[i] = v;
288
+ }
289
+ }
290
+ return mins;
291
+ }
292
+
293
+ /** Jaccard estimate = fraction of agreeing sketch positions (1 for identical texts). */
294
+ export function jaccard(a, b) {
295
+ const n = Math.min(a.length, b.length);
296
+ if (!n) return 0;
297
+ let eq = 0;
298
+ for (let i = 0; i < n; i++) if (a[i] === b[i]) eq++;
299
+ return eq / n;
300
+ }
301
+
302
+ /** The retrievable text of a claim, per kind (fallback: its canonical body). */
303
+ export function claimText(claim) {
304
+ const b = claim.body ?? {};
305
+ switch (claim.kind) {
306
+ case "lesson":
307
+ return [
308
+ b.whatWentWrong,
309
+ b.correctedBehavior,
310
+ ...(b.trigger?.keywords ?? []),
311
+ ...(b.trigger?.symbols ?? []),
312
+ ]
313
+ .filter(Boolean)
314
+ .join(" ");
315
+ case "fact":
316
+ return [b.name, b.text].filter(Boolean).join(" ");
317
+ case "diagnosis":
318
+ return [b.signature, b.note].filter(Boolean).join(" ");
319
+ case "summary":
320
+ return b.text ?? canonicalize(b);
321
+ default:
322
+ return canonicalize(b);
323
+ }
324
+ }
325
+
326
+ // Memoize on the claim object — sketch(claimText) is deterministic per id and claims
327
+ // are immutable, so first-use caching is safe and keeps retrieve()/clusters() from
328
+ // re-hashing every claim on every call. (noAssignInExpressions is off in biome.json.)
329
+ const sketchOf = (claim) => (claim._sketch ??= sketch(claimText(claim)));
330
+
331
+ /**
332
+ * Eq. 3 retrieval score (paper §7.1): σ(a·rel + b·rec + g·val) × scope weight.
333
+ * `query` may be a string or a precomputed sketch. The `g·val` term is the protocol's
334
+ * load-bearing addition — outcome-confirmed claims outrank merely-recent ones.
335
+ *
336
+ * `sim` (optional) replaces the lexical `rel` term with a caller-supplied similarity
337
+ * (the ADR-0005 embeddings tier — built by callers from embed.js; this pure core
338
+ * NEVER imports a provider). It returns a cosine in [-1,1] or null; null (or any
339
+ * non-finite value) falls back to MinHash Jaccard per claim, and negatives clamp to 0
340
+ * — "anti-similar" is just irrelevant, never a penalty below unrelated.
341
+ * @param {*} query
342
+ * @param {any} claim
343
+ * @param {{nowDay?:number, weights?:typeof EQ3_WEIGHTS, sim?:(query:any, claim:any)=>number|null}} [opts]
344
+ */
345
+ export function score(query, claim, { nowDay = 0, weights = EQ3_WEIGHTS, sim } = {}) {
346
+ let rel = null;
347
+ if (sim) {
348
+ const s = sim(query, claim);
349
+ if (typeof s === "number" && Number.isFinite(s)) rel = Math.max(0, Math.min(1, s));
350
+ }
351
+ if (rel === null) {
352
+ const qs = Array.isArray(query) ? query : sketch(query);
353
+ rel = jaccard(qs, sketchOf(claim));
354
+ }
355
+ const x = weights.a * rel + weights.b * rec(claim, nowDay) + weights.g * val(claim, nowDay);
356
+ const sigma = 1 / (1 + Math.exp(-x));
357
+ const scopeW = SCOPE_WEIGHT[claim.scope?.level] ?? 0.5;
358
+ return sigma * scopeW;
359
+ }
360
+
361
+ /** Rank live (non-dormant, non-tombstoned) claims for a query; caps at `budget`.
362
+ * Optional `sim` as in score() — the caller-built embedding similarity; the query
363
+ * string (not the sketch) is what a sim sees.
364
+ * @param {*} query
365
+ * @param {any[]} claims
366
+ * @param {{nowDay?:number, budget?:number, weights?:typeof EQ3_WEIGHTS,
367
+ * sim?:((query:any, claim:any)=>number|null)|null}} [opts] */
368
+ export function retrieve(
369
+ query,
370
+ claims,
371
+ { nowDay = 0, budget = 12, weights = EQ3_WEIGHTS, sim } = {},
372
+ ) {
373
+ const q = String(query);
374
+ const qs = sketch(q);
375
+ const boundSim = sim ? (_qs, c) => sim(q, c) : undefined;
376
+ return claims
377
+ .filter((c) => !c.tombstone && !isDormant(c, nowDay))
378
+ .map((c) => ({ claim: c, score: score(qs, c, { nowDay, weights, sim: boundSim }) }))
379
+ .sort((a, b) => b.score - a.score || (a.claim.id < b.claim.id ? -1 : 1))
380
+ .slice(0, budget);
381
+ }
382
+
383
+ /**
384
+ * Consolidation clustering (the murāja‘a job, ilm→fahm): union-find over pairs with
385
+ * Jaccard ≥ tau. O(n²) pairwise on sketches — fine at session scale; LSH banding is
386
+ * the documented scale path. Returns clusters of ≥2 as arrays of claim ids.
387
+ */
388
+ export function clusters(claims, { tau = 0.7 } = {}) {
389
+ const items = claims.map((c) => ({ id: c.id, s: sketchOf(c) }));
390
+ const parent = new Map(items.map((i) => [i.id, i.id]));
391
+ const find = (x) => {
392
+ while (parent.get(x) !== x) {
393
+ parent.set(x, parent.get(parent.get(x)));
394
+ x = parent.get(x);
395
+ }
396
+ return x;
397
+ };
398
+ for (let i = 0; i < items.length; i++)
399
+ for (let j = i + 1; j < items.length; j++)
400
+ if (jaccard(items[i].s, items[j].s) >= tau) parent.set(find(items[i].id), find(items[j].id));
401
+ const groups = new Map();
402
+ for (const it of items) {
403
+ const r = find(it.id);
404
+ if (!groups.has(r)) groups.set(r, []);
405
+ groups.get(r).push(it.id);
406
+ }
407
+ return [...groups.values()].filter((g) => g.length >= 2).map((g) => g.sort());
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // The CRDT merge. State = four grow-only maps:
412
+ // claims: id → {v, kind, body, scope} (bytes are pure content — identical
413
+ // for the same id on every replica)
414
+ // evidence: id → outcome[] (union by content hash)
415
+ // provenance: id → record[] (union by content hash — every
416
+ // author's mint is kept, attribution
417
+ // is a set, not a fight)
418
+ // tombstones: id → record[] (union by content hash — concurrent
419
+ // retractions both survive)
420
+ // Merge is set union throughout; (S, ⊔) is a join-semilattice (commutative,
421
+ // associative, idempotent), so replicas converge under ANY merge order. The single-
422
+ // record views (claim.provenance, claim.tombstone) are derived deterministically
423
+ // (earliest by (t, h)), so they converge too. Property-tested.
424
+ // ---------------------------------------------------------------------------
425
+
426
+ /** Dedupe by content hash and sort by (t, h) — the ONE record order everywhere, so a
427
+ * log's on-disk line order (which differs across replicas after a union merge) can
428
+ * never leak into views or derived values. */
429
+ export const sortRecords = (arr) => {
430
+ const byHash = new Map();
431
+ for (const o of arr) if (o?.h && !byHash.has(o.h)) byHash.set(o.h, o);
432
+ return [...byHash.values()].sort(
433
+ (a, b) => (a.t ?? 0) - (b.t ?? 0) || (a.h < b.h ? -1 : a.h > b.h ? 1 : 0),
434
+ );
435
+ };
436
+
437
+ const mergeLogMap = (m1 = {}, m2 = {}) => {
438
+ const out = {};
439
+ for (const id of new Set([...Object.keys(m1), ...Object.keys(m2)]))
440
+ out[id] = sortRecords([...(m1[id] ?? []), ...(m2[id] ?? [])]);
441
+ return out;
442
+ };
443
+
444
+ /** An empty ledger state. */
445
+ export function emptyState() {
446
+ return { claims: {}, evidence: {}, provenance: {}, tombstones: {} };
447
+ }
448
+
449
+ /** Semilattice join of two ledger states. Pure; inputs are not mutated. */
450
+ export function mergeStates(s1, s2) {
451
+ const claims = { ...s1.claims };
452
+ // Claim values are pure content keyed by their own hash — identical bytes on every
453
+ // replica, so first-in is not a choice, it's a no-op.
454
+ for (const [id, c] of Object.entries(s2.claims ?? {})) claims[id] ??= c;
455
+ return {
456
+ claims,
457
+ evidence: mergeLogMap(s1.evidence, s2.evidence),
458
+ provenance: mergeLogMap(s1.provenance, s2.provenance),
459
+ tombstones: mergeLogMap(s1.tombstones, s2.tombstones),
460
+ };
461
+ }
462
+
463
+ /** Materialize a state into claim views: evidence attached, provenance = earliest
464
+ * mint record, tombstone = earliest retraction (deterministic across replicas). */
465
+ export function liveClaims(state) {
466
+ return Object.values(state.claims)
467
+ .map((c) => ({
468
+ ...c,
469
+ evidence: state.evidence?.[c.id] ?? [],
470
+ provenance: state.provenance?.[c.id]?.[0] ?? c.provenance ?? {},
471
+ provenanceAll: state.provenance?.[c.id] ?? [],
472
+ tombstone: state.tombstones?.[c.id]?.[0],
473
+ }))
474
+ .sort((a, b) => (a.id < b.id ? -1 : 1));
475
+ }