From 276e039184632c22fd00f2c88e6791fbf555db3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:57:08 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat(know):=20A7=20knowledge-router=20?= =?UTF-8?q?=E2=80=94=20total=20routing=20of=20facts=20to=20storage=20homes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the formal-synthesis paper's A7 step as code (Theorem T6 totality): src/knowledge_router.js routes any fact to one of seven storage homes (claude-md / rule / skill / state / decision / ledger-fact / recall) with the same exemplar k-NN math as intent.js (labeled HOME_EXEMPLARS bank + setOverlap + confidence gate, reusing intentGrams). Below the gate the fact falls back to `ledger-fact` with provenance:"fallback" — never "none": the ledger's decay-toward-unsure semantics make an unsure placement safe. storeFact dispatches to the EXISTING stores verbatim: appendDecision (decide.js), shadowFact into the repo ledger (ledger_bridge.js), and recall add + personal-ledger shadow (the exact `forge recall add` CLI path). Curated-file homes (claude-md/rule/skill/state) are advise-only — routing returns advice naming the right command instead of a blind write. Secrets are refused before any dispatch (hasSecret), and storeFact never throws. Surfaces: - CLI `forge know "" [--dry-run|--json]` (Memory group). - Cortex hook: distilled lessons (ENABLE_CORTEX_DISTILL=1) that route to decision/ledger-fact at confidence >=0.5 also shadow there, try/catch fail-open inside enrichCreated. - Docs: README + GUIDE command mentions, GUIDE worked section, CHANGELOG [Unreleased] bullet. No new env vars, no new deps, no new ledger KINDS. Tests (test/knowledge_router.test.js, 12): totality fuzz (100 arbitrary strings all land in a real HOMES member), per-family routing from unseen phrasings, dry-run never writes, advise-only homes never write, secret refusal, decision/ledger/recall auto-writes verified against their stores, CLI --dry-run --json smoke. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Ebv4QDTqzPzgApBW2KUsU --- CHANGELOG.md | 12 ++ README.md | 1 + docs/GUIDE.md | 27 ++- src/cli.js | 39 ++++ src/commands.js | 3 +- src/cortex_hook_main.js | 14 +- src/knowledge_router.js | 353 ++++++++++++++++++++++++++++++++++ test/knowledge_router.test.js | 179 +++++++++++++++++ 8 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 src/knowledge_router.js create mode 100644 test/knowledge_router.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d7882..35c4e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`forge know` — the A7 knowledge-router.** Total routing (formal-synthesis + Theorem T6) of any fact to its storage home: exemplar k-NN over a labeled bank + (`src/knowledge_router.js`) picks among claude-md / rule / skill / state / + decision / ledger-fact / recall; below-confidence facts fall back to the ledger + (provenance `fallback`) instead of being dropped. Append-only homes are written + directly (decide log, repo-ledger fact claim, personal recall store), curated + files get advice naming the right command, secrets are refused before dispatch, + and `--dry-run`/`--json` route without writing. Distilled Cortex lessons that + read like decisions or durable facts auto-route to those homes (fail-open). + ## [0.19.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index 873574c..ad25e52 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. | | `forge reuse` | proof-carrying code cache — query / mint / stats | | | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | | | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | +| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | | **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | | | `forge preflight` | assumption / info-gap check | | | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index cdee1cc..c767479 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -28,7 +28,7 @@ Every command is real and wired. Grouped by what it does: | Group | Commands | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Config / cross-tool sync** | `forge init` · `forge sync` · `forge doctor` · `forge update` · `forge docs` · `forge config` · `forge harden` · `forge catalog` · `forge brand` | -| **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` | +| **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` · `forge know` | | **Code graph & retrieval** | `forge atlas` · `forge stack` · `forge context` | | **Substrate / pre-action** | `forge substrate` · `forge preflight` · `forge route` · `forge impact` · `forge scope` · `forge imagine` · `forge anchor` · `forge diagnose` · `forge lean` · `forge cost` | | **Verification & safety** | `forge verify` · `forge scan` · `forge spec` | @@ -355,6 +355,31 @@ nothing durable recorded the choice. `forge decide` appends one ADR-lite line to design: a decision that stops being true gets a _new_ entry, never an edit — the log is history, and `forge docs sync` exempts it for exactly that reason. +### `forge know ""` — route any fact to its storage home + +The substrate has several memory shelves (decisions, ledger facts, personal recall, +handoff state, contributor rules…) and the routing discipline used to be prose — exactly +what sessions forget, so knowledge landed nowhere and was re-learned. `forge know` makes +the routing a function, and a **total** one (formal-synthesis Theorem T6): the same +exemplar k-NN as `forge route`/intent classification picks the home, and a fact that +resembles nothing in the bank falls back to the ledger (where unverified claims decay +toward _unsure_) — it is never dropped. + +```console +$ forge know "we chose sqlite over postgres because zero ops" + home: decision (confidence 0.667) → .forge/decisions.md + ledger + nearest: "we chose sqlite over postgres because zero ops" (0.667) + stored: D-0008 +``` + +Append-only homes (`decision`, `ledger-fact`, `recall`) are written directly; curated +files (`claude-md`, `rule`, `skill`, `state`) get advice naming the right command instead +of a blind write — routing to `state`, for example, points at `forge handoff`. `--dry-run` +routes without writing anything; `--json` for tooling. Secrets are refused before any +dispatch, like every forge store. With `ENABLE_CORTEX_DISTILL=1`, distilled Cortex lessons +that read like decisions or durable facts are auto-routed to those homes too (fail-open, +best-effort). + ### `forge docs sync` — which prose did this diff make stale? `forge docs check` reconciles the registries; `docs sync` answers the diff-shaped diff --git a/src/cli.js b/src/cli.js index 3b6d08c..3fad3aa 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1339,6 +1339,45 @@ async function run(argv) { console.log(` recorded ${r.id}: ${r.text}`); return; } + if (cmd === "know") { + // A7 knowledge-router: total routing (T6) of a fact to its storage home — an unsure + // fact still lands (ledger fallback), it is never dropped. + const { HOMES, routeFact, storeFact } = await import("./knowledge_router.js"); + const json = argv.includes("--json"); + const dry = argv.includes("--dry-run"); + const text = argv + .slice(1) + .filter((a) => a !== "--json" && a !== "--dry-run") + .join(" "); + if (!text) { + console.error(`usage: ${BRAND.cli} know "" [--dry-run] [--json]`); + process.exitCode = 1; + return; + } + const route = routeFact(text); + const r = storeFact(process.cwd(), text, { + mode: dry ? "advise" : "auto", + route, + }); + if (json) return console.log(JSON.stringify({ ...route, ...r, dryRun: dry }, null, 2)); + heading(`${BRAND.brand} know — knowledge routing (A7)\n`); + const why = + route.provenance === "fallback" + ? "fallback — resembles no exemplar; the ledger absorbs unsure placements" + : `confidence ${route.confidence}`; + console.log(` home: ${route.home} (${why}) → ${HOMES[route.home].where}`); + const near = route.neighbors[0]; + if (near) console.log(` nearest: "${near.text}" (${near.sim.toFixed(3)})`); + if (!r.ok) { + console.error(` ${r.reason}`); + process.exitCode = 1; + return; + } + if (r.stored) console.log(` stored: ${r.ref}`); + else if (dry) console.log(" (dry-run — nothing written)"); + if (r.advice) console.log(` advice: ${r.advice}`); + return; + } if (cmd === "diagnose") { const { diagnose, THRASH_K } = await import("./diagnose.js"); const json = argv.includes("--json"); diff --git a/src/commands.js b/src/commands.js index a6046ff..024d5a7 100644 --- a/src/commands.js +++ b/src/commands.js @@ -33,6 +33,7 @@ export const COMMANDS = { anchor: "goal-drift check — are your actual (git) changes still on the stated goal?", handoff: "bounded session snapshot — rewrite .forge/state.md, re-injected each session start", decide: "append-only decision log — D-#### ADR-lite entries in .forge/decisions.md", + know: "route any fact to its storage home (decision / ledger / recall / …) — total, never dropped", diagnose: "doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation", imagine: "consequence simulation — predicted breaks + the minimal dry-run test suite for a task", @@ -46,7 +47,7 @@ export const COMMANDS = { export const GROUPS = { Core: ["init", "sync", "doctor", "catalog", "docs", "update"], - Memory: ["cortex", "recall", "remember", "brain", "ledger", "reuse", "handoff", "decide"], + Memory: ["cortex", "recall", "remember", "brain", "ledger", "reuse", "handoff", "decide", "know"], Substrate: [ "substrate", "preflight", diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 2b90718..a5b696a 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -37,7 +37,19 @@ async function enrichCreated(root, results) { context: lesson.trigger, signals: lesson.provenance?.signals ?? [], }); - if (better) applyDistillation(root, r.id, better); + if (!better) continue; + applyDistillation(root, r.id, better); + // A7 auto-routing: a distilled lesson whose prose reads like a settled decision or + // a durable repo fact ALSO lands in that home (decisions.md / ledger) — confidently + // (≥0.5) routed knowledge reaches the shelf the next session actually reads. + // Fail-open like everything in this file: routing must never break the distill loop. + try { + const { routeFact, storeFact } = await import("./knowledge_router.js"); + const text = `${better.correctedBehavior} — ${better.whatWentWrong}`; + const route = routeFact(text); + if ((route.home === "decision" || route.home === "ledger-fact") && route.confidence >= 0.5) + storeFact(root, text, { route }); + } catch {} } } diff --git a/src/knowledge_router.js b/src/knowledge_router.js new file mode 100644 index 0000000..f8c458d --- /dev/null +++ b/src/knowledge_router.js @@ -0,0 +1,353 @@ +// forge knowledge-router — A7 of the TASK loop: route(fact) → storage home, TOTAL by +// construction (formal-synthesis Theorem T6). The discipline already existed as prose +// ("decisions go in decisions.md, facts in the ledger…") but prose routing is exactly +// what sessions forget — so knowledge landed nowhere and was re-learned. This module is +// the third routing leg beside route.js (model tiers) and intent.js (intent classes): +// the SAME exemplar k-NN math (labeled rows + overlap coefficient + confidence gate), +// tuned by adding example rows, never by editing regexes. Totality is the one place the +// shape differs from classifyIntent: a fact resembling nothing in the bank falls back to +// the ledger (`ledger-fact`, provenance:"fallback") — the ledger is the home whose decay +// semantics make an unsure placement safe — it is NEVER "none". +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { appendDecision } from "./decide.js"; +import { intentGrams } from "./intent.js"; +import { shadowFact } from "./ledger_bridge.js"; +import { repoLedger } from "./ledger_store.js"; +import { setOverlap } from "./math.js"; +import { defaultStore, add as recallAdd, reindex } from "./recall.js"; +import { hasSecret } from "./secrets.js"; +import { slug } from "./util.js"; + +/** + * The storage homes — DATA, one row per home. `write` is the dispatch mode: + * "auto" homes have an append-only store this module may write directly; + * "advise" homes are curated FILES (rewritten wholesale by a human or another + * command), so routing there returns advice, never a blind write. + * @type {Record} + */ +export const HOMES = { + "claude-md": { + write: "advise", + where: "CLAUDE.md / AGENTS.md", + advice: `project-wide instruction — add it to the canonical source (AGENTS.md, or source/ then \`${BRAND.cli} sync\`) so every agent loads it`, + }, + rule: { + write: "advise", + where: "rules / guards", + advice: `a policy is enforced, not remembered — encode it as a rule or guard (global/rules/, a PreToolUse guard) rather than prose`, + }, + skill: { + write: "advise", + where: "a skill / runbook", + advice: `a procedure belongs in replayable steps — capture it as a skill or runbook document, not a one-line memory`, + }, + state: { + write: "advise", + where: ".forge/state.md", + advice: `session state — record it with \`${BRAND.cli} handoff\` so the next session resumes instead of re-assuming`, + }, + decision: { + write: "auto", + where: ".forge/decisions.md + ledger", + advice: `a settled choice — \`${BRAND.cli} decide ""\` appends the ADR-lite line`, + }, + "ledger-fact": { + write: "auto", + where: ".forge/ledger", + advice: `a verifiable repo fact — \`${BRAND.cli} remember "" ""\` shadows it into the ledger`, + }, + recall: { + write: "auto", + where: "~/.forge/recall", + advice: `a personal preference — \`${BRAND.cli} recall add "" ""\` keeps it across repos`, + }, +}; + +/** The totality fallback (T6): unsure placements land here, never nowhere. */ +export const FALLBACK_HOME = "ledger-fact"; + +/** Labeled home bank — DATA, not decision code. Extend coverage by adding rows. */ +export const HOME_EXEMPLARS = [ + // claude-md — durable project-wide conventions every agent must follow + { + text: "always run the full test suite before committing", + home: "claude-md", + }, + { text: "this repo uses tabs not spaces for indentation", home: "claude-md" }, + { text: "all api handlers must validate their input", home: "claude-md" }, + { text: "never use default exports in this codebase", home: "claude-md" }, + { text: "commit messages follow conventional commits", home: "claude-md" }, + { text: "use the shared logger instead of console log", home: "claude-md" }, + { + text: "docs must move in the same pull request as the code", + home: "claude-md", + }, + { + text: "prefer composition over inheritance in the services layer", + home: "claude-md", + }, + { + text: "every module gets a header comment explaining why it exists", + home: "claude-md", + }, + { text: "imports are esm only never require", home: "claude-md" }, + // rule — enforced policy: block / never / deny (a guard, not a memory) + { text: "never commit directly to the main branch", home: "rule" }, + { text: "block any edit to the generated dist folder", home: "rule" }, + { text: "deny network calls from unit tests", home: "rule" }, + { text: "reject pull requests without a changelog entry", home: "rule" }, + { text: "do not delete migration files ever", home: "rule" }, + { text: "guard against force pushes to release branches", home: "rule" }, + { text: "never log user emails in production", home: "rule" }, + { text: "block writes outside the project directory", home: "rule" }, + { text: "credentials must never be committed to the repo", home: "rule" }, + { text: "forbid new runtime dependencies without an adr", home: "rule" }, + // skill — a reusable multi-step procedure / runbook + { + text: "how to cut a release bump version update changelog tag publish", + home: "skill", + }, + { text: "steps to add a new database migration", home: "skill" }, + { text: "the procedure for rotating the api keys", home: "skill" }, + { text: "how to regenerate the protobuf bindings", home: "skill" }, + { + text: "runbook for restoring the staging database from backup", + home: "skill", + }, + { + text: "how to profile a slow endpoint with the flame graph tooling", + home: "skill", + }, + { text: "checklist for onboarding a new microservice", home: "skill" }, + { text: "workflow to reproduce the flaky test locally", home: "skill" }, + { text: "how to run the app against a local postgres", home: "skill" }, + { text: "steps to debug a failing ci pipeline", home: "skill" }, + // state — in-flight session progress the NEXT session resumes from + { + text: "halfway through migrating the auth module tests still failing", + home: "state", + }, + { + text: "next step is to wire pagination into the export endpoint", + home: "state", + }, + { + text: "currently refactoring the payment service two files left", + home: "state", + }, + { + text: "paused while waiting for the api schema from the backend team", + home: "state", + }, + { + text: "todo tomorrow finish the error handling in the upload path", + home: "state", + }, + { + text: "in progress renaming the user manager across the callers", + home: "state", + }, + { + text: "the branch has uncommitted changes to the cache layer", + home: "state", + }, + { text: "still need to update the docs for the new flag", home: "state" }, + { + text: "resume from the failing integration test in checkout", + home: "state", + }, + { + text: "work remaining hook the new validator into the form", + home: "state", + }, + // decision — "we chose X over Y because Z" (settled, with a why) + { text: "we chose sqlite over postgres because zero ops", home: "decision" }, + { text: "picked vitest instead of jest for startup speed", home: "decision" }, + { + text: "decided to keep the monorepo rather than splitting packages", + home: "decision", + }, + { + text: "went with server side rendering because seo matters here", + home: "decision", + }, + { + text: "we rejected graphql because the api surface is small", + home: "decision", + }, + { + text: "chose to vendor the parser instead of adding a dependency", + home: "decision", + }, + { text: "agreed to drop support for the legacy browser", home: "decision" }, + { + text: "we opted for feature flags over long lived branches", + home: "decision", + }, + { + text: "settled on a queue in redis because it is already deployed", + home: "decision", + }, + { + text: "decision use jwt sessions because the gateway validates them", + home: "decision", + }, + // ledger-fact — a verifiable fact about THIS repo/system (also the T6 fallback) + { + text: "the api rate limit is 100 requests per minute", + home: "ledger-fact", + }, + { + text: "postgres runs on port 5433 in this environment", + home: "ledger-fact", + }, + { + text: "the payments service times out after 30 seconds", + home: "ledger-fact", + }, + { + text: "the ci pipeline takes about twelve minutes end to end", + home: "ledger-fact", + }, + { text: "the default branch is called trunk", home: "ledger-fact" }, + { text: "user ids are uuids not integers", home: "ledger-fact" }, + { text: "the mobile app pins the tls certificate", home: "ledger-fact" }, + { + text: "the staging bucket is named acme staging assets", + home: "ledger-fact", + }, + { text: "sessions expire after 24 hours", home: "ledger-fact" }, + { text: "the csv exporter caps at 50000 rows", home: "ledger-fact" }, + // recall — personal, cross-repo preference (first-person signal) + { text: "i prefer short commit messages", home: "recall" }, + { text: "my editor is neovim with lsp enabled", home: "recall" }, + { text: "i like seeing the diff before any commit", home: "recall" }, + { text: "my timezone is ist so schedule builds accordingly", home: "recall" }, + { text: "i want explanations in hindi when possible", home: "recall" }, + { text: "my default shell is fish", home: "recall" }, + { text: "i always want a summary at the end of a session", home: "recall" }, + { text: "call me by my first name in replies", home: "recall" }, + { text: "i prefer yarn over npm on my machine", home: "recall" }, + { text: "my laptop has 16gb ram so keep builds light", home: "recall" }, +]; + +// intentGrams, not a new tokenizer: task verbs and function-word stripping behave the +// same for "what kind of knowledge is this" as for "what kind of work is this". +const EXEMPLAR_GRAMS = HOME_EXEMPLARS.map((e) => ({ + ...e, + grams: intentGrams(e.text), +})); + +/** + * k-NN over the home bank: similarity-weighted vote among the top-k neighbors, gated on + * the best similarity. TOTAL (T6): below the gate — or an unparseable text — the answer + * is the FALLBACK_HOME with provenance "fallback", never "none". Neighbors ride along so + * every routing is attributable to its evidence (mizan: the confidence IS the evidence). + * @param {string} text + * @param {{k?:number, minConf?:number}} [opts] + * @returns {{home:string, confidence:number, provenance:"knn"|"fallback", write:"auto"|"advise", neighbors:{text:string, home:string, sim:number}[]}} + */ +export function routeFact(text, { k = 3, minConf = 0.25 } = {}) { + const grams = intentGrams(text); + const sims = !grams.size + ? [] + : EXEMPLAR_GRAMS.map((e) => ({ + text: e.text, + home: e.home, + sim: setOverlap(grams, e.grams), + })) + .sort((a, b) => b.sim - a.sim) + .slice(0, k); + const top = sims[0]; + if (!top || top.sim < minConf) { + return { + home: FALLBACK_HOME, + confidence: Number((top?.sim ?? 0).toFixed(3)), + provenance: "fallback", + write: HOMES[FALLBACK_HOME].write, + neighbors: sims, + }; + } + const votes = new Map(); + for (const s of sims) votes.set(s.home, (votes.get(s.home) ?? 0) + s.sim); + const [home] = [...votes.entries()].sort((a, b) => b[1] - a[1])[0]; + return { + home, + confidence: Number(top.sim.toFixed(3)), + provenance: "knn", + write: HOMES[home].write, + neighbors: sims, + }; +} + +/** A short, stable fact NAME from its text (ledger/recall stores key facts by name). */ +export function factName(text) { + const s = slug(text).split("-").slice(0, 6).join("-"); + return s || "fact"; +} + +/** + * Store a routed fact in its home. Refuses secrets (same rule as every store, checked + * HERE so no dispatch target can be reached with one). "advise"-mode homes — and every + * home when `mode:"advise"` (the CLI's --dry-run) — return advice and write NOTHING. + * Dispatch reuses the existing stores verbatim: appendDecision, shadowFact into the + * repo ledger, recall add + personal-ledger shadow (the exact `recall add` CLI path). + * Never throws — a routing must never break its caller. + * @param {string} root repo root + * @param {string} text the fact + * @param {{mode?:"auto"|"advise", route?:ReturnType}} [opts] + * @returns {{ok:boolean, home:string, stored:boolean, reason?:string, refused?:boolean, ref?:string, advice?:string}} + */ +export function storeFact(root, text, { mode = "auto", route } = {}) { + const body = String(text ?? "") + .trim() + .replace(/\s+/g, " "); + const r = route ?? routeFact(body); + if (!body) return { ok: false, home: r.home, stored: false, reason: "empty fact" }; + if (hasSecret(body)) { + return { + ok: false, + home: r.home, + stored: false, + refused: true, + reason: "refused: looks like a secret/credential — store a pointer, not the value", + }; + } + const advice = HOMES[r.home]?.advice ?? HOMES[FALLBACK_HOME].advice; + if (mode === "advise" || r.write === "advise") + return { ok: true, home: r.home, stored: false, advice }; + try { + if (r.home === "decision") { + const d = appendDecision(root, body); + return d.ok + ? { ok: true, home: r.home, stored: true, ref: d.id } + : { ok: false, home: r.home, stored: false, reason: d.reason }; + } + if (r.home === "recall") { + const store = defaultStore(); + const res = recallAdd(store, factName(body), body); + if (!res.ok) return { ok: false, home: r.home, stored: false, reason: res.reason }; + // Shadow into the PERSONAL ledger beside the global store, then re-index — the + // same best-effort pair `forge recall add` performs (repo promotion stays explicit). + try { + shadowFact(join(store, "ledger"), factName(body), body); + reindex(store); + } catch {} + return { ok: true, home: r.home, stored: true, ref: res.slug }; + } + // ledger-fact (including every T6 fallback): a claim in the repo ledger, where + // decay semantics make an unsure placement safe (unverified → fades to unsure). + const s = shadowFact(repoLedger(root), factName(body), body); + return s.ok + ? { ok: true, home: "ledger-fact", stored: true, ref: s.id } + : { ok: false, home: "ledger-fact", stored: false, reason: s.reason }; + } catch (err) { + return { + ok: false, + home: r.home, + stored: false, + reason: String(err?.message ?? err), + }; + } +} diff --git a/test/knowledge_router.test.js b/test/knowledge_router.test.js new file mode 100644 index 0000000..162a5ad --- /dev/null +++ b/test/knowledge_router.test.js @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + FALLBACK_HOME, + factName, + HOME_EXEMPLARS, + HOMES, + routeFact, + storeFact, +} from "../src/knowledge_router.js"; +import { loadClaims, repoLedger } from "../src/ledger_store.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +test("routeFact: every home recognized from unseen phrasings (per-family routing)", () => { + assert.equal(routeFact("always run the linter before committing changes").home, "claude-md"); + assert.equal(routeFact("never push directly to the release branch").home, "rule"); + assert.equal(routeFact("how to restore the production database from backup").home, "skill"); + assert.equal(routeFact("next step is to finish pagination in the export endpoint").home, "state"); + assert.equal( + routeFact("we chose redis over rabbitmq because it is already deployed").home, + "decision", + ); + assert.equal(routeFact("the api gateway times out after 30 seconds").home, "ledger-fact"); + assert.equal(routeFact("i prefer detailed commit messages").home, "recall"); +}); + +test("routeFact: TOTAL (T6) — 100 arbitrary strings all land in a real home", () => { + // Deterministic LCG so the fuzz corpus is reproducible run-to-run. + let seed = 42; + const rnd = () => { + seed = (seed * 1103515245 + 12345) % 2 ** 31; + return seed / 2 ** 31; + }; + const alphabets = [ + "abcdefghijklmnopqrstuvwxyz ", + "0123456789 -_/", + "日本語テキスト漢字 ", + "🎉🚀💥🔥 ", + "{}[]()<>!?#$%^&* ", + ]; + const corpus = ["", " ", "???", "a", "\n\t"]; + while (corpus.length < 100) { + const alpha = alphabets[Math.floor(rnd() * alphabets.length)]; + const len = Math.floor(rnd() * 80); + let s = ""; + for (let i = 0; i < len; i++) s += alpha[Math.floor(rnd() * alpha.length)]; + corpus.push(s); + } + for (const s of corpus) { + const r = routeFact(s); + assert.ok(r.home in HOMES, `"${s.slice(0, 30)}" routed to unknown home ${r.home}`); + assert.ok(["knn", "fallback"].includes(r.provenance)); + assert.ok(r.confidence >= 0 && r.confidence <= 1); + } +}); + +test("routeFact: below the gate → the ledger fallback with provenance, never 'none'", () => { + const r = routeFact("zzz qqq xyzzy plugh"); + assert.equal(r.home, FALLBACK_HOME); + assert.equal(r.provenance, "fallback"); + const hi = routeFact("we chose sqlite over postgres because zero ops"); + assert.equal(hi.provenance, "knn"); + assert.ok(hi.neighbors.length > 0 && hi.neighbors[0].sim >= hi.confidence - 1e-9); +}); + +test("HOMES/exemplars: every exemplar row labels a real home; bank covers all homes", () => { + const labeled = new Set(HOME_EXEMPLARS.map((e) => e.home)); + for (const h of labeled) assert.ok(h in HOMES, `exemplar home ${h} not in HOMES`); + for (const h of Object.keys(HOMES)) assert.ok(labeled.has(h), `home ${h} has no exemplars`); + assert.ok(HOMES[FALLBACK_HOME].write === "auto", "fallback home must be writable (totality)"); +}); + +test("storeFact: dry-run (mode advise) never writes, whatever the home", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + for (const text of [ + "we chose sqlite over postgres because zero ops", + "the api rate limit is 100 requests per minute", + "zzz qqq xyzzy plugh", + ]) { + const r = storeFact(root, text, { mode: "advise" }); + assert.equal(r.ok, true); + assert.equal(r.stored, false); + assert.ok(r.advice, "advise mode explains where the fact belongs"); + } + assert.ok(!existsSync(join(root, ".forge")), "dry-run created .forge/"); +}); + +test("storeFact: advise-only homes (files) get advice, no write — even in auto mode", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const r = storeFact(root, "next step is to finish pagination in the export endpoint"); + assert.equal(r.home, "state"); + assert.equal(r.stored, false); + assert.match(r.advice ?? "", /handoff/); + assert.ok(!existsSync(join(root, ".forge"))); +}); + +test("storeFact: secret refusal — routed but never stored, nothing touches disk", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const r = storeFact(root, "we chose this api_key=sk-abcdefghijklmnopqrstuvwx for staging"); + assert.equal(r.ok, false); + assert.equal(r.refused, true); + assert.equal(r.stored, false); + assert.ok(!existsSync(join(root, ".forge")), "a refused secret still wrote something"); +}); + +test("storeFact: decision home appends the ADR-lite line", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const r = storeFact(root, "we chose sqlite over postgres because zero ops"); + assert.equal(r.ok, true); + assert.equal(r.home, "decision"); + assert.equal(r.stored, true); + assert.equal(r.ref, "D-0001"); + const log = readFileSync(join(root, ".forge", "decisions.md"), "utf8"); + assert.match(log, /\*\*D-0001\*\*.*we chose sqlite over postgres because zero ops/); +}); + +test("storeFact: ledger-fact home (and the T6 fallback) mint a fact claim", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const fact = storeFact(root, "the api rate limit is 100 requests per minute"); + assert.equal(fact.home, "ledger-fact"); + assert.equal(fact.stored, true); + const fb = storeFact(root, "zzz qqq xyzzy plugh"); + assert.equal(fb.home, "ledger-fact"); + assert.equal(fb.stored, true); + const claims = loadClaims(repoLedger(root)); + const bodies = claims.filter((c) => c.kind === "fact").map((c) => c.body?.text); + assert.ok(bodies.includes("the api rate limit is 100 requests per minute")); + assert.ok(bodies.includes("zzz qqq xyzzy plugh")); +}); + +test("storeFact: recall home writes the personal store (FORGE_HOME-scoped)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const home = mkdtempSync(join(tmpdir(), "forge-know-home-")); + process.env.FORGE_HOME = home; + try { + const r = storeFact(root, "i prefer detailed commit messages"); + assert.equal(r.home, "recall"); + assert.equal(r.stored, true); + const slugged = factName("i prefer detailed commit messages"); + assert.ok(existsSync(join(home, "recall", "facts", `${slugged}.md`))); + assert.ok(!existsSync(join(root, ".forge")), "recall must not touch the repo ledger"); + } finally { + delete process.env.FORGE_HOME; + } +}); + +test("factName: short stable slug, never empty", () => { + assert.equal( + factName("The API rate limit is 100 requests per minute"), + "the-api-rate-limit-is-100", + ); + assert.equal(factName("???"), "fact"); +}); + +test("cli: forge know --dry-run --json routes without writing", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const p = spawnSync( + "node", + [CLI, "know", "we chose sqlite because zero dependencies", "--dry-run", "--json"], + { cwd: root, encoding: "utf8" }, + ); + assert.equal(p.status, 0, p.stderr); + const out = JSON.parse(p.stdout); + assert.equal(out.home, "decision"); + assert.equal(out.dryRun, true); + assert.equal(out.stored, false); + assert.ok(!existsSync(join(root, ".forge")), "--dry-run wrote to disk"); + const empty = spawnSync("node", [CLI, "know"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(empty.status, 1, "bare know without a fact must usage-error"); +}); From 647b8ebe2d9bf35f47ae823e8a6d6cba86df9bf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 03:00:57 +0000 Subject: [PATCH 02/12] feat(precommit): commit-level gate rung (F1 classifier + secret scan) wired via harden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the middle rung of the gate lattice (turn ⊂ commit ⊂ PR): - src/commit_gate.js — stagedFiles (git diff --cached -z), classifyPath reuse from gate.js (code staged with no doc/state artifact → completeness finding), and secrets.js detection over staged ADDED lines only as the gitleaks fallback (confirmed at redaction-grade precision so ordinary code like `token = process.env.TOKEN` is never a false block). - Modes via FORGE_COMMIT_GATE: warn (default, print + allow), block (refuse), 0/off (kill switch); a detected secret blocks in every mode (mizan — a credential in history is not repairable). Fail-open like stopGate. - forge precommit CLI command (+ --json), registered in commands.js (Quality). - harden.js: pre-commit hook is now gitleaks-if-present + `node /src/cli.js precommit`, fail-open when node/package are missing, and no-clobber — a user-authored hook is kept and ours lands beside it as pre-commit.. - Docs in the same change: README/GUIDE command rows, GUIDE env table row for FORGE_COMMIT_GATE, CHANGELOG [Unreleased] bullet. - Tests: test/commit_gate.test.js (warn/pass/block/secret/removed-lines/kill- switch/fail-open/unicode -z/pure-table) + harden hook install/no-clobber. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Ebv4QDTqzPzgApBW2KUsU --- CHANGELOG.md | 8 ++ README.md | 3 +- docs/GUIDE.md | 106 +++++++++--------- src/cli.js | 24 ++++- src/commands.js | 6 +- src/commit_gate.js | 224 +++++++++++++++++++++++++++++++++++++++ src/harden.js | 66 +++++++++--- test/commit_gate.test.js | 205 +++++++++++++++++++++++++++++++++++ test/harden.test.js | 40 ++++++- 9 files changed, 611 insertions(+), 71 deletions(-) create mode 100644 src/commit_gate.js create mode 100644 test/commit_gate.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c4e4c..4025db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). files get advice naming the right command, secrets are refused before dispatch, and `--dry-run`/`--json` route without writing. Distilled Cortex lessons that read like decisions or durable facts auto-route to those homes (fail-open). +- **Commit-level gate rung (`forge precommit`).** The gate lattice's middle rung + (turn ⊂ commit ⊂ PR): `src/commit_gate.js` classifies staged files with the same + registry-derived classifier as the Stop gate (code staged with no doc/state artifact + → finding) and runs the built-in secret detector over staged added lines as a + gitleaks fallback. `FORGE_COMMIT_GATE` sets the mode (`warn` default · `block` · + `0` kill switch); a detected secret blocks in every mode. `forge harden` now installs + a pre-commit hook that runs gitleaks when present, then the commit gate — and never + clobbers a user-authored hook (writes `pre-commit.forge` beside it instead). ## [0.19.0] - 2026-07-17 diff --git a/README.md b/README.md index ad25e52..f82724b 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. | | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it | | | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | | | `forge config` | provider setup — show / switch / add providers, set the default model | -| | `forge harden` | wire gitleaks pre-commit + sandbox settings | +| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | | | `forge catalog` | Start-Here index of every tool / crew / guard | | | `forge brand` | print the brand token map | | **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / import | @@ -207,6 +207,7 @@ forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. | | `forge lean` | scope-minimality footprint (advisory) | | | `forge cost` | real per-day spend · measured stage factors (`--stages`) | | **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance | +| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | | | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | | | `forge spec` | spec-as-contract drift — init / lock / check | | **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index c767479..36e9153 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -31,7 +31,7 @@ Every command is real and wired. Grouped by what it does: | **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` · `forge know` | | **Code graph & retrieval** | `forge atlas` · `forge stack` · `forge context` | | **Substrate / pre-action** | `forge substrate` · `forge preflight` · `forge route` · `forge impact` · `forge scope` · `forge imagine` · `forge anchor` · `forge diagnose` · `forge lean` · `forge cost` | -| **Verification & safety** | `forge verify` · `forge scan` · `forge spec` | +| **Verification & safety** | `forge verify` · `forge precommit` · `forge scan` · `forge spec` | | **UI / design** | `forge taste` · `forge uicheck` | | **Dashboard** | `forge dash` | @@ -204,7 +204,7 @@ weights). `ANTHROPIC_MODEL` / `FORGE_MODEL` override the tier choice entirely. Run `forge route gateway` to emit a LiteLLM config so the routing happens automatically. -**`forge route calibrate`** is the *advisory → gated promotion* (overview §4): it fits an +**`forge route calibrate`** is the _advisory → gated promotion_ (overview §4): it fits an affine correction of the rubric's score toward a held-out labeled fixture and reports whether that calibration **measurably** beats the raw rubric (lower held-out MAE past a margin) — the same kill-criteria discipline as the risk predictor (`src/predictor.js`), @@ -225,7 +225,7 @@ Forge route calibrate — outcome-calibrated routing (measured gate) ``` Here the gate does exactly its job: the rubric already generalizes well, the affine -calibration would make held-out error *worse*, so it is **refused**. A promotion only +calibration would make held-out error _worse_, so it is **refused**. A promotion only happens when the measurement earns it — an assertion never does. ### `forge config` — provider setup @@ -817,22 +817,23 @@ Plain `forge cost` remains the per-day spend view via `ccusage`. ### The rest -| Command | Answers | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `forge init` | Emit every tool's native config from one source. | -| `forge sync` | Recompile `source/` → each tool's files (idempotent). | -| `forge doctor` | Health check: layers, install, drift, cortex. | -| `forge docs check` | Docs↔code drift: commands, env vars, MCP tools, CHANGELOG reconciled against the code (CI-gated on the forge repo itself). | -| `forge docs sync` | Diff-driven stale-docs sweep: UPDATED / STALE (file:line hits) / VERIFIED-UNAFFECTED per artifact (see the full section above). | -| `forge catalog` | Start-Here index of every tool / crew / guard. | -| `forge brain` / `forge remember` | Portable project memory inlined into `AGENTS.md`. | -| `forge cost` | Real per-day spend (via `ccusage`) + the cost ceiling; `--stages` for the measured report. | -| `forge scan ` | Vet a skill/MCP (SKILL.md/.mcp.json) for injection/RCE/exfil before install. | -| `forge harden` | Wire gitleaks pre-commit + sandbox settings. | -| `forge spec [init\|lock\|check]` | Spec-as-contract drift check. | -| `forge brand` | Print the active brand token map. | -| `forge lean ""` | Scope-minimality footprint for a task — advisory (the Lean Path as a command). | -| `forge taste [ - - -
- - forge dash - - -
- -
-
-

Ledger — proof-carrying memory .forge/ledger/

-
-
- - - - -
-
-

Contested — val ∈ [0.40, 0.60] with ≥1 contradiction

-
-

Author trust — earned from oracle outcomes, floored at 0.50

-
-
- -
-

Cost / Cache — measured, not asserted .forge/metrics.jsonl

-
-
-
-

Recent events

-
    -
    - -
    -

    Impact — blast radius .forge/atlas.json

    -
    - - -
    -
    pick a symbol to see what a change would touch
    -
    -
    - -
    lens on .forge/ — every number traces to forge ledger blame <id>. the only writes are ratify/retract (append-only; CLI twins: forge ledger ratify|retract).
    - - - + :root { + --bg: #171310; + --panel: #201a15; + --panel-2: #272019; + --line: #372c22; + --fg: #f2ede7; + --muted: #a99e90; + --faint: #7d7263; + --ember: #f26430; + --ember-wash: rgba(242, 100, 48, 0.14); + --r-s: 4px; /* radius level 1: chips, bars, inputs */ + --r-m: 10px; /* radius level 2: panels */ + --s1: 4px; + --s2: 8px; + --s3: 12px; + --s4: 16px; + --s6: 24px; + --s8: 32px; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; + } + * { + box-sizing: border-box; + } + body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 13px/1.55 var(--sans); + -webkit-font-smoothing: antialiased; + } + .mono { + font-family: var(--mono); + } + .muted { + color: var(--muted); + } + .faint { + color: var(--faint); + } + + header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + gap: var(--s3); + padding: var(--s3) var(--s6); + background: rgba(23, 19, 16, 0.85); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); + } + .spark { + width: 8px; + height: 8px; + border-radius: var(--r-s); + background: var(--ember); + } + .brand { + font-family: var(--mono); + font-size: 13px; + letter-spacing: 0.02em; + } + .brand b { + color: var(--ember); + font-weight: 600; + } + #hdr-meta { + margin-left: auto; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + } + button { + font: 12px var(--mono); + color: var(--fg); + cursor: pointer; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s1) var(--s3); + } + button:hover { + border-color: var(--ember); + color: var(--ember); + } + + main { + max-width: 1180px; + margin: 0 auto; + padding: var(--s6); + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s4); + } + .panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--r-m); + padding: var(--s4); + min-width: 0; + } + .panel.wide { + grid-column: 1 / -1; + } + @media (max-width: 880px) { + main { + grid-template-columns: 1fr; + } + } + .panel h2 { + margin: 0 0 var(--s3); + font: 600 11px var(--mono); + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--muted); + } + .panel h2 .cmd { + text-transform: none; + letter-spacing: 0; + color: var(--faint); + float: right; + } + h3 { + margin: var(--s6) 0 var(--s2); + font: 600 11px var(--mono); + letter-spacing: 0.08em; + color: var(--faint); + text-transform: uppercase; + } + .empty { + color: var(--faint); + font-family: var(--mono); + font-size: 12px; + padding: var(--s2) 0; + } + + /* stat tiles */ + .stats { + display: flex; + flex-wrap: wrap; + gap: var(--s2); + margin-bottom: var(--s3); + } + .stat { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s2) var(--s3); + min-width: 84px; + } + .stat .n { + font: 600 18px var(--mono); + display: block; + } + .stat .n.ember { + color: var(--ember); + } + .stat .l { + font-size: 11px; + color: var(--faint); + } + + /* tables */ + table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + th { + text-align: left; + font: 600 10px var(--mono); + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--faint); + padding: var(--s1) var(--s2); + border-bottom: 1px solid var(--line); + } + td { + padding: var(--s1) var(--s2); + border-bottom: 1px solid var(--line); + vertical-align: top; + } + tr:last-child td { + border-bottom: 0; + } + tr.tomb td { + opacity: 0.45; + } + tr.tomb .text { + text-decoration: line-through; + } + td.num { + font-family: var(--mono); + text-align: right; + } + .tag { + font: 11px var(--mono); + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: 0 var(--s1); + display: inline-block; + white-space: nowrap; + } + .tag.contested { + color: var(--ember); + border-color: var(--ember); + background: var(--ember-wash); + } + + /* val / trust / confidence bars — width IS the number; label repeats it */ + .bar { + display: inline-block; + width: 64px; + height: 6px; + vertical-align: middle; + background: var(--panel-2); + border-radius: var(--r-s); + overflow: hidden; + margin-right: var(--s2); + } + .bar i { + display: block; + height: 100%; + background: var(--ember); + border-radius: var(--r-s); + } + .bar.dim i { + background: var(--faint); + } + .barnum { + font-family: var(--mono); + font-size: 11px; + } + + /* the provenance affordance: every number traces to a blame command */ + .blame { + font: 11px var(--mono); + color: var(--faint); + cursor: copy; + white-space: nowrap; + } + .blame:hover { + color: var(--ember); + } + .blame.copied { + color: var(--ember); + } + + /* the two writes (spec §2): small per-row actions, append-only on the server */ + .act { + font: 11px var(--mono); + color: var(--faint); + cursor: pointer; + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: 0 var(--s1); + white-space: nowrap; + display: inline-block; + } + .act:hover { + color: var(--ember); + border-color: var(--ember); + background: var(--ember-wash); + } + td.acts { + white-space: nowrap; + } + + select, + input[type="text"] { + font: 12px var(--mono); + color: var(--fg); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s1) var(--s2); + } + input[type="text"]:focus, + select:focus { + outline: none; + border-color: var(--ember); + } + .toolbar { + display: flex; + align-items: center; + gap: var(--s2); + margin-bottom: var(--s3); + flex-wrap: wrap; + } + .toolbar .grow { + flex: 1; + } + + .log { + font: 11px/1.7 var(--mono); + color: var(--muted); + margin: 0; + padding: 0; + list-style: none; + } + .log .st { + color: var(--fg); + } + .log .plus { + color: var(--ember); + } + + /* dash v2: sparklines, radar rings, timeline */ + .spark-svg { + display: block; + width: 100%; + height: 28px; + } + .trend { + display: grid; + grid-template-columns: 96px 1fr auto; + align-items: center; + gap: var(--s3); + padding: var(--s1) 0; + border-bottom: 1px solid var(--line); + } + .trend:last-child { + border-bottom: 0; + } + .trend .lbl { + font: 11px var(--mono); + color: var(--muted); + } + .trend .n { + font: 11px var(--mono); + color: var(--faint); + text-align: right; + white-space: nowrap; + } + + .rings { + display: flex; + flex-wrap: wrap; + gap: var(--s2); + margin-bottom: var(--s3); + } + .ringcount { + display: inline-flex; + align-items: center; + gap: var(--s1); + font: 11px var(--mono); + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s1) var(--s2); + } + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + } + .dot.adopt { + background: #46b17a; + } + .dot.trial { + background: #d9a441; + } + .dot.assess { + background: var(--muted); + } + .dot.hold { + background: var(--ember); + } + td.ring { + white-space: nowrap; + } + + .tl { + margin: 0; + padding: 0; + list-style: none; + font: 11px/1.6 var(--mono); + } + .tl li { + display: grid; + grid-template-columns: 44px 60px 1fr; + gap: var(--s2); + padding: var(--s1) 0; + border-bottom: 1px solid var(--line); + align-items: baseline; + } + .tl li:last-child { + border-bottom: 0; + } + .tl .day { + color: var(--faint); + } + .tl .ev { + color: var(--muted); + } + .tl .ev.retract { + color: var(--ember); + } + .tl .txt { + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + footer { + max-width: 1180px; + margin: 0 auto; + padding: 0 var(--s6) var(--s8); + font: 11px var(--mono); + color: var(--faint); + } + + + +
    + + forge dash + + +
    + +
    +
    +

    + Ledger — proof-carrying memory .forge/ledger/ +

    +
    +
    + + + + +
    +
    +

    Contested — val ∈ [0.40, 0.60] with ≥1 contradiction

    +
    +

    Author trust — earned from oracle outcomes, floored at 0.50

    +
    +
    + +
    +

    + Cost / Cache — measured, not asserted + .forge/metrics.jsonl +

    +
    +
    +
    +

    Recent events

    +
      +
      + +
      +

      + Impact — blast radius .forge/atlas.json +

      +
      + + +
      +
      + pick a symbol to see what a change would touch +
      +
      + +
      +

      + Radar — dependency currency forge radar +

      +
      +
      + no radar cache yet — run: forge radar +
      +
      + +
      +

      + Trends — stage history .forge/metrics.jsonl +

      +
      + no metrics yet — stages append as you work +
      +
      + +
      +

      + Memory browser — ranked recall + forge deja "<task>" +

      +
      + + + +
      +
      + type to search, or leave blank for the whole ledger by confidence +
      +
      + +
      +

      + Session timeline — durable memory events + .forge/ledger/ +

      +
        +
        +
        + +
        + lens on .forge/ — every number traces to + forge ledger blame <id>. the only writes + are ratify/retract (append-only; CLI twins: + forge ledger ratify|retract). +
        + + + diff --git a/src/dash.js b/src/dash.js index 22d2387..edf4f4c 100644 --- a/src/dash.js +++ b/src/dash.js @@ -13,7 +13,7 @@ import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { impact, load as loadAtlas } from "./atlas.js"; import { estimateSpendFromLogs } from "./cost_report.js"; -import { authorTrust, claimText, val, validOutcome } from "./ledger.js"; +import { authorTrust, claimText, retrieve, val, validOutcome } from "./ledger.js"; import { getClaimByPrefix, loadClaims, @@ -23,7 +23,7 @@ import { tombstone, } from "./ledger_store.js"; import { read as readMetrics, summarize } from "./metrics.js"; -import { epochDay, gitAuthor } from "./util.js"; +import { clamp01, epochDay, gitAuthor, MS_PER_DAY } from "./util.js"; /** A claim is contested when its val sits in this band AND it carries ≥1 oracle * contradiction — genuinely disputed, not merely fresh (a fresh claim is 0.5 with @@ -35,7 +35,12 @@ const CLAIM_CAP = 200; const RECENT_CAP = 20; const emptyLedger = () => ({ - stats: { total: 0, tombstoned: 0, byKind: {}, val: { dormant: 0, uncertain: 0, trusted: 0 } }, + stats: { + total: 0, + tombstoned: 0, + byKind: {}, + val: { dormant: 0, uncertain: 0, trusted: 0 }, + }, claims: [], contested: [], trust: {}, @@ -68,7 +73,12 @@ function ledgerSection(root, nowDay) { return (c.evidence ?? []).some((e) => validOutcome(e) && e.result === "contradict"); }) .map((c) => claimRow(c, nowDay)); - return { stats: stats(dir, nowDay), claims, contested, trust: authorTrust(all) }; + return { + stats: stats(dir, nowDay), + claims, + contested, + trust: authorTrust(all), + }; } function metricsSection(root) { @@ -102,7 +112,12 @@ export function dashData(root, { nowDay = epochDay() } = {}) { let atlas = { built: false, symbols: 0, files: 0 }; try { const a = loadAtlas(root); - if (a) atlas = { built: true, symbols: a.symbols?.length ?? 0, files: a.files ?? 0 }; + if (a) + atlas = { + built: true, + symbols: a.symbols?.length ?? 0, + files: a.files ?? 0, + }; } catch {} let spend = null; try { @@ -147,6 +162,227 @@ export function dashSummary(root, { nowDay = epochDay() } = {}) { }; } +// --- dash v2: history / memory-browser / radar / timeline lenses ----------- +// Each is a pure, self-contained payload builder that NEVER throws — a corrupt or +// missing store degrades to an empty section, same discipline as dashData. + +/** Payload caps for the v2 lenses. */ +const HISTORY_CAP_DAYS = 90; +const CLAIM_BROWSE_BUDGET = 50; +const TIMELINE_CAP = 60; +const FRESH_HALF_LIFE_DAYS = 45; + +const metricDay = (e) => Math.floor((Number.isFinite(e?.t) ? e.t : 0) / MS_PER_DAY); + +/** + * Metrics history bucketed by day and by stage, capped to the last `capDays` days + * (I4 trend lens). The window anchors on the latest observed day (or nowDay), so a + * repo whose clock and nowDay disagree still yields its real trailing window. + * @param {string} root + * @param {{nowDay?: number, capDays?: number}} [opts] + */ +export function historyData(root, { nowDay = epochDay(), capDays = HISTORY_CAP_DAYS } = {}) { + let events = []; + try { + events = readMetrics(root); + } catch {} + const anchor = events.reduce((m, e) => Math.max(m, metricDay(e)), nowDay); + const floor = anchor - capDays; + const buckets = new Map(); + const stages = new Map(); + let totalEvents = 0; + let totalSaved = 0; + for (const e of events) { + const day = metricDay(e); + if (day <= floor) continue; + const saved = Number.isFinite(e.savedEstimate) ? e.savedEstimate : 0; + const outcome = e.outcome || ""; + const b = buckets.get(day) ?? { day, events: 0, saved: 0, byOutcome: {} }; + b.events++; + b.saved += saved; + if (outcome) b.byOutcome[outcome] = (b.byOutcome[outcome] ?? 0) + 1; + buckets.set(day, b); + const stage = e.stage || ""; + const s = stages.get(stage) ?? { + events: 0, + saved: 0, + byOutcome: {}, + series: new Map(), + }; + s.events++; + s.saved += saved; + if (outcome) s.byOutcome[outcome] = (s.byOutcome[outcome] ?? 0) + 1; + const sp = s.series.get(day) ?? { day, events: 0, saved: 0 }; + sp.events++; + sp.saved += saved; + s.series.set(day, sp); + stages.set(stage, s); + totalEvents++; + totalSaved += saved; + } + const bucketList = [...buckets.values()].sort((a, b) => a.day - b.day); + const stageObj = {}; + for (const [name, s] of [...stages.entries()].sort((a, b) => b[1].events - a[1].events)) + stageObj[name] = { + events: s.events, + saved: s.saved, + byOutcome: s.byOutcome, + series: [...s.series.values()].sort((a, b) => a.day - b.day), + }; + return { + window: capDays, + from: bucketList.length ? bucketList[0].day : null, + to: bucketList.length ? bucketList[bucketList.length - 1].day : null, + buckets: bucketList, + stages: stageObj, + totals: { events: totalEvents, saved: totalSaved }, + }; +} + +/** Pure recency factor (0..1) from a claim's mint day — a freshness bar to sit next + * to the val (confidence) bar in the memory browser. val already folds decay; this + * exposes age on its own so a stale-but-trusted claim reads honestly. */ +const freshness = (c, nowDay) => { + const t = c.provenance?.t; + if (!Number.isFinite(t)) return 0; + return clamp01(2 ** (-Math.max(0, nowDay - t) / FRESH_HALF_LIFE_DAYS)); +}; + +const claimBrowseRow = (c, nowDay, score) => ({ + ...claimRow(c, nowDay), + fresh: Number(freshness(c, nowDay).toFixed(3)), + score: score == null ? null : Number(score.toFixed(4)), +}); + +/** + * Memory browser: ranked claims for the given query (Eq.3 rel×rec×val via `retrieve`) + * or, with no query, the whole live ledger sorted by val. Optional kind filter. + * @param {string} root + * @param {{q?: string, kind?: string, nowDay?: number, budget?: number}} [opts] + */ +export function claimsData( + root, + { q = "", kind = "", nowDay = epochDay(), budget = CLAIM_BROWSE_BUDGET } = {}, +) { + let all = []; + try { + all = loadClaims(repoLedger(root)); + } catch {} + const query = String(q ?? "").trim(); + let ranked; + if (query) { + ranked = retrieve(query, all, { nowDay, budget: budget * 2 }); + } else { + ranked = all + .filter((c) => !c.tombstone) + .map((claim) => ({ claim, score: null })) + .sort( + (a, b) => val(b.claim, nowDay) - val(a.claim, nowDay) || (a.claim.id < b.claim.id ? -1 : 1), + ); + } + let rows = ranked.map(({ claim, score }) => claimBrowseRow(claim, nowDay, score)); + const wantKind = String(kind ?? "").trim(); + if (wantKind) rows = rows.filter((r) => r.kind === wantKind); + rows = rows.slice(0, budget); + return { + q: query, + kind: wantKind, + total: all.length, + count: rows.length, + rows, + }; +} + +const RADAR_RINGS = ["adopt", "trial", "assess", "hold"]; +const RADAR_ORDER = { hold: 0, assess: 1, trial: 2, adopt: 3 }; + +/** + * Radar rings read straight from the `.forge/radar.json` cache (hooks/dash never + * fetch — radar.js owns the network). Tolerant of shape drift; a missing or + * unparseable cache degrades to an empty, present:false panel (a clean 200). + * @param {string} root + */ +export function radarData(root) { + let raw; + try { + raw = JSON.parse(readFileSync(join(root, ".forge", "radar.json"), "utf8")); + } catch { + return { present: false, t: null, deps: [], counts: {} }; + } + const depsRaw = raw?.deps ?? {}; + const entries = Array.isArray(depsRaw) + ? depsRaw.map((d) => [d?.name, d]) + : Object.entries(depsRaw); + const counts = {}; + const deps = []; + for (const [name, d] of entries) { + if (!name || typeof d !== "object" || d == null) continue; + const ring = RADAR_RINGS.includes(d.ring) ? d.ring : "assess"; + const score = Number.isFinite(d.score) ? Number(d.score.toFixed(3)) : null; + counts[ring] = (counts[ring] ?? 0) + 1; + deps.push({ + name: String(name), + ring, + score, + version: typeof d.version === "string" ? d.version : "", + latest: typeof d.latest === "string" ? d.latest : "", + evidence: d.evidence && typeof d.evidence === "object" ? d.evidence : {}, + }); + } + deps.sort( + (a, b) => + RADAR_ORDER[a.ring] - RADAR_ORDER[b.ring] || + (b.score ?? 0) - (a.score ?? 0) || + (a.name < b.name ? -1 : 1), + ); + return { + present: true, + t: Number.isFinite(raw?.t) ? raw.t : null, + deps, + counts, + }; +} + +/** + * Session timeline drawn from the DURABLE ledger (mint + tombstone events), newest + * first — not the ephemeral session logs that clear between sessions. + * @param {string} root + * @param {{cap?: number}} [opts] + */ +export function timelineData(root, { cap = TIMELINE_CAP } = {}) { + let all = []; + try { + all = loadClaims(repoLedger(root)); + } catch {} + const events = []; + for (const c of all) { + const id8 = c.id.slice(0, 8); + const mintT = c.provenance?.t; + if (Number.isFinite(mintT)) + events.push({ + day: mintT, + type: "mint", + kind: c.kind, + id8, + author: c.provenance?.author ?? "", + text: claimText(c).slice(0, 120), + }); + if (c.tombstone && Number.isFinite(c.tombstone.t)) + events.push({ + day: c.tombstone.t, + type: "retract", + kind: c.kind, + id8, + author: c.tombstone.author ?? "", + text: c.tombstone.reason + ? String(c.tombstone.reason).slice(0, 120) + : claimText(c).slice(0, 120), + }); + } + events.sort((a, b) => b.day - a.day || (a.id8 < b.id8 ? -1 : 1)); + return { count: Math.min(events.length, cap), events: events.slice(0, cap) }; +} + const HTML_PATH = join(dirname(fileURLToPath(import.meta.url)), "dash.html"); const sendJson = (res, code, body) => { @@ -187,7 +423,9 @@ async function handleWrite(root, pathname, req, res) { } const id = typeof body?.id === "string" ? body.id.trim() : ""; if (id.length < 2) - return sendJson(res, 400, { error: 'body must be {"id": ""}' }); + return sendJson(res, 400, { + error: 'body must be {"id": ""}', + }); const dir = repoLedger(root); const author = gitAuthor(); const t = epochDay(); @@ -206,8 +444,10 @@ const WRITE_ROUTES = new Set(["/api/ratify", "/api/retract"]); /** * The dashboard server: GET / → the page, GET /api/data → dashData, GET - * /api/impact?target=X → blast radius (when an atlas exists), POST /api/ratify and - * POST /api/retract → the spec's two append-only writes. Everything else 404. + * /api/history → metrics trends, GET /api/claims?q=&kind= → memory browser, GET + * /api/radar → dependency rings (cache-only), GET /api/timeline → durable event + * stream, GET /api/impact?target=X → blast radius (when an atlas exists), POST + * /api/ratify and POST /api/retract → the spec's two append-only writes. Else 404. * Localhost-only by default — pass a host explicitly to expose it, on your own head. * @param {string} root * @param {{port?: number, host?: string}} [opts] @@ -235,18 +475,36 @@ export function serve(root, { port = 4242, host = "127.0.0.1" } = {}) { return res.end(html); } if (url.pathname === "/api/data") return sendJson(res, 200, dashData(root)); + if (url.pathname === "/api/history") return sendJson(res, 200, historyData(root)); + if (url.pathname === "/api/radar") return sendJson(res, 200, radarData(root)); + if (url.pathname === "/api/timeline") return sendJson(res, 200, timelineData(root)); + if (url.pathname === "/api/claims") + return sendJson( + res, + 200, + claimsData(root, { + q: url.searchParams.get("q") ?? "", + kind: url.searchParams.get("kind") ?? "", + }), + ); if (url.pathname === "/api/spend") { const spend = estimateSpendFromLogs(); return sendJson(res, 200, spend || { totalCost: 0, sessions: 0, byModel: [] }); } if (url.pathname === "/api/impact") { const target = url.searchParams.get("target"); - if (!target) return sendJson(res, 400, { error: "usage: /api/impact?target=" }); + if (!target) + return sendJson(res, 400, { + error: "usage: /api/impact?target=", + }); let atlas = null; try { atlas = loadAtlas(root); } catch {} - if (!atlas) return sendJson(res, 404, { error: "no atlas — run `forge atlas build` first" }); + if (!atlas) + return sendJson(res, 404, { + error: "no atlas — run `forge atlas build` first", + }); return sendJson(res, 200, impact(atlas, target)); } return sendJson(res, 404, { error: "not found" }); diff --git a/test/dash.test.js b/test/dash.test.js index 18ae2d0..73abf1c 100644 --- a/test/dash.test.js +++ b/test/dash.test.js @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { dashData, serve } from "../src/dash.js"; +import { claimsData, dashData, historyData, radarData, serve, timelineData } from "../src/dash.js"; import { mintClaim, outcomeRecord } from "../src/ledger.js"; import { appendEvidence, @@ -18,7 +18,12 @@ const tmp = () => mkdtempSync(join(tmpdir(), "forge-dash-")); const NOW = 100; const mint = (dir, name, text, author = "alice") => { - const c = mintClaim({ kind: "fact", body: { name, text }, provenance: { author }, t: NOW }).claim; + const c = mintClaim({ + kind: "fact", + body: { name, text }, + provenance: { author }, + t: NOW, + }).claim; putClaim(dir, c); return c; }; @@ -87,9 +92,17 @@ test("dashData: atlas section reports a built atlas", () => { mkdirSync(join(root, ".forge"), { recursive: true }); writeFileSync( join(root, ".forge", "atlas.json"), - JSON.stringify({ version: 2, files: 3, symbols: [{ name: "a" }, { name: "b" }] }), + JSON.stringify({ + version: 2, + files: 3, + symbols: [{ name: "a" }, { name: "b" }], + }), ); - assert.deepEqual(dashData(root, { nowDay: NOW }).atlas, { built: true, symbols: 2, files: 3 }); + assert.deepEqual(dashData(root, { nowDay: NOW }).atlas, { + built: true, + symbols: 2, + files: 3, + }); }); test("dashData: corrupt/missing stores degrade to empty sections, never throw", () => { @@ -187,7 +200,10 @@ test("serve: POST /api/ratify and /api/retract are the two append-only writes", assert.ok(d.ledger.claims.some((c) => c.kind === "decision")); // retract → the claim reads as tombstoned in the payload, reason preserved on disk. - const r2 = await post("/api/retract", { id: contested.id.slice(0, 8), reason: "wrong port" }); + const r2 = await post("/api/retract", { + id: contested.id.slice(0, 8), + reason: "wrong port", + }); assert.equal(r2.status, 200); assert.equal((await r2.json()).ok, true); const d2 = await (await fetch(`${base}/api/data`)).json(); @@ -229,3 +245,154 @@ test("serve: /api/impact traces blast radius once an atlas exists", async () => server.close(); } }); + +// --- dash v2 lenses -------------------------------------------------------- + +const DAY_MS = 86400000; + +test("historyData: metrics bucketed by day×stage, saved summed", () => { + const { root } = fixture(); + const h = historyData(root, { nowDay: NOW }); + assert.equal(h.totals.events, 2, "two metric lines"); + assert.equal(h.stages.cache.saved, 1200); + assert.equal(h.stages.gate.byOutcome.pass, 1); + assert.ok(h.buckets.length >= 1, "at least one day bucket"); + assert.ok(Array.isArray(h.stages.cache.series), "per-stage daily series for sparklines"); + assert.equal(h.window, 90); +}); + +test("historyData: events older than the cap window are dropped", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + const now = 20000; // a day number + writeFileSync( + join(root, ".forge", "metrics.jsonl"), + `${JSON.stringify({ t: (now - 5) * DAY_MS, stage: "gate", outcome: "pass" })}\n` + + `${JSON.stringify({ t: (now - 200) * DAY_MS, stage: "gate", outcome: "fail" })}\n`, + ); + const h = historyData(root, { nowDay: now, capDays: 90 }); + assert.equal(h.totals.events, 1, "only the in-window event survives"); + assert.equal(h.stages.gate.byOutcome.pass, 1); + assert.equal(h.stages.gate.byOutcome.fail, undefined); +}); + +test("historyData: corrupt/missing metrics degrade to an empty payload", () => { + const empty = historyData(tmp(), { nowDay: NOW }); + assert.deepEqual(empty.buckets, []); + assert.deepEqual(empty.stages, {}); + assert.equal(empty.totals.events, 0); +}); + +test("claimsData: no query ranks live claims by val, with fresh + confidence", () => { + const { root } = fixture(); + const m = claimsData(root, { nowDay: NOW }); + assert.equal(m.total, 3); + assert.ok(m.rows.length >= 2, "tombstoned claim is excluded, live ones shown"); + assert.ok(!m.rows.some((r) => r.tombstoned), "no-query browse shows only live claims"); + for (const r of m.rows) { + assert.equal(typeof r.val, "number"); + assert.equal(typeof r.fresh, "number"); + assert.equal(r.score, null, "no score without a query"); + assert.equal(r.id8.length, 8); + } +}); + +test("claimsData: a query ranks via retrieve (scores present) and kind filters", () => { + const { root, contested } = fixture(); + const m = claimsData(root, { q: "dev server port 4242", nowDay: NOW }); + assert.ok( + m.rows.some((r) => r.id8 === contested.id.slice(0, 8)), + "the matching claim surfaces", + ); + assert.ok( + m.rows.every((r) => typeof r.score === "number"), + "query rows carry a retrieval score", + ); + const facts = claimsData(root, { kind: "fact", nowDay: NOW }); + assert.ok(facts.rows.every((r) => r.kind === "fact")); + const none = claimsData(root, { kind: "nonexistent", nowDay: NOW }); + assert.deepEqual(none.rows, []); +}); + +test("radarData: reads the cache, tolerates shape drift, degrades when absent", () => { + assert.deepEqual(radarData(tmp()), { + present: false, + t: null, + deps: [], + counts: {}, + }); + + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge", "radar.json"), "{ not json"); + assert.equal(radarData(root).present, false, "unparseable cache → empty panel"); + + writeFileSync( + join(root, ".forge", "radar.json"), + JSON.stringify({ + t: 1700000000000, + deps: { + left: { ring: "hold", score: 0.9, version: "1.0.0", latest: "3.0.0" }, + mid: { ring: "trial", score: 0.4 }, + weird: { ring: "??", score: "nope" }, // drift: bad ring, non-numeric score + }, + }), + ); + const r = radarData(root); + assert.equal(r.present, true); + assert.equal(r.deps.length, 3); + assert.equal(r.deps[0].ring, "hold", "hold sorts first"); + assert.equal(r.counts.hold, 1); + assert.equal(r.counts.trial, 1); + assert.equal(r.counts.assess, 1, "drifted ring falls back to assess"); + const weird = r.deps.find((d) => d.name === "weird"); + assert.equal(weird.score, null, "non-numeric score → null, never NaN"); +}); + +test("timelineData: durable mint + tombstone events, newest first", () => { + const { root, retracted } = fixture(); + const t = timelineData(root); + assert.ok(t.events.length >= 4, "3 mints + 1 tombstone"); + assert.ok( + t.events.some((e) => e.type === "retract" && e.id8 === retracted.id.slice(0, 8)), + "the tombstone shows as a retract event", + ); + for (let i = 1; i < t.events.length; i++) + assert.ok(t.events[i - 1].day >= t.events[i].day, "newest first"); +}); + +test("timelineData: broken ledger degrades to no events", () => { + assert.deepEqual(timelineData(tmp()), { count: 0, events: [] }); +}); + +test("serve: v2 endpoints answer 200 with their payloads", async () => { + const { root } = fixture(); + writeFileSync( + join(root, ".forge", "radar.json"), + JSON.stringify({ t: 1, deps: { foo: { ring: "adopt", score: 0.1 } } }), + ); + const server = serve(root, { port: 0 }); + await new Promise((resolve) => server.on("listening", resolve)); + const addr = /** @type {import("node:net").AddressInfo} */ (server.address()); + const base = `http://127.0.0.1:${addr.port}`; + try { + const hist = await (await fetch(`${base}/api/history`)).json(); + assert.equal(hist.totals.events, 2); + + const radar = await (await fetch(`${base}/api/radar`)).json(); + assert.equal(radar.present, true); + assert.equal(radar.deps[0].name, "foo"); + + const tl = await (await fetch(`${base}/api/timeline`)).json(); + assert.ok(tl.events.length >= 3); + + const mem = await (await fetch(`${base}/api/claims?q=port`)).json(); + assert.equal(mem.q, "port"); + assert.ok(mem.rows.every((r) => typeof r.score === "number")); + + const memAll = await (await fetch(`${base}/api/claims`)).json(); + assert.equal(memAll.total, 3); + } finally { + server.close(); + } +}); From 91a627e739118ab91b9001509e01be3a8d1af3bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:00:47 +0000 Subject: [PATCH 09/12] feat(report): static self-contained HTML report emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `forge report` — the offline twin of `forge dash`. New zero-dep src/report.js emits ONE self-contained .forge/report.html (no server, no fetch, no CDN, no JS to read it) that opens offline, mails, or attaches to a PR. - renderReport(root) is PURE: reuses dashData(root) (dash.js) for the ledger and metrics payload, buckets metrics.jsonl into a 90-day activity sparkline via a ~15-line server-side inline SVG helper (no xmlns → zero external refs), and reads the .forge/radar.json cache DIRECTLY and defensively — report.js never imports radar.js, so absent/corrupt cache simply omits the radar section. - Palette comes from rootTokensCss() (brand.js), so the report's tokens are the brand's single source, never a forked copy. All dynamic strings escaped. - writeReport(root) is the only side-effecting entry point; CLI `forge report` writes .forge/report.html (or `--out `). Every-surface: commands.js row + Config group placement, README + GUIDE command docs, CHANGELOG [Unreleased] bullet, one test file (test/report.test.js: self-containment, brand token block, escaping, radar defensiveness, history bucketing, sparkline). No new env vars, zero new runtime deps. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Ebv4QDTqzPzgApBW2KUsU --- CHANGELOG.md | 7 ++ README.md | 83 ++++++------- docs/GUIDE.md | 16 +++ src/cli.js | 24 ++++ src/commands.js | 3 +- src/report.js | 277 ++++++++++++++++++++++++++++++++++++++++++++ test/report.test.js | 166 ++++++++++++++++++++++++++ 7 files changed, 534 insertions(+), 42 deletions(-) create mode 100644 src/report.js create mode 100644 test/report.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4408829..48d0804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `/api/radar`, and `/api/timeline`; the page polls every 5s, paused while the tab is hidden. The append-only write discipline (only `POST /api/ratify` and `/api/retract`) is unchanged, and corrupt or missing stores degrade to empty sections. +- **Static HTML report (`forge report`).** New zero-dep `src/report.js` emits a + self-contained `.forge/report.html` — the offline twin of `forge dash` (no server, no + fetch, no CDN, no JS to read it). `renderReport` is pure and reuses `dashData`, buckets + `metrics.jsonl` into a 90-day activity sparkline (server-side inline SVG), reads the + `.forge/radar.json` cache directly and defensively (absent → the radar section is + omitted), and draws its palette from `rootTokensCss()` so it matches the dashboard. + `--out ` overrides the default location. ## [0.19.0] - 2026-07-17 diff --git a/README.md b/README.md index 51f075b..4b7c66e 100644 --- a/README.md +++ b/README.md @@ -173,48 +173,49 @@ Output is plain text when piped; on a TTY it adds brand-palette color and confid meters. `NO_COLOR` turns color off, `FORCE_COLOR=1` forces it on (e.g. in CI, `0` forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. -| Group | Command | Does | +| Group | Command | Does | | -------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| **Config layer** | `forge init` | emit every tool's native config from one source | -| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | -| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | -| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | -| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | -| | `forge config` | provider setup — show / switch / add providers, set the default model | -| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | -| | `forge catalog` | Start-Here index of every tool / crew / guard | -| | `forge brand` | print the brand token map | -| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | -| | `forge recall` | cross-session personal memory — list / add / consolidate | -| | `forge remember` | durable, repo-committable fact | -| | `forge brain` | portable project-memory index | -| | `forge cortex` | self-correcting lessons — `status` / `why` | -| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | -| | `forge reuse` | proof-carrying code cache — query / mint / stats | -| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | -| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | -| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | -| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | -| | `forge preflight` | assumption / info-gap check | -| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | -| | `forge impact` | predict blast radius for a symbol or file | -| | `forge scope` | cluster + surface coupled files | -| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | -| | `forge context` | budgeted context assembly + completeness gate | -| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | -| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | -| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | -| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | -| | `forge lean` | scope-minimality footprint (advisory) | -| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | -| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | -| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | -| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | -| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | -| | `forge spec` | spec-as-contract drift — init / lock / check | -| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | -| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | -| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | +| **Config layer** | `forge init` | emit every tool's native config from one source | +| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | +| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | +| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | +| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | +| | `forge config` | provider setup — show / switch / add providers, set the default model | +| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | +| | `forge catalog` | Start-Here index of every tool / crew / guard | +| | `forge brand` | print the brand token map | +| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | +| | `forge recall` | cross-session personal memory — list / add / consolidate | +| | `forge remember` | durable, repo-committable fact | +| | `forge brain` | portable project-memory index | +| | `forge cortex` | self-correcting lessons — `status` / `why` | +| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | +| | `forge reuse` | proof-carrying code cache — query / mint / stats | +| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | +| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | +| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | +| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | +| | `forge preflight` | assumption / info-gap check | +| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | +| | `forge impact` | predict blast radius for a symbol or file | +| | `forge scope` | cluster + surface coupled files | +| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | +| | `forge context` | budgeted context assembly + completeness gate | +| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | +| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | +| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | +| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | +| | `forge lean` | scope-minimality footprint (advisory) | +| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | +| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | +| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | +| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | +| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | +| | `forge spec` | spec-as-contract drift — init / lock / check | +| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | +| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | +| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | +| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | **→ Every command with a worked example and real output: diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 227f1ac..9ea8ce9 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -916,6 +916,22 @@ Forge dash — read-only lens on .forge/ http://127.0.0.1:4242 (localhost-only · Ctrl-C to stop) ``` +### `forge report [--out ]` — the static HTML report + +The offline twin of `forge dash`: instead of serving a live localhost lens, it renders +ONE self-contained HTML file — no server, no fetch, no CDN, no JavaScript needed to read +it — that you can open offline, email, or attach to a PR. It reuses the same `dashData` +payload, buckets `.forge/metrics.jsonl` into a 90-day activity sparkline (server-side +inline SVG), and folds in the tech-radar cache (`.forge/radar.json`) when present. The +palette is the brand's own token block (`rootTokensCss`), so the report matches the +dashboard exactly. Default output is `.forge/report.html`; `--out` overrides it. + +```console +$ forge report + wrote /repo/.forge/report.html + open it in a browser — fully offline, no server needed. +``` + ### `forge cost --stages` — the measured cost report Per-stage cost factors as pure arithmetic over `.forge/metrics.jsonl`. A stage with no diff --git a/src/cli.js b/src/cli.js index c5af19c..55a61ea 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2028,6 +2028,30 @@ async function run(argv) { console.log(` ADVISE (subjective, human-only): ${ADVISORY_ONLY.slice(0, 4).join(", ")} …`); return; } + if (cmd === "report") { + // Static twin of `dash`: emit ONE self-contained HTML file (no server, opens + // offline) instead of serving a live localhost lens. `--out ` overrides the + // default `.forge/report.html`. + const { renderReport, writeReport } = await import("./report.js"); + const oi = argv.indexOf("--out"); + heading(`${BRAND.brand} report — static snapshot of .forge/\n`); + if (oi >= 0) { + const out = argv[oi + 1]; + if (!out) { + console.error("usage: forge report [--out ]"); + process.exitCode = 1; + return; + } + const { writeFileSync } = await import("node:fs"); + writeFileSync(out, renderReport(process.cwd())); + console.log(` wrote ${out}`); + return; + } + const path = writeReport(process.cwd()); + console.log(` wrote ${path}`); + console.log(" open it in a browser — fully offline, no server needed."); + return; + } if (cmd === "dash") { const { serve } = await import("./dash.js"); const i = argv.indexOf("--port"); diff --git a/src/commands.js b/src/commands.js index 1aec6e5..2e9e0f6 100644 --- a/src/commands.js +++ b/src/commands.js @@ -48,6 +48,7 @@ export const COMMANDS = { uicheck: "deterministic UI checks — contrast · fingerprint · design · visual ", dash: "live dashboard: ledger, metrics trends, radar, memory browser, timeline, blast radius", + report: "emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server", brand: "print the active brand token map", docs: "docs↔code drift — check (registry reconcile) / sync (diff-driven stale-docs sweep)", }; @@ -79,7 +80,7 @@ export const GROUPS = { "lean", ], Quality: ["verify", "precommit", "radar", "scan", "spec", "taste", "uicheck", "harden"], - Config: ["config", "cost", "dash", "brand", "atlas", "stack"], + Config: ["config", "cost", "dash", "report", "brand", "atlas", "stack"], }; /** Commands that exist but are deliberately not advertised in --help or docs tables. */ diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..70b4a7b --- /dev/null +++ b/src/report.js @@ -0,0 +1,277 @@ +// forge report — a static, self-contained HTML snapshot of the .forge/ substrate. +// Where `dash.js` serves a live localhost lens, `report` emits ONE file you can open +// offline, mail, or attach to a PR: no server, no fetch, no CDN, no JS required to read +// it. DATA reuse is deliberate — the payload is `dashData(root)` (dash.js), history is +// `read()` from metrics.js bucketed by day, and the tech-radar section reads the +// `.forge/radar.json` cache DIRECTLY and defensively (absent/corrupt → the section is +// simply omitted; report.js never imports radar.js, so it stands alone if radar was +// never built). Visuals are server-side inline SVG sparklines. The token block comes +// from `rootTokensCss()` (brand.js) so the report's palette is the brand's single +// source, never a forked copy. `renderReport` is PURE (returns the HTML string, writes +// nothing); `writeReport` is the only side-effecting entry point. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND, rootTokensCss } from "./brand.js"; +import { dashData } from "./dash.js"; +import { read as readMetrics } from "./metrics.js"; +import { epochDay, MS_PER_DAY } from "./util.js"; + +/** How far back the history strip looks (matches the dash /api/history 90-day cap). */ +const HISTORY_DAYS = 90; + +/** Minimal HTML-attribute/text escaper — the report interpolates ledger claim text and + * radar notes that originate outside our control, so every dynamic string is escaped. */ +export function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} + +/** Absolute path of the emitted report for a repo root. */ +export const reportPath = (root) => join(root, ".forge", "report.html"); + +/** + * Read the radar cache without importing radar.js (built in parallel, may not exist). + * Accepts either a bare array of entries or `{ generatedAt, entries: [...] }`; coerces + * loose fields to strings. Absent, empty, or unparseable → null (section omitted). + * @param {string} root + */ +export function readRadar(root) { + try { + const p = join(root, ".forge", "radar.json"); + if (!existsSync(p)) return null; + const data = JSON.parse(readFileSync(p, "utf8")); + const raw = Array.isArray(data) ? data : Array.isArray(data?.entries) ? data.entries : []; + const entries = raw + .map((e) => ({ + name: String(e?.name ?? e?.dep ?? ""), + ring: String(e?.ring ?? e?.status ?? ""), + note: String(e?.note ?? e?.reason ?? ""), + })) + .filter((e) => e.name); + if (!entries.length) return null; + return { + generatedAt: typeof data?.generatedAt === "string" ? data.generatedAt : "", + entries, + }; + } catch { + return null; + } +} + +/** + * Bucket metrics.jsonl into a fixed [nowDay-days+1 … nowDay] window: per-day event + * counts (for the sparkline) and a per-stage rollup (events + summed savedEstimate). + * @param {string} root + * @param {{nowDay?: number, days?: number}} [opts] + */ +export function historyByDay(root, { nowDay = epochDay(), days = HISTORY_DAYS } = {}) { + const start = nowDay - days + 1; + const counts = new Array(days).fill(0); + const byStage = {}; + let total = 0; + for (const e of readMetrics(root)) { + const t = Number(e.t); + if (!Number.isFinite(t)) continue; + const day = Math.floor(t / MS_PER_DAY); + if (day < start || day > nowDay) continue; + counts[day - start] += 1; + total += 1; + const s = (byStage[e.stage || "?"] ??= { events: 0, saved: 0 }); + s.events += 1; + if (Number.isFinite(e.savedEstimate)) s.saved += e.savedEstimate; + } + return { start, days, counts, byStage, total }; +} + +/** + * Server-side inline SVG sparkline — no xmlns (the HTML parser handles inline SVG), so + * the markup carries zero external references. Flat/empty series → a baseline line. + * @param {number[]} values + * @param {{width?: number, height?: number, pad?: number}} [opts] + */ +export function sparkline(values, { width = 280, height = 34, pad = 3 } = {}) { + const n = values.length; + const max = Math.max(1, ...values); + const stepX = n > 1 ? (width - pad * 2) / (n - 1) : 0; + const y = (v) => height - pad - (v / max) * (height - pad * 2); + const pts = + n === 0 + ? `${pad},${(height - pad).toFixed(1)} ${width - pad},${(height - pad).toFixed(1)}` + : values.map((v, i) => `${(pad + i * stepX).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + return ( + `` + + `` + ); +} + +const num = (x) => (Number.isFinite(x) ? x : 0); + +/** A confidence/value cell rendered as a proportional inline bar (0..1). */ +function valBar(v) { + const pct = Math.round(Math.max(0, Math.min(1, num(v))) * 100); + return ( + `` + + `${num(v).toFixed(2)}` + ); +} + +function ledgerRows(claims) { + if (!claims.length) return `no claims yet`; + return claims + .slice(0, 40) + .map( + (c) => + `` + + `${escapeHtml(c.id8)}` + + `${escapeHtml(c.kind)}` + + `${valBar(c.val)}` + + `${escapeHtml(c.text)}`, + ) + .join(""); +} + +function stageRows(byStage) { + const names = Object.keys(byStage).sort(); + if (!names.length) return `no measured stages`; + return names + .map( + (s) => + `${escapeHtml(s)}` + + `${byStage[s].events}` + + `${Math.round(byStage[s].saved)}`, + ) + .join(""); +} + +function radarSection(radar) { + if (!radar) return ""; + const rows = radar.entries + .slice(0, 60) + .map( + (e) => + `${escapeHtml(e.name)}` + + `${escapeHtml(e.ring)}` + + `${escapeHtml(e.note)}`, + ) + .join(""); + const stamp = radar.generatedAt ? ` · cached ${escapeHtml(radar.generatedAt)}` : ""; + return ( + `

        Tech radar${stamp}

        ` + + `` + + `${rows}
        dependencyringnote
        ` + ); +} + +/** + * Render the whole report as one self-contained HTML string. PURE — no filesystem + * writes, no network, no throw on corrupt stores (dashData already degrades to empty + * sections). The output references nothing external: styles come from `rootTokensCss()` + * and every chart is inline SVG. + * @param {string} root + * @param {{nowDay?: number}} [opts] + */ +export function renderReport(root, { nowDay = epochDay() } = {}) { + const data = dashData(root, { nowDay }); + const hist = historyByDay(root, { nowDay }); + const radar = readRadar(root); + const st = data.ledger.stats; + const stages = data.metrics.stages || {}; + const stageCount = Object.keys(stages).length; + const generated = new Date().toISOString(); + + const cards = [ + ["claims", st.total], + ["trusted", st.val?.trusted ?? 0], + ["contested", data.ledger.contested.length], + ["tombstoned", st.tombstoned], + ["events (90d)", hist.total], + ["stages", stageCount], + ] + .map( + ([label, value]) => + `
        ${escapeHtml(String(value))}
        ` + + `
        ${escapeHtml(label)}
        `, + ) + .join(""); + + const atlasLine = data.atlas.built + ? `atlas: ${data.atlas.symbols} symbols across ${data.atlas.files} files` + : "atlas: not built"; + + const css = ` + ${rootTokensCss()} + *{box-sizing:border-box} + body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 var(--sans); + -webkit-font-smoothing:antialiased} + .mono{font-family:var(--mono)}.muted{color:var(--muted)}.faint{color:var(--faint)} + .wrap{max-width:1000px;margin:0 auto;padding:24px 20px 64px} + header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap; + border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:20px} + header .b{font-family:var(--mono);font-size:15px} + header .b b{color:var(--brand);font-weight:600} + header .meta{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted)} + h1{font-size:15px;margin:0} + h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted); + margin:0 0 12px;font-weight:600} + .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px; + margin-bottom:20px} + .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px} + .card-n{font-size:22px;color:var(--brand);font-weight:600} + .card-l{font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin-top:2px} + .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px; + padding:16px 18px;margin-bottom:16px} + table{width:100%;border-collapse:collapse;font-size:12px} + th{text-align:left;color:var(--faint);font-weight:500;padding:4px 8px; + border-bottom:1px solid var(--line)} + td{padding:5px 8px;border-bottom:1px solid var(--line);vertical-align:top} + tr:last-child td{border-bottom:0} + tr.tomb td{opacity:.5;text-decoration:line-through} + .bar{display:inline-block;width:52px;height:6px;border-radius:4px;background:var(--bg-2); + vertical-align:middle;margin-right:6px;overflow:hidden} + .bar-fill{display:block;height:100%;background:var(--brand)} + .spark{display:block;margin:4px 0 10px} + footer{color:var(--faint);font-size:11px;margin-top:24px;text-align:center} + a{color:var(--brand)}`; + + return ( + "\n" + + '' + + '' + + `${escapeHtml(BRAND.brand)} report — ${escapeHtml(data.repo)}` + + `
        ` + + `
        ${escapeHtml(BRAND.brand)} report` + + `

        ${escapeHtml(data.repo)}

        ` + + `generated ${escapeHtml(generated)} · static · offline
        ` + + `
        ${cards}
        ` + + `

        Activity — last ${hist.days} days

        ` + + `${sparkline(hist.counts)}
        ${atlasLine}
        ` + + `

        Stages

        ` + + `` + + `${stageRows(hist.byStage)}
        stageeventssaved (est. tokens)
        ` + + radarSection(radar) + + `

        Ledger — top claims by value

        ` + + `` + + `${ledgerRows(data.ledger.claims)}
        idkindvalclaim
        ` + + `
        ${escapeHtml(BRAND.brand)} v${escapeHtml(BRAND.version)} · ` + + `${escapeHtml(BRAND.cli)} report — regenerate anytime
        ` + + "
        \n" + ); +} + +/** + * Render and write the report to `.forge/report.html`. Returns the absolute path. + * @param {string} root + * @param {{nowDay?: number}} [opts] + */ +export function writeReport(root, opts = {}) { + const html = renderReport(root, opts); + const dir = join(root, ".forge"); + mkdirSync(dir, { recursive: true }); + const path = reportPath(root); + writeFileSync(path, html); + return path; +} diff --git a/test/report.test.js b/test/report.test.js new file mode 100644 index 0000000..f6bc510 --- /dev/null +++ b/test/report.test.js @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { BRAND } from "../src/brand.js"; +import { mintClaim, outcomeRecord } from "../src/ledger.js"; +import { appendEvidence, putClaim, repoLedger, tombstone } from "../src/ledger_store.js"; +import { record } from "../src/metrics.js"; +import { + historyByDay, + readRadar, + renderReport, + reportPath, + sparkline, + writeReport, +} from "../src/report.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "forge-report-")); +const NOW = 100; + +const mint = (dir, name, text, author = "alice") => { + const c = mintClaim({ + kind: "fact", + body: { name, text }, + provenance: { author }, + t: NOW, + }).claim; + putClaim(dir, c); + return c; +}; +const ev = (dir, id, result, ref) => + appendEvidence(dir, id, outcomeRecord({ oracle: "test.run", result, ref, t: NOW }).outcome); + +/** A fixture repo: 3 claims (one contested, one tombstoned) + 2 metrics lines. */ +function fixture() { + const root = tmp(); + const dir = repoLedger(root); + const trusted = mint(dir, "style", "tabs are actually spaces here"); + ev(dir, trusted.id, "confirm", "run-1"); + const contested = mint(dir, "port", "the dev server listens on 4242"); + ev(dir, contested.id, "confirm", "run-2"); + ev(dir, contested.id, "contradict", "run-3"); + const retracted = mint(dir, "old", "we deploy from the ops box"); + tombstone(dir, retracted.id, { author: "alice", reason: "obsolete", t: NOW }); + record(root, { stage: "cache", outcome: "exact", savedEstimate: 1200 }); + record(root, { stage: "gate", outcome: "pass" }); + return { root, trusted, contested, retracted }; +} + +test("renderReport: self-contained — no external http references", () => { + const { root } = fixture(); + const html = renderReport(root); + assert.match(html, /^/); + // The core self-containment guarantee: nothing to fetch over the network. + assert.ok(!/https?:\/\//.test(html), "report must carry zero external http(s) references"); + assert.ok(!/