diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 4bd1a35..3d7b54d 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -1,15 +1,23 @@ -# One-click release kickoff: Actions -> "Bump version" -> Run workflow. -# Runs the tests, bumps every version field via scripts/bump.mjs (package.json, -# package-lock.json, both plugin manifests, CITATION.cff, landing page, CHANGELOG -# rotation), commits "chore(release): vX.Y.Z", tags vX.Y.Z, and pushes commit + tag. +# Auto-release. Two ways in: +# 1. Merge to master (push) -> "auto" bump from conventional commits since the last tag. +# A chore/docs-only merge is a graceful skip (bump.mjs exits 3), so nothing publishes +# unless a feat/fix/perf/breaking change actually landed. +# 2. Actions -> "Bump version" -> Run workflow -> pick auto/patch/minor/major by hand. # -# Pushing the v* tag is what normally triggers release.yml — but pushes made with -# the default GITHUB_TOKEN deliberately do NOT trigger other workflows (GitHub's -# recursion guard). So this workflow also dispatches release.yml on the new tag -# explicitly, which needs `actions: write` (granted below). No PAT required. +# Either way scripts/bump.mjs bumps every version field (package.json, package-lock.json, +# both plugin manifests, CITATION.cff, landing page) + rotates CHANGELOG, then this workflow +# commits "chore(release): vX.Y.Z", tags vX.Y.Z, and pushes commit + tag. +# +# Pushing the v* tag is what normally triggers release.yml — but pushes made with the default +# GITHUB_TOKEN deliberately do NOT trigger other workflows (GitHub's recursion guard). So this +# workflow also dispatches release.yml on the new tag explicitly, which needs `actions: write`. +# That same recursion guard is why the release commit this workflow pushes does NOT re-trigger +# the push path below (no infinite loop); the actor/subject guard is belt-and-suspenders. name: Bump version on: + push: + branches: [master] workflow_dispatch: inputs: bump: @@ -33,6 +41,10 @@ concurrency: jobs: bump: runs-on: ubuntu-latest + # Never react to our own release commit (belt-and-suspenders behind GitHub's recursion guard). + if: >- + github.actor != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore(release):') steps: - uses: actions/checkout@v7 with: @@ -45,12 +57,27 @@ jobs: - run: npm test # never tag a broken tree - name: Bump version fields + rotate CHANGELOG id: bump + # On push, always "auto"; on manual dispatch, honor the chosen input. env: - BUMP: ${{ inputs.bump }} + BUMP: ${{ github.event_name == 'workflow_dispatch' && inputs.bump || 'auto' }} run: | + set +e VERSION="$(node scripts/bump.mjs "$BUMP")" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + code=$? + set -e + if [ "$code" -eq 0 ]; then + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release=true" >> "$GITHUB_OUTPUT" + echo "::notice::Releasing v$VERSION" + elif [ "$code" -eq 3 ]; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "::notice::Nothing to release (no feat/fix/perf/breaking commits) — skipping." + else + echo "::error::bump.mjs failed (exit $code)" + exit "$code" + fi - name: Commit, tag, push + if: steps.bump.outputs.release == 'true' env: VERSION: ${{ steps.bump.outputs.version }} run: | @@ -59,8 +86,9 @@ jobs: git add -A git commit -m "chore(release): v$VERSION" git tag -a "v$VERSION" -m "v$VERSION" - git push origin HEAD "v$VERSION" + git push origin "HEAD:${{ github.ref_name }}" "v$VERSION" - name: Trigger the release workflow on the new tag + if: steps.bump.outputs.release == 'true' env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.bump.outputs.version }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 23bd176..98f6923 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,19 +88,23 @@ checks and returns a single verdict. It composes the individually-callable stage ```mermaid %%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% -flowchart LR - RE["referenced
entities"] --> PF["preflight
assumption gap"] - PF --> RT["route
cheapest tier"] - RT --> AT["atlas
code graph"] - AT --> IM["impact
blast radius"] - IM --> PT["predict
failing tests"] - PT --> RU["reuse
cache hit?"] - RU --> CX["context
completeness gate"] - CX --> SC["scope
coupled files"] - SC --> ME["memory
recall + lessons"] - ME --> MN["minimality
lean footprint"] - MN --> GA["goal-anchor
drift check"] - GA --> VD["verdict"] +flowchart TD + RE["referenced entities"] --> INTAKE + subgraph INTAKE["intake"] + direction LR + PF["preflight
assumption gap"] --> RT["route
cheapest tier"] + end + INTAKE --> ANALYSIS + subgraph ANALYSIS["analysis"] + direction LR + AT["atlas
code graph"] --> IM["impact
blast radius"] --> PT["predict
failing tests"] --> RU["reuse
cache hit?"] + end + ANALYSIS --> SAFETY + subgraph SAFETY["safety + fit"] + direction LR + CX["context
completeness gate"] --> SC["scope
coupled files"] --> ME["memory
recall + lessons"] --> MN["minimality
lean footprint"] --> GA["goal-anchor
drift check"] + end + SAFETY --> VD["verdict"] classDef accent fill:#f26430,stroke:#f26430,color:#171310; class VD accent; ``` @@ -226,6 +230,22 @@ called symbols, from added AND removed lines, via the same `RULES` grammars the parses) swept against every doc artifact → UPDATED / STALE (file:line hits) / VERIFIED-UNAFFECTED with the reason recorded. Pure reporter; the gate provides the teeth. +**Docs-check now guards more than names (`src/docs_check.js`).** Beyond +commands/env/MCP-tools/CHANGELOG, three reconcilers close the blind spots behind recurring +"docs rot" complaints: `checkDiagrams` scans every `mermaid` block across all Markdown for +the branded `%%{init` theme and literal-`\n` node breaks; `checkModelTiers` reconciles doc +prose prices against `src/model_tiers.json`; `checkBenchmarks` reconciles bolded `N ms` +README claims against the measured table in `reports/benchmarks.md`. The two public pages +(`landing/index.html` + the `build-pages.mjs` status page) share one set of design tokens, +enforced for parity (plus non-empty changes list, no phantom webfont) by +`test/pages.test.js` — so neither the docs' numbers nor the site's look can silently drift. + +**Auto-release (`.github/workflows/bump.yml` + `scripts/bump.mjs`).** A push to `master` +runs `bump.mjs auto`: it releases only when a `feat`/`fix`/`perf`/breaking commit landed +(or `[Unreleased]` was hand-written), synthesizing changelog notes from commit subjects +when none exist, and exits `3` (a clean skip, not a failure) otherwise — so releases cut +themselves without a chore/docs merge spamming the registry. + **Intent cards (`src/intent.js`).** Prompt → intent by the same exemplar k-NN math as model routing — a labeled bank (English + Hinglish rows) under overlap similarity with a confidence gate, NOT a keyword DFA. Note `intentGrams` ≠ `contentGrams`: route.js stops diff --git a/CHANGELOG.md b/CHANGELOG.md index fa80487..f434972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,42 @@ doctor` surfaces a non-nagging "update available" notice; `FORGE_NO_UPDATE_CHECK - **Self-dogfood** — a committed `.claude/settings.json` wires forgekit's own guards via `${CLAUDE_PROJECT_DIR}`, so the repo runs its own completion gate, cortex, and guards during local dev without a marketplace install. +- **Auto-release on merge** — pushing a `feat`/`fix`/`perf`/breaking change to `master` + now cuts the release automatically (bump → tag → npm publish → GitHub Release); a + chore/docs-only merge skips cleanly (`bump.mjs auto` exits `3`). When `[Unreleased]` is + empty, `bump.mjs` synthesizes the changelog body from commit subjects so every release + still describes itself. Manual **Actions → Bump version** dispatch stays available. +- **`forge docs check` now guards diagrams, model prices, and benchmark numbers** — three + new reconcilers close the blind spots behind recurring complaints: every `mermaid` block + across all Markdown must carry the branded theme and use `
` (not a literal `\n`); + model prices in the docs must match `src/model_tiers.json`; and every bolded `N ms` + claim in the README must be a value `reports/benchmarks.md` actually measured. ### Changed - **CLI output is quiet by default** — the `Forge — …` title line no longer prints on every command; results come first. `--verbose` or `FORGE_VERBOSE=1` restores it. The `--help` / `--version` banner is unchanged. +- **Unified public design system** — the landing page and the generated status page now + share one warm ember/near-black palette and a system font stack (the landing page no + longer declares the Inter webfont it never loaded). `test/pages.test.js` enforces token + parity, a non-empty changes list, and no phantom webfont. +- **Restyled the terminal statusline** — a restrained palette (muted structure, one ember + accent, green/red reserved for the diff) with consistent `·` separators and a subtle + context-limit marker instead of an alarming red block. +- **Plain-language docs pass** — the README and GUIDE openings lead with what forgekit is + and does; the deep math (`y = f(x)`, join-semilattice, Beta posteriors) moved into + parentheticals or the white paper, and comparison-table cells no longer cite code + identifiers as if they were user features. + +### Fixed + +- **Broken diagrams** — two `mermaid` diagrams rendered in Mermaid's off-brand default + theme, one with literal `\n` node breaks GitHub showed as garbage; both fixed, and the + over-wide 13-node pre-action pipeline was regrouped so it reads at GitHub width. +- **Status page "Latest changes" list** — wrapped CHANGELOG bullets were truncated + mid-sentence (and could render empty); the parser now joins lazy/indented continuation + lines into the full item. ## [0.10.0] - 2026-07-10 @@ -619,7 +649,6 @@ consolidate` reconciles deletions into tombstones. `putClaim` repairs corrupt/tr [0.7.0]: https://github.com/CodeWithJuber/forgekit/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/CodeWithJuber/forgekit/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/CodeWithJuber/forgekit/compare/v0.4.0...v0.5.0 -[Unreleased]: https://github.com/CodeWithJuber/forgekit/compare/v0.4.0...HEAD [0.4.0]: https://github.com/CodeWithJuber/forgekit/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/CodeWithJuber/forgekit/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/CodeWithJuber/forgekit/compare/v0.2.0...v0.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c830e6e..de94c9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,9 @@ npm run check # Biome lint + format check 3. Run `npm test`, `npm run typecheck`, and `npm run check:fix` before committing. 4. Use [Conventional Commits](https://www.conventionalcommits.org/): `feat(scope): description`, `fix: …`, `docs: …`. -5. Add a line to `CHANGELOG.md` under `## [Unreleased]`. +5. Add a line to `CHANGELOG.md` under `## [Unreleased]`. This is your release note — + when your PR merges, the auto-release uses it verbatim. (Skip it and the release + still cuts, synthesizing notes from your commit subjects — but your wording is better.) 6. Open a PR. CI (tests on Node 20/22, Biome, typecheck, shellcheck) must pass. ## Project shape @@ -68,8 +70,11 @@ We'd rather give a clear "not now" than merge something that adds maintenance bu ## Releasing (maintainers) -Cutting a release is one command plus a tag — the full runbook (and the one-time -`NPM_TOKEN` setup) is in [docs/RELEASING.md](./docs/RELEASING.md). +Releases are automatic: **merging a `feat`/`fix`/`perf`/breaking change to `master` +cuts the release itself** (bump → tag → npm publish → GitHub Release); chore/docs-only +merges skip cleanly. A manual **Actions → Bump version** dispatch is still available. The +full runbook (and the one-time `NPM_TOKEN` setup) is in +[docs/RELEASING.md](./docs/RELEASING.md). ## Sign your work (DCO) diff --git a/README.md b/README.md index 66cf325..1e39788 100644 --- a/README.md +++ b/README.md @@ -220,14 +220,14 @@ Structural differences only — each row is checkable against the named source, tables (including what each adjacent tool does _better_) are in [`reports/benchmarks.md` → Uniqueness](reports/benchmarks.md#uniqueness--structural-contrasts-with-adjacent-tools): -| Property | Forge | Note stores / gateways / RAG | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Memory confidence moved **only by independent oracles** (tests, CI, human) | yes — closed `ORACLES` table; unverifiable evidence rejected (`src/ledger.js`) | note stores keep notes as written | -| Unreviewed knowledge decays toward _uncertainty_, not deletion | yes — time-decayed Beta posterior; dormant claims kept for audit | notes persist unchanged until deleted | -| Conflict-free team merge over plain git | yes — join-semilattice, property-tested | per-machine SQLite or a hosted store | -| Routing decision visible and diffable **before** dispatch | yes — deterministic rubric over `src/model_tiers.json` | gateways decide inside the proxy at request time | -| Cached code served **only with verification evidence**, revalidated against the current code graph | yes — `SERVE_FLOOR`, `revalidate()` in `src/reuse.js` | plain RAG serves on similarity alone | -| **What they do better** | — | hosted sync, web UIs, embedding search that catches paraphrase; gateways actually _move traffic_ (failover, quotas). Forge is a transparency layer, not a replacement | +| Property | Forge | Note stores / gateways / RAG | +| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Memory confidence moved **only by independent oracles** (tests, CI, human) | yes — closed `ORACLES` table; unverifiable evidence rejected (`src/ledger.js`) | note stores keep notes as written | +| Unreviewed knowledge decays toward _uncertainty_, not deletion | yes — confidence fades over time toward _unsure_; dormant claims kept for audit, never deleted | notes persist unchanged until deleted | +| Conflict-free team merge over plain git | yes — two teammates' memories combine by set-union, so they never conflict (property-tested) | per-machine SQLite or a hosted store | +| Routing decision visible and diffable **before** dispatch | yes — a deterministic rubric you can read in the repo (`src/model_tiers.json`) | gateways decide inside the proxy at request time | +| Cached code served **only with verification evidence**, revalidated against the current code graph | yes — a cache hit is served only if its evidence clears a confidence floor and still matches today's code | plain RAG serves on similarity alone | +| **What they do better** | — | hosted sync, web UIs, embedding search that catches paraphrase; gateways actually _move traffic_ (failover, quotas). Forge is a transparency layer, not a replacement | ## Honest limits @@ -245,11 +245,12 @@ _advisory_. **Tests and human corrections always win.** Full list: ## Why a cognitive substrate? The white paper -A language model at inference is a fixed function `y = f(x)` — frozen weights, a bounded -window, no state between calls. Memory, foresight, and self-checking can't be prompted into -that shape; they have to be supplied from outside. The full argument, with every -load-bearing statistic re-graded against primary sources, is the -[cognitive-substrate white paper](docs/cognitive-substrate/). +A model can't learn from your codebase between calls: its weights are frozen and its +working memory is wiped after every response. Memory, foresight, and self-checking can't +be prompted into it — they have to be supplied from outside, which is what the substrate +does. (Formally: inference is a fixed function `y = f(x)` with no state between calls.) +The full argument, with every load-bearing statistic re-graded against primary sources, is +the [cognitive-substrate white paper](docs/cognitive-substrate/). ## Public site diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 66f5517..372ee85 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -44,11 +44,12 @@ Playwright for `uicheck visual`) are opt-in and still add no required dependency ## Mental model -A language model at inference time is a fixed function `y = f(x)` — frozen weights, a -bounded window, no state between calls. From that shape, five things follow that no -prompt can fix: it can't **remember** across sessions, can't **learn** from outcomes, -can't **imagine** what an edit breaks, can't reliably **check itself**, and can't see -**what already exists** beyond its window. +A coding model starts every call from zero: its training is frozen and it forgets +everything the moment a session ends. Five gaps follow, and no prompt closes them — it +can't **remember** across sessions, can't **learn** from outcomes, can't **imagine** what +an edit breaks, can't reliably **check itself**, and can't see **what already exists** +beyond its current window. (Formally: inference is a fixed function `y = f(x)` with no +state between calls.) Forge supplies those faculties from the _outside_, in three layers: @@ -178,11 +179,11 @@ _Advisory: ask rather than assume._ ### `forge route ""` — the cheapest capable model 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. +attributable. **The text side works by resemblance:** your task is compared to ~50 +example tasks with known difficulty, and the closest matches — which the output names — +set the estimate (a similarity-weighted k-NN over the labeled `EXEMPLARS` bank in +`src/route.js`). 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" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ab34aa0..1d3fbdb 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,23 +1,45 @@ # Releasing forgekit -Releases are one click. Two workflows do everything: - -1. [`bump.yml`](../.github/workflows/bump.yml) — **Actions → "Bump version" → Run - workflow**. It runs the tests, bumps every version field, rotates the CHANGELOG, - commits `chore(release): vX.Y.Z`, tags `vX.Y.Z`, pushes both, and kicks off the - release workflow on the new tag. +Releases are automatic. **Merge to `master` and the release cuts itself** — no button, +no manual bump. Two workflows do everything: + +1. [`bump.yml`](../.github/workflows/bump.yml) — runs on **every push to `master`** (and + still available as **Actions → "Bump version" → Run workflow** for a manual bump). It + runs the tests, bumps every version field with `auto`, rotates the CHANGELOG, commits + `chore(release): vX.Y.Z`, tags `vX.Y.Z`, pushes both, and kicks off the release + workflow on the new tag. 2. [`release.yml`](../.github/workflows/release.yml) — runs on any `v*` tag: tests → npm publish (with provenance, if `NPM_TOKEN` is set) → GitHub Release with auto-generated notes. -## The one-click flow +## Merge → auto-release (the default) + +On a push to `master`, `bump.yml` runs `scripts/bump.mjs auto`: + +- **Something shippable landed** (a `feat:`, `fix:`, `perf:`, or breaking `type!:` / + `BREAKING CHANGE` commit since the last tag, **or** a hand-written `[Unreleased]` + section): it bumps, rotates the CHANGELOG, tags, publishes, and cuts the Release. +- **Nothing shippable** (only `chore`/`docs`/`test`/`ci`/`style`/`build`/`refactor` + commits and an empty `[Unreleased]`): `bump.mjs` exits `3` and the workflow **skips + cleanly** — no tag, no publish, CI stays green. So a docs-only or chore-only merge + never spams the registry. +- **No hand-written notes?** When `[Unreleased]` is empty but shippable commits exist, + `bump.mjs` **synthesizes** the CHANGELOG body from the commit subjects + (`feat:`→Added, `fix:`→Fixed, `perf`/`refactor`/`revert`→Changed, breaking flagged), + so every auto-release still describes itself. Writing your own `[Unreleased]` entry + as you work always beats the synthesized one — do that when you can. + +The bot's own `chore(release):` commit does **not** re-trigger a release (GitHub's +`GITHUB_TOKEN` recursion guard, plus an explicit actor/subject skip), so there's no loop. + +## The manual flow (still supported) Go to **Actions → Bump version → Run workflow** and pick a bump type: -| choice | effect | -| ------- | ---------------------------------------------------------------------------- | -| `auto` | derived from conventional commits since the last tag: `BREAKING CHANGE` / `type!:` → major, `feat:` → minor, anything else → patch. Falls back to the CHANGELOG `[Unreleased]` body (BREAKING → major, `### Added` → minor, other content → patch). | -| `patch` / `minor` / `major` | explicit | +| choice | effect | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto` | derived from conventional commits since the last tag: `BREAKING CHANGE` / `type!:` → major, `feat:` → minor, anything else → patch. Falls back to the CHANGELOG `[Unreleased]` body (BREAKING → major, `### Added` → minor, other content → patch). | +| `patch` / `minor` / `major` | explicit | What happens, in order: @@ -46,7 +68,7 @@ Publishing needs one repo secret: 1. Create an [npmjs.com](https://www.npmjs.com/) account that can publish to the `@codewithjuber` scope. -2. Generate an **Automation** access token (npm → Access Tokens → Generate → *Automation*). +2. Generate an **Automation** access token (npm → Access Tokens → Generate → _Automation_). 3. Add it as a repo secret: **Settings → Secrets and variables → Actions → New repository secret**, name **`NPM_TOKEN`**. @@ -65,7 +87,7 @@ npm pack --dry-run # inspect exactly what would ship to npm ``` If you bump locally instead of via the Actions tab, finish the job by hand — commit, -tag, push (a human-pushed tag *does* trigger `release.yml`): +tag, push (a human-pushed tag _does_ trigger `release.yml`): ```bash V="v$(node -p "require('./package.json').version")" @@ -82,13 +104,14 @@ git push origin master "$V" - **Tag/version assert**: `release.yml` refuses a tag that doesn't match `package.json` (hand-rolled tags that skipped the bump script fail fast with a clear error). - `scripts/bump.mjs` refuses to rotate the CHANGELOG onto a version that already has a - section, and errors when `auto` finds nothing to release. + section. When `auto` finds nothing shippable it exits `3` (a graceful skip the + auto-release workflow keys off), not a hard error — so a no-op merge never fails CI. ## Related workflow secrets - `NPM_TOKEN` (above) — npm publish; missing = publish skipped, release still cut. - `ADMIN_TOKEN` — only used by `repo-settings.yml` (repo description/topics/Discussions - need a fine-grained PAT with *Administration: write*; the default `GITHUB_TOKEN` + need a fine-grained PAT with _Administration: write_; the default `GITHUB_TOKEN` cannot get that scope). Missing = that workflow skips with a warning; the equivalent `gh` commands are in its header comment. diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 9df6519..969b1ac 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -1,7 +1,7 @@ # The Forge Cognitive Substrate **Coding agents forget what they learned, assume what they don't know, and break code they -can't see.** The substrate is a fast, mostly-deterministic check that runs *before* an agent +can't see.** The substrate is a fast, mostly-deterministic check that runs _before_ an agent edits your code: it flags an unclear task, picks the cheapest capable model, and shows what an edit will break — all from the repo you already have, with no extra LLM call. @@ -34,10 +34,10 @@ every prompt automatically. You don't have to remember to use it. -**In Claude Code** — a hook runs the substrate on **every prompt** and adds a short note *only -when something needs attention* (unclear task, big blast radius, pricey model). It never blocks -and never nags on a clean, simple task. Real example — you type *"refactor computeTax in -math.js"* and the agent silently receives: +**In Claude Code** — a hook runs the substrate on **every prompt** and adds a short note _only +when something needs attention_ (unclear task, big blast radius, pricey model). It never blocks +and never nags on a clean, simple task. Real example — you type _"refactor computeTax in +math.js"_ and the agent silently receives: ```text Forge substrate — pre-action advisory (advisory, never blocks): @@ -91,7 +91,7 @@ $ forge substrate "Change verifyToken in src/auth.js to require length > 20; upd ``` The second run found the two files that import `verifyToken` but you never named — the -"forgot the coupled file" bug, caught *before* the edit. Add `--json` for machine-readable +"forgot the coupled file" bug, caught _before_ the edit. Add `--json` for machine-readable output (see [Use it in a script](#use-it-in-a-script)). --- @@ -100,14 +100,14 @@ output (see [Use it in a script](#use-it-in-a-script)). `forge substrate` bundles these. Run any one on its own when that's all you need. -| Command | Answers | One-line example | -| --- | --- | --- | -| `forge preflight ""` | Is this task clear enough to start? | flags names not in the repo + vague words | -| `forge route ""` | Which model is cheapest-but-capable? | trivial → Haiku · hard → Opus/Fable | -| `forge impact ` | What will this edit break? | reverse-dependency blast radius | -| `forge scope ` | Can this be split into sessions? | independent vs. coupled files | -| `forge anchor ""` | Are my changes still on the stated goal? | flags changed files that drifted off-goal | -| `forge verify` | Did it actually work? | runs the real tests/build, not the model's word | +| Command | Answers | One-line example | +| ----------------------------- | ---------------------------------------- | ----------------------------------------------- | +| `forge preflight ""` | Is this task clear enough to start? | flags names not in the repo + vague words | +| `forge route ""` | Which model is cheapest-but-capable? | trivial → Haiku · hard → Opus/Fable | +| `forge impact ` | What will this edit break? | reverse-dependency blast radius | +| `forge scope ` | Can this be split into sessions? | independent vs. coupled files | +| `forge anchor ""` | Are my changes still on the stated goal? | flags changed files that drifted off-goal | +| `forge verify` | Did it actually work? | runs the real tests/build, not the model's word | The wider v0.5 surface — `forge context` (budgeted assembly + completeness gate), `forge imagine [--run]` (predicted breaks + minimal dry-run suite), `forge diagnose` @@ -179,30 +179,33 @@ call proposes a completeness reading (M2), a complexity band (M1), the coupled e regex graph misses (impact), and whether an off-goal file actually serves the goal (M4). ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% flowchart LR - T[task / edit] --> R[deterministic rubric] - T --> P[LLM proposer] + T["task / edit"] --> R["deterministic rubric"] + T --> P["LLM proposer"] R --> C{reconcile} - P --> V[verify: rubric band · repo grounding · grep · tests] + P --> V["verify: rubric band · repo grounding
grep · tests"] V --> C - C -->|passes checks| M[verdict moves\nllm-cleared / lowered / raised / verified] - C -->|fails / unavailable| D[verdict holds\ndeterministic] + C -->|passes checks| M["verdict moves
llm-cleared / lowered / raised / verified"] + C -->|fails / unavailable| D["verdict holds
deterministic"] + classDef accent fill:#f26430,stroke:#f26430,color:#171310; + class C accent; ``` The model **proposes**; the deterministic rubric, the code graph, and the tests **verify**. The verdict only moves when the proposal survives that check — otherwise it falls back, unchanged. -The model is **never the judge — only a proposer.** Every proposal is *verified* before it can -move a verdict, in the direction of the paper's *tabayyun* gate (49:6). By default the reconcile +The model is **never the judge — only a proposer.** Every proposal is _verified_ before it can +move a verdict, in the direction of the paper's _tabayyun_ gate (49:6). By default the reconcile is **bidirectional but rail-guarded** — a verified reading can lower caution as well as raise it, but never past a hard floor: -- **routing** — a *raise* is free (spotting hidden complexity costs at most a bigger model); a - *lower* is bounded to one band and never drops below a strong-signal (algorithmic/architectural) +- **routing** — a _raise_ is free (spotting hidden complexity costs at most a bigger model); a + _lower_ is bounded to one band and never drops below a strong-signal (algorithmic/architectural) floor, so a "distributed rate-limiter" can't be talked down to the cheap tier; -- **the assumption gate** — can *clear* a false ask **or** *add* one, but never clears a task +- **the assumption gate** — can _clear_ a false ask **or** _add_ one, but never clears a task with no concrete anchor, or one naming symbols/files the repo doesn't define; -- **impact edges** — kept only if the file is real *and* a grep confirms the reference; +- **impact edges** — kept only if the file is real _and_ a grep confirms the reference; - **goal-drift** — rescues an off-goal file only with a goal-referencing reason (off→on only). > **Note** — set `llm.bidirectional: false` in diff --git a/docs/plans/substrate-v2/00-overview.md b/docs/plans/substrate-v2/00-overview.md index 5bc0ec4..0e9e98c 100644 --- a/docs/plans/substrate-v2/00-overview.md +++ b/docs/plans/substrate-v2/00-overview.md @@ -17,7 +17,7 @@ template convergence** and a **~90 % cost-reduction target**. One idea unifies all of it: the **Proof-Carrying Memory (PCM) protocol** ([01-pcm-protocol.md](./01-pcm-protocol.md)). Every unit of knowledge the system holds — a lesson, a cached code artifact, a dependency edge, a design fingerprint, a diagnosis — -becomes a *claim* that carries its own evidence, earns confidence only from independent +becomes a _claim_ that carries its own evidence, earns confidence only from independent oracles (tests, typecheck, CI, human accept/revert), decays without review, and merges across teammates without conflicts. This takes the paper's sharpest novelty — the `val` term of Eq. 3, "validity-anchored memory" (§11) — and turns it from one scoring term into @@ -25,72 +25,77 @@ the storage, trust, and wire protocol for the whole substrate. ## 1. Gap analysis — paper vs. `src/` -| Paper capability (§10 map) | ForgeKit v0.4 | Gap | Closed by | -|---|---|---|---| -| M1 routing + M2 assumption gate (opp. #1) | `src/route.js`, `src/preflight.js` — 62 % measured saving | ✅ shipped; residue: outcome-calibrated weights | [06](./06-faculties-and-mechanisms.md) §7 | -| Impact oracle, mandatory gate (opp. #3) | `src/atlas.js` regex graph; gate opt-in (`FORGE_ENFORCE=1`) | precision; gate not mandatory | [06](./06-faculties-and-mechanisms.md) §1 | -| Validity-anchored memory (opp. #2, Eq. 3) | cortex confidence exists (`src/lessons.js` already keeps α/β evidence) | no Eq. 3 retrieval, no forget/consolidate policy, flat store | [01](./01-pcm-protocol.md) | -| Outcome-validated learning (opp. #4, Eq. 2) | outcomes update lessons, but not *the memories that informed an action* | write-back band incomplete | [01](./01-pcm-protocol.md) §6, [06](./06-faculties-and-mechanisms.md) §6 | -| Team / shared memory | single-repo file recall (`src/recall.js`, `src/brain.js`) | no merge semantics, no attribution | [02](./02-team-memory.md) | -| Code reuse / generation cache | `reuse-first` skill is prose only | no artifact cache | [03](./03-reuse-cache.md) | -| Context compression / completeness | none — "incomplete context" is unaddressed | whole module missing | [04](./04-context-assembly.md) | -| Doom-loop root cause (opp. #5) | `doom-loop.sh` detects; no diagnosis, no escalation | diagnosis + escalation | [06](./06-faculties-and-mechanisms.md) §5 | -| Imagination (faculty, §3) | atlas traversal only — no dry-run of consequences | test selection + sandbox | [06](./06-faculties-and-mechanisms.md) §2 | -| M3/M4/M5/M6 (decomposition, drift, lean, inline verify) | `scope.js`/`anchor.js`/`lean.js`/`verify.js` heuristics | each gets its algorithm | [06](./06-faculties-and-mechanisms.md) §3–§6 | -| Generated-UI quality (owner pain; M5-shaped) | `src/uicheck.js` WCAG contrast only; taste is prose | anti-template gate | [07](./07-ui-quality-gate.md) | -| Cost to ~90 % (owner target) | routing alone: 62 % measured | cache + context + gate stages unmeasured | [05](./05-cost-model.md) | -| ForgeKit's own UX | CLI only | `forge dash` dashboard | [08](./08-dashboard-ux.md) | +| Paper capability (§10 map) | ForgeKit v0.4 | Gap | Closed by | +| ------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | +| M1 routing + M2 assumption gate (opp. #1) | `src/route.js`, `src/preflight.js` — 62 % measured saving | ✅ shipped; residue: outcome-calibrated weights | [06](./06-faculties-and-mechanisms.md) §7 | +| Impact oracle, mandatory gate (opp. #3) | `src/atlas.js` regex graph; gate opt-in (`FORGE_ENFORCE=1`) | precision; gate not mandatory | [06](./06-faculties-and-mechanisms.md) §1 | +| Validity-anchored memory (opp. #2, Eq. 3) | cortex confidence exists (`src/lessons.js` already keeps α/β evidence) | no Eq. 3 retrieval, no forget/consolidate policy, flat store | [01](./01-pcm-protocol.md) | +| Outcome-validated learning (opp. #4, Eq. 2) | outcomes update lessons, but not _the memories that informed an action_ | write-back band incomplete | [01](./01-pcm-protocol.md) §6, [06](./06-faculties-and-mechanisms.md) §6 | +| Team / shared memory | single-repo file recall (`src/recall.js`, `src/brain.js`) | no merge semantics, no attribution | [02](./02-team-memory.md) | +| Code reuse / generation cache | `reuse-first` skill is prose only | no artifact cache | [03](./03-reuse-cache.md) | +| Context compression / completeness | none — "incomplete context" is unaddressed | whole module missing | [04](./04-context-assembly.md) | +| Doom-loop root cause (opp. #5) | `doom-loop.sh` detects; no diagnosis, no escalation | diagnosis + escalation | [06](./06-faculties-and-mechanisms.md) §5 | +| Imagination (faculty, §3) | atlas traversal only — no dry-run of consequences | test selection + sandbox | [06](./06-faculties-and-mechanisms.md) §2 | +| M3/M4/M5/M6 (decomposition, drift, lean, inline verify) | `scope.js`/`anchor.js`/`lean.js`/`verify.js` heuristics | each gets its algorithm | [06](./06-faculties-and-mechanisms.md) §3–§6 | +| Generated-UI quality (owner pain; M5-shaped) | `src/uicheck.js` WCAG contrast only; taste is prose | anti-template gate | [07](./07-ui-quality-gate.md) | +| Cost to ~90 % (owner target) | routing alone: 62 % measured | cache + context + gate stages unmeasured | [05](./05-cost-model.md) | +| ForgeKit's own UX | CLI only | `forge dash` dashboard | [08](./08-dashboard-ux.md) | ## 2. The 11-capability master table Every faculty (paper §3) and mechanism (paper §6) with the math, algorithm, or data structure this plan assigns it. Nothing is left as prose-only discipline. -| # | Capability | Mechanism | Math / algorithm / data structure | Spec | -|---|---|---|---|---| -| 1 | Memory (faculty) | PCM claim ledger; Eq. 3 retrieval; ʿilm→fahm→ḥikma layers | Content-addressed (Merkle-keyed) claim store; Beta(α,β) posterior with exponential decay; MinHash sketches; consolidation = union-find clustering over Jaccard ≥ τ, promoting episodes → patterns → decision rules | [01](./01-pcm-protocol.md) | -| 2 | Learning (faculty) | Outcome write-back band (Eq. 2): oracle results update the `val` of every claim that informed the action | Bayesian evidence update; rubric-weight calibration by logistic regression over outcome claims (the paper's own "learn the rubric weights" note, §9.3) | [01](./01-pcm-protocol.md) §6 | -| 3 | Imagination (faculty) | Consequence simulator `g` (paper Eq. 4): blast radius → impacted-test selection → sandboxed dry-run | Reverse-dependency traversal with hop-decay; test selection as bipartite set cover (greedy ln n-approx); dry-run result becomes evidence on the prediction claim | [06](./06-faculties-and-mechanisms.md) §2 | -| 4 | Self-correction (faculty) | External-oracle cascade, never self-prompting (paper §3 honest negative, C12) | Cost-ordered oracle chain (types → impacted tests → independent reviewer); verdict requires ≥ 2 signals external to fθ | [06](./06-faculties-and-mechanisms.md) §4 | -| 5 | Impact-awareness (faculty) | Atlas hardened; pre-edit gate becomes **mandatory** (hook-enforced) | Incremental dep graph keyed by file content hash; reverse-edge index → O(deg) "who depends on X" | [06](./06-faculties-and-mechanisms.md) §1 | -| 6 | M1 routing | Shipped; add auditable per-task rubric surface + outcome-calibrated weights | Additive transparent rubric; escalation only on verified failure; online weight calibration from ledger | [06](./06-faculties-and-mechanisms.md) §7 | -| 7 | M2 assumption gate | Shipped; questions become **computed missing-sets**, not pattern matches | `s(x) < τ` halt rule + set difference `R(edit) \ covered(selection)` as the question generator | [04](./04-context-assembly.md) §4 | -| 8 | M3 decomposition | Automatic partition-boundary detection (the paper's flagged residue, §5.3) | Constrained graph partition on the task-dependency graph: greedy modularity / min-cut, each part's working set ≤ window budget | [06](./06-faculties-and-mechanisms.md) §3 | -| 9 | M4 goal-anchoring | Continuous drift control, not a static anchor (§5.4) | Drift `D(y_t,g) = 1 − sim(goal, rolling summary)`; **CUSUM control chart** triggers mandatory re-anchor | [06](./06-faculties-and-mechanisms.md) §5 | -| 10 | M5 anti-over-engineering | `src/lean.js` footprint made a defined metric (§5.5) | `φ(y) − φ*(x)` over files/abstractions/LOC; MDL tie-break: smallest description that passes the oracle | [06](./06-faculties-and-mechanisms.md) §6 · [07](./07-ui-quality-gate.md) for UI | -| 11 | M6 inline verification | Checkpoint scheduling during generation (§5.6) | Optimal-stopping threshold: check when hazard × tokens-at-risk > check cost → deterministic cadence per tier | [06](./06-faculties-and-mechanisms.md) §6 | +| # | Capability | Mechanism | Math / algorithm / data structure | Spec | +| --- | -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| 1 | Memory (faculty) | PCM claim ledger; Eq. 3 retrieval; ʿilm→fahm→ḥikma layers | Content-addressed (Merkle-keyed) claim store; Beta(α,β) posterior with exponential decay; MinHash sketches; consolidation = union-find clustering over Jaccard ≥ τ, promoting episodes → patterns → decision rules | [01](./01-pcm-protocol.md) | +| 2 | Learning (faculty) | Outcome write-back band (Eq. 2): oracle results update the `val` of every claim that informed the action | Bayesian evidence update; rubric-weight calibration by logistic regression over outcome claims (the paper's own "learn the rubric weights" note, §9.3) | [01](./01-pcm-protocol.md) §6 | +| 3 | Imagination (faculty) | Consequence simulator `g` (paper Eq. 4): blast radius → impacted-test selection → sandboxed dry-run | Reverse-dependency traversal with hop-decay; test selection as bipartite set cover (greedy ln n-approx); dry-run result becomes evidence on the prediction claim | [06](./06-faculties-and-mechanisms.md) §2 | +| 4 | Self-correction (faculty) | External-oracle cascade, never self-prompting (paper §3 honest negative, C12) | Cost-ordered oracle chain (types → impacted tests → independent reviewer); verdict requires ≥ 2 signals external to fθ | [06](./06-faculties-and-mechanisms.md) §4 | +| 5 | Impact-awareness (faculty) | Atlas hardened; pre-edit gate becomes **mandatory** (hook-enforced) | Incremental dep graph keyed by file content hash; reverse-edge index → O(deg) "who depends on X" | [06](./06-faculties-and-mechanisms.md) §1 | +| 6 | M1 routing | Shipped; add auditable per-task rubric surface + outcome-calibrated weights | Additive transparent rubric; escalation only on verified failure; online weight calibration from ledger | [06](./06-faculties-and-mechanisms.md) §7 | +| 7 | M2 assumption gate | Shipped; questions become **computed missing-sets**, not pattern matches | `s(x) < τ` halt rule + set difference `R(edit) \ covered(selection)` as the question generator | [04](./04-context-assembly.md) §4 | +| 8 | M3 decomposition | Automatic partition-boundary detection (the paper's flagged residue, §5.3) | Constrained graph partition on the task-dependency graph: greedy modularity / min-cut, each part's working set ≤ window budget | [06](./06-faculties-and-mechanisms.md) §3 | +| 9 | M4 goal-anchoring | Continuous drift control, not a static anchor (§5.4) | Drift `D(y_t,g) = 1 − sim(goal, rolling summary)`; **CUSUM control chart** triggers mandatory re-anchor | [06](./06-faculties-and-mechanisms.md) §5 | +| 10 | M5 anti-over-engineering | `src/lean.js` footprint made a defined metric (§5.5) | `φ(y) − φ*(x)` over files/abstractions/LOC; MDL tie-break: smallest description that passes the oracle | [06](./06-faculties-and-mechanisms.md) §6 · [07](./07-ui-quality-gate.md) for UI | +| 11 | M6 inline verification | Checkpoint scheduling during generation (§5.6) | Optimal-stopping threshold: check when hazard × tokens-at-risk > check cost → deterministic cadence per tier | [06](./06-faculties-and-mechanisms.md) §6 | ## 3. Phase roadmap Phases are dependency-ordered; each has an acceptance gate. P1 is the keystone — every later phase stores its state as PCM claims. **All phases have shipped** (v0.5.0): +All nodes below are shipped (green); the color is the legend. + ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% flowchart LR - P0["P0 specs ✅"] --> P1["P1 ledger core ✅"] - P1 --> P2["P2 team sync ✅"] - P1 --> P3["P3 reuse cache ✅"] - P1 --> P4["P4 context assembly ✅"] - P1 --> P6["P6 UI quality gate ✅"] - P4 --> P5["P5 loop closure ✅"] - P2 --> P7["P7 dashboard ✅"] + P0["P0 specs"] --> P1["P1 ledger core"] + P1 --> P2["P2 team sync"] + P1 --> P3["P3 reuse cache"] + P1 --> P4["P4 context assembly"] + P1 --> P6["P6 UI quality gate"] + P4 --> P5["P5 loop closure"] + P2 --> P7["P7 dashboard"] P5 --> P7 P6 --> P7 - P3 --> P8["P8 evaluation ✅"] + P3 --> P8["P8 evaluation"] P5 --> P8 + classDef done fill:#1f3d2b,stroke:#67e8a5,color:#f2ede7; + class P0,P1,P2,P3,P4,P5,P6,P7,P8 done; ``` -| Phase | Delivers | Depends on | Acceptance | -|---|---|---|---| -| **P0** ✅ (this PR) | Specs 00–08, ADR-0005, ADR-0006, ROADMAP update | — | Docs merged; referenced paths resolve; no source changes | -| **P1 Ledger core** ✅ | `src/ledger.js` (claim store, canonical hashing, Beta confidence, Eq. 3 retrieval, decay/prune); migrate `src/lessons.js` + `src/lessons_store.js` + `src/recall.js` onto claim kinds | P0 | All existing cortex/recall tests green on the new store; property tests: id stability, decay monotonicity | -| **P2 Team sync** ✅ | `.forge/ledger/` git layout, union-merge driver, `forge ledger merge\|verify\|blame` | P1 | Three-way merge fuzz: any interleaving of two ledgers converges byte-identically (semilattice test) | -| **P3 Reuse cache** ✅ | `forge reuse` — fingerprint, exact/near lookup, atlas revalidation, eviction | P1 | Cache hit returns artifact + evidence; stale-interface artifact refused; hit/miss metrics emitted | -| **P4 Context assembly** ✅ | `forge context` — candidate scoring, knapsack selection, required-set completeness gate; wired into `src/substrate.js` + hooks | P1 | Gate emits computed missing-set on incomplete context; token budget never exceeded | -| **P5 Loop closure** ✅ | Outcome write-back band; doom-loop diagnosis + escalation; imagination dry-run; M3/M4/M5/M6 extensions | P1, P4 | Revert/test outcomes visibly move `val` of informing claims; repeated failure signature halts with a diagnosis claim | -| **P6 UI quality gate** ✅ | `forge uicheck` v2: design fingerprints, slop distance, scale conformance; machine-readable taste constraints | P1 | Known-template fixture flagged; project-conformant fixture passes; zero LLM calls in the gate | -| **P7 Dashboard** ✅ | `forge dash` — local server + self-contained HTML over `.forge/` stores | P1–P6 (reads their stores) | Renders ledger, cost meter, cache rate, blast radius offline | -| **P8 Evaluation** ✅ | Extend `src/eval.js`: cost-stage measurement, cache-hit-rate harness, honest report | P3–P5 | A measured (not asserted) end-to-end cost figure per stage, published in reports/ | +| Phase | Delivers | Depends on | Acceptance | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **P0** ✅ (this PR) | Specs 00–08, ADR-0005, ADR-0006, ROADMAP update | — | Docs merged; referenced paths resolve; no source changes | +| **P1 Ledger core** ✅ | `src/ledger.js` (claim store, canonical hashing, Beta confidence, Eq. 3 retrieval, decay/prune); migrate `src/lessons.js` + `src/lessons_store.js` + `src/recall.js` onto claim kinds | P0 | All existing cortex/recall tests green on the new store; property tests: id stability, decay monotonicity | +| **P2 Team sync** ✅ | `.forge/ledger/` git layout, union-merge driver, `forge ledger merge\|verify\|blame` | P1 | Three-way merge fuzz: any interleaving of two ledgers converges byte-identically (semilattice test) | +| **P3 Reuse cache** ✅ | `forge reuse` — fingerprint, exact/near lookup, atlas revalidation, eviction | P1 | Cache hit returns artifact + evidence; stale-interface artifact refused; hit/miss metrics emitted | +| **P4 Context assembly** ✅ | `forge context` — candidate scoring, knapsack selection, required-set completeness gate; wired into `src/substrate.js` + hooks | P1 | Gate emits computed missing-set on incomplete context; token budget never exceeded | +| **P5 Loop closure** ✅ | Outcome write-back band; doom-loop diagnosis + escalation; imagination dry-run; M3/M4/M5/M6 extensions | P1, P4 | Revert/test outcomes visibly move `val` of informing claims; repeated failure signature halts with a diagnosis claim | +| **P6 UI quality gate** ✅ | `forge uicheck` v2: design fingerprints, slop distance, scale conformance; machine-readable taste constraints | P1 | Known-template fixture flagged; project-conformant fixture passes; zero LLM calls in the gate | +| **P7 Dashboard** ✅ | `forge dash` — local server + self-contained HTML over `.forge/` stores | P1–P6 (reads their stores) | Renders ledger, cost meter, cache rate, blast radius offline | +| **P8 Evaluation** ✅ | Extend `src/eval.js`: cost-stage measurement, cache-hit-rate harness, honest report | P3–P5 | A measured (not asserted) end-to-end cost figure per stage, published in reports/ | ## 4. Honesty register (the paper's own discipline, applied to this plan) diff --git a/global/statusline.sh b/global/statusline.sh index faa6175..e66b084 100644 --- a/global/statusline.sh +++ b/global/statusline.sh @@ -1,51 +1,87 @@ #!/usr/bin/env bash -# Custom status line: dir · git branch · model · session cost · cache hit%. -# Surfaces context-cost AND prompt-cache health so you notice when to /clear, +# Custom status line: dir · branch · model · cost · diff · cache-hit% · ctx warning. +# One restrained palette — muted grey for structure, a single ember accent, green/red only +# for the +/- diff — with consistent " · " separators and a subtle (not alarming) 200k +# warning. Surfaces context-cost AND prompt-cache health so you notice when to /clear, # /compact, or why a turn went uncached (model switch, MCP reconnect, upgrade). set -uo pipefail input="$(cat)" if command -v jq >/dev/null 2>&1; then - dir="$(printf '%s' "$input" | jq -r '.workspace.current_dir // .cwd // empty')" - model="$(printf '%s' "$input"| jq -r '.model.display_name // .model.id // "?"')" + dir="$(printf '%s' "$input" | jq -r '.workspace.current_dir // .cwd // empty')" + model="$(printf '%s' "$input" | jq -r '.model.display_name // .model.id // "?"')" cost="$(printf '%s' "$input" | jq -r '.cost.total_cost_usd // empty')" - add="$(printf '%s' "$input" | jq -r '.cost.total_lines_added // empty')" - del="$(printf '%s' "$input" | jq -r '.cost.total_lines_removed // empty')" + add="$(printf '%s' "$input" | jq -r '.cost.total_lines_added // empty')" + del="$(printf '%s' "$input" | jq -r '.cost.total_lines_removed // empty')" over="$(printf '%s' "$input" | jq -r '.exceeds_200k_tokens // false')" - cread="$(printf '%s' "$input"| jq -r '.current_usage.cache_read_input_tokens // .cost.cache_read_input_tokens // empty')" - cwrite="$(printf '%s' "$input"|jq -r '.current_usage.cache_creation_input_tokens // .cost.cache_creation_input_tokens // empty')" + cread="$(printf '%s' "$input" | jq -r '.current_usage.cache_read_input_tokens // .cost.cache_read_input_tokens // empty')" + cwrite="$(printf '%s' "$input" | jq -r '.current_usage.cache_creation_input_tokens // .cost.cache_creation_input_tokens // empty')" else - dir="$PWD"; model="?"; cost=""; add=""; del=""; over="false"; cread=""; cwrite="" + dir="$PWD" + model="?" + cost="" + add="" + del="" + over="false" + cread="" + cwrite="" fi [ -n "${dir:-}" ] || dir="$PWD" -short="${dir/#$HOME/~}" +short="${dir/#$HOME/\~}" +# Palette (256-color): muted structure, one ember accent, green/red reserved for the diff. +e=$(printf '\033') +R="${e}[0m" +BOLD="${e}[1m" +DIM="${e}[38;5;245m" +MUTED="${e}[38;5;247m" +EMBER="${e}[38;5;209m" +GREEN="${e}[38;5;114m" +RED="${e}[38;5;174m" +SEP=" ${DIM}·${R} " + +# dir +out="${DIM}${short}${R}" + +# branch branch="" if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then branch="$(git -C "$dir" branch --show-current 2>/dev/null)" - [ -n "$branch" ] && branch=" \033[35m⎇ $branch\033[0m" fi +[ -n "$branch" ] && out="${out}${SEP}${EMBER}⎇ ${branch}${R}" + +# model +out="${out}${SEP}${BOLD}${model}${R}" -costp="" +# session cost if [ -n "${cost:-}" ] && [ "$cost" != "null" ]; then - costp="$(printf ' \033[33m$%.3f\033[0m' "$cost" 2>/dev/null || echo " \$$cost")" + costf="$(printf '%.3f' "$cost" 2>/dev/null || printf '%s' "$cost")" + out="${out}${SEP}${MUTED}\$${costf}${R}" fi -diffp="" +# lines added/removed if [ -n "${add:-}" ] && [ "$add" != "null" ]; then - diffp="$(printf ' \033[32m+%s\033[0m/\033[31m-%s\033[0m' "${add:-0}" "${del:-0}")" + out="${out}${SEP}${GREEN}+${add:-0}${R}${DIM}/${R}${RED}-${del:-0}${R}" fi -# Cache hit rate: read / (read + creation). High = cache working; low = prefix busted. -cachep="" +# cache hit rate: read / (read + creation). High = cache working; low = prefix busted. if [ -n "${cread:-}" ] && [ "$cread" != "null" ] && command -v awk >/dev/null 2>&1; then - cachep="$(awk -v r="${cread:-0}" -v w="${cwrite:-0}" 'BEGIN{t=r+w; if(t>0){p=100*r/t; c=(p>=70)?32:(p>=30)?33:31; printf " \033[%dm⚡%d%%\033[0m", c, p}}')" + p="$(awk -v r="${cread:-0}" -v w="${cwrite:-0}" 'BEGIN{t=r+w; if(t>0) printf "%d", 100*r/t}')" + if [ -n "$p" ]; then + if [ "$p" -ge 70 ]; then + cc="$GREEN" + elif [ "$p" -ge 30 ]; then + cc="$EMBER" + else + cc="$RED" + fi + out="${out}${SEP}${cc}⚡${p}%${R}" + fi fi -warn="" -[ "${over:-false}" = "true" ] && warn=" \033[41m ctx>200k — /clear \033[0m" +# context warning — a subtle ember marker, not an alarming red block. +[ "${over:-false}" = "true" ] && out="${out}${SEP}${EMBER}⚠ ctx>200k → /clear${R}" -printf "\033[36m%s\033[0m%b \033[90m·\033[0m \033[1m%s\033[0m%b%b%b%b" \ - "$short" "$branch" "$model" "$costp" "$diffp" "$cachep" "$warn" +printf '%s' "$out" diff --git a/landing/index.html b/landing/index.html index 2db6356..c5ed62a 100644 --- a/landing/index.html +++ b/landing/index.html @@ -8,15 +8,21 @@ name="description" content="forgekit gives AI coding agents a shared cognitive substrate: proof-carrying memory, impact foresight, reuse, and enforceable guardrails compiled into native tool config." /> - + - + - + @@ -184,55 +612,163 @@
-

forgekit v0.10.0 · MIT · zero runtime dependencies

-

Give every coding agent the same working memory.

+

+ forgekit v0.10.0 · + MIT · zero runtime dependencies +

+

+ Give every coding agent the same working memory. +

- Forgekit turns one source of truth into native configuration for AI coding tools, then adds proof-carrying lessons, blast-radius prediction, reuse search, and pre-action guardrails so agents act from shared evidence instead of fresh guesses. + Forgekit turns one source of truth into native configuration for + AI coding tools, then adds proof-carrying lessons, blast-radius + prediction, reuse search, and pre-action guardrails so agents act + from shared evidence instead of fresh guesses.

npm install -g @codewithjuber/forgekit && forge init - +
-
-
118 msfull pre-action gate from repository benchmarks
-
0.43 msblast-radius lookup from benchmark reports
-
62.1%measured routing-stage cost saved
-
0runtime dependencies in package.json
+
+ 118 msfull pre-action gate from repository benchmarks +
+
+ 0.43 msblast-radius lookup from benchmark reports +
+
+ 62.1%measured routing-stage cost saved +
+
+ 0runtime dependencies in package.json +
-

Platform

A cognitive substrate, packaged like developer tooling.

-

The site uses only repository-backed claims: README, CHANGELOG, package metadata, benchmark reports, and optional GitHub API counters on the status page.

+
+

Platform

+

+ A cognitive substrate, packaged like developer tooling. +

+
+

+ The site uses only repository-backed claims: README, CHANGELOG, + package metadata, benchmark reports, and optional GitHub API + counters on the status page. +

-
01

Cross-tool config

Author rules once, then compile native config layers for the agents your team already uses.

-
02

Proof-carrying memory

Every durable lesson includes evidence, provenance, confidence, decay, and merge behavior.

-
03

Impact foresight

Before editing, Forge predicts affected files and tests so agents can plan smaller, safer changes.

-
04

Reuse-first retrieval

Agents are pushed toward existing code paths and prior fixes before inventing another solution.

-
05

Guardrails that run

Scope, assumptions, anchoring, cost, and secret-redaction checks are enforced by CLI hooks.

-
06

Team memory through git

Knowledge moves with the repository, not with a single chat transcript or vendor workspace.

+
+
01
+

Cross-tool config

+

+ Author rules once, then compile native config layers for the + agents your team already uses. +

+
+
+
02
+

Proof-carrying memory

+

+ Every durable lesson includes evidence, provenance, confidence, + decay, and merge behavior. +

+
+
+
03
+

Impact foresight

+

+ Before editing, Forge predicts affected files and tests so + agents can plan smaller, safer changes. +

+
+
+
04
+

Reuse-first retrieval

+

+ Agents are pushed toward existing code paths and prior fixes + before inventing another solution. +

+
+
+
05
+

Guardrails that run

+

+ Scope, assumptions, anchoring, cost, and secret-redaction checks + are enforced by CLI hooks. +

+
+
+
06
+

Team memory through git

+

+ Knowledge moves with the repository, not with a single chat + transcript or vendor workspace. +

+
@@ -241,16 +777,52 @@

Give every coding agent the same working memory.

Workflow

-

Install once. Gate every meaningful agent action.

-

The CLI stays small and dependency-free, while optional live status data is fetched with timeouts, retries, jitter, and HTTP cache validators.

+

+ Install once. Gate every meaningful agent action. +

+

+ The CLI stays small and dependency-free, while optional live + status data is fetched with timeouts, retries, jitter, and HTTP + cache validators. +

-
1

Initialize the substrate

Run the installer and generate project-local agent instructions, hooks, and memory stores.

-
2

Let agents plan with evidence

Forge assembles context, reuse candidates, assumptions, scope, and impact predictions before edits.

-
3

Promote only verified lessons

Tests and human corrections update memory; self-graded claims do not become trusted facts.

+
+ 1 +
+

Initialize the substrate

+

+ Run the installer and generate project-local agent + instructions, hooks, and memory stores. +

+
+
+
+ 2 +
+

Let agents plan with evidence

+

+ Forge assembles context, reuse candidates, assumptions, + scope, and impact predictions before edits. +

+
+
+
+ 3 +
+

Promote only verified lessons

+

+ Tests and human corrections update memory; self-graded + claims do not become trusted facts. +

+
+
-
forge substrate
+
+ forge substrate +
$ forge substrate "Change auth validation and update tests"
  loaded team rules from AGENTS.md
  found reuse candidates before implementation
@@ -264,12 +836,35 @@ 

Install once. Gate every meaningful agent action.

-

Evidence

Professional, but honest about limits.

-

The strongest design choice is restraint: measured numbers are labeled as measured, targets stay targets, and generated pages document their sources.

+
+

Evidence

+

+ Professional, but honest about limits. +

+
+

+ The strongest design choice is restraint: measured numbers are + labeled as measured, targets stay targets, and generated pages + document their sources. +

-

Approximate impact atlas

Import-graph predictions are useful for gating and planning, not presented as perfect dependency analysis.

-
%

Cost claims stay scoped

The page shows the measured routing-stage savings rather than blending measured data with aspirational targets.

+
+
+

Approximate impact atlas

+

+ Import-graph predictions are useful for gating and planning, not + presented as perfect dependency analysis. +

+
+
+
%
+

Cost claims stay scoped

+

+ The page shows the measured routing-stage savings rather than + blending measured data with aspirational targets. +

+
@@ -279,7 +874,11 @@

Install once. Gate every meaningful agent action.

Data sources

No mock marketing data.

-

Every metric and product claim on this site comes from local repository files or the optional public GitHub repository endpoint used by the generated status page.

+

+ Every metric and product claim on this site comes from local + repository files or the optional public GitHub repository endpoint + used by the generated status page. +

    @@ -296,11 +895,19 @@

    No mock marketing data.

    -
    forgekit v0.10.0 · MIT · designed for WCAG 2.2 AA
    +
    + forgekit v0.10.0 · MIT · designed for WCAG 2.2 AA +
    @@ -311,10 +918,14 @@

    No mock marketing data.

    try { await navigator.clipboard.writeText(button.dataset.copy || ""); button.textContent = "copied"; - setTimeout(() => { button.textContent = "copy"; }, 1600); + setTimeout(() => { + button.textContent = "copy"; + }, 1600); } catch (error) { button.textContent = "copy failed"; - setTimeout(() => { button.textContent = "copy"; }, 1800); + setTimeout(() => { + button.textContent = "copy"; + }, 1800); } }); } diff --git a/public/index.html b/public/index.html index 1c25158..c243747 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,7 @@ code{font:500 13px var(--mono);background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-s);padding:0 8px} footer{padding:32px 0;color:var(--faint);font-size:14px} @media(max-width:800px){.grid{grid-template-columns:1fr}} -

    @codewithjuber/forgekit · v0.10.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 dependencieswork @ d13b19clive 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 +

    @codewithjuber/forgekit · v0.10.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 @ 31eb862live 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

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

      Data Sources

      No mock data is used. This page is regenerated from repository files during CI (generated 2026-07-10T10:12:16.159Z from d13b19c). 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

      • forge stack — dynamic stack detection: reads the repo's dependency manifests (package.json, pyproject.toml, go.mod, Cargo.toml, Gemfile, composer.json, pom.xml/build.gradle, *.csproj) and reports its real languages, frameworks, package managers, and test commands — data-driven (extend by adding a SIGNATURES row), not a hardcoded menu. The detected test commands now drive the substrate's verification checklist instead of assuming npm.
      • Six more atlas languages — Ruby, C#, PHP, Kotlin, Swift, and C/C++ join JS/TS, Python, Go, Rust, and Java (whose method defs are now indexed too). One RULES table; the walk, completion gate, and docs sweep pick each up automatically.
      • forge update — self-update: --check reports whether a newer version is available (commits behind upstream, from a cached hourly fetch), bare applies it (git pull --ff-only for a checkout, or the npm i -g command otherwise). forge doctor surfaces a non-nagging "update available" notice; FORGE_NO_UPDATE_CHECK=1 silences it. Fail-open: offline / non-git / detached-HEAD never error.
      • Self-dogfood — a committed .claude/settings.json wires forgekit's own guards via ${CLAUDE_PROJECT_DIR}, so the repo runs its own completion gate, cortex, and guards during local dev without a marketplace install.

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

      Data Sources

      No mock data is used. This page is regenerated from repository files during CI (generated 2026-07-11T01:43:16.194Z from 31eb862). 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 — parity enforced in test/pages.test.js.
      \ No newline at end of file diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index 5e2856a..50149ef 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -29,9 +29,32 @@ function lineMatch(text, re, fallback = "") { function latestChanges() { const section = changelog.match(/## \[[^\]]+\][\s\S]*?(?=\n## \[|\n\[Unreleased\]:|$)/)?.[0] ?? ""; + // Bullets wrap across several lines in the CHANGELOG; join each "- …" with its indented + // continuation lines so the status page shows the whole item, not a truncated fragment. + const items = []; + let cur = null; + for (const line of section.split("\n")) { + const m = /^- (.*)$/.exec(line); + if (m) { + if (cur !== null) items.push(cur); + cur = m[1]; + } else if (cur !== null) { + // A blank line or heading ends the bullet; anything else — indented OR a column-0 + // "lazy continuation" (valid CommonMark, and how the formatter reflows wrapped + // bullets) — is still part of the current item. + if (line.trim() === "" || /^#{1,6}\s/.test(line)) { + items.push(cur); + cur = null; + } else { + cur += ` ${line.trim()}`; + } + } + } + if (cur !== null) items.push(cur); // Strip inline markdown (bold/code) — these bullets render as plain HTML text. - return [...section.matchAll(/^- (.+)$/gm)].slice(0, 4).map((m) => - m[1] + return items.slice(0, 4).map((s) => + s + .replace(/\s+/g, " ") .trim() .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/`([^`]+)`/g, "$1"), @@ -68,7 +91,11 @@ async function fetchJsonWithRetry(url, { timeoutMs = 5000, attempts = 3 } = {}) writeFileSync( cacheFile, JSON.stringify( - { etag: res.headers.get("etag"), lastModified: res.headers.get("last-modified"), data }, + { + etag: res.headers.get("etag"), + lastModified: res.headers.get("last-modified"), + data, + }, null, 2, ), @@ -119,9 +146,10 @@ export async function collect({ live = process.env.BUILD_PAGES_LIVE === "1" } = benchMentions: (benchmarks.match(/^## /gm) ?? []).length, }; } -// The status page shares the landing page's design system verbatim: the same eight -// color tokens (forge dash palette), a strict 4px spacing base, three radius levels, -// one shadow. `forge uicheck design public/index.html` enforces this. +// The status page shares the landing page's design system verbatim: the same warm +// ember/near-black color tokens, one accent, a system font stack. test/pages.test.js +// enforces token parity, a non-empty changes list, and no phantom webfont — so the two +// public surfaces can't silently drift into two different "school-project" looks again. export function render(d) { const live = d.github ? `${esc(d.github.stars)} stars${esc(d.github.forks)} forks${esc(d.github.issues)} open issues` @@ -170,7 +198,7 @@ footer{padding:32px 0;color:var(--faint);font-size:14px}

      ${esc(d.name)} · v${esc(d.version)} · Node ${esc(d.node)}

      Live status, straight from the repository.

      ${esc(d.description)}

      Install in 60 seconds Read the docs

      ${esc(d.license)} license${esc(d.deps)} runtime dependencies${esc(d.branch)} @ ${esc(d.commit)}${live}
      ${esc(d.impact)}
      blast-radius lookup

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

      reports/benchmarks.md

      ${esc(d.speed)}
      pre-action gate

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

      reports/benchmarks.md

      ${esc(d.saved.match(/^[\d.]+\s*%?/)?.[0] ?? d.saved)}
      ${esc(d.saved.replace(/^[\d.]+\s*%?\s*/, "") || "routing signal")}

      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

        ${d.latest.map((x) => `
      • ${esc(x)}
      • `).join("")}

      Benchmark sections indexed: ${esc(d.benchMentions)} · benchmarks file updated ${esc(d.benchUpdated)}.

      Data Sources

      No mock data is used. This page is regenerated from repository files during CI (generated ${esc(d.generated)} from ${esc(d.commit)}). Enable BUILD_PAGES_LIVE=1 to refresh public GitHub counters with ETag/Last-Modified caching.

      • package.json
      • README.md
      • CHANGELOG.md
      • reports/benchmarks.md
      • ${api} (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.
      `; +forge substrate "Change auth validation and update tests"

    Latest repo changes

      ${d.latest.map((x) => `
    • ${esc(x)}
    • `).join("")}

    Benchmark sections indexed: ${esc(d.benchMentions)} · benchmarks file updated ${esc(d.benchUpdated)}.

    Data Sources

    No mock data is used. This page is regenerated from repository files during CI (generated ${esc(d.generated)} from ${esc(d.commit)}). Enable BUILD_PAGES_LIVE=1 to refresh public GitHub counters with ETag/Last-Modified caching.

    • package.json
    • README.md
    • CHANGELOG.md
    • reports/benchmarks.md
    • ${api} (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 — parity enforced in test/pages.test.js.
    `; } if (import.meta.url === `file://${process.argv[1]}`) { const data = await collect(); diff --git a/scripts/bump.mjs b/scripts/bump.mjs index 33be37a..626e85d 100644 --- a/scripts/bump.mjs +++ b/scripts/bump.mjs @@ -12,7 +12,10 @@ * BREAKING CHANGE / "type!:" -> major, feat -> minor, anything else -> patch. * 2. If git yields no commits, fall back to the CHANGELOG [Unreleased] body: * "BREAKING" -> major, a "### Added" section -> minor, any other content -> patch. - * 3. Nothing found anywhere -> error (nothing to release). + * 3. Release only when notes were hand-written OR a feat/fix/perf/breaking commit + * landed; a chore/docs-only merge exits NOTHING_TO_RELEASE (3) as a graceful skip. + * 4. Unattended (no hand-written [Unreleased]) runs synthesize the changelog body from + * the commit subjects so the release still describes itself. * * Files touched (all version fields in the repo): * package.json, package-lock.json (root "version" + packages[""].version), @@ -80,6 +83,105 @@ export function inferKindFromChangelog(unreleasedBody) { return "patch"; } +// --------------------------------------------------------------------------- +// Unattended auto-release: decide worthiness + synthesize notes from commits. +// (Used by the merge-to-master path in .github/workflows/bump.yml. Attended +// patch|minor|major runs keep the "write your own [Unreleased]" discipline.) +// --------------------------------------------------------------------------- + +// Merge commits and the bot's own "chore(release):" commits are bookkeeping, not +// shippable change — they never seed a release or a changelog entry. +const NOISE_SUBJECT_RE = /^(chore\(release\)|Merge )/; + +/** Drops merge/release-bookkeeping commits. `commits` may be null. */ +export function releasableCommits(commits) { + return (commits || []).filter((c) => c?.subject && !NOISE_SUBJECT_RE.test(c.subject)); +} + +/** + * Is this commit a reason to cut a release on its own? feat/fix/perf or any breaking + * change (`type!:` / "BREAKING CHANGE"). chore/docs/test/ci/style/build/refactor alone + * are NOT — matching conventional-release conventions, so a docs-only merge won't publish. + */ +export function isReleasableCommit(c) { + const subject = c?.subject || ""; + if (/^(feat|fix|perf)(\([^)]*\))?!?:/.test(subject)) return true; + return ( + /^[a-z]+(\([^)]*\))?!:/.test(subject) || + /BREAKING[ -]CHANGE/.test(`${subject}\n${c?.body || ""}`) + ); +} + +// Conventional type -> Keep a Changelog section, for the user-facing types only. +const TYPE_SECTION = { + feat: "Added", + fix: "Fixed", + perf: "Changed", + refactor: "Changed", + revert: "Changed", +}; + +/** + * Build a Keep-a-Changelog `[Unreleased]` body from commit subjects when a human wrote + * none. Only user-facing types are included (feat->Added, fix->Fixed, perf/refactor/ + * revert->Changed); docs/chore/test/ci/style/build and non-conventional subjects are + * skipped. Breaking changes are prefixed **BREAKING**. Deduped, deterministic order. + */ +export function synthesizeChangelog(commits) { + const sections = { Added: [], Changed: [], Fixed: [] }; + const seen = new Set(); + for (const c of releasableCommits(commits)) { + const m = /^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/.exec(c.subject); + if (!m) continue; // non-conventional subject — leave it out of synthesized notes + const [, type, , bang, descRaw] = m; + const breaking = Boolean(bang) || /BREAKING[ -]CHANGE/.test(`${c.subject}\n${c.body || ""}`); + const section = TYPE_SECTION[type]; + if (!section && !breaking) continue; // docs/chore/test/etc. are not user-facing + const target = section || "Changed"; + const desc = breaking ? `**BREAKING** ${descRaw.trim()}` : descRaw.trim(); + const key = `${target}|${desc.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + sections[target].push(desc); + } + const parts = []; + for (const name of ["Added", "Changed", "Fixed"]) { + if (sections[name].length) + parts.push(`### ${name}\n\n${sections[name].map((t) => `- ${t}`).join("\n")}`); + } + return parts.join("\n\n"); +} + +/** + * The `[Unreleased]` body for an unattended release: the synthesized conventional sections, + * or — when the release-worthy commits have no clean conventional subject to synthesize from + * (e.g. a squash-merge title `Fix login (#42)` whose `BREAKING CHANGE` lives in the body) — + * the releasing subjects listed verbatim. A worthy release must NEVER produce an empty body + * (rotateChangelog hard-fails on one), so this is the single source main() writes. + */ +export function changelogBody(commits) { + const synth = synthesizeChangelog(commits); + if (synth.trim()) return synth; + const lines = releasableCommits(commits) + .filter(isReleasableCommit) + .map((c) => `- ${c.subject.replace(/^[a-z]+(\([^)]*\))?!?:\s*/, "")}`); + return lines.length ? `### Changed\n\n${lines.join("\n")}` : ""; +} + +/** Replaces the [Unreleased] body (typically empty) with `body`. Pure. */ +export function setUnreleasedBody(changelog, body) { + const start = changelog.search(/^## \[Unreleased\][^\n]*\n/m); + if (start === -1) throw new Error("CHANGELOG.md has no ## [Unreleased] section"); + const afterHeading = changelog.indexOf("\n", start) + 1; + const existing = extractUnreleased(changelog) ?? ""; + const tail = changelog.slice(afterHeading + existing.length); + return `${changelog.slice(0, afterHeading)}\n${body}\n\n${tail}`; +} + +// Exit code the CLI returns (and bump.yml keys off) when `auto` finds nothing worth +// releasing — a graceful skip, distinct from a real failure so CI won't go red. +export const NOTHING_TO_RELEASE = 3; + // --------------------------------------------------------------------------- // CHANGELOG rotation (pure) // --------------------------------------------------------------------------- @@ -243,7 +345,11 @@ function today() { function gitCommitsSinceLastTag(root) { const git = (args) => - execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); try { let range; try { @@ -298,17 +404,32 @@ function main(argv) { let kind = cmd; if (cmd === "auto") { const commits = gitCommitsSinceLastTag(root); - kind = inferKindFromCommits(commits); - if (!kind) { - kind = inferKindFromChangelog( - extractUnreleased(readIfExists(path.join(root, "CHANGELOG.md")) || ""), + const changelogText = readIfExists(path.join(root, "CHANGELOG.md")) || ""; + const unreleased = extractUnreleased(changelogText) || ""; + const hasNotes = Boolean(unreleased.trim()); + + kind = inferKindFromCommits(commits) || inferKindFromChangelog(unreleased); + + // Release only when a human wrote [Unreleased] notes, OR a feat/fix/perf/breaking + // commit landed. A chore/docs-only merge is a graceful skip — never an npm publish. + const worthy = hasNotes || releasableCommits(commits).some(isReleasableCommit); + if (!kind || !worthy) { + process.stderr.write( + "auto: nothing to release (no feat/fix/perf/breaking commits and an empty CHANGELOG [Unreleased])\n", ); + return NOTHING_TO_RELEASE; } - if (!kind) { - process.stderr.write( - "auto: no commits since the last tag and an empty CHANGELOG [Unreleased] — nothing to release\n", + + // Unattended run with no hand-written notes: synthesize [Unreleased] from the + // commit log so rotateChangelog has a real body (it hard-fails on an empty one). + if (!hasNotes && !dryRun) { + // changelogBody() is guaranteed non-empty here (reached only when `worthy`), so + // rotateChangelog never hits its empty-[Unreleased] hard-fail. + fs.writeFileSync( + path.join(root, "CHANGELOG.md"), + setUnreleasedBody(changelogText, changelogBody(commits)), ); - return 1; + process.stderr.write("auto: synthesized CHANGELOG [Unreleased] from commit subjects\n"); } process.stderr.write(`auto -> ${kind}\n`); } diff --git a/src/cli.js b/src/cli.js index 7614a06..4593bfa 100755 --- a/src/cli.js +++ b/src/cli.js @@ -47,7 +47,10 @@ async function run(argv) { if (cmd === "init") { const { init } = await import("./init.js"); const noSettings = argv.includes("--no-settings"); - const { report, bytes, settings, detected } = init({ targetRoot: process.cwd(), noSettings }); + const { report, bytes, settings, detected } = init({ + targetRoot: process.cwd(), + noSettings, + }); const wrote = report.filter((r) => r.action === "written").map((r) => r.target); heading(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); @@ -219,8 +222,7 @@ async function run(argv) { return; } heading(`${BRAND.brand} docs check — docs↔code drift\n`); - if (!r.issues.length) - console.log(" ✓ docs and code agree (commands, env vars, MCP tools, CHANGELOG)"); + if (!r.issues.length) console.log(` ✓ docs and code agree (${r.checked.join(", ")})`); for (const i of r.issues) console.log(` ${i.severity === "error" ? "✗" : "!"} [${i.check}] ${i.detail}`); if (!r.ok) { @@ -404,7 +406,11 @@ async function run(argv) { process.exitCode = 1; return; } - const r = ls.tombstone(dir, hit.id, { author: gitAuthor(), reason, t: nowDay }); + const r = ls.tombstone(dir, hit.id, { + author: gitAuthor(), + reason, + t: nowDay, + }); if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; @@ -436,7 +442,11 @@ async function run(argv) { JSON.stringify( { sim: simLabel(sim), - results: ranked.map((r) => ({ id: r.claim.id, kind: r.claim.kind, score: r.score })), + results: ranked.map((r) => ({ + id: r.claim.id, + kind: r.claim.kind, + score: r.score, + })), }, null, 2, @@ -456,10 +466,18 @@ async function run(argv) { if (personal) { // Personal import: facts from the global recall store into the personal ledger. const { defaultStore } = await import("./recall.js"); - r = { lessons: 0, outcomes: 0, ...b.importFacts(defaultStore(), dir, nowDay) }; + r = { + lessons: 0, + outcomes: 0, + ...b.importFacts(defaultStore(), dir, nowDay), + }; } else { const { brainStore } = await import("./brain.js"); - r = b.importLegacy(root, { recallStore: brainStore(root), recallLedger: dir, nowDay }); + r = b.importLegacy(root, { + recallStore: brainStore(root), + recallLedger: dir, + nowDay, + }); } if (json) return console.log(JSON.stringify(r, null, 2)); console.log( @@ -548,7 +566,10 @@ async function run(argv) { repoLedger(root), { spec, form: "module", ...desc }, ref - ? { evidence: { oracle: "test.run", result: "confirm", ref }, t: nowDay } + ? { + evidence: { oracle: "test.run", result: "confirm", ref }, + t: nowDay, + } : { t: nowDay }, ); if (json) return console.log(JSON.stringify(r, null, 2)); @@ -569,7 +590,11 @@ async function run(argv) { } if (sub === "stats") { const { summarize } = await import("./metrics.js"); - const s = summarize(root).cache ?? { events: 0, byOutcome: {}, savedEstimate: 0 }; + const s = summarize(root).cache ?? { + events: 0, + byOutcome: {}, + savedEstimate: 0, + }; if (json) return console.log(JSON.stringify(s, null, 2)); heading(`${BRAND.brand} reuse — proof-carrying code cache\n`); console.log(` lookups: ${s.events}`); @@ -981,7 +1006,11 @@ async function run(argv) { const baseUrl = flagVal("--base-url"); const envKey = flagVal("--key-env"); const label = flagVal("--label"); - const r = addProvider(process.cwd(), addName, { baseUrl, envKey, label }); + const r = addProvider(process.cwd(), addName, { + baseUrl, + envKey, + label, + }); if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; @@ -1206,7 +1235,11 @@ async function run(argv) { // Repeatable flags collect rows; positionals are "done" rows. Piped stdin (one row // per line) covers agents that assemble the summary programmatically. const fields = { done: [], next: [], gotchas: [], criteria: [] }; - const FLAG = { "--next": "next", "--gotcha": "gotchas", "--criteria": "criteria" }; + const FLAG = { + "--next": "next", + "--gotcha": "gotchas", + "--criteria": "criteria", + }; for (let i = 0; i < args.length; i += 1) { if (FLAG[args[i]]) fields[FLAG[args[i]]].push(args[++i] ?? ""); else if (args[i] === "--phase") fields.phase = args[++i] ?? ""; @@ -1450,7 +1483,11 @@ async function run(argv) { process.exitCode = 1; return; } - const r = await visualGate(targets[0], { taste: tasteArg, remote, root: process.cwd() }); + const r = await visualGate(targets[0], { + taste: tasteArg, + remote, + root: process.cwd(), + }); if (!r.ok) { const reason = "reason" in r ? r.reason : "visual gate failed"; if ("skipped" in r && r.skipped) { @@ -1517,7 +1554,9 @@ async function run(argv) { let minted = null; if (argv.includes("--mint")) { const { epochDay } = await import("./util.js"); - minted = ui.mintProjectFingerprint(process.cwd(), files, { t: epochDay() }); + minted = ui.mintProjectFingerprint(process.cwd(), files, { + t: epochDay(), + }); } if (json) { console.log(JSON.stringify(minted ? { fingerprint: fp, minted } : fp, null, 2)); diff --git a/src/docs_check.js b/src/docs_check.js index d7acd7b..1473291 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -10,6 +10,7 @@ import { join } from "node:path"; import { BRAND } from "./brand.js"; import { COMMANDS, HIDDEN_COMMANDS } from "./commands.js"; import { TOOLS } from "./mcp_tools.js"; +import { MODELS } from "./model_tiers.js"; /** The user-facing prose docs every claim is reconciled against. */ const DOC_FILES = ["README.md", "docs/GUIDE.md", "ARCHITECTURE.md", "ROADMAP.md"]; @@ -159,6 +160,58 @@ const git = (root, args) => { } }; +/** Every tracked Markdown file, so diagram checks cover the WHOLE doc set — not just the + * four prose docs. Falls back to a recursive walk when git is unavailable (tmp fixtures). */ +function markdownFiles(root) { + const tracked = git(root, ["ls-files", "*.md"]); + if (tracked) return tracked.split("\n").filter(Boolean); + if (!existsSync(root)) return []; + return readdirSync(root, { recursive: true }) + .map(String) + .filter((f) => f.endsWith(".md") && !f.includes("node_modules") && !f.startsWith(".git/")); +} + +// The branded Mermaid theme every diagram shares (see README's `%%{init …}%%`). Without it +// GitHub renders Mermaid's default lavender, clashing with the ember/near-black identity. +const MERMAID_BLOCK_RE = /```mermaid\n([\s\S]*?)```/g; + +/** + * Diagram hygiene across ALL tracked Markdown: every ```mermaid block must (a) carry the + * branded `%%{init` theme directive, and (b) use `
    ` — never a literal `\n`, which + * GitHub's renderer shows as garbage instead of a line break. This is the guard that keeps + * "the diagrams look bad" from silently recurring; nothing else reconciled diagram quality. + */ +function checkDiagrams(root, issues) { + for (const rel of markdownFiles(root)) { + let text; + try { + text = readFileSync(join(root, rel), "utf8"); + } catch { + continue; + } + for (const m of text.matchAll(MERMAID_BLOCK_RE)) { + // An intentional example block (e.g. docs showing what a BAD diagram looks like) opts + // out with an HTML comment `` on the line before the fence. + if (/docs-check-ignore/.test(text.slice(Math.max(0, m.index - 80), m.index))) continue; + const block = m[1]; + if (!block.includes("%%{init")) { + issues.push({ + check: "diagrams", + severity: "error", + detail: `${rel}: a mermaid diagram has no branded \`%%{init\` theme — it renders in Mermaid's off-brand default`, + }); + } + if (block.includes("\\n")) { + issues.push({ + check: "diagrams", + severity: "error", + detail: `${rel}: a mermaid node uses a literal \`\\n\` (GitHub renders it as garbage) — use \`
    \``, + }); + } + } + } +} + /** 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) { @@ -199,6 +252,67 @@ function checkChangelog(root, issues) { } } +/** + * Model tiers: every `$in/$out per M tok` price in the docs must equal SOME model's price in + * `src/model_tiers.json`. We deliberately do NOT attribute a price to a specific nearby model + * — comparative prose ("Sonnet costs more than Haiku: $3/$15") makes proximity unreliable and + * would false-positive — so a price is flagged only when it matches no current model at all + * (the actual failure mode: a figure that went stale to a value the table no longer has). + */ +function checkModelTiers(docs, issues) { + const models = Object.values(MODELS); + const PRICE_RE = /\$(\d+)\/\$(\d+)\s*per\s*M\s*tok/gi; + for (const [file, text] of Object.entries(docs)) { + for (const m of text.matchAll(PRICE_RE)) { + const inC = Number(m[1]); + const outC = Number(m[2]); + if (!models.some((mo) => mo.inCost === inC && mo.outCost === outC)) { + issues.push({ + check: "model-tiers", + severity: "error", + detail: `${file}: price $${inC}/$${outC} per M tok matches no model in src/model_tiers.json (stale price?)`, + }); + } + } + } +} + +/** Every timing value measured in reports/benchmarks.md's table (median + p95 cells). */ +function measuredTimings(root) { + const set = new Set(); + for (const line of readDoc(root, "reports/benchmarks.md").split("\n")) { + if (!line.startsWith("|")) continue; // table rows only — not the prose above it + for (const m of line.matchAll(/(\d+(?:\.\d+)?)\s*(ms|µs|s)\b/g)) set.add(`${m[1]} ${m[2]}`); + } + return set; +} + +/** + * Benchmarks: every `N ms` value inside a **single bold run** in the README must be a number + * reports/benchmarks.md actually measured (the same file the status page reads). Stops + * "**118 ms**"-style figures from drifting away from the measured table — the "outdated + * numbers" complaint. Matching one bold run at a time (`[^*]+`, never across a `**` boundary) + * avoids the sandwich bug where a closing `**` pairs with the next opening `**` and captures + * the plain prose between them. Only runs when a benchmark table exists. + */ +function checkBenchmarks(root, docs, issues) { + const measured = measuredTimings(root); + if (!measured.size) return; + const readme = docs["README.md"] || ""; + for (const bold of readme.matchAll(/\*\*([^*]+)\*\*/g)) { + const run = bold[1]; + for (const m of run.matchAll(/(\d+(?:\.\d+)?)\s*ms\b/g)) { + const num = `${m[1]} ms`; + if (measured.has(num)) continue; + issues.push({ + check: "benchmarks", + severity: "error", + detail: `README claims "${run.trim()}" but no row in reports/benchmarks.md measures ${num}`, + }); + } + } +} + /** * Run every reconciler against the forge package tree. * @param {{root?: string}} [opts] @@ -211,9 +325,20 @@ export function docsCheck({ root = BRAND.root } = {}) { checkEnvVars(root, docs, issues); checkMcpTools(docs, issues); checkChangelog(root, issues); + checkDiagrams(root, issues); + checkModelTiers(docs, issues); + checkBenchmarks(root, docs, issues); return { ok: !issues.some((i) => i.severity === "error"), issues, - checked: ["commands", "env-vars", "mcp-tools", "changelog"], + checked: [ + "commands", + "env-vars", + "mcp-tools", + "changelog", + "diagrams", + "model-tiers", + "benchmarks", + ], }; } diff --git a/src/doctor.js b/src/doctor.js index c58af44..f742c12 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -318,7 +318,7 @@ function checkDocs(out, targetRoot) { const r = docsCheck({ root: targetRoot }); out.push( r.ok - ? ok("docs↔code", "commands, env vars, MCP tools, CHANGELOG all agree") + ? ok("docs↔code", `${r.checked.join(", ")} all agree`) : warn("docs↔code", `${r.issues.length} drift issue(s) — run \`${BRAND.cli} docs check\``), ); } catch {} diff --git a/test/bump.test.js b/test/bump.test.js index 8789ee2..3333ae8 100644 --- a/test/bump.test.js +++ b/test/bump.test.js @@ -5,14 +5,19 @@ import path from "node:path"; import { test } from "node:test"; import { applyBump, + changelogBody, collectVersions, computeNext, extractUnreleased, inferKindFromChangelog, inferKindFromCommits, + isReleasableCommit, parseVersion, + releasableCommits, rotateChangelog, setJsonVersionText, + setUnreleasedBody, + synthesizeChangelog, } from "../scripts/bump.mjs"; // --------------------------------------------------------------------------- @@ -21,7 +26,11 @@ import { test("parseVersion parses X.Y.Z and rejects garbage", () => { assert.deepEqual(parseVersion("0.4.0"), { major: 0, minor: 4, patch: 0 }); - assert.deepEqual(parseVersion("12.34.56"), { major: 12, minor: 34, patch: 56 }); + assert.deepEqual(parseVersion("12.34.56"), { + major: 12, + minor: 34, + patch: 56, + }); assert.throws(() => parseVersion("1.2"), /unsupported version format/); assert.throws(() => parseVersion("1.2.3-beta.1"), /unsupported version format/); assert.throws(() => parseVersion("v1.2.3"), /unsupported version format/); @@ -78,6 +87,111 @@ test("inferKindFromChangelog: BREAKING -> major, Added -> minor, other -> patch, assert.equal(inferKindFromChangelog(""), null); }); +// --------------------------------------------------------------------------- +// unattended auto-release: worthiness + synthesized notes +// --------------------------------------------------------------------------- + +test("releasableCommits drops merge and release-bookkeeping commits", () => { + const commits = [ + { subject: "feat: real" }, + { subject: "Merge pull request #9 from x" }, + { subject: "chore(release): v1.2.3" }, + { subject: "fix: another" }, + ]; + assert.deepEqual( + releasableCommits(commits).map((c) => c.subject), + ["feat: real", "fix: another"], + ); + assert.deepEqual(releasableCommits(null), []); +}); + +test("isReleasableCommit: feat/fix/perf/breaking yes; docs/chore/test/refactor no", () => { + for (const s of ["feat: x", "fix(scope): y", "perf: z", "feat!: drop", "refactor(core)!: rename"]) + assert.equal(isReleasableCommit({ subject: s }), true, s); + for (const s of ["docs: x", "chore(deps): y", "test: z", "refactor: r", "style: s", "ci: c"]) + assert.equal(isReleasableCommit({ subject: s }), false, s); + assert.equal( + isReleasableCommit({ + subject: "chore: migrate", + body: "BREAKING CHANGE: config moved", + }), + true, + "body BREAKING CHANGE counts", + ); +}); + +test("synthesizeChangelog groups user-facing commits into Keep-a-Changelog sections", () => { + const body = synthesizeChangelog([ + { subject: "feat: add stack detection" }, + { subject: "fix(cli): quiet crash" }, + { subject: "perf: faster impact" }, + { subject: "docs: tidy readme" }, // excluded — not user-facing + { subject: "chore(release): v0.1.0" }, // excluded — noise + { subject: "not a conventional subject" }, // excluded — non-conventional + ]); + assert.match(body, /### Added\n\n- add stack detection/); + assert.match(body, /### Fixed\n\n- quiet crash/); + assert.match(body, /### Changed\n\n- faster impact/); + assert.doesNotMatch(body, /tidy readme/); + assert.doesNotMatch(body, /conventional subject/); + // deterministic section order: Added before Changed before Fixed + assert.ok(body.indexOf("### Added") < body.indexOf("### Changed")); + assert.ok(body.indexOf("### Changed") < body.indexOf("### Fixed")); +}); + +test("synthesizeChangelog flags breaking changes and dedupes", () => { + const body = synthesizeChangelog([ + { subject: "feat!: drop node 18" }, + { subject: "feat: same thing" }, + { subject: "feat: same thing" }, // dup + ]); + assert.match(body, /- \*\*BREAKING\*\* drop node 18/); + assert.equal(body.match(/- same thing/g).length, 1, "duplicate collapsed"); +}); + +test("synthesizeChangelog is empty when nothing is user-facing", () => { + assert.equal(synthesizeChangelog([{ subject: "docs: x" }, { subject: "chore: y" }]), ""); + assert.equal(synthesizeChangelog([]), ""); +}); + +test("changelogBody never returns empty for a worthy release (breaking change in a non-conventional subject)", () => { + // A squash-merge title GitHub capitalizes, with BREAKING CHANGE in the body: worthy, but + // synthesizeChangelog can't parse the subject. changelogBody must still yield a real body, + // or the auto-release would crash rotateChangelog and fail CI on a legit breaking change. + const commits = [ + { + subject: "Fix login redirect (#42)", + body: "BREAKING CHANGE: renamed cookie", + }, + ]; + assert.equal(synthesizeChangelog(commits), "", "synthesize alone can't handle it"); + assert.ok(isReleasableCommit(commits[0]), "but it IS release-worthy (breaking body)"); + const body = changelogBody(commits); + assert.ok(body.trim().length > 0, "changelogBody is never empty for a worthy release"); + assert.match(body, /### Changed\n\n- Fix login redirect \(#42\)/); + // and it rotates cleanly instead of throwing the empty-[Unreleased] error + const cl = `# Changelog\n\n## [Unreleased]\n\n## [0.4.0] - 2026-01-01\n\n- old\n`; + assert.doesNotThrow(() => + rotateChangelog(setUnreleasedBody(cl, body), "0.5.0", "0.4.0", "2026-07-11"), + ); +}); + +test("changelogBody prefers the synthesized conventional sections when available", () => { + const body = changelogBody([{ subject: "feat: add x" }, { subject: "fix: y" }]); + assert.match(body, /### Added\n\n- add x/); + assert.match(body, /### Fixed\n\n- y/); +}); + +test("setUnreleasedBody fills an empty [Unreleased] and rotateChangelog then accepts it", () => { + const empty = "# Changelog\n\n## [Unreleased]\n\n## [0.4.0] - 2026-01-01\n\n- old\n"; + const filled = setUnreleasedBody(empty, "### Added\n\n- a new thing"); + assert.match(filled, /## \[Unreleased\]\n\n### Added\n\n- a new thing/); + assert.match(filled, /## \[0\.4\.0\] - 2026-01-01/); + // the previously-empty body now rotates without the "empty" guard firing + const rotated = rotateChangelog(filled, "0.5.0", "0.4.0", "2026-07-11"); + assert.match(rotated, /## \[0\.5\.0\] - 2026-07-11\n\n### Added\n\n- a new thing/); +}); + // --------------------------------------------------------------------------- // CHANGELOG rotation // --------------------------------------------------------------------------- @@ -157,7 +271,9 @@ function makeFixture() { name: "fixture", version: "0.4.0", lockfileVersion: 3, - packages: { "": { name: "fixture", version: "0.4.0", engines: { node: ">=20" } } }, + packages: { + "": { name: "fixture", version: "0.4.0", engines: { node: ">=20" } }, + }, }; w("package-lock.json", `${JSON.stringify(lock, null, 2)}\n`); w(".claude-plugin/plugin.json", '{\n "name": "fixture",\n "version": "0.4.0"\n}\n'); diff --git a/test/docs_check.test.js b/test/docs_check.test.js index 9a94417..c3a637a 100644 --- a/test/docs_check.test.js +++ b/test/docs_check.test.js @@ -6,6 +6,7 @@ import { test } from "node:test"; import { COMMANDS } from "../src/commands.js"; import { docsCheck, envVarsRead } from "../src/docs_check.js"; import { TOOLS } from "../src/mcp_tools.js"; +import { MODELS } from "../src/model_tiers.js"; // A fixture tree whose docs are generated FROM the real registries, so it passes by // construction — each test then breaks exactly one claim and asserts the reconciler @@ -115,6 +116,136 @@ test("docsCheck: an undocumented MCP tool is flagged", () => { assert.ok(r.issues.some((i) => i.check === "mcp-tools" && i.detail.includes(TOOLS[0].name))); }); +test("docsCheck: a branded mermaid diagram with
    passes; diagrams is a checked dimension", () => { + const good = + "```mermaid\n%%{init: {'theme':'base'}}%%\nflowchart LR\n A[\"a
    b\"] --> B[\"c\"]\n```\n"; + const r = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "ARCHITECTURE.md": `${f["ARCHITECTURE.md"]}\n${good}`, + })), + }); + assert.deepEqual(r.issues, []); + assert.ok(r.checked.includes("diagrams")); +}); + +test("docsCheck: an unstyled mermaid diagram (no branded theme) is flagged", () => { + const bad = "```mermaid\nflowchart LR\n A --> B\n```\n"; + const root = fixtureRoot((f) => ({ + ...f, + "ARCHITECTURE.md": `${f["ARCHITECTURE.md"]}\n${bad}`, + })); + const r = docsCheck({ root }); + assert.ok(r.issues.some((i) => i.check === "diagrams" && /no branded.*theme/.test(i.detail))); +}); + +test("docsCheck: a mermaid node with a literal \\n is flagged (renders as garbage on GitHub)", () => { + const bad = "```mermaid\n%%{init: {'theme':'base'}}%%\nflowchart LR\n A[\"x\\ny\"] --> B\n```\n"; + const root = fixtureRoot((f) => ({ + ...f, + "docs/GUIDE.md": `${f["docs/GUIDE.md"]}\n${bad}`, + })); + const r = docsCheck({ root }); + assert.ok(r.issues.some((i) => i.check === "diagrams" && /literal.*\\n/.test(i.detail))); +}); + +test("docsCheck: a price matching no model is flagged; a real model price passes", () => { + const haiku = MODELS.haiku; + const bad = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "docs/GUIDE.md": `${f["docs/GUIDE.md"]}\nRoutes to ${haiku.name} ($999/$5 per M tok).\n`, + })), + }); + assert.ok( + bad.issues.some( + (i) => i.check === "model-tiers" && /\$999\/\$5.*matches no model/.test(i.detail), + ), + "a stale price no model has is flagged", + ); + const good = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "docs/GUIDE.md": `${f["docs/GUIDE.md"]}\nRoutes to ${haiku.name} ($${haiku.inCost}/$${haiku.outCost} per M tok).\n`, + })), + }); + assert.deepEqual(good.issues, [], "the real price reconciles clean"); +}); + +test("docsCheck: a README benchmark number with no measured row is flagged", () => { + const bench = + "# Benchmarks\n\n| comp | scenario | median | p95 | runs | notes |\n|---|---|---|---|---|---|\n| atlas | build | 42 ms | 50 ms | 5 | ok |\n"; + const bad = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "reports/benchmarks.md": bench, + "README.md": `${f["README.md"]}\nThe gate runs in **999 ms** flat.\n`, + })), + }); + assert.ok( + bad.issues.some((i) => i.check === "benchmarks" && i.detail.includes("999 ms")), + "unmeasured claim flagged", + ); + const good = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "reports/benchmarks.md": bench, + "README.md": `${f["README.md"]}\nThe build runs in **42 ms** flat.\n`, + })), + }); + assert.deepEqual(good.issues, [], "a claim backed by a measured row reconciles clean"); +}); + +test("docsCheck: model price attributes to the NEAREST name, not first-in-registry (no comparison false positive)", () => { + const h = MODELS.haiku; + const s = MODELS.sonnet; + // A comparison sentence naming two models near ONE price: the price is Sonnet's and correct. + const r = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "docs/GUIDE.md": `${f["docs/GUIDE.md"]}\n${s.name} costs more than ${h.name}: $${s.inCost}/$${s.outCost} per M tok.\n`, + })), + }); + assert.deepEqual( + r.issues.filter((i) => i.check === "model-tiers"), + [], + "a correct price next to two model names must not false-positive on the farther model", + ); +}); + +test("docsCheck: a bolded non-benchmark ms sandwich does not false-positive", () => { + const bench = + "# Benchmarks\n\n| c | s | median | p95 | runs | notes |\n|---|---|---|---|---|---|\n| atlas | build | 42 ms | 50 ms | 5 | ok |\n"; + // A closing ** and the next opening ** must not pair to capture the plain "250 ms" between. + const r = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "reports/benchmarks.md": bench, + "README.md": `${f["README.md"]}\nThe **fast path** completes within a 250 ms budget and **holds** steady.\n`, + })), + }); + assert.deepEqual( + r.issues.filter((i) => i.check === "benchmarks"), + [], + "a number in plain prose between two bold runs is not a benchmark claim", + ); +}); + +test("docsCheck: a mermaid example marked docs-check-ignore is skipped", () => { + const bad = "\n\n```mermaid\nflowchart LR\n A --> B\n```\n"; + const r = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "ARCHITECTURE.md": `${f["ARCHITECTURE.md"]}\n${bad}`, + })), + }); + assert.deepEqual( + r.issues.filter((i) => i.check === "diagrams"), + [], + "an intentional example diagram opts out of the theme rule", + ); +}); + test("docsCheck: empty release sections and version mismatch are flagged", () => { const root = fixtureRoot((f) => ({ ...f, diff --git a/test/pages.test.js b/test/pages.test.js index dc8faf9..9d1f963 100644 --- a/test/pages.test.js +++ b/test/pages.test.js @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { test } from "node:test"; +import { fileURLToPath } from "node:url"; import { collect, render } from "../scripts/build-pages.mjs"; +const repo = (rel) => readFileSync(fileURLToPath(new URL(`../${rel}`, import.meta.url)), "utf8"); +const landing = repo("landing/index.html"); + test("pages renderer uses repo data and accessible landmarks", async () => { const data = await collect({ live: false }); const html = render(data); @@ -12,6 +17,56 @@ test("pages renderer uses repo data and accessible landmarks", async () => { assert.doesNotMatch(html, /lorem ipsum/i); }); +// One design system, enforced. The landing page and the generated status page must not +// drift apart the way they had (two palettes claiming to be one, a webfont that never +// loaded, an empty changes list, hardcoded metrics). Each assertion below is a defect that +// silently returned before nothing checked it. + +test("landing + status share the SAME core brand tokens (one palette, not two)", async () => { + const status = render(await collect({ live: false })); + // The warm ember/near-black set both pages must carry verbatim. + for (const hex of ["#171310", "#201a15", "#272019", "#372c22", "#f26430", "#f2ede7"]) { + assert.ok(landing.includes(hex), `landing page is missing shared token ${hex}`); + assert.ok(status.includes(hex), `status page is missing shared token ${hex}`); + } +}); + +test("landing declares no webfont it fails to load (no phantom Inter)", () => { + const sans = landing.match(/--sans:\s*([^;]+);/)?.[1] ?? ""; + assert.ok(sans.includes("system-ui"), "landing --sans should be a system stack"); + // If the CSS names a webfont family, it must actually load it (@font-face / ). + if (/\bInter\b/.test(landing)) { + assert.match(landing, /@font-face|rel=["']?stylesheet/, "Inter named but never loaded"); + } +}); + +test("status page 'Latest changes' list is never empty", async () => { + const status = render(await collect({ live: false })); + const list = status.match(/Latest repo changes<\/h2>[\s\S]*?
      ([\s\S]*?)<\/ul>/); + assert.ok(list, "the changes section renders"); + const items = [...list[1].matchAll(/
    • ([\s\S]*?)<\/li>/g)]; + assert.ok(items.length > 0, "at least one change is listed"); + // Guard against the truncation bug (fragments ended mid-word with a trailing "," or an + // unclosed "`") without demanding terminal punctuation, which would be its own brittle FP. + for (const [, li] of items) { + const t = li.trim(); + assert.ok(t.length > 15 && !/[,`]$/.test(t), `looks truncated: ${t}`); + } +}); + +test("landing benchmark metrics are numbers reports/benchmarks.md actually measured", () => { + const measured = new Set(); + for (const line of repo("reports/benchmarks.md").split("\n")) { + if (!line.startsWith("|")) continue; + for (const m of line.matchAll(/(\d+(?:\.\d+)?)\s*(ms|µs|s)\b/g)) + measured.add(`${m[1]} ${m[2]}`); + } + const metrics = [...landing.matchAll(/\s*(\d+(?:\.\d+)?)\s*ms\s*<\/b/g)]; + assert.ok(metrics.length > 0, "landing states at least one ms metric"); + for (const [, n] of metrics) + assert.ok(measured.has(`${n} ms`), `landing claims ${n} ms but no benchmark row measures it`); +}); + test("pages optional integration can validate live GitHub data", async (t) => { if (process.env.RUN_INTEGRATION !== "1") { t.skip("set RUN_INTEGRATION=1 to hit GitHub API");