diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd2c215..065e216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,20 @@ jobs: # and CITATION.cff must all carry the same version (scripts/bump.mjs keeps them in sync). - run: node scripts/bump.mjs check + docs-drift: + name: Docs match code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the CHANGELOG check compares src vs CHANGELOG.md commit times + - uses: actions/setup-node@v6 + with: + node-version: 22 + # Commands, env vars, MCP tools, and CHANGELOG reconciled against the code — + # a feature can't merge with its docs missing (src/docs_check.js). + - run: node src/cli.js docs check + dependency-review: name: Dependency review runs-on: ubuntu-latest diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b063c92..4199386 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,7 +25,7 @@ for the full list. ## Locked decisions - **Brand = `Forge`** — CLI `forge`; layer names: skills→**tools**, agents→**crew**, hooks→**guards**, code-graph→**atlas**, minimalism→**lean**, memory→**recall**. - Brand stored as **one token** (`brand.json` → `FORGE_BRAND`); rebrand = 1 edit. + Brand stored as **one token** (the `brand` key in `brand.json`); rebrand = 1 edit. - **Distributable id = `forgekit`** (npm package + marketplace id) — fixed even if the brand token changes, so a rename never breaks install. - **Scope = full multi-tool day 1** — nine tools plus MCP, from one canonical source. @@ -68,8 +68,10 @@ The four layers, brand-named and emitted cross-tool: *enforces* rather than suggests.** A guard is a deterministic hook the model cannot drift from. Prose rules in CLAUDE.md get acknowledged and then forgotten after compaction; a guard does not. Every enforceable invariant belongs here. -- **mcp** — the protocol layer. Forge ships the `atlas` code-graph server and the - substrate tools (`substrate_check` / `predict_impact` / `assumption_gate`). +- **mcp** — the protocol layer. Forge ships one stdio server (`src/cortex_mcp.js`) + exposing 19 MCP tools: the substrate checks (`substrate_check` / `predict_impact` / + `assumption_gate` / …), memory reads AND writes (`forge_remember`, ledger + ratify/retract), and ops/health — the full table is in docs/GUIDE.md. Cross-cutting concerns thread through all four: **atlas** (the code graph), **lean** (minimalism — shipped as *both* a tool and a Stop-guard, so it applies whether or not @@ -243,7 +245,7 @@ Roo Code and VS Code receive the Forge MCP server via `forge init` ``` forgekit/ package.json # npm CLI: bin `forge` → src/cli.js - brand.json # single FORGE_BRAND token + layer-name map + brand.json # single brand token + layer-name map README.md # Start-Here index + one bootstrap command src/ cli.js # init | sync | doctor | substrate | ledger | reuse | … (`forge --help` for all) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d02576..b1c179f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,72 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **Gateway environments work end to end** — `ANTHROPIC_AUTH_TOKEN` is recognized + everywhere `ANTHROPIC_API_KEY` is; `ANTHROPIC_MODEL` / `FORGE_MODEL` pin one model + (bypassing tier routing); a gateway-looking `ANTHROPIC_BASE_URL` auto-classifies as + LiteLLM; and the LLM proposer falls back to **direct HTTP** (`src/llm.js`, Anthropic + Messages API) when the `claude` CLI is absent — or on `FORGE_LLM_HTTP=1`. +- **`forge docs check`** (+ CI job + doctor check) — reconciles README/GUIDE/ + ARCHITECTURE/ROADMAP against the code: every CLI command documented, every env var + read is documented and every documented var is real, MCP tool counts/names match the + registry, CHANGELOG sections non-empty. First run found 56 real drift issues, + including a phantom env var. `scripts/bump.mjs` now refuses to rotate an empty + `[Unreleased]`. +- **Docs are in the impact graph** — the atlas parses markdown into doc nodes with + `references` edges to the code they name, so `forge impact src/foo.js` lists the + docs that go stale, and the pre-edit hook says so before the edit. +- **Persistent goal** — `forge anchor set/show/clear` stores the active goal in + `.forge/goal.md`; SessionStart re-injects it and a bare `forge anchor` checks + against it. `goalDrift` also returns a graded `driftScore` for the CUSUM detector. +- **AGENTS.md auto-repair** — the Stop hook re-runs sync when the managed AGENTS.md + drifts from its canonical inputs (disable: `FORGE_AUTOSYNC=0`). +- **Entropy secret detection** — `src/secrets.js` is the single source of truth + (format grammars + Shannon-entropy gate for unknown-vendor tokens); the + `secret-redact` guard now imports it, ending the JS/shell regex divergence. +- **`src/math.js`** — Shannon entropy, charset classes, exact set Jaccard/overlap. + +### Changed +- **Routing scores by exemplar similarity, not keyword lists** — the text rubric is + similarity-weighted k-NN over a labeled `EXEMPLARS` bank (overlap-coefficient on + stopword-filtered unigram+bigram sets, credibility-shrunk); the four topic keyword + regexes and their additive magic weights are gone. Tune routing by adding labeled + rows, not by editing weights. +- **Lesson matching is graded** — the keyword tier of `matchScore` scores by token + overlap (same-module partial credit) instead of all-or-nothing string equality. +- **Substrate minimality warnings derive from computed signals** (preflight missing + dimensions + route score) instead of a second keyword copy. +- **`forge scan` detects obfuscated payloads** — long high-entropy base64 blobs flag + as findings alongside the signature rules. +- **`providerStatus` probes `/health` on any custom base URL** and reports behavioral + gateway evidence (a proxy that answers /health is a gateway, whatever its hostname). + ## [0.8.1] - 2026-07-08 +### Added +- **MCP write tools** — `forge_remember`, `forge_ledger_ratify`, + `forge_ledger_retract` join the read tools (19 tools total). + +### Changed +- Simplified CLI surface and improved dashboard UX empty states. +### Fixed +- Stale documentation across command references. ## [0.8.0] - 2026-07-08 +### Added +- **Forge work system** — auto-install flow, multi-provider routing, the cost + dashboard (`forge dash`), and the cortex MCP server's read-path tools. +- **Zero-config provider auto-detection** — `autoDetectProvider()` resolves the + provider from the environment (LiteLLM local/hosted, OpenRouter, Anthropic); + `forge init` reports what it found. +- **Hosted LiteLLM gateway support** — `emitGatewayConfig()` writes a + `litellm.config.yaml` exposing complexity tiers as model aliases. + +### Fixed +- TypeScript errors and Biome 2.5.2 lint warnings across source and tests. + ## [0.7.0] - 2026-07-08 diff --git a/CLAUDE.md b/CLAUDE.md index b223809..bd12758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,5 +18,7 @@ - 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. +- Run `npm test && npx biome check && npm run typecheck && node src/cli.js docs check` + before committing — the docs check fails CI when commands/env vars/MCP tools/CHANGELOG + drift from the code, so update docs IN THE SAME CHANGE, not later. - Version lives in `package.json` — `scripts/bump.mjs` keeps all manifests in sync. diff --git a/README.md b/README.md index 1e242c2..4eefbd1 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,10 @@ git pull && forge ledger merge On Claude Code the substrate then runs on **every prompt automatically** via a `UserPromptSubmit` hook — advisory only, silent on clean tasks. Every other tool gets a -native config rule plus MCP tools (`substrate_check`, `predict_impact`, `assumption_gate`, -`route_task`, `scope_files`) it can call itself. +native config rule plus **19 MCP tools** it can call itself — pre-action checks +(`substrate_check`, `predict_impact`, `assumption_gate`, `route_task`, `scope_files`), +memory reads and writes, and ops/health — the full list with schemas is in +[`docs/GUIDE.md`](docs/GUIDE.md#mcp-tools). ## Commands @@ -154,6 +156,8 @@ default 25-file threshold). | **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 | +| | `forge docs` | docs↔code drift check — commands, env vars, MCP tools, CHANGELOG vs reality | +| | `forge config` | provider setup — show / switch / add providers, set the default model | | | `forge harden` | wire gitleaks pre-commit + sandbox settings | | | `forge catalog` | Start-Here index of every tool / crew / guard | | | `forge brand` | print the brand token map | @@ -171,7 +175,7 @@ default 25-file threshold). | | `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 anchor` | goal-drift check (advisory) | +| | `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`) | diff --git a/ROADMAP.md b/ROADMAP.md index ac4bb0b..772982f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,10 +7,14 @@ 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.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). +## Now (`master`, v0.8.1+) +Everything from 0.8.0 plus gateway-environment support end to end — `ANTHROPIC_AUTH_TOKEN` +recognized everywhere, `ANTHROPIC_MODEL`/`FORGE_MODEL` model override, LiteLLM-gateway +auto-classification of `ANTHROPIC_BASE_URL`, direct-HTTP LLM calls when the `claude` CLI +is absent (`src/llm.js`) — plus decision math replacing keyword heuristics (exemplar k-NN +routing, entropy secret detection), docs↔code drift gating (`forge docs check`, in CI), +docs in the impact graph, and a persistent goal (`forge anchor set`). +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) @@ -22,12 +26,15 @@ confidence only from independent oracles, and merges across teammates conflict-f ## 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. + for LiteLLM (local + hosted gateway), OpenRouter, and Anthropic (key, auth token, + or custom base URL); `forge init` reports what it found, no manual config needed. + (OpenAI and Gemini detection: see Next.) +- **Hosted LiteLLM gateway** — `emitGatewayConfig()` writes a `litellm.config.yaml` + exposing the complexity tiers as model aliases; point `ANTHROPIC_BASE_URL` at the + proxy and every model call routes through it. +- **MCP server** — the cortex MCP server (`src/cortex_mcp.js`) exposes read-path + tools for ledger, brain, atlas, recall, cost, substrate, and dashboard (19 MCP tools + as of 0.8.x, including the write tools added in 0.8.0). - **Cost dashboard** — `forge dash` serves a local HTML dashboard showing model spend, event timeline, and ledger health from `.forge/` data. @@ -46,6 +53,9 @@ confidence only from independent oracles, and merges across teammates conflict-f `/status/`. ## Next +- **OpenAI + Gemini provider detection** — extend `autoDetectProvider()` beyond + Anthropic/OpenRouter/LiteLLM (`OPENAI_API_KEY`, `GEMINI_API_KEY`) with the same + zero-config contract. - **Legacy store retirement** — the read-path flip has shipped: every read surface (cortex injection/status, the substrate advisory, routing, `recall list`, brain's AGENTS.md index) is now a merged view (legacy ∪ ledger) via `src/ledger_read.js`, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 21fb8b3..4dcdcc3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -27,7 +27,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 harden` · `forge catalog` · `forge brand` | +| **Config / cross-tool sync** | `forge init` · `forge sync` · `forge doctor` · `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` | | **Code graph & retrieval** | `forge atlas` · `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` | @@ -177,22 +177,53 @@ _Advisory: ask rather than assume._ ### `forge route ""` — the cheapest capable model -A transparent additive rubric (never a second LLM call). Every point is attributable -to a named signal you can inspect and override. +A transparent, deterministic rubric (never a second LLM call), and every score is +attributable. The text side is **similarity-weighted k-NN over a labeled exemplar +bank** (`EXEMPLARS` in `src/route.js`): your task is compared to ~50 example tasks +with known complexities, and the nearest neighbors — which the output names — set the +estimate. The repo side scores real signals (files in scope, impact fan-out, churn, +past-mistake density, ambiguity). Whichever facet detects difficulty sets the tier. ```console $ forge route "write an is_prime function" → Haiku 4.5 (simple, $1/$5 per M tok) lint, formatting, docs, stubs, trivial well-defined edits - complexity 0.13 · driven by: ambiguity, base cost of any task + driven by: similar to "check if a number is prime" (sim 1.00, complexity 0.08) $ forge route "design and implement a distributed rate limiter with sliding windows across 3 services" - → Fable 5 (extreme, $10/$50 per M tok) - complexity 0.95 · driven by: algorithmic/systems difficulty, architectural/design scope + → Fable 5 / Opus (premium tier) + driven by: similar to "implement a rate limiter with a token bucket" (sim 0.71, complexity 0.78) ``` +Unseen phrasings route by resemblance — "two threads deadlock when the queue is full" +lands in the concurrency neighborhood without any keyword list needing the literal +token "race condition". To tune routing, add labeled rows to `EXEMPLARS` (data, not +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 config` — provider setup + +Shows, switches, and registers model providers, and sets the default model. Forge +auto-detects the provider from the environment with zero config — the priority order is +`LITELLM_BASE_URL` → `ANTHROPIC_BASE_URL` (a URL that answers `/health` or names a +gateway is classified as one) → `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → +`ANTHROPIC_AUTH_TOKEN`. An explicit `.forge/providers.json` always wins over detection. + +```console +$ forge config show # active provider + how it was resolved +$ forge config providers # everything detected + configured +$ forge config provider # switch +$ forge config provider add --base-url [--env-key VAR] +$ forge config model # default model +$ forge config gateway # emit litellm.config.yaml (same as forge route gateway) +$ forge config setup # guided first-time setup +``` + +Corporate gateway environments work out of the box: with `ANTHROPIC_BASE_URL` + +`ANTHROPIC_AUTH_TOKEN` set (LiteLLM-style gateways), detection classifies the gateway, +auth uses the token as a Bearer credential, and `ANTHROPIC_MODEL` pins the model. + ### `forge impact ` — what will this edit break? Reverse-dependency blast radius from the atlas graph. Run `forge atlas build` first. @@ -243,7 +274,14 @@ Forge anchor — goal-drift check `src/auth.js` maps to the goal (named file + where `verifyToken` lives); `src/report.js` doesn't — so it's surfaced as drift to confirm or undo. Coarse and advisory by design -(path/keyword match, not semantic). `forge substrate` folds this in automatically. +(path/keyword match, not semantic). `forge substrate` folds this in automatically. The +result also carries `driftScore` (the off-goal fraction per checkpoint) — the graded +signal the `cusum` change-point detector accumulates to catch *sustained* small drift. + +**The goal persists.** `forge anchor set ""` stores it in `.forge/goal.md`; every +new session re-injects it at SessionStart, a bare `forge anchor` checks against it, and +`forge anchor show` / `forge anchor clear` manage it. A goal set on Monday still anchors +Thursday's session — no more each-session re-assumption of what you're working toward. ### `forge verify` — did it actually work? @@ -587,6 +625,7 @@ Plain `forge cost` remains the per-day spend view via `ccusage`. | `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 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. | @@ -649,15 +688,32 @@ Nothing to wire — the plugin's [`hooks/hooks.json`](../hooks/hooks.json) insta > `forge substrate "" --json` (or the MCP tool `substrate_check`). If > `okToProceed` is false, ask the questions first; read `impact.impactedFiles` before editing. -…and exposes the substrate as MCP tools any MCP-capable agent can call directly: +…and exposes the substrate as **19 MCP tools** any MCP-capable agent can call directly +(the stdio server is launched with `forge cortex-mcp`, wired automatically via the +emitted `.mcp.json`): + + | MCP tool | Does | | --- | --- | | `substrate_check` | full pre-action check | +| `preflight_check` | assumption / info-gap check | | `assumption_gate` | ask/proceed + questions | -| `predict_impact` | blast radius | +| `predict_impact` | blast radius (code **and** the docs that reference it) | | `route_task` | model recommendation | | `scope_files` | independent vs. coupled | +| `cortex_lessons` | learned lessons for given files/symbols | +| `cortex_status` | memory lifecycle summary | +| `forge_brain` | durable project facts | +| `forge_ledger_query` | ranked retrieval over the PCM ledger | +| `forge_remember` | **write**: add a durable project fact | +| `forge_ledger_ratify` | **write**: human-ratify a claim into a decision | +| `forge_ledger_retract` | **write**: tombstone a claim | +| `forge_diagnose` | doom-loop failure check | +| `forge_doctor` | health check | +| `forge_provider_status` | provider detection + gateway reachability | +| `forge_cost` | spend + stage factors | +| `forge_dash_data` / `forge_dash_summary` | dashboard data feeds | Forge never pretends it can force a hook into a tool that has none — **ambient on Claude Code, agent-invoked everywhere else.** @@ -726,7 +782,7 @@ Create `global/crew/.md` with frontmatter. It installs into `~/.claude/age | --- | --- | | how often it asks | `source/substrate.json` → `defaults.askThreshold` (0.6) | | blast-radius sensitivity | `source/substrate.json` → `defaults.impactThreshold` (0.1) | -| a routing signal | `src/route.js` → `rubricComplexity()` | +| a routing outcome | `src/route.js` → add a labeled row to `EXEMPLARS` (data, not weights); constants in `RUBRIC` | | model tiers / prices | `src/model_tiers.js` | | an assumption question | `src/preflight.js` → `DIMENSIONS[]` | | the verify checklist | `src/substrate.js` → `verificationChecklist()` | @@ -756,9 +812,39 @@ Add an emitter module in `src/emit/.js` (mirror an existing one like `test/sync.test.js` keeps it honest. ### Rebrand -Edit `brand.json` (`FORGE_BRAND`), the `bin` key in `package.json`, and `name` in +Edit the brand token in `brand.json`, the `bin` key in `package.json`, and `name` in `.claude-plugin/plugin.json`. The whole CLI, banner, and emitted headers follow. +### Environment variables + +The complete env contract (`forge docs check` keeps this table honest — a variable the +code reads but this table misses fails CI on the forge repo): + +| Variable | Does | +| --- | --- | +| `ANTHROPIC_API_KEY` | direct Anthropic auth (also used by gateways when set) | +| `ANTHROPIC_AUTH_TOKEN` | gateway/proxy Bearer credential — recognized everywhere the API key is | +| `ANTHROPIC_BASE_URL` | custom API endpoint; gateway-looking URLs auto-classify as LiteLLM | +| `ANTHROPIC_MODEL` / `FORGE_MODEL` | pin one model — bypasses tier routing entirely | +| `LITELLM_BASE_URL` / `LITELLM_API_KEY` | hosted LiteLLM gateway endpoint + key (highest detection priority) | +| `OPENROUTER_API_KEY` | OpenRouter provider | +| `FORGE_LLM` | `1` enables the LLM proposer layer (off = fully deterministic) | +| `FORGE_LLM_AMBIENT` | `1` lets the ambient hook use the proposer too | +| `FORGE_LLM_HTTP` | `1` forces direct HTTP (Anthropic Messages API) instead of the `claude` CLI; automatic when the CLI is absent | +| `FORGE_ENFORCE` | `1` turns the substrate advisory into a hard block on the strongest signals | +| `FORGE_AUTOSYNC` | `0` disables the Stop-hook AGENTS.md auto-repair | +| `FORGE_EMBED` / `FORGE_EMBED_MODEL` / `FORGE_EMBED_TIMEOUT_MS` | optional embeddings tier (ADR-0005) | +| `FORGE_HOME` | override `~/.forge` (recall store location) | +| `FORGE_ROOT` | repo root override for the MCP server | +| `FORGE_AUTHOR` | identity stamped on ledger provenance (defaults to git identity) | +| `FORGE_COST_CEILING` | daily spend (USD) the cost-budget guard warns at (default 10) | +| `FORGE_LOOP_THRESHOLD` | identical tool calls before the doom-loop guard speaks (default 4) | +| `FORGE_LEAN_THRESHOLD` | lines-per-task-word ratio the lean guard nudges at | +| `FORGE_VERIFY_TIMEOUT_MS` | verify test-run timeout (default 600000) | +| `FORGE_SKILLGATE_NOEXTERNAL` | `1` skips the external scanner in `forge scan` (heuristic only) | +| `ENABLE_CORTEX_DISTILL` | `1` distills new lessons into prose via a cheap model call | +| `FORGE_DEBUG` | `1` writes fail-safe error details to stderr instead of swallowing them | + --- ## Honest limits diff --git a/global/guards/secret-redact.sh b/global/guards/secret-redact.sh index 6280580..4f9c8c4 100755 --- a/global/guards/secret-redact.sh +++ b/global/guards/secret-redact.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # PostToolUse guard — redact secrets from a tool's output BEFORE it enters context, -# using the `updatedToolOutput` hook primitive. Defensive + advisory: only emits a -# rewrite when something matched. (updatedToolOutput min version is not pinned in the -# docs — this degrades to a no-op on tools/versions that ignore it.) +# using the `updatedToolOutput` hook primitive. Detection/redaction logic lives in +# src/secrets.js (format grammars + entropy scoring — ONE source of truth), imported +# via node so this script can never disagree with the JS refusal sites. Degrades to a +# narrower sed pass over known credential formats only if node or the module is +# unreachable. Defensive + advisory: only emits a rewrite when something changed. set -uo pipefail command -v jq >/dev/null 2>&1 || exit 0 @@ -10,7 +12,33 @@ INPUT="$(cat)" out="$(printf '%s' "$INPUT" | jq -r '.tool_response // .tool_output // empty' 2>/dev/null)" [ -n "$out" ] || exit 0 -red="$(printf '%s' "$out" | sed -E 's/(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})/[REDACTED]/g')" +# Fast prefilter: PostToolUse fires after EVERY tool call, and most outputs contain +# nothing remotely secret-shaped — skip the node spawn entirely unless a candidate +# (known credential prefix, PEM header, key-ish assignment, or a 20+ char token run) +# is present. The node pass then decides precisely. +printf '%s' "$out" | grep -qE -- '-----BEGIN |ghp_|github_pat_|sk-|xox[baprs]-|AIza|ya29\.|eyJ|AKIA|(api[_-]?key|secret|passwd|password|token)[A-Za-z0-9_-]*["'"'"']?[[:space:]]*[:=]|[A-Za-z0-9+=_-]{20,}' || exit 0 + +# ~/.forge is a symlink to /global, so pwd -P lands inside the real tree in +# both install modes (install.sh symlink and CLAUDE_PLUGIN_ROOT plugin checkout). +DIR="$(cd "$(dirname "$0")" && pwd -P)" +SECRETS_JS="$DIR/../../src/secrets.js" + +red="" +if command -v node >/dev/null 2>&1 && [ -f "$SECRETS_JS" ]; then + # setEncoding: string-concatenating raw Buffers corrupts multibyte UTF-8 split + # across chunk boundaries, and the mangled text would be emitted as a rewrite. + red="$(printf '%s' "$out" | node -e ' + process.stdin.setEncoding("utf8"); + let raw = ""; + process.stdin.on("data", (d) => { raw += d; }); + process.stdin.on("end", async () => { + const { redactSecrets } = await import(process.argv[1]); + process.stdout.write(redactSecrets(raw)); + });' "$SECRETS_JS" 2>/dev/null)" || red="" +fi +if [ -z "$red" ]; then + red="$(printf '%s' "$out" | sed -E 's/(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{20,}|ya29\.[A-Za-z0-9._-]+|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/[REDACTED]/g')" +fi if [ "$red" != "$out" ]; then jq -n --arg o "$red" '{hookSpecificOutput:{hookEventName:"PostToolUse",updatedToolOutput:$o}}' diff --git a/reports/benchmarks.md b/reports/benchmarks.md index cc5b013..40e878c 100644 --- a/reports/benchmarks.md +++ b/reports/benchmarks.md @@ -166,7 +166,7 @@ that forgekit structurally does not. |---|---|---| | routing decision visible *before* dispatch | yes — returns band, signals, and per-signal reasons the user can read and override (`src/route.js`) | decision is made inside the proxy at request time | | rubric versioned in the repo | yes — deterministic scoring over `src/model_tiers.json`, diffable in PRs | routing/cost logic lives in gateway config or the provider's service | -| same input ⇒ same route | yes — regex rubric is deterministic; LLM adjudication is opt-in and clamped inside band rails (a proposal can never jump past them) | depends on gateway load/cost/failover state | +| same input ⇒ same route | yes — the exemplar k-NN rubric is deterministic (same text, same neighbors, same score); LLM adjudication is opt-in and clamped inside band rails (a proposal can never jump past them) | depends on gateway load/cost/failover state | | **what the gateways have that forgekit doesn't** | — | they actually *move traffic*: proxying, failover, quotas, key management. `forge route` is advisory and at most **emits** a LiteLLM config exposing its tiers as aliases (`src/route.js`) — it is a transparency layer over gateways, not a replacement | ### Proof-carrying reuse vs plain RAG retrieval diff --git a/scripts/bump.mjs b/scripts/bump.mjs index d0fcc04..33be37a 100644 --- a/scripts/bump.mjs +++ b/scripts/bump.mjs @@ -109,6 +109,13 @@ export function rotateChangelog(changelog, newVersion, prevVersion, date) { } const body = extractUnreleased(changelog); if (body === null) throw new Error("CHANGELOG.md has no ## [Unreleased] section"); + // An empty [Unreleased] would rotate into an empty release section — a header + // with no record of what shipped (exactly how 0.8.0/0.8.1 went undocumented). + // Write the changelog first; the release notes ARE part of the release. + if (!body.trim()) + throw new Error( + "CHANGELOG.md [Unreleased] is empty — describe the release before bumping (see forge docs check)", + ); const start = changelog.search(/^## \[Unreleased\][^\n]*\n/m); const afterHeading = changelog.indexOf("\n", start) + 1; const head = changelog.slice(0, afterHeading); diff --git a/src/adjudicate.js b/src/adjudicate.js index 53e042e..a835624 100644 --- a/src/adjudicate.js +++ b/src/adjudicate.js @@ -14,7 +14,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { buildHttpRunner as httpRunner } from "./llm.js"; import { MODELS } from "./model_tiers.js"; import { envModelOverride } from "./providers.js"; -import { SECRET_RE } from "./recall.js"; +import { hasSecret } from "./recall.js"; /** * Is the LLM proposer layer active for this call? Explicit opt-in wins; env is the default. @@ -77,9 +77,9 @@ export function extractJson(text) { */ export function adjudicate({ prompt, parse, run = buildRunner() }) { try { - if (SECRET_RE.test(String(prompt))) return null; // never send a secret to the model + if (hasSecret(prompt)) return null; // never send a secret to the model const raw = run(prompt); - if (SECRET_RE.test(String(raw))) return null; // never trust a reply that leaked one back + if (hasSecret(raw)) return null; // never trust a reply that leaked one back const obj = extractJson(raw); if (obj == null) return null; const parsed = parse(obj); diff --git a/src/anchor.js b/src/anchor.js index 10bd8c6..6978571 100644 --- a/src/anchor.js +++ b/src/anchor.js @@ -154,6 +154,10 @@ export function goalDrift(root, goal, opts = {}) { } const drift = changedFiles.length > 0 && (offGoal.length > 0 || onGoal.length === 0); + // Graded drift magnitude Dₜ ∈ [0,1] — the fraction of this checkpoint's changes that + // wandered off-goal. This is the signal cusum() below expects: the binary `drift` + // flag answers "any drift now?", the score accumulates into "sustained drift?". + const driftScore = changedFiles.length ? offGoal.length / changedFiles.length : 0; return { goal: String(goal), keywords, @@ -161,6 +165,7 @@ export function goalDrift(root, goal, opts = {}) { onGoal, offGoal, drift, + driftScore, provenance, }; } diff --git a/src/atlas.js b/src/atlas.js index 6b95487..6a1418e 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -34,6 +34,17 @@ const RULES = { ".java": [{ re: /\b(?:class|interface|enum)\s+([A-Za-z_]\w*)/g, kind: "type" }], }; +// Documentation extensions — first-class in the walk, extracted by extractDoc() (a +// doc node + `references` edges to the code it names), never by the symbol RULES. +// This is the missing code→doc half of impact: change a symbol, its docs show up +// as dependents. +const DOC_EXTS = new Set([".md"]); + +// Docs excluded from the graph: generated files the Stop hook rewrites (AGENTS.md +// auto-sync would re-stale the atlas after every session) and the changelog, which +// churns on every change and whose references describe HISTORY, not current code. +const DOC_SKIP = /^(AGENTS|CLAUDE|GEMINI|CHANGELOG)\.md$/i; + const IMPORT_RE = /(?:import\s+(?:[^"'\n]+\s+from\s+)?["']([^"']+)["']|require\(["']([^"']+)["']\)|^\s*(?:from\s+([\w.]+)\s+)?import\s+([\w*,\s]+))/gm; const BUILTINS = new Set([ @@ -94,7 +105,11 @@ function walk(dir, files, cap) { continue; } if (st.isDirectory()) walk(path, files, cap); - else if (RULES[extname(name)] && files.length < cap) files.push(path); + else if ( + (RULES[extname(name)] || (DOC_EXTS.has(extname(name)) && !DOC_SKIP.test(name))) && + files.length < cap + ) + files.push(path); } } @@ -114,6 +129,41 @@ function nearestSource(nodes, line, fallback) { return best; } +// A markdown file becomes ONE doc node whose outgoing `references` edges point at the +// code it names: backticked `src/foo.js` paths and `symbolName` identifiers, plus +// [link](path) targets. In the reverse-BFS those edges make every referencing doc a +// DEPENDENT of the code — `forge impact src/route.js` now lists the docs that go +// stale, so end-to-end propagation includes documentation, not just callers. +function extractDoc(rel, text) { + const doc = { id: `doc:${rel}`, name: rel, kind: "doc", file: rel, line: 1 }; + const edges = []; + const seen = new Set(); + const refEdge = (target, confidence, line) => { + if (seen.has(target)) return; + seen.add(target); + edges.push({ source: doc.id, target, kind: "references", confidence, line }); + }; + for (const m of text.matchAll(/`([^`\n]+)`/g)) { + const line = lineOf(text, m.index); + for (const raw of m[1].trim().split(/\s+/)) { + const tok = raw.replace(/[(),;:]+$/, "").replace(/^\.\//, ""); + if (!tok) continue; + if (/[/\\]/.test(tok) && RULES[extname(tok)]) { + refEdge(`module:${moduleId(tok)}`, 0.8, line); // `src/foo.js` → its module node + } else if (/^[A-Za-z_$][\w$]*(\(\))?$/.test(tok) && tok.length >= 3) { + const name = tok.replace(/\(\)$/, ""); + if (!BUILTINS.has(name)) refEdge(name, 0.6, line); // `symbolName` → resolveEdges links it + } + } + } + for (const m of text.matchAll(/\]\(([^)#\s]+)\)/g)) { + const tok = m[1].replace(/^\.\//, ""); + if (/^[a-z]+:/i.test(tok)) continue; // external URL, not a repo path + if (RULES[extname(tok)]) refEdge(`module:${moduleId(tok)}`, 0.8, lineOf(text, m.index)); + } + return { symbols: [], nodes: [doc], edges, hash: hash(text) }; +} + function extractFile(path, root, preRead) { const ext = extname(path); const rules = RULES[ext]; @@ -126,6 +176,7 @@ function extractFile(path, root, preRead) { return { symbols: [], nodes: [], edges: [], hash: "" }; } } + if (DOC_EXTS.has(ext)) return extractDoc(rel, text); const mod = { id: `module:${moduleId(rel)}`, diff --git a/src/cli.js b/src/cli.js index f626c5d..52af723 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2,62 +2,9 @@ // forge — zero-dependency dispatcher. Works identically whether installed via the // npm bin, the hardened install.sh symlink, or the Claude Code plugin. import { BRAND } from "./brand.js"; - -const COMMANDS = { - init: "scaffold this repo's config — emits every tool from one shared source", - sync: "recompile the canonical source into each tool's native config files", - doctor: "health-check installed tools, guards, MCP auth, and config drift", - taste: "enable one UI-taste tool for this repo (no arg = list)", - atlas: "build / query the code-graph (where-is-Y, has-symbol)", - recall: "manage cross-session memory (list / add / consolidate)", - catalog: "Start Here — list every tool, crew, and guard with a one-line why", - scan: "vet a skill/MCP for injection/RCE/exfil before install (skill-gate)", - verify: "independent verification gate — tests + hallucinated-symbol + provenance", - harden: "wire security controls — gitleaks pre-commit + sandbox settings", - remember: "add a durable fact to this repo's portable memory (forge brain)", - brain: "show / rebuild the portable project memory index", - cost: "real per-day spend via ccusage + measured stage factors (--stages)", - spec: "spec-as-contract — init (OpenSpec) / lock / check drift", - cortex: "self-correcting project memory — status / why ", - ledger: - "proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / import", - reuse: "proof-carrying code cache — query / mint --file / stats", - context: "budgeted context assembly + completeness gate — what an edit NEEDS known", - preflight: "assumption check — what a task names that the repo doesn't define", - config: "provider setup — show / switch / add providers, set default model", - route: "recommend the cheapest capable model for a task (+ gateway config)", - impact: "predict blast radius for a symbol or file from the atlas graph", - substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify", - scope: "decompose files into independent clusters (+ coupled files you didn't name)", - anchor: "goal-drift check — are your actual (git) changes still on the stated goal?", - 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", - lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for", - uicheck: - "deterministic UI checks — contrast · fingerprint · design · visual ", - dash: "local dashboard over the ledger, metrics, and blast radius", - 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"], -}; +// The command surface lives in commands.js as data — docs_check.js reconciles the +// README/GUIDE tables against the same table this help is rendered from. +import { COMMANDS, GROUPS } from "./commands.js"; const printVersion = () => console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.version}`); @@ -185,6 +132,27 @@ async function run(argv) { if (failed) process.exitCode = 1; return; } + if (cmd === "docs") { + // Self-check of the forge package's own docs against its code (commands table, + // env reads, MCP registry, CHANGELOG). `forge docs check` is the only subcommand. + const { docsCheck } = await import("./docs_check.js"); + const r = docsCheck(); + if (argv.includes("--json")) { + console.log(JSON.stringify(r, null, 2)); + if (!r.ok) process.exitCode = 1; + return; + } + console.log(`${BRAND.brand} docs check — docs↔code drift\n`); + if (!r.issues.length) + console.log(" ✓ docs and code agree (commands, env vars, MCP tools, CHANGELOG)"); + for (const i of r.issues) + console.log(` ${i.severity === "error" ? "✗" : "!"} [${i.check}] ${i.detail}`); + if (!r.ok) { + console.log(`\n${r.issues.filter((i) => i.severity === "error").length} problem(s)`); + process.exitCode = 1; + } + return; + } if (cmd === "recall") { const r = await import("./recall.js"); const store = r.defaultStore(); @@ -1115,13 +1083,39 @@ async function run(argv) { } if (cmd === "anchor") { const { goalDrift, renderAnchor } = await import("./anchor.js"); + const { clearGoal, getGoal, setGoal } = await import("./goal.js"); const json = argv.includes("--json"); - const goal = argv - .slice(1) - .filter((a) => a !== "--json") - .join(" "); + const args = argv.slice(1).filter((a) => a !== "--json"); + const sub = args[0]; + // Persistent goal management: `set` stores it, SessionStart re-injects it, and a + // bare `forge anchor` checks against it — the goal survives the session that set it. + if (sub === "set") { + const r = setGoal(process.cwd(), args.slice(1).join(" ")); + if (!r.ok) { + console.error(`${BRAND.cli} anchor set: ${r.reason}`); + process.exitCode = 1; + return; + } + console.log( + `goal set: ${r.goal}\n(injected each session start; \`forge anchor\` checks against it)`, + ); + return; + } + if (sub === "show") { + const g = getGoal(process.cwd()); + console.log(g ? `active goal: ${g}` : 'no active goal — set one: forge anchor set ""'); + return; + } + if (sub === "clear") { + clearGoal(process.cwd()); + console.log("goal cleared."); + return; + } + const goal = args.join(" ") || getGoal(process.cwd()); if (!goal) { - console.error('usage: forge anchor "" [--json]'); + console.error( + `usage: ${BRAND.cli} anchor "" [--json]\n ${BRAND.cli} anchor set|show|clear — persist the goal across sessions`, + ); process.exitCode = 1; return; } diff --git a/src/commands.js b/src/commands.js new file mode 100644 index 0000000..c8a82ad --- /dev/null +++ b/src/commands.js @@ -0,0 +1,63 @@ +// forge commands — the CLI's command surface as DATA. cli.js renders --help from +// this table and docs_check.js reconciles README/GUIDE against it, so a command can +// no longer ship (or disappear) without the docs check noticing. One line per command. + +export const COMMANDS = { + init: "scaffold this repo's config — emits every tool from one shared source", + sync: "recompile the canonical source into each tool's native config files", + doctor: "health-check installed tools, guards, MCP auth, and config drift", + taste: "enable one UI-taste tool for this repo (no arg = list)", + atlas: "build / query the code-graph (where-is-Y, has-symbol)", + recall: "manage cross-session memory (list / add / consolidate)", + catalog: "Start Here — list every tool, crew, and guard with a one-line why", + scan: "vet a skill/MCP for injection/RCE/exfil before install (skill-gate)", + verify: "independent verification gate — tests + hallucinated-symbol + provenance", + harden: "wire security controls — gitleaks pre-commit + sandbox settings", + remember: "add a durable fact to this repo's portable memory (forge brain)", + brain: "show / rebuild the portable project memory index", + cost: "real per-day spend via ccusage + measured stage factors (--stages)", + spec: "spec-as-contract — init (OpenSpec) / lock / check drift", + cortex: "self-correcting project memory — status / why ", + ledger: + "proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / import", + reuse: "proof-carrying code cache — query / mint --file / stats", + context: "budgeted context assembly + completeness gate — what an edit NEEDS known", + preflight: "assumption check — what a task names that the repo doesn't define", + config: "provider setup — show / switch / add providers, set default model", + route: "recommend the cheapest capable model for a task (+ gateway config)", + impact: "predict blast radius for a symbol or file from the atlas graph", + substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify", + scope: "decompose files into independent clusters (+ coupled files you didn't name)", + anchor: "goal-drift check — are your actual (git) changes still on the stated goal?", + 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", + lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for", + uicheck: + "deterministic UI checks — contrast · fingerprint · design · visual ", + dash: "local dashboard over the ledger, metrics, and blast radius", + brand: "print the active brand token map", + docs: "docs↔code drift check — commands, env vars, MCP tools, CHANGELOG vs reality", +}; + +export const GROUPS = { + Core: ["init", "sync", "doctor", "catalog", "docs"], + 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"], +}; + +/** Commands that exist but are deliberately not advertised in --help or docs tables. */ +export const HIDDEN_COMMANDS = ["cortex-mcp"]; diff --git a/src/cortex_distill.js b/src/cortex_distill.js index e16e809..8c1225a 100644 --- a/src/cortex_distill.js +++ b/src/cortex_distill.js @@ -4,7 +4,7 @@ // shells out to the `claude` CLI via the shared adjudicate runner (same primitive every other // faculty uses); the runner is injectable so the pure prompt/parse logic is testable without it. import { buildRunner } from "./adjudicate.js"; -import { SECRET_RE } from "./recall.js"; +import { hasSecret } from "./recall.js"; /** * Pure: build the distillation prompt from an episode. @@ -39,7 +39,7 @@ export function parseDistilled(text) { .trim() .slice(0, 200); if (!whatWentWrong || !correctedBehavior) return null; - if (SECRET_RE.test(`${whatWentWrong} ${correctedBehavior}`)) return null; // never persist a secret + if (hasSecret(`${whatWentWrong} ${correctedBehavior}`)) return null; // never persist a secret return { whatWentWrong, correctedBehavior }; } diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 9bba9c7..54b3145 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -67,15 +67,27 @@ async function main() { clearSession(root, sid); await enrichCreated(root, results); } + // Auto-sync: anything this session taught the memory (lessons, facts) reaches + // every AGENTS.md-reading tool NOW — drift used to be detected by doctor but + // repaired by nobody. Only touches an already-Forge-managed AGENTS.md; + // kill switch FORGE_AUTOSYNC=0. Fail-safe like everything else here. + try { + const { autoSyncIfDrifted } = await import("./sync.js"); + autoSyncIfDrifted(root); + } catch {} } else if (mode === "session-start") { - const block = startupBlock(root, today); + // Learned lessons + the persistent goal — the two things a fresh session forgets. + const { goalBlock } = await import("./goal.js"); + const block = [startupBlock(root, today), goalBlock(root)].filter(Boolean).join("\n"); if (block) emit("SessionStart", block); } else if (mode === "pre-edit") { // A doom loop (the same failure recurring) is the loudest thing to say — it means "stop", // so it takes precedence over lesson/risk advice. const loop = doomLoopAdvisory(readSession(root, sid)); const advice = loop || (await preEditAdvisory(root, hook.tool_input?.file_path, today)); - if (advice) emit("PreToolUse", advice); + const docs = await staleDocsAdvisory(root, hook.tool_input?.file_path); + const combined = [advice, docs].filter(Boolean).join("\n\n"); + if (combined) emit("PreToolUse", combined); } else if (mode === "preflight") { // Ambient cognitive substrate: assumption gate + (when an atlas is already cached) // model routing, blast-radius, memory, and minimality — surfaced before the agent acts. @@ -131,6 +143,27 @@ async function preEditAdvisory(root, file, today) { : ""; } +// Docs that reference the file about to change (atlas doc edges, CACHED graph only — +// a hook never builds). The end-to-end nudge: a code edit carries its docs with it. +async function staleDocsAdvisory(root, file) { + if (!file || file.endsWith(".md")) return ""; + try { + const { load, impact } = await import("./atlas.js"); + const atlas = load(root); + if (!atlas) return ""; + // root + separator, not a bare prefix: '/home/u/repo' must not strip '/home/u/repository'. + const rel = + file.startsWith(`${root}/`) || file.startsWith(`${root}\\`) + ? file.slice(root.length + 1) + : file; + const docs = impact(atlas, rel, { maxHops: 2 }).impactedFiles.filter((f) => f.endsWith(".md")); + if (!docs.length) return ""; + return `Forge impact — docs that reference ${rel}: ${docs.slice(0, 5).join(", ")}. If this change alters behavior, update them in the same pass (\`forge impact ${rel}\` for the full list).`; + } catch { + return ""; + } +} + main() .catch((err) => { if (process.env.FORGE_DEBUG === "1") diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index 8893d90..0d1a5e2 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -28,196 +28,11 @@ const PKG_VERSION = (() => { const root = process.env.FORGE_ROOT || process.cwd(); const today = epochDay; -const TOOLS = [ - { - name: "cortex_lessons", - description: - "Lessons Forge Cortex learned from past mistakes on THIS repo, for the given files/symbols. Background context — verify before acting, don't blindly obey.", - inputSchema: { - type: "object", - properties: { - files: { - type: "array", - items: { type: "string" }, - description: "file paths in play", - }, - symbols: { - type: "array", - items: { type: "string" }, - description: "symbol names in play", - }, - }, - }, - }, - { - name: "cortex_status", - description: "Summary of learned lessons on this repo (counts by state, top by confidence).", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "preflight_check", - description: - "BEFORE starting a task, check what it names that the repo doesn't define — the things you'd otherwise ASSUME. Returns a clarify list or an all-clear. Ask instead of assuming.", - inputSchema: { - type: "object", - properties: { task: { type: "string", description: "the task/prompt" } }, - required: ["task"], - }, - }, - { - name: "route_task", - description: - "Recommend the cheapest CAPABLE model for a task by code-task complexity (files, fan-out, churn, past mistakes, ambiguity). Advisory — don't burn a top model on a trivial task.", - inputSchema: { - type: "object", - properties: { task: { type: "string" } }, - required: ["task"], - }, - }, +// The tool registry lives in mcp_tools.js as pure data — docs_check.js reconciles +// docs against it without importing this server. Re-exported for compatibility. +import { TOOLS } from "./mcp_tools.js"; - { - name: "assumption_gate", - description: - "Score specification completeness before work starts. Returns shouldAsk, risk, missing dimensions, and concrete questions.", - inputSchema: { - type: "object", - properties: { task: { type: "string", description: "the task/prompt" } }, - required: ["task"], - }, - }, - { - name: "predict_impact", - description: - "Predict blast radius for a symbol or file using Forge atlas reverse-dependency traversal.", - inputSchema: { - type: "object", - properties: { - target: { type: "string", description: "symbol name, qualified name, or file" }, - threshold: { type: "number", description: "confidence threshold, default 0.1" }, - }, - required: ["target"], - }, - }, - { - name: "substrate_check", - description: - "Full Forge cognitive-substrate pre-action check: assumption gate, route, impact, scope, memory, minimality, and verification checklist.", - inputSchema: { - type: "object", - properties: { task: { type: "string", description: "the task/prompt" } }, - required: ["task"], - }, - }, - { - name: "scope_files", - description: - "Decompose files into INDEPENDENT clusters (run as separate sessions) vs coupled, and surface coupled files you didn't name (the 'forgot the related module' guard).", - inputSchema: { - type: "object", - properties: { files: { type: "array", items: { type: "string" } } }, - required: ["files"], - }, - }, - { - name: "forge_cost", - description: - "Cost report — measured stage factors (gate, cache, route, context) from .forge/metrics.jsonl with multiplicative composition.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "forge_dash_data", - description: - "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/.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "forge_ledger_query", - description: - "Query the proof-carrying memory ledger with a natural language query. Returns ranked matching claims.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "what you are about to do or looking for" }, - }, - required: ["query"], - }, - }, - { - name: "forge_diagnose", - description: - "Doom-loop check — record a failure and check if the same signature has recurred (3x = escalation). Prevents thrashing.", - inputSchema: { - type: "object", - properties: { - errorText: { type: "string", description: "the error message" }, - file: { type: "string", description: "file where the error occurred" }, - symbol: { type: "string", description: "symbol involved" }, - }, - required: ["errorText"], - }, - }, - { - name: "forge_doctor", - description: - "Health check — verify installed tools, guards, MCP auth, config drift, and system state.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "forge_provider_status", - description: - "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"], - }, - }, -]; +export { TOOLS }; async function callTool(name, args = {}) { if (name === "cortex_lessons") { diff --git a/src/diagnose.js b/src/diagnose.js index b774a45..3dd736c 100644 Binary files a/src/diagnose.js and b/src/diagnose.js differ diff --git a/src/docs_check.js b/src/docs_check.js new file mode 100644 index 0000000..d7acd7b --- /dev/null +++ b/src/docs_check.js @@ -0,0 +1,219 @@ +// forge docs — docs↔code drift reconcilers. Every check compares a DOCUMENT CLAIM +// against the SOURCE OF TRUTH that already exists in code (the commands.js table, the +// cortex_mcp TOOLS registry, actual process.env reads, package.json + git history), so +// a feature can no longer ship without its docs and the gap silently accumulate — the +// exact failure that let a whole command family go undocumented. Self-check: it runs +// against the forge package root (BRAND.root), not the host repo. +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { COMMANDS, HIDDEN_COMMANDS } from "./commands.js"; +import { TOOLS } from "./mcp_tools.js"; + +/** The user-facing prose docs every claim is reconciled against. */ +const DOC_FILES = ["README.md", "docs/GUIDE.md", "ARCHITECTURE.md", "ROADMAP.md"]; + +// Env vars read in src that are NOT user-facing contract: child-process plumbing and +// values injected by host tools rather than set by users. +const INTERNAL_ENV = new Set(["_FORGE_LLM_KEY", "FORGE_EMBED_KEY", "CLAUDE_PLUGIN_ROOT"]); + +// Prefixes that mark an env var as OURS to document. A doc may freely mention other +// tools' vars (GITHUB_TOKEN, PATH) — those aren't claims about forge's own surface. +const ENV_PREFIX_RE = /\b((?:FORGE|ANTHROPIC|LITELLM|OPENROUTER|ENABLE_CORTEX)_[A-Z0-9_]+)\b/g; + +function readDoc(root, rel) { + const p = join(root, rel); + return existsSync(p) ? readFileSync(p, "utf8") : ""; +} + +function srcFiles(root) { + const dir = join(root, "src"); + if (!existsSync(dir)) return []; + // recursive: src/emit/*.js reads are part of the same env contract. + return readdirSync(dir, { recursive: true }) + .map(String) + .filter((f) => f.endsWith(".js")) + .map((f) => join(dir, f)); +} + +/** Every env var name the package actually reads: process.env.X in src/*.js plus + * $X / ${X...} in the shell guards (they are part of the same env contract). */ +export function envVarsRead(root = BRAND.root) { + const vars = new Set(); + for (const file of srcFiles(root)) { + const text = readFileSync(file, "utf8"); + for (const m of text.matchAll(/process\.env\.([A-Z_][A-Z0-9_]*)/g)) vars.add(m[1]); + for (const m of text.matchAll(/process\.env\[["']([A-Z_][A-Z0-9_]*)["']\]/g)) vars.add(m[1]); + } + const guards = join(root, "global", "guards"); + if (existsSync(guards)) { + for (const f of readdirSync(guards).filter((f) => f.endsWith(".sh"))) { + const text = readFileSync(join(guards, f), "utf8"); + // Only forge-contract prefixes: a guard's own ALL-CAPS locals ($INPUT, $DIR) + // are shell internals, not env surface the docs owe anyone. + for (const m of text.matchAll( + /\$\{?((?:FORGE|ANTHROPIC|LITELLM|OPENROUTER|ENABLE_CORTEX|CLAUDE)_[A-Z0-9_]*)/g, + )) + vars.add(m[1]); + } + } + return vars; +} + +/** Commands table vs README/GUIDE: every command documented, nothing phantom. */ +function checkCommands(docs, issues) { + for (const target of ["README.md", "docs/GUIDE.md"]) { + const text = docs[target]; + if (!text) continue; + for (const name of Object.keys(COMMANDS)) { + // BRAND.cli, not a literal: after a rebrand the docs say ` ` and + // this check must follow them or every command reports as undocumented. + if (!new RegExp(`\\b${BRAND.cli} ${name}\\b`).test(text)) { + issues.push({ + check: "commands", + severity: "error", + detail: `\`${BRAND.cli} ${name}\` is implemented but ${target} never mentions it`, + }); + } + } + } + for (const [file, text] of Object.entries(docs)) { + for (const m of text.matchAll(new RegExp(`\`${BRAND.cli} ([a-z][a-z-]*)\\b`, "g"))) { + const name = m[1]; + if (!(name in COMMANDS) && !HIDDEN_COMMANDS.includes(name)) { + issues.push({ + check: "commands", + severity: "error", + detail: `${file} documents \`${BRAND.cli} ${name}\` but no such command exists`, + }); + } + } + } +} + +/** Env vars: everything src reads is documented; everything docs name is real. */ +function checkEnvVars(root, docs, issues) { + const read = envVarsRead(root); + const prose = Object.values(docs).join("\n"); + for (const v of read) { + if (INTERNAL_ENV.has(v)) continue; + if (!prose.includes(v)) { + issues.push({ + check: "env-vars", + severity: "error", + detail: `src reads ${v} but no prose doc (${DOC_FILES.join(", ")}) documents it`, + }); + } + } + const documented = new Set(); + for (const [file, text] of Object.entries(docs)) { + for (const m of text.matchAll(ENV_PREFIX_RE)) { + if (!documented.has(`${file}:${m[1]}`) && !read.has(m[1]) && !INTERNAL_ENV.has(m[1])) { + documented.add(`${file}:${m[1]}`); + issues.push({ + check: "env-vars", + severity: "error", + detail: `${file} documents ${m[1]} but nothing in src reads it (phantom var)`, + }); + } + } + } +} + +/** MCP tools: documented counts match the registry; every tool name appears somewhere. */ +function checkMcpTools(docs, issues) { + const actual = TOOLS.length; + for (const [file, text] of Object.entries(docs)) { + for (const m of text.matchAll(/(\d+)\s+MCP tools\b/gi)) { + if (Number(m[1]) !== actual) { + issues.push({ + check: "mcp-tools", + severity: "error", + detail: `${file} claims ${m[1]} MCP tools; the registry serves ${actual}`, + }); + } + } + } + const prose = Object.values(docs).join("\n"); + for (const t of TOOLS) { + if (!prose.includes(t.name)) { + issues.push({ + check: "mcp-tools", + severity: "error", + detail: `MCP tool ${t.name} is served but never documented`, + }); + } + } +} + +const git = (root, args) => { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +}; + +/** CHANGELOG: latest release header matches package.json; no empty release sections; + * [Unreleased] must not be empty while src has commits the changelog hasn't seen. */ +function checkChangelog(root, issues) { + const text = readDoc(root, "CHANGELOG.md"); + if (!text) return; + const sections = [ + ...text.matchAll(/^## \[([^\]]+)\][^\n]*\n([\s\S]*?)(?=^## \[|\n*$(?![\s\S]))/gm), + ]; + const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; + const released = sections.filter((s) => s[1].toLowerCase() !== "unreleased"); + if (released.length && released[0][1] !== version) { + issues.push({ + check: "changelog", + severity: "error", + detail: `CHANGELOG's latest release is [${released[0][1]}] but package.json is ${version}`, + }); + } + for (const [, name, body] of released) { + if (!body.trim()) { + issues.push({ + check: "changelog", + severity: "error", + detail: `CHANGELOG section [${name}] is an empty header — released work is unrecorded`, + }); + } + } + const unreleased = sections.find((s) => s[1].toLowerCase() === "unreleased"); + if (unreleased && !unreleased[2].trim()) { + const srcT = Number(git(root, ["log", "-1", "--format=%ct", "--", "src"]) || 0); + const clT = Number(git(root, ["log", "-1", "--format=%ct", "--", "CHANGELOG.md"]) || 0); + if (srcT && clT && srcT > clT) { + issues.push({ + check: "changelog", + severity: "error", + detail: "src changed since CHANGELOG.md was last touched, but [Unreleased] is empty", + }); + } + } +} + +/** + * Run every reconciler against the forge package tree. + * @param {{root?: string}} [opts] + * @returns {{ok: boolean, issues: {check:string, severity:string, detail:string}[], checked: string[]}} + */ +export function docsCheck({ root = BRAND.root } = {}) { + const docs = Object.fromEntries(DOC_FILES.map((f) => [f, readDoc(root, f)])); + const issues = []; + checkCommands(docs, issues); + checkEnvVars(root, docs, issues); + checkMcpTools(docs, issues); + checkChangelog(root, issues); + return { + ok: !issues.some((i) => i.severity === "error"), + issues, + checked: ["commands", "env-vars", "mcp-tools", "changelog"], + }; +} diff --git a/src/doctor.js b/src/doctor.js index 8bb1c86..0d6ddab 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -6,6 +6,7 @@ import { join } from "node:path"; import { isStale, load as loadAtlas } from "./atlas.js"; import { BRAND } from "./brand.js"; import { summary as cortexSummary } from "./cortex.js"; +import { docsCheck } from "./docs_check.js"; import { extractHash, hashContent } from "./emit/_shared.js"; import { verify as ledgerVerify, repoLedger } from "./ledger_store.js"; import { PRICING_VERIFIED } from "./model_tiers.js"; @@ -307,6 +308,21 @@ function checkProvider(out, targetRoot) { } } +// Docs↔code drift — a self-check of the forge package's own docs, so it only runs +// when doctor is pointed at the forge repo itself (contributors + CI), never at a +// host project whose README rightly says nothing about forge commands. +function checkDocs(out, targetRoot) { + try { + if (readJson(join(targetRoot, "package.json")).name !== BRAND.pkg) return; + const r = docsCheck({ root: targetRoot }); + out.push( + r.ok + ? ok("docs↔code", "commands, env vars, MCP tools, CHANGELOG all agree") + : warn("docs↔code", `${r.issues.length} drift issue(s) — run \`${BRAND.cli} docs check\``), + ); + } catch {} +} + export function doctor({ targetRoot = process.cwd() } = {}) { const results = []; checkNode(results); @@ -318,6 +334,7 @@ export function doctor({ targetRoot = process.cwd() } = {}) { checkTooling(results); checkInstall(results); checkDrift(results, targetRoot); + checkDocs(results, targetRoot); checkAtlas(results, targetRoot); checkPricing(results); checkMcp(results, targetRoot); diff --git a/src/goal.js b/src/goal.js new file mode 100644 index 0000000..ace03f3 --- /dev/null +++ b/src/goal.js @@ -0,0 +1,61 @@ +// forge goal — the persistent active goal for a repo. `forge anchor` was per-invocation +// only: the goal lived in one command's argv and vanished with the session, so the next +// session re-assumed. Storing it in .forge/goal.md lets SessionStart re-inject it, lets +// goalDrift default to it, and makes "what are we actually doing" survive context loss. +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { hasSecret } from "./secrets.js"; + +const goalPath = (root) => join(root, ".forge", "goal.md"); + +/** Persist the active goal. Refuses secrets (same rule as every forge store). */ +export function setGoal(root, text, { t = Date.now() } = {}) { + const goal = String(text ?? "").trim(); + if (!goal) return { ok: false, reason: "empty goal — state what you are working toward" }; + if (hasSecret(goal)) + return { ok: false, reason: "refused: goal looks like it contains a secret/credential" }; + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync( + goalPath(root), + `# Goal\n\n${goal}\n\n\n`, + ); + return { ok: true, goal }; +} + +/** The active goal text, or null when none is set. */ +export function getGoal(root) { + const p = goalPath(root); + if (!existsSync(p)) return null; + try { + // Everything before the first provenance comment IS the goal — a slice, not a + // comment-stripping replace (CodeQL: replace can leave a partial `