diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bf8e39c..4e51e68 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -7,7 +7,7 @@ "repository": "https://github.com/CodeWithJuber/forgekit", "license": "MIT", "keywords": ["codex", "mcp", "ai-coding", "agents-md", "cognitive-substrate"], - "skills": "skills", + "skills": "global/tools", "mcpServers": ".mcp.json", "interface": { "displayName": "Forge", diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b223809 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# forgekit — contributor instructions + +## Stack +- Node.js >=20, pure ESM (`"type": "module"`), zero runtime dependencies. +- Linter/formatter: Biome 2.5.2 (dev dependency). +- Types: TypeScript via JSDoc annotations — no `.ts` files, checked by `tsc`. + +## Commands +- Install: `npm ci` +- Test: `npm test` (node:test, 471+ tests) +- Lint + format: `npx biome check` (or `npm run check`) +- Typecheck: `npm run typecheck` +- Build pages: `npm run pages:build` + +## Rules +- **Zero runtime dependencies** — CI enforces this. Everything uses Node.js built-ins. +- ESM only — use `import`, never `require`. +- Match existing patterns: dynamic `await import()` for optional modules, brand + tokens from `src/brand.js` (never hardcode "Forge"/"forge"), `BRAND.root` for + package root paths. +- Run `npm test && npx biome check && npm run typecheck` before committing. +- Version lives in `package.json` — `scripts/bump.mjs` keeps all manifests in sync. diff --git a/ROADMAP.md b/ROADMAP.md index ac6a6b9..ac4bb0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,19 +7,10 @@ every tool. This is where that brain is headed. Direction, not promises — shaped by the two field reports this project is grounded in (the SDLC pain-point map and the ecosystem landscape). Open a Discussion to weigh in. -## Now (`master`, v0.6.0) -Cross-tool config for nine agents (Claude Code, Codex, Cursor, Gemini, Aider, Copilot, -Windsurf, Zed, Continue) plus MCP config for Roo & VS Code, verification layer -(`forge verify`), security gate (`forge scan`), portable memory (`forge brain`), cost -governor (`forge cost`, `--stages` for measured factors), spec-as-contract drift -(`forge spec`), goal-drift check (`forge anchor`), the cognitive-substrate pre-action -gate (`forge substrate`, `forge impact`) — **plus the whole Substrate v2 surface**: -`forge ledger` (proof-carrying team memory), `forge reuse` (verified-code cache), -`forge context` (budgeted assembly + completeness gate), `forge diagnose` (doom-loop), -`forge imagine [--run]` (consequence simulation + sandboxed dry-run), `forge uicheck` -v2 (fingerprints + design gate), and `forge dash`. The full paper and evidence bundle -live in [docs/cognitive-substrate/](./docs/cognitive-substrate/). See -[CHANGELOG.md](./CHANGELOG.md). +## Now (`master`, v0.8.0) +Everything from 0.7.0 plus branding layer (`forge brand`), model-tier exports +(`forgekit/model-tiers`), and the Codex plugin manifest — same toolkit, two plugin +surfaces (Claude Code + Codex). See [CHANGELOG.md](./CHANGELOG.md). ## Shipped — Substrate v2 (all phases P0–P8, v0.5.0) The plan lives in [docs/plans/substrate-v2/](./docs/plans/substrate-v2/00-overview.md) @@ -29,6 +20,17 @@ protocol** (ADR-0006) — every stored thing is a claim that carries its evidenc confidence only from independent oracles, and merges across teammates conflict-free (git-native CRDT ledger). +## Shipped — 0.7.0 +- **Zero-config provider auto-detection** — `autoDetectProvider()` probes env vars + for OpenRouter, LiteLLM (local + hosted gateway), OpenAI, Anthropic, and Gemini; + `forge init` reports what it found, no manual config needed. +- **Hosted LiteLLM gateway** — `emitGatewayConfig()` writes a guard that injects + `LITELLM_GATEWAY_URL` + key so every model call routes through the team proxy. +- **15 MCP tools** — the cortex MCP server (`src/cortex_mcp.js`) now exposes + read-path tools for ledger, brain, atlas, recall, cost, substrate, and dashboard. +- **Cost dashboard** — `forge dash` serves a local HTML dashboard showing model spend, + event timeline, and ledger health from `.forge/` data. + ## Shipped — 0.6.0 - **Embeddings tier** — optional vector backend (`src/embed.js`, ADR-0005 dependency tier, stdlib fallback kept): `FORGE_EMBED=cmd:` or diff --git a/landing/index.html b/landing/index.html index 4dd60bc..61153e6 100644 --- a/landing/index.html +++ b/landing/index.html @@ -738,9 +738,9 @@

One brain for
every coding agent.

>
- 438 tests + 471 tests 0 runtime dependencies - 9 tools, one config + 14 tools, one config MIT · Node ≥ 20
diff --git a/package.json b/package.json index 505450c..680a693 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "hooks", ".claude-plugin", ".codex-plugin", - "skills", ".mcp.json", "bin", "brand.json", diff --git a/public/index.html b/public/index.html index fb930c1..6131266 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ -forgekit status — live repository data

@codewithjuber/forgekit · v0.6.0 · Node >=20

Live status, straight from the repository.

One config for every AI coding agent — a cross-tool config layer plus a cognitive substrate (memory, blast-radius, guardrails) for Claude Code, Codex, Cursor, Gemini, Aider, and more.

Install in 60 seconds Read the docs

MIT license0 runtime dependenciesclaude/whitepaper-implementation-plan-x8f7bh @ 26b76cdlive GitHub stats disabled
0.43 ms
blast-radius lookup

Measured from this repo's benchmark report, not a marketing placeholder.

reports/benchmarks.md

118 ms
pre-action gate

Assumptions, routing, reuse, context, impact, scope, and anchoring.

reports/benchmarks.md

62.1%
cost saved vs always-premium

Documented from the white-paper prototype and exposed by Forge cost reports.

whitepaper prototype

Quickstart

npm install -g @codewithjuber/forgekit +

@codewithjuber/forgekit · v0.8.0 · Node >=20

Live status, straight from the repository.

One brain for every AI coding agent — the cognitive substrate every frozen model is missing (proof-carrying memory, impact foresight, enforced guardrails), authored once and delivered as native config to Claude Code, Codex, Cursor, Gemini, Aider, and more.

Install in 60 seconds Read the docs

MIT license0 runtime dependenciesclaude/forge-work-system-setup-p26ka5 @ 26586e2live GitHub stats disabled
0.43 ms
blast-radius lookup

Measured from this repo's benchmark report, not a marketing placeholder.

reports/benchmarks.md

118 ms
pre-action gate

Assumptions, routing, reuse, context, impact, scope, and anchoring.

reports/benchmarks.md

62.1%
cost saved

Documented from the white-paper prototype and exposed by Forge cost reports.

whitepaper prototype

Quickstart

npm install -g @codewithjuber/forgekit forge init forge doctor -forge substrate "Change auth validation and update tests"

Latest repo changes

  • Optional embeddings tier (src/embed.js, ADR-0005; ROADMAP "Next"): set
  • forge uicheck visual <file-or-url> — the Playwright visual loop
  • Ledger read-path flip (P2). Reads are now a merged view (legacy ∪ ledger) via the

Benchmark sections indexed: 3 · benchmarks file updated 2026-07-07.

Data Sources

No mock data is used. This page is regenerated from repository files during CI (generated 2026-07-07T21:15:58.111Z from 26b76cd). Enable BUILD_PAGES_LIVE=1 to refresh public GitHub counters with ETag/Last-Modified caching.

  • package.json
  • README.md
  • CHANGELOG.md
  • reports/benchmarks.md
  • https://api.github.com/repos/CodeWithJuber/forgekit (optional, no auth, only when BUILD_PAGES_LIVE=1)
WCAG-minded semantic HTML, keyboard focus, responsive 320px–1920px+, and reduced-motion-safe. Same design tokens as the landing page — forge uicheck design gates both.
\ No newline at end of file +forge substrate "Change auth validation and update tests"

Latest repo changes

    Benchmark sections indexed: 3 · benchmarks file updated 2026-07-08.

    Data Sources

    No mock data is used. This page is regenerated from repository files during CI (generated 2026-07-08T18:42:41.820Z from 26586e2). Enable BUILD_PAGES_LIVE=1 to refresh public GitHub counters with ETag/Last-Modified caching.

    • package.json
    • README.md
    • CHANGELOG.md
    • reports/benchmarks.md
    • https://api.github.com/repos/CodeWithJuber/forgekit (optional, no auth, only when BUILD_PAGES_LIVE=1)
    WCAG-minded semantic HTML, keyboard focus, responsive 320px–1920px+, and reduced-motion-safe. Same design tokens as the landing page — forge uicheck design gates both.
    \ No newline at end of file diff --git a/skills/cognitive-substrate/SKILL.md b/skills/cognitive-substrate/SKILL.md deleted file mode 100644 index 0bc7f8b..0000000 --- a/skills/cognitive-substrate/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: cognitive-substrate -description: >- - Use before ambiguous, expensive, multi-file, or mutating coding work to run - Forge's cognitive substrate: assumption gate, model routing, impact - prediction, scope decomposition, memory/lesson lookup, minimality check, and - verification planning. Trigger when a user asks to edit/refactor/fix/design - production code, integrate features, choose model effort, inspect blast - radius, or avoid agent assumptions. ---- - -# Cognitive Substrate - -Wrap the frozen model in a pre-action check: gate assumptions, route model effort, predict -blast radius, decompose scope, surface past lessons, and plan verification — **before** editing. - -## When to run it - -Any ambiguous, expensive, multi-file, or mutating task. Skip one-line, well-specified fixes. -In Claude Code it fires automatically on every prompt (UserPromptSubmit hook); in other -agents run the CLI yourself, or call the MCP tool `substrate_check`. - -## Run - -```bash -forge substrate "" --json # full contract; use the fields below -``` - -## Act on the result - -1. **`okToProceed: false`** → ask the returned `assumption.questions` before editing. Do not guess. -2. **`route.tier`** → start at that model tier (cheapest capable); escalate only after an external verifier fails. -3. **`impact.impactedFiles`** → read these before editing a named symbol/file (the blast radius). -4. **`scope.clusters`** → split independent groups into separate sessions; note coupled files you didn't name. -5. **`memory.advisory`** → context, not law; tests and human corrections override it. -6. **`verification.checklist`** → run it and show output before claiming done. - -## Worked example - -`forge substrate "make the auth better"` → `proceed: ASK FIRST` + clarify -questions: ask them instead of editing. A clear task returns `proceed: yes` plus -the impacted files — including importers you didn't name. - -## Deeper single checks - -`forge preflight ""` (assumptions) · `forge route ""` (model tier) · -`forge impact ` (blast radius) · `forge scope ` (decomposition) · -`forge context ""` (budgeted context assembly; *computes* what's missing) · -`forge imagine "" [--run]` (predicted breaks + minimal covering test suite; -`--run` dry-runs it in a sandboxed worktree) · -`forge diagnose ""` (doom-loop: 3× the same failure signature = stop retrying, -escalate one tier with the minted diagnosis claim at the head of the prompt). -MCP equivalents: `assumption_gate`, `route_task`, `predict_impact`, `scope_files`. - -For the full guide (how it works, extending it, the honesty boundary) and the white paper, -see `docs/cognitive-substrate/README.md`. `references/capability-map.md` maps faculties to commands. diff --git a/skills/cognitive-substrate/references/capability-map.md b/skills/cognitive-substrate/references/capability-map.md deleted file mode 100644 index 1eca342..0000000 --- a/skills/cognitive-substrate/references/capability-map.md +++ /dev/null @@ -1,17 +0,0 @@ -# Cognitive substrate capability map - -| Paper capability | Forge surface | Guarantee | -| --- | --- | --- | -| Memory | `forge recall`, `forge cortex`, `forge ledger` | Facts/lessons persist as content-addressed ledger claims; `forge ledger blame` shows provenance. | -| Learning | `forge cortex`, ledger oracles | External outcomes (tests, CI, human accept/revert) move claim confidence; model weights do not change. | -| Imagination | `forge imagine [--run]`, `forge impact` | Predicted breaks + minimal covering test suite; `--run` dry-runs it in a sandboxed worktree. | -| Self-correction | `forge verify`, `forge diagnose` | Tests/builds beat model claims; 3× the same failure signature mints a diagnosis + escalation. | -| Impact-awareness | `forge atlas`, `forge impact` | Known symbols/files and likely dependents are surfaced. | -| M1 routing | `forge route` | Transparent model-tier recommendation. | -| M2 assumption gate | `forge preflight`, `forge context` | Under-specified tasks return *computed* missing-set questions. | -| M3 decomposition | `forge scope` | Import clusters show independent vs coupled files. | -| M4 goal anchoring | `forge anchor`, `forge substrate` | Changed files are checked against the stated goal. | -| M5 anti-over-engineering | `forge lean`, `forge uicheck design` | Footprint vs ask; UI slop-distance + fingerprint conformance gate. | -| M6 inline verification | `forge verify` | External checks are required before done. | - -Limits: static graph edges are conservative; memory relevance and model routing are advisory; non-hook tools cannot be forcibly blocked. diff --git a/src/cli.js b/src/cli.js index a36a167..f626c5d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -40,17 +40,40 @@ const COMMANDS = { brand: "print the active brand token map", }; +const GROUPS = { + Core: ["init", "sync", "doctor", "catalog"], + Memory: ["cortex", "recall", "remember", "brain", "ledger", "reuse"], + Substrate: [ + "substrate", + "preflight", + "route", + "impact", + "scope", + "context", + "anchor", + "diagnose", + "imagine", + "lean", + ], + Quality: ["verify", "scan", "spec", "taste", "uicheck", "harden"], + Config: ["config", "cost", "dash", "brand", "atlas"], +}; + const printVersion = () => console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.version}`); function printHelp() { printVersion(); console.log(`\n${BRAND.tagline}\n`); console.log(`Usage: ${BRAND.cli} [options]\n`); - console.log("Commands:"); - for (const [name, desc] of Object.entries(COMMANDS)) { - console.log(` ${name.padEnd(8)} ${desc}`); + for (const [group, cmds] of Object.entries(GROUPS)) { + console.log(`${group}:`); + for (const name of cmds) { + if (COMMANDS[name]) console.log(` ${name.padEnd(12)} ${COMMANDS[name]}`); + } + console.log(); } - console.log(`\nRun \`${BRAND.cli} --help\` for details.`); + console.log(`Start here: \`${BRAND.cli} catalog\``); + console.log(`Run \`${BRAND.cli} --help\` for details.`); } async function run(argv) { @@ -139,7 +162,7 @@ async function run(argv) { console.log(`${BRAND.brand} sync — one source → every tool\n`); for (const r of report) { console.log( - ` ${r.action.padEnd(16)} ${String(r.target).padEnd(22)} ${r.tool}${r.note ? " · " + r.note : ""}`, + ` ${r.action.padEnd(16)} ${String(r.target).padEnd(22)} ${r.tool}${r.note ? ` · ${r.note}` : ""}`, ); } for (const w of warnings) console.warn(` ! ${w}`); @@ -158,7 +181,7 @@ async function run(argv) { const icon = { ok: "✓", warn: "!", fail: "✗" }; console.log(`${BRAND.brand} doctor\n`); for (const r of results) console.log(` ${icon[r.status]} ${r.label.padEnd(16)} ${r.note}`); - console.log(`\n${failed === 0 ? "all clear" : failed + " problem(s)"}`); + console.log(`\n${failed === 0 ? "all clear" : `${failed} problem(s)`}`); if (failed) process.exitCode = 1; return; } @@ -596,7 +619,7 @@ async function run(argv) { if (r.findings?.length) { for (const f of r.findings) console.log(` [${f.sev}] ${f.msg}`); } else if (r.raw) { - console.log(" " + r.raw.trim().split("\n").slice(-6).join("\n ")); + console.log(` ${r.raw.trim().split("\n").slice(-6).join("\n ")}`); } else { console.log(" no obvious red flags"); } diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index 9a04e47..8893d90 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -8,7 +8,7 @@ import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { lessonsForContext, summary } from "./cortex.js"; import { report as costReport } from "./cost_report.js"; -import { dashData } from "./dash.js"; +import { dashData, dashSummary } from "./dash.js"; import { diagnose } from "./diagnose.js"; import { doctor } from "./doctor.js"; import { assessTask, clarifyBlock, preflightRepo } from "./preflight.js"; @@ -130,6 +130,12 @@ const TOOLS = [ "Dashboard JSON payload — ledger stats, metrics, atlas info. Same data forge dash serves at /api/data.", inputSchema: { type: "object", properties: {} }, }, + { + name: "forge_dash_summary", + description: + "Lightweight dashboard health check — just counts (claims, tombstoned, contested, atlas built, metric events). Cheaper than forge_dash_data.", + inputSchema: { type: "object", properties: {} }, + }, { name: "forge_brain", description: "Project memory index — list all remembered facts stored in .forge/brain/.", @@ -173,6 +179,44 @@ const TOOLS = [ "Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks.", inputSchema: { type: "object", properties: {} }, }, + { + name: "forge_remember", + description: + "Store a durable fact in this repo's portable memory (.forge/brain/). Use for non-obvious, lasting knowledge (env quirks, decisions, gotchas).", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "short slug for the fact (used as filename)" }, + body: { type: "string", description: "the fact content (markdown)" }, + }, + required: ["name", "body"], + }, + }, + { + name: "forge_ledger_ratify", + description: + "Promote a ledger claim's confidence — record an independent oracle ratification (the claim held under test).", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "claim ID or unique prefix" }, + }, + required: ["id"], + }, + }, + { + name: "forge_ledger_retract", + description: + "Tombstone a ledger claim with a reason — mark it as no longer valid so it stops influencing routing and memory.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "claim ID or unique prefix" }, + reason: { type: "string", description: "why the claim is being retracted" }, + }, + required: ["id", "reason"], + }, + }, ]; async function callTool(name, args = {}) { @@ -212,6 +256,7 @@ async function callTool(name, args = {}) { } if (name === "forge_cost") return JSON.stringify(costReport(root), null, 2); if (name === "forge_dash_data") return JSON.stringify(dashData(root), null, 2); + if (name === "forge_dash_summary") return JSON.stringify(dashSummary(root), null, 2); if (name === "forge_brain") { try { const { brainStore, list, buildIndex } = await import("./brain.js"); @@ -287,6 +332,34 @@ async function callTool(name, args = {}) { 2, ); } + if (name === "forge_remember") { + const { brainStore, remember } = await import("./brain.js"); + const store = brainStore(root); + remember(store, String(args.name ?? ""), String(args.body ?? "")); + return `Remembered "${args.name}" in ${store}.`; + } + if (name === "forge_ledger_ratify") { + const { ratify, repoLedger, getClaimByPrefix } = await import("./ledger_store.js"); + const { gitAuthor } = await import("./util.js"); + const dir = repoLedger(root); + const claim = getClaimByPrefix(dir, String(args.id ?? "")); + if (!claim) return `No claim matching prefix "${args.id}".`; + ratify(dir, claim.id, { author: gitAuthor(), t: today() }); + return `Ratified claim ${claim.id}.`; + } + if (name === "forge_ledger_retract") { + const { tombstone, repoLedger, getClaimByPrefix } = await import("./ledger_store.js"); + const { gitAuthor } = await import("./util.js"); + const dir = repoLedger(root); + const claim = getClaimByPrefix(dir, String(args.id ?? "")); + if (!claim) return `No claim matching prefix "${args.id}".`; + tombstone(dir, claim.id, { + author: gitAuthor(), + reason: String(args.reason ?? ""), + t: today(), + }); + return `Retracted claim ${claim.id}: ${args.reason}`; + } return null; } diff --git a/src/dash.html b/src/dash.html index d860d68..1942a12 100644 --- a/src/dash.html +++ b/src/dash.html @@ -210,7 +210,7 @@

    Impact — blast radius .forge/atlas.json

    // Every claim row carries its provenance affordance — click to copy the exact command. const blameCell = (id8) => - 'forge ledger blame ' + esc(id8) + ""; + `forge ledger blame ${esc(id8)}`; // The two writes (spec §2), per live row. Ratify = human-only hikma promotion (the // server stamps YOUR git identity as author); retract = tombstone with a reason. @@ -240,37 +240,37 @@

    Impact — blast radius .forge/atlas.json

    function renderLedger(d) { const L = d.ledger; - $("hdr-repo").textContent = "· " + d.repo; + $("hdr-repo").textContent = `· ${d.repo}`; $("hdr-meta").textContent = - L.stats.total + " claims · " + L.stats.tombstoned + " tombstoned · day " + d.nowDay; + `${L.stats.total} claims · ${L.stats.tombstoned} tombstoned · day ${d.nowDay}`; $("ledger-stats").innerHTML = '' + L.stats.total + 'claims' + '' + L.stats.val.trusted + 'trusted ≥0.65' + '' + L.stats.val.uncertain + 'uncertain' + '' + L.stats.val.dormant + 'dormant <0.35' + Object.entries(L.stats.byKind).map(([k, n]) => - '' + n + '' + esc(k) + "").join(""); + `${n}${esc(k)}`).join(""); const kinds = Object.keys(L.stats.byKind).sort(); const sel = $("kind-filter"); const keep = sel.value; sel.innerHTML = '' + - kinds.map((k) => '").join(""); + kinds.map((k) => ``).join(""); if (kinds.includes(keep)) sel.value = keep; const shown = L.claims.filter((c) => !sel.value || c.kind === sel.value); - $("claim-count").textContent = shown.length + " of " + L.stats.total + " shown"; + $("claim-count").textContent = `${shown.length} of ${L.stats.total} shown`; $("claims").innerHTML = shown.length ? claimTable(shown) : '
    no claims yet — mint one: forge remember "<name>" "<fact>"
    '; $("contested").innerHTML = L.contested.length - ? '' + L.contested.length + " contested" + claimTable(L.contested) + ? `${L.contested.length} contested${claimTable(L.contested)}` : '
    none — no live claim is both uncertain and contradicted
    '; const trust = Object.entries(L.trust).sort((a, b) => b[1] - a[1]); $("trust").innerHTML = trust.length ? "" + trust.map(([a, u]) => - '").join("") + + ``).join("") + "
    ' + esc(a) + "" + bar(u) + "
    ${esc(a)}${bar(u)}
    " : '
    no authored claims yet
    '; } @@ -280,35 +280,42 @@

    Impact — blast radius .forge/atlas.json

    const saved = stages.reduce((n, [, s]) => n + (s.savedEstimate || 0), 0); const events = stages.reduce((n, [, s]) => n + (s.events || 0), 0); const spend = d.spend; - $("cost-stats").innerHTML = - (spend && spend.totalCost > 0 - ? '$' + spend.totalCost.toFixed(2) + 'real spend' + - '' + spend.sessions + 'sessions' - : '') + - '' + saved.toLocaleString() + 'est. tokens saved' + - '' + events + 'stage events'; - if (spend && spend.byModel && spend.byModel.length) { + const noData = (!spend || spend.totalCost <= 0) && saved === 0 && events === 0; + if (noData) { + $("cost-stats").innerHTML = + '
    no cost data yet — run forge substrate or forge cost to start tracking
    '; + } else { + $("cost-stats").innerHTML = + (spend && spend.totalCost > 0 + ? '$' + spend.totalCost.toFixed(2) + 'real spend' + + '' + spend.sessions + 'sessions' + : '') + + '' + saved.toLocaleString() + 'est. tokens saved' + + '' + events + 'stage events'; + } + if (spend?.byModel?.length) { $("spend-section").innerHTML = '

    Spend by model

    ' + '' + spend.byModel.map((m) => '').join('') + '
    modelcostin tokout tok
    ' + esc(m.model) + '$' + m.cost.toFixed(4) + '' + (m.inTokens || 0).toLocaleString() + '' + (m.outTokens || 0).toLocaleString() + '
    '; } else { - $("spend-section").innerHTML = ''; + $("spend-section").innerHTML = + '
    no model spend data yet — real costs appear when ccusage is configured
    '; } $("stages").innerHTML = stages.length ? "" + stages.map(([name, s]) => '").join("") + "
    stageeventsoutcomessaved
    ' + esc(name) + '' + s.events + "" + Object.entries(s.byOutcome || {}).map(([o, n]) => - '' + esc(o) + " " + n + "").join(" ") + + `${esc(o)} ${n}`).join(" ") + '' + (s.savedEstimate || 0).toLocaleString() + "
    " : '
    no metrics yet — stages append to .forge/metrics.jsonl as you work
    '; $("recent").innerHTML = d.metrics.recent.slice().reverse().map((e) => "
  • " + new Date(e.t).toLocaleTimeString() + ' ' + esc(e.stage) + " " + - esc(e.outcome || "") + (e.savedEstimate ? ' +' + e.savedEstimate + "" : "") + - "
  • ").join("") || '
  • '; + esc(e.outcome || "") + (e.savedEstimate ? ` +${e.savedEstimate}` : "") + + "").join("") || '
  • no recent events — the substrate records cache hits, gate passes, and routing decisions as you work
  • '; } async function runImpact() { @@ -320,9 +327,9 @@

    Impact — blast radius .forge/atlas.json

    return; } out.innerHTML = '
    tracing…
    '; - const res = await fetch("/api/impact?target=" + encodeURIComponent(target)); + const res = await fetch(`/api/impact?target=${encodeURIComponent(target)}`); const r = await res.json(); - if (!res.ok) { out.innerHTML = '
    ' + esc(r.error || "error") + "
    "; return; } + if (!res.ok) { out.innerHTML = `
    ${esc(r.error || "error")}
    `; return; } if (!r.found) { out.innerHTML = '
    "' + esc(target) + '" not found in the atlas (' + r.totalGraphNodes + " nodes)
    "; @@ -338,7 +345,7 @@

    Impact — blast radius .forge/atlas.json

    '' + esc(x.node.name || x.id) + '' + esc(x.node.file || "") + "").join("") + "" + - (r.impacted.length > 40 ? '
    … ' + (r.impacted.length - 40) + " more
    " : "") + + (r.impacted.length > 40 ? `
    … ${r.impacted.length - 40} more
    ` : "") + '
    same trace on the CLI: forge impact ' + esc(target) + "
    "; } @@ -376,7 +383,7 @@

    Impact — blast radius .forge/atlas.json

    } const el = ev.target.closest(".blame"); if (!el) return; - navigator.clipboard?.writeText("forge ledger blame " + el.dataset.id8).then(() => { + navigator.clipboard?.writeText(`forge ledger blame ${el.dataset.id8}`).then(() => { el.classList.add("copied"); const prev = el.textContent; el.textContent = "copied ✓"; diff --git a/src/dash.js b/src/dash.js index fbbea94..22d2387 100644 --- a/src/dash.js +++ b/src/dash.js @@ -111,6 +111,42 @@ export function dashData(root, { nowDay = epochDay() } = {}) { return { repo: basename(root), nowDay, ledger, metrics, atlas, spend }; } +/** + * Lightweight summary — counts and status only (no full claim list). + * @param {string} root + * @param {{nowDay?: number}} [opts] + */ +export function dashSummary(root, { nowDay = epochDay() } = {}) { + let ledgerStats = emptyLedger().stats; + let contested = 0; + try { + const dir = repoLedger(root); + ledgerStats = stats(dir, nowDay); + const all = loadClaims(dir); + contested = all.filter((c) => { + if (c.tombstone) return false; + const v = val(c, nowDay); + if (v < CONTESTED_BAND[0] || v > CONTESTED_BAND[1]) return false; + return (c.evidence ?? []).some((e) => validOutcome(e) && e.result === "contradict"); + }).length; + } catch {} + let atlasBuilt = false; + try { + atlasBuilt = Boolean(loadAtlas(root)); + } catch {} + let metricEvents = 0; + try { + metricEvents = readMetrics(root).length; + } catch {} + return { + claims: ledgerStats.total, + tombstoned: ledgerStats.tombstoned, + contested, + atlasBuilt, + metricEvents, + }; +} + const HTML_PATH = join(dirname(fileURLToPath(import.meta.url)), "dash.html"); const sendJson = (res, code, body) => { diff --git a/src/emit/copilot.js b/src/emit/copilot.js index 621cff9..274e670 100644 --- a/src/emit/copilot.js +++ b/src/emit/copilot.js @@ -1,7 +1,7 @@ // GitHub Copilot's coding agent reads the root AGENTS.md natively (since 2025-08-28). export default { tool: "Copilot", - emit(ctx) { + emit(_ctx) { return { tool: this.tool, target: "AGENTS.md", diff --git a/src/emit/gemini.js b/src/emit/gemini.js index abb2dc5..cb2079b 100644 --- a/src/emit/gemini.js +++ b/src/emit/gemini.js @@ -29,7 +29,7 @@ export default { const had = current.includes("AGENTS.md"); context.fileName = [...new Set([...current, "AGENTS.md"])]; settings._forge = ctx.shared.markerString(ctx.hash); - const action = ctx.shared.writeIfChanged(path, JSON.stringify(settings, null, 2) + "\n"); + const action = ctx.shared.writeIfChanged(path, `${JSON.stringify(settings, null, 2)}\n`); return { tool: this.tool, target: ".gemini/settings.json", diff --git a/src/emit/mcp.js b/src/emit/mcp.js index a8d00c2..29cf945 100644 --- a/src/emit/mcp.js +++ b/src/emit/mcp.js @@ -33,7 +33,7 @@ function mergeJson(path, key, servers) { } if (!added) return { action: "unchanged", note: "present" }; mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(obj, null, 2) + "\n"); + writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`); return { action: "written", note: `+${added} server(s)` }; } @@ -58,7 +58,7 @@ function emitContinueYaml(dir, servers) { lines.push(` - name: ${name}`, " type: stdio", ` command: ${def.command}`, " args:"); for (const a of def.args || []) lines.push(` - ${JSON.stringify(a)}`); } - const content = lines.join("\n") + "\n"; + const content = `${lines.join("\n")}\n`; if (existsSync(path) && readFileSync(path, "utf8") === content) return { action: "unchanged", note: "present" }; mkdirSync(dir, { recursive: true }); diff --git a/src/init.js b/src/init.js index 7e4f9b1..4606548 100644 --- a/src/init.js +++ b/src/init.js @@ -57,17 +57,28 @@ function unionStrings(a = [], b = []) { return [...set]; } -/** Merge Forge hook entries into existing hook arrays, matching by command to avoid duplicates. */ +/** Extract guard identity (basename + trailing args) from a hook command for dedup. + * `bash ~/.forge/guards/cortex.sh prompt` and + * `"${CLAUDE_PLUGIN_ROOT}"/global/guards/cortex.sh prompt` both → `cortex.sh prompt`. */ +function guardKey(command) { + const m = command.match(/([^/\\"]+\.sh)\s*(.*)/); + return m ? `${m[1]} ${m[2]}`.trim() : command; +} + +/** Merge Forge hook entries into existing hook arrays, matching by guard identity to avoid duplicates. */ function mergeHooks(existing = {}, template = {}) { const merged = { ...existing }; for (const [event, entries] of Object.entries(template)) { const existingEntries = merged[event] || []; - const existingCommands = new Set( - existingEntries.flatMap((e) => (e.hooks || []).map((h) => h.command)).filter(Boolean), + const existingKeys = new Set( + existingEntries + .flatMap((e) => (e.hooks || []).map((h) => h.command)) + .filter(Boolean) + .map(guardKey), ); const newEntries = []; for (const entry of entries) { - const hooks = (entry.hooks || []).filter((h) => !existingCommands.has(h.command)); + const hooks = (entry.hooks || []).filter((h) => !existingKeys.has(guardKey(h.command))); if (hooks.length) { newEntries.push({ ...entry, hooks }); } diff --git a/src/sync.js b/src/sync.js index 2ab679d..cff218a 100644 --- a/src/sync.js +++ b/src/sync.js @@ -31,7 +31,7 @@ export function assemble(rules) { for (const rule of section.rules || []) out.push(`- ${rule}`); out.push(""); } - return out.join("\n").trimEnd() + "\n"; + return `${out.join("\n").trimEnd()}\n`; } function loadRules(targetRoot) { diff --git a/templates/project-layer/CLAUDE.md b/templates/project-layer/CLAUDE.md index c795484..bc1b5b5 100644 --- a/templates/project-layer/CLAUDE.md +++ b/templates/project-layer/CLAUDE.md @@ -1,27 +1,24 @@ -# Hostlelo — project instructions +# — project instructions -Drop this into a Hostlelo repo root. Adds project context on top of the global +Drop this into a repo root. Adds project context on top of the global `~/.claude/CLAUDE.md`. Keep it lean; prune anything Claude gets right without it. Shared cross-tool rules (stack, commands, engineering rules) live in **@AGENTS.md** so Cursor/Codex/Gemini read the same source. This file holds only Claude-specific -+ Hostlelo-specific bits. Keep CLAUDE.md thin; put general rules in AGENTS.md. ++ project-specific bits. Keep CLAUDE.md thin; put general rules in AGENTS.md. ## Stack -- Runtime: Node.js. DB: self-hosted Postgres (18+) with **pgvector** on the VPS. -- LLM: routed through **OpenRouter** — never hardcode a single provider; use the - configured routing/env, and pick cheap models for classify/embed, stronger - models only for hard generation. -- Delivery surfaces: customer AI **chat widget** (`widget.js`) and **Telegram** - owner control plane. Infra: Linode VPS + Cloudflare Tunnel/Access. +- Runtime: . DB: . +- LLM: routed through — never hardcode a single provider; use the + configured routing/env. +- Delivery surfaces: . ## Non-negotiables -- **Grounding-first.** Customer-facing answers (esp. prices/plans) must come from - the verified KB, never guessed. If unverified, say so or defer — do not invent - numbers, features, or availability. +- **Grounding-first.** Customer-facing answers must come from verified data, never + guessed. If unverified, say so or defer — do not invent numbers, features, or + availability. - **Secrets** live in env / the secrets store, never in code, logs, or commits. `.env*` and key files are hook-protected — don't work around it. -- Neutral, worldwide tone in customer replies (no forced regional greeting). ## Workflow - Prefer readymade, well-maintained OSS libraries/SDKs over hand-rolled code — @@ -29,7 +26,7 @@ so Cursor/Codex/Gemini read the same source. This file holds only Claude-specifi - Before DB or deploy changes: read the migration/deploy path first; never run destructive SQL (`DROP`/`TRUNCATE`) or touch prod without explicit confirmation. - Verify every change: run the app's tests/typecheck/build and show output. -- Deploy via the `hostlelo-deploy` skill (safe, staged, with rollback notes). +- Deploy via the `` skill (safe, staged, with rollback notes). ## Commands diff --git a/test/brain.test.js b/test/brain.test.js index a3d72e8..b56bdac 100644 --- a/test/brain.test.js +++ b/test/brain.test.js @@ -33,7 +33,7 @@ test("brain is inlined into AGENTS.md by sync (so every tool shares it)", () => test("buildIndex caps items and reports overflow (cliff-safe)", () => { const store = brainStore(fixture()); - for (let i = 0; i < 5; i++) remember(store, "fact" + i, "body " + i); + for (let i = 0; i < 5; i++) remember(store, `fact${i}`, `body ${i}`); const idx = buildIndex(store, { capItems: 2 }); assert.equal(idx.indexed, 2); assert.equal(idx.overflow, 3); diff --git a/test/cortex_mcp.test.js b/test/cortex_mcp.test.js index 7b15d5a..f68f9cf 100644 --- a/test/cortex_mcp.test.js +++ b/test/cortex_mcp.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -24,10 +24,14 @@ test("handle: tools/list exposes the cortex + preflight tools", async () => { "scope_files", "forge_cost", "forge_dash_data", + "forge_dash_summary", "forge_brain", "forge_ledger_query", "forge_diagnose", "forge_doctor", + "forge_remember", + "forge_ledger_ratify", + "forge_ledger_retract", ]) { assert.ok(names.includes(t), `exposes ${t}`); } @@ -75,3 +79,60 @@ test("live server over stdio returns learned lessons for a repo", () => { assert.match(call.result.content[0].text, /Lessons for the files in play/); assert.match(call.result.content[0].text, /tax\.ts/); }); + +test("forge_remember writes a fact to .forge/brain/ via stdio", () => { + const root = mkdtempSync(join(tmpdir(), "forge-mcp-rem-")); + mkdirSync(join(root, ".forge", "brain"), { recursive: true }); + const requests = [ + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "forge_remember", + arguments: { name: "test-fact", body: "testing MCP write" }, + }, + }), + ].join("\n"); + const r = spawnSync("node", [SERVER], { + input: `${requests}\n`, + encoding: "utf8", + env: { ...process.env, FORGE_ROOT: root }, + timeout: 10000, + }); + const responses = r.stdout + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + const call = responses.find((x) => x.id === 2); + assert.match(call.result.content[0].text, /Remembered/); + const written = readFileSync(join(root, ".forge", "brain", "facts", "test-fact.md"), "utf8"); + assert.match(written, /testing MCP write/); +}); + +test("forge_ledger_retract returns error for missing claim via stdio", () => { + const root = mkdtempSync(join(tmpdir(), "forge-mcp-ret-")); + mkdirSync(join(root, ".forge", "ledger"), { recursive: true }); + const requests = [ + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "forge_ledger_retract", arguments: { id: "nonexistent", reason: "test" } }, + }), + ].join("\n"); + const r = spawnSync("node", [SERVER], { + input: `${requests}\n`, + encoding: "utf8", + env: { ...process.env, FORGE_ROOT: root }, + timeout: 10000, + }); + const responses = r.stdout + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + const call = responses.find((x) => x.id === 2); + assert.match(call.result.content[0].text, /No claim matching/); +}); diff --git a/test/doomloop.test.js b/test/doomloop.test.js index 7f8b027..70ccba1 100644 --- a/test/doomloop.test.js +++ b/test/doomloop.test.js @@ -38,7 +38,7 @@ test("doom-loop stays quiet for varied actions", () => { last = run({ session_id: "loop-quiet", tool_name: "Bash", - tool_input: { command: "echo " + i }, + tool_input: { command: `echo ${i}` }, }); } assert.equal(last.code, 0); diff --git a/test/harden.test.js b/test/harden.test.js index 71b7e18..2eb264e 100644 --- a/test/harden.test.js +++ b/test/harden.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; diff --git a/test/init.test.js b/test/init.test.js index f077969..8e6adb5 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { test } from "node:test"; -import { catalog, init } from "../src/init.js"; +import { catalog, init, mergeSettings } from "../src/init.js"; test("init emits the shared config for a fresh repo in one call", () => { const root = mkdtempSync(join(tmpdir(), "forge-init-")); @@ -13,6 +13,54 @@ test("init emits the shared config for a fresh repo in one call", () => { assert.ok(existsSync(join(root, ".aider.conf.yml")), ".aider.conf.yml"); }); +test("mergeSettings deduplicates plugin-style and settings-style hooks", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-hooks-")); + const settingsPath = join(tmp, ".claude", "settings.json"); + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: '"${CLAUDE_PLUGIN_ROOT}"/global/guards/cortex.sh prompt', + }, + { + type: "command", + command: '"${CLAUDE_PLUGIN_ROOT}"/global/guards/cortex.sh preflight', + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { type: "command", command: '"${CLAUDE_PLUGIN_ROOT}"/global/guards/lean-guard.sh' }, + ], + }, + ], + }, + }), + ); + mergeSettings({ settingsPath }); + const result = JSON.parse(readFileSync(settingsPath, "utf8")); + const promptHooks = result.hooks.UserPromptSubmit.flatMap((e) => + (e.hooks || []).map((h) => h.command), + ); + const cortexPromptCount = promptHooks.filter((c) => c.includes("cortex.sh prompt")).length; + assert.equal( + cortexPromptCount, + 1, + "cortex.sh prompt must not duplicate across plugin + settings paths", + ); + const stopHooks = result.hooks.Stop.flatMap((e) => (e.hooks || []).map((h) => h.command)); + const leanCount = stopHooks.filter((c) => c.includes("lean-guard.sh")).length; + assert.equal(leanCount, 1, "lean-guard.sh must not duplicate"); +}); + test("catalog indexes tools (with a why), crew, and guards", () => { const c = catalog(); assert.ok(