@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.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- 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
|
+
}
|