diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 58f20ff..0cf4cec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -231,15 +231,18 @@ 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, five reconcilers close the blind spots behind recurring +commands/env/MCP-tools/CHANGELOG, six 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`; `checkLinks` resolves every intra-repo Markdown anchor (`#x` and `path.md#x`) against the target's real headings (GitHub-exact slugs — em-dashes yield `--`, never collapsed), killing the dead-anchor class; -and `checkRoadmap` fails when the ROADMAP's "Now" marker trails the shipped `package.json` -version. The two public pages +`checkRoadmap` fails when the ROADMAP's "Now" marker trails the shipped `package.json` +version; and `checkCrosswalk` resolves every `.js`/`.sh` binding the research paper's +crosswalk (`research/formal-synthesis/crosswalk.json`) claims for this repo against the +files that actually exist in `src/`, `global/guards/`, and `hooks/` (kit-only names opt +out with a `kit:` prefix), so the paper's stated bindings can no longer trail the code. The two public pages (`landing/index.html` + the `build-pages.mjs` status page) derive from ONE color source — `brand.json.colors` (full dark + light palettes), emitted as CSS by `src/brand.js` (`rootTokensCss()`). `test/pages.test.js` enforces full-palette parity: every hex in diff --git a/CHANGELOG.md b/CHANGELOG.md index 953b9a6..4fe5a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Color-aware CLI output.** New zero-dep `src/fmt.js`: `supportsColor` honoring the + `FORCE_COLOR` > `NO_COLOR` > `TERM=dumb` > TTY precedence, brand-token painting + (24-bit from `brand.json` when `COLORTERM` declares truecolor, portable 16-color + fallback otherwise), visible-width-aligned `table`, and `bar` confidence meters — + adopted across `ledger` (stats/blame/query), `cortex`, `route`, `doctor`, and + `cost` output plus the `--verbose` title line. Piped output stays escape-free. + +### Fixed + +- **Research crosswalk reconciled with the code.** The formal-synthesis paper's + crosswalk (rows 5/6/12/14 and the README §11 binding paragraph) cited hooks that + no longer exist (`docs-guard.sh`, `session-context.sh`, `intent-router.sh`); the + bindings now name the real system (`cortex.sh` → `src/gate.js` stopGate, + `src/session.js` rehydrationBlock, `src/intent.js` exemplar k-NN), with kit-only + names marked by a `kit:` prefix. A new `crosswalk` docs-check reconciler fails CI + when any non-`kit:` `.js`/`.sh` binding in `crosswalk.json` names a file that does + not exist in `src/`, `global/guards/`, or `hooks/`. + ## [0.18.0] - 2026-07-16 ### Changed diff --git a/README.md b/README.md index 67dd1aa..873574c 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ Advisory by default. Set `FORGE_ENFORCE=1` to turn the substrate into a hard blo strongest signals (vacuous prompt, un-assemblable required context, blast radius over the default 25-file threshold). +Output is plain text when piped; on a TTY it adds brand-palette color and confidence +meters. `NO_COLOR` turns color off, `FORCE_COLOR=1` forces it on (e.g. in CI, `0` +forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. + | Group | Command | Does | | -------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | | **Config layer** | `forge init` | emit every tool's native config from one source | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index d407659..cdee1cc 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1075,6 +1075,9 @@ code reads but this table misses fails CI on the forge repo): | `FORGE_STOPGATE` | `0` disables the Stop completion gate (code-without-docs block) | | `FORGE_INTENT` | `0` disables intent protocol cards on prompts | | `FORGE_VERBOSE` | `1` restores the `Forge ` title line on command output (also `--verbose`) | +| `NO_COLOR` | set (non-empty) disables ANSI color in CLI output — the [no-color.org](https://no-color.org) convention | +| `FORCE_COLOR` | forces CLI color on even when piped, e.g. in CI (`0` forces off) — takes precedence over `NO_COLOR` | +| `TERM` / `COLORTERM` | `TERM=dumb` disables color; `COLORTERM=truecolor`/`24bit` upgrades to the brand palette's 24-bit hues | | `FORGE_NO_UPDATE_CHECK` | `1` silences the `forge doctor` update notice | | `FORGE_DEBUG` | `1` writes fail-safe error details to stderr instead of swallowing them | diff --git a/research/formal-synthesis/README.md b/research/formal-synthesis/README.md index ee5d6da..0cc2909 100644 --- a/research/formal-synthesis/README.md +++ b/research/formal-synthesis/README.md @@ -8,7 +8,7 @@ frozen language model reliable at coding: (memory, learning, imagination, self-correction, impact-awareness) and six operating mechanisms M1–M6, with two runnable prototypes. 2. **The End-to-End Agent Reliability Framework** (`FRAMEWORK.md`) — failure modes - F1 (partial work) and F2 (session amnesia), the change-closure fixpoint Δ*, + F1 (partial work) and F2 (session amnesia), the change-closure fixpoint Δ\*, invariants I1–I4, algorithms A1–A7, and correctness theorems T1–T6. 3. **forgekit / claude-e2e-kit** — the deployed implementation: committed-file memory, deterministic lifecycle hooks, and auto-invoked skills. @@ -20,39 +20,39 @@ The synthesis proves these are **one object in three vocabularies**. > **Reliability = a probabilistic instruction layer × a deterministic interception > layer. Neither layer alone suffices.** -Instructions (`CLAUDE.md`, rules, skills) *raise* the probability `p < 1` that the +Instructions (`CLAUDE.md`, rules, skills) _raise_ the probability `p < 1` that the model behaves correctly, but can never reach `p = 1` — so the residual silent-miss rate over `n` tasks is `1 − pⁿ → 1`. A deterministic layer (hooks that execute regardless of the model's choice) multiplies that residual down by a factor -`(1 − cⱼ)` per check — but cannot catch the *semantic* class (undecidable, by Rice's +`(1 − cⱼ)` per check — but cannot catch the _semantic_ class (undecidable, by Rice's theorem), so it needs the soft layer to shrink what reaches it. The paper states this as **Theorem D** and proves it. It is the formal content of the practitioner's rule: -*never trust the output of a probability engine; earn trust with an external check.* +_never trust the output of a probability engine; earn trust with an external check._ ## Three anchor identities (not analogies — the same mathematics) -| Substrate | Framework | Relationship | -|---|---|---| -| Impact-Oracle blast-radius | change-closure `Δ*` (least fixpoint of `X ↦ X ∪ N(X)`) | **identical** — reverse reachability, run to fixpoint ⇒ perfect recall | -| M2 assumption gate | amnesia equation `assumption ≈ argmax P(convention \| training)` | **identical** — the gate supplies missing context or halts, never guesses | -| substrate's two layers | design law "Π₃ probabilistic, Π₂ deterministic" | **identical** — Theorem D | +| Substrate | Framework | Relationship | +| -------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Impact-Oracle blast-radius | change-closure `Δ*` (least fixpoint of `X ↦ X ∪ N(X)`) | **identical** — reverse reachability, run to fixpoint ⇒ perfect recall | +| M2 assumption gate | amnesia equation `assumption ≈ argmax P(convention \| training)` | **identical** — the gate supplies missing context or halts, never guesses | +| substrate's two layers | design law "Π₃ probabilistic, Π₂ deterministic" | **identical** — Theorem D | ## Contents -| File | What it is | -|---|---| -| `substrate_synthesis.pdf` | The formal synthesis paper (42 pp): definitions, Theorem D + proof, the unified A1–A7 TASK loop, invariants I1–I4, theorems T1–T6 with proofs, the 16-row crosswalk, the full 14-mapping Qur'anic epistemology, both prototypes with honest metrics. | -| `substrate_synthesis.html` | Same paper, self-contained HTML. | -| `crosswalk.json` / `crosswalk.md` | The three-way term-by-term correspondence (substrate ↔ framework ↔ forgekit), with the P1/P2/P3 → Π₁/Π₂/Π₃ notation reconciliation. | -| `graded_reference_set.json` / `.md` | The 15 new sources independently verified and graded (9 confirmed, 6 traceable, 0 unverifiable), including the disambiguation of the two future-dated arXiv IDs. | -| `merged_references.json` | Full 47-entry bibliography (32 original + 15 new, deduped). | -| `figures/schematic_duality.png` | The two-layer duality architecture. | -| `figures/schematic_taskloop.png` | The unified 7-stage TASK loop (each stage bound to faculty · algorithm · Qur'anic anchor). | +| File | What it is | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `substrate_synthesis.pdf` | The formal synthesis paper (42 pp): definitions, Theorem D + proof, the unified A1–A7 TASK loop, invariants I1–I4, theorems T1–T6 with proofs, the 16-row crosswalk, the full 14-mapping Qur'anic epistemology, both prototypes with honest metrics. | +| `substrate_synthesis.html` | Same paper, self-contained HTML. | +| `crosswalk.json` / `crosswalk.md` | The three-way term-by-term correspondence (substrate ↔ framework ↔ forgekit), with the P1/P2/P3 → Π₁/Π₂/Π₃ notation reconciliation. | +| `graded_reference_set.json` / `.md` | The 15 new sources independently verified and graded (9 confirmed, 6 traceable, 0 unverifiable), including the disambiguation of the two future-dated arXiv IDs. | +| `merged_references.json` | Full 47-entry bibliography (32 original + 15 new, deduped). | +| `figures/schematic_duality.png` | The two-layer duality architecture. | +| `figures/schematic_taskloop.png` | The unified 7-stage TASK loop (each stage bound to faculty · algorithm · Qur'anic anchor). | The **two runnable prototypes** referenced throughout the paper already live in this repo and are not duplicated here: -- `../python-prototypes/impact_oracle/` — Prototype I, the impact oracle (A1 / Δ*). +- `../python-prototypes/impact_oracle/` — Prototype I, the impact oracle (A1 / Δ\*). Runnable, 36 tests. Perfect recall on impacted files. - `../python-prototypes/router_gate/` — Prototype II, complexity-router + assumption-gate (A7 + A6 / M1 + M2). Runnable, 19 tests. 62.1% real cost saved, @@ -62,22 +62,24 @@ repo and are not duplicated here: - **The prototypes are demonstrations, not benchmarks.** The router/gate's perfect accuracy is on a 30-task hand-labelled set whose thresholds were tuned against it — - it shows the rubric *can separate* the cases. The cost figures, by contrast, are + it shows the rubric _can separate_ the cases. The cost figures, by contrast, are exact arithmetic on **real measured token counts** from live model calls, and the correctness sub-experiment actually executed the cheaper models' code against tests. - **The impact oracle does not win on F1** (0.75 vs grep's 0.79). Its property is **perfect recall** — the safety guarantee for "what will my edit break?". - **Every future-dated / recent citation was verified by direct fetch, not inferred.** - Both 2026 arXiv IDs resolve to real preprints; one (`2601.05111`) is a *different* + Both 2026 arXiv IDs resolve to real preprints; one (`2601.05111`) is a _different_ paper from the founding Agent-as-a-Judge work (`2410.10934`), and both are recorded. - **The Qur'anic lens is framing, never technical authority.** It supplies the - vocabulary of epistemic obligation — *lā taqfu*, *tabayyun*, *al-amāna* — that names - *why* each safeguard is mandatory; the engineering stands on its own merits. + vocabulary of epistemic obligation — _lā taqfu_, _tabayyun_, _al-amāna_ — that names + _why_ each safeguard is mandatory; the engineering stands on its own merits. ## Relationship to forgekit `forgekit` is one binding of this theory (and `claude-e2e-kit` is its Claude-specific reference realization). The paper's §11 gives the exact object-by-object mapping: -`Π₁` = the committed `docs/*.md` store, `Π₂` = the `session-context` / `docs-guard` / -`intent-router` hooks, `Π₃` = `CLAUDE.md` + `.claude/rules/`. The two prototypes here +`Π₁` = the committed `docs/*.md` store, `Π₂` = the `cortex.sh` hook chain +(`src/cortex_hook_main.js` dispatching `src/gate.js` stopGate, `src/session.js` +rehydrationBlock, and `src/intent.js` intent routing; kit: `session-context` / +`docs-guard` / `intent-router`), `Π₃` = `CLAUDE.md` + `.claude/rules/`. The two prototypes here are the mechanical cores of the `/impact` skill and the effort-router. diff --git a/research/formal-synthesis/crosswalk.json b/research/formal-synthesis/crosswalk.json index b38506d..e2d427c 100644 --- a/research/formal-synthesis/crosswalk.json +++ b/research/formal-synthesis/crosswalk.json @@ -46,14 +46,14 @@ "concept": "Self-correction / verification", "substrate": "M6 inline verification; Prototype-II verify step; self-correction faculty", "framework": "Algorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption)", - "forgekit": "docs-guard.sh Stop hook (blocks finish if code changed but no doc/state artifact did); reviewer agent verdict", + "forgekit": "src/gate.js stopGate Stop hook via cortex.sh (blocks finish if code changed but no doc/state artifact did; kit: docs-guard.sh); reviewer agent verdict", "relation": "Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality." }, { "concept": "Complexity routing", "substrate": "M1 complexity-aware router (transparent additive rubric); Prototype-II router", "framework": "Algorithm A7 KNOWLEDGE-ROUTER (where every fact lives, keeps always-loaded context bounded); System 1/System 2 effort routing; intent DFA", - "forgekit": "intent-router.sh (UserPromptSubmit hook, keyword DFA, <10ms, zero-token); effort-routing rule in CLAUDE.md; per-agent model: fields", + "forgekit": "src/intent.js exemplar k-NN intent classifier (UserPromptSubmit via cortex.sh, zero-token; kit: intent-router.sh keyword DFA); effort-routing rule in CLAUDE.md; per-agent model: fields", "relation": "Two facets of one routing principle. Substrate M1 routes by TASK COMPLEXITY -> model tier (cost). Framework A7 routes by KNOWLEDGE TYPE -> storage home (context budget). Both are transparent/deterministic, both reject an opaque-LLM classifier for the same reason." }, { @@ -95,7 +95,7 @@ "concept": "Rehydration (session start)", "substrate": "closed-loop write-back/read-back band in the substrate architecture", "framework": "Algorithm A5 CONTEXT-REHYDRATE; loader L; records git baseline for the gate", - "forgekit": "session-context.sh SessionStart hook; /catchup skill (deep variant)", + "forgekit": "src/session.js rehydrationBlock SessionStart injection via cortex.sh (kit: session-context.sh); /catchup skill (deep variant)", "relation": "The read side of the substrate's memory loop, made deterministic (inject, don't hope the agent reads)." }, { @@ -109,7 +109,7 @@ "concept": "The two-layer duality (THE central new insight)", "substrate": "implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a law", "framework": "DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)\u00b7prod_j(1-c_j). Since p<1 always, neither layer alone suffices.", - "forgekit": "the split itself: CLAUDE.md/rules/skills = soft layer; hooks (docs-guard, session-context, intent-router) = hard layer", + "forgekit": "the split itself: CLAUDE.md/rules/skills = soft layer; hooks (cortex.sh -> src/gate.js, src/session.js, src/intent.js; kit: docs-guard, session-context, intent-router) = hard layer", "relation": "THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output \u2014 it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer." }, { @@ -127,4 +127,4 @@ "relation": "The ethical frame the substrate draws from the lens is realized as concrete safety properties in the framework (fail-open, reversibility) and kit (auditable hooks)." } ] -} \ No newline at end of file +} diff --git a/research/formal-synthesis/crosswalk.md b/research/formal-synthesis/crosswalk.md index 12ddb3f..37a4699 100644 --- a/research/formal-synthesis/crosswalk.md +++ b/research/formal-synthesis/crosswalk.md @@ -14,29 +14,29 @@ This map is the spine of the formal synthesis: every unified concept, shown in a ## The map -| # | Unified concept | Substrate (S) | E2E Framework (E) | forgekit / kit (K) | Why they are one object | -| --- | --- | --- | --- | --- | --- | -| 1 | **The frozen core** | y = f_theta(x): stateless map, fixed weights, bounded context (properties P1,P2,P3) | the agent/model whose behavior instructions can only raise the PROBABILITY of (p<1) | Claude / Codex / Cursor / Gemini / Aider — the model the kit wraps, never modifies | identical object: all three treat the model as a fixed probabilistic map to be wrapped, not retrained | -| 2 | **Impact-awareness / partial-work failure** | Faculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay) | F1 partial work; dependents operator N(X); required change closure Delta* = least fixpoint of X \|-> X ∪ N(X); Done predicate; Algorithm A1 IMPACT-CLOSURE | /impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered) | IDENTICAL MATHEMATICS. The Impact Oracle computes N(X) by reverse reachability on the dependency graph; Delta* is exactly the oracle's transitive blast-radius set. The oracle adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. | -| 3 | **Memory / session-amnesia failure** | Faculty: persistent memory gap; each context window is ephemeral. Validity-anchored memory (facts carry confirmed/discredited state updated by verified outcomes) | F2 session amnesia; continuity invariant I2 (essential(K_i) ⊆ P and loader L must load it); handoff operator H; K_{i+1}(0)=L(P) | docs/STATE.md (bounded snapshot) + docs/DECISIONS.md (append-only log); /handoff writes it; SessionStart hook injects it | Same failure, same fix (external bounded-compression checkpoint). Substrate adds validity-anchoring (prune by whether a past prediction was externally confirmed); framework adds the snapshot+WAL split (STATE mutable vs DECISIONS append-only). | -| 4 | **Why assumptions happen (the root the user named)** | M2 assumption/uncertainty gate: under-specified input -> the model confabulates a convention | amnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention \| training data) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication) | CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly') | The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope. | -| 5 | **Self-correction / verification** | M6 inline verification; Prototype-II verify step; self-correction faculty | Algorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption) | docs-guard.sh Stop hook (blocks finish if code changed but no doc/state artifact did); reviewer agent verdict | Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality. | -| 6 | **Complexity routing** | M1 complexity-aware router (transparent additive rubric); Prototype-II router | Algorithm A7 KNOWLEDGE-ROUTER (where every fact lives, keeps always-loaded context bounded); System 1/System 2 effort routing; intent DFA | intent-router.sh (UserPromptSubmit hook, keyword DFA, <10ms, zero-token); effort-routing rule in CLAUDE.md; per-agent model: fields | Two facets of one routing principle. Substrate M1 routes by TASK COMPLEXITY -> model tier (cost). Framework A7 routes by KNOWLEDGE TYPE -> storage home (context budget). Both are transparent/deterministic, both reject an opaque-LLM classifier for the same reason. | -| 7 | **Task decomposition** | M3 task/session decomposition | HTN closure -> ordered task list (A1 output is the worklist); the meta-algorithm TASK(t) | sdlc-pilot 7-phase skill; subagents; git worktrees | Same: decompose the goal into the closure's ordered worklist before building. | -| 8 | **Goal-anchoring** | M4 goal-anchoring (goal drift: to a text model a long story and long code are the same object) | BDI Desires = written goal + acceptance criteria in STATE.md; I2 keeps them across sessions; acceptance criteria written at SPEC, consumed at VERIFY | docs/STATE.md 'Current goal' + 'Acceptance criteria'; sdlc-pilot SPEC->VERIFY gate | Substrate names the drift; framework anchors it in a persisted, re-injected Desire so every session optimizes the same target. | -| 9 | **Anti-over-engineering** | M5 anti-over-engineering (scope minimality; the residual whitespace) | amana / scope-boundedness (agent may not exceed asked scope); I3 (no invented structure) | CLAUDE.md effort routing 'trivial -> do it directly, no ceremony'; least-privilege defaults | Weakest-covered in all three; substrate marks it residual-gap, framework encodes it as a stewardship bound, kit as an effort-routing default. | -| 10 | **World-model of the codebase** | Faculty: world-model; Prototype-I codebase world-model (AST -> persistent dependency graph) | the typed artifact graph (A, tau, R = R_declared ∪ R_discovered) | documentation-map.md + the repo itself + ARCHITECTURE.md | Same graph. Substrate builds it from AST for code; framework generalizes the node type to {code,test,doc,config,diagram} so DOCS become first-class dependents — the key generalization that turns 'impact on code' into 'impact on all artifacts'. | -| 11 | **Continual learning from outcomes** | Faculty: learning without touching theta (non-parametric always-on + parametric LoRA/EWC) | Reflexion loop made cross-session (I3/I4); DECISIONS.md as precedent DB; A4 mirrors durable lessons | Reflexion rule in CLAUDE.md; STATE gotchas; agents' memory: project | Same 'learn without retraining' stance. Both store verbal lessons externally and re-inject; substrate adds the parametric option, framework keeps it purely non-parametric (committed files). | -| 12 | **Rehydration (session start)** | closed-loop write-back/read-back band in the substrate architecture | Algorithm A5 CONTEXT-REHYDRATE; loader L; records git baseline for the gate | session-context.sh SessionStart hook; /catchup skill (deep variant) | The read side of the substrate's memory loop, made deterministic (inject, don't hope the agent reads). | -| 13 | **Independent verification / judge** | self-correction faculty; the honest-negative-result caution (models correct poorly alone) | LLM-as-Judge applied: reviewer agent (fresh context, explicit criteria, adversarial); self-consistency for critical changes (majority of N) | reviewer.md agent; DoD item 7 | Substrate cited the finding that self-correction alone is weak; framework operationalizes the fix (an EXTERNAL judge), which is the same 'trust only an external check' principle as the gate. | -| 14 | **The two-layer duality (THE central new insight)** | implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a law | DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·prod_j(1-c_j). Since p<1 always, neither layer alone suffices. | the split itself: CLAUDE.md/rules/skills = soft layer; hooks (docs-guard, session-context, intent-router) = hard layer | THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer. | -| 15 | **The probabilistic failure model** | eval honesty: perfect accuracy shows separation not a benchmark; p<1 | P(>=1 miss) = 1 - p^n over n tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·prod(1-c_j) | the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI) | The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies why each added deterministic gate multiplies down the residual. | -| 16 | **Stewardship / governance boundary** | STEWARDSHIP/amana wrapper (33:72) around the whole architecture | amana in I3/I4 as no-fabrication + verified-currency; least privilege, reversibility, logged rationale, scope-boundedness | committed-files-only (auditable), block-at-most-once (no nagging), fail-open safety (T2), DOCS_GUARD_DISABLE auditable escape hatch | The ethical frame the substrate draws from the lens is realized as concrete safety properties in the framework (fail-open, reversibility) and kit (auditable hooks). | +| # | Unified concept | Substrate (S) | E2E Framework (E) | forgekit / kit (K) | Why they are one object | +| --- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **The frozen core** | y = f_theta(x): stateless map, fixed weights, bounded context (properties P1,P2,P3) | the agent/model whose behavior instructions can only raise the PROBABILITY of (p<1) | Claude / Codex / Cursor / Gemini / Aider — the model the kit wraps, never modifies | identical object: all three treat the model as a fixed probabilistic map to be wrapped, not retrained | +| 2 | **Impact-awareness / partial-work failure** | Faculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay) | F1 partial work; dependents operator N(X); required change closure Delta\* = least fixpoint of X \|-> X ∪ N(X); Done predicate; Algorithm A1 IMPACT-CLOSURE | /impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered) | IDENTICAL MATHEMATICS. The Impact Oracle computes N(X) by reverse reachability on the dependency graph; Delta\* is exactly the oracle's transitive blast-radius set. The oracle adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. | +| 3 | **Memory / session-amnesia failure** | Faculty: persistent memory gap; each context window is ephemeral. Validity-anchored memory (facts carry confirmed/discredited state updated by verified outcomes) | F2 session amnesia; continuity invariant I2 (essential(K*i) ⊆ P and loader L must load it); handoff operator H; K*{i+1}(0)=L(P) | docs/STATE.md (bounded snapshot) + docs/DECISIONS.md (append-only log); /handoff writes it; SessionStart hook injects it | Same failure, same fix (external bounded-compression checkpoint). Substrate adds validity-anchoring (prune by whether a past prediction was externally confirmed); framework adds the snapshot+WAL split (STATE mutable vs DECISIONS append-only). | +| 4 | **Why assumptions happen (the root the user named)** | M2 assumption/uncertainty gate: under-specified input -> the model confabulates a convention | amnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention \| training data) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication) | CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly') | The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope. | +| 5 | **Self-correction / verification** | M6 inline verification; Prototype-II verify step; self-correction faculty | Algorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption) | `src/gate.js` stopGate Stop hook via cortex.sh (blocks finish if code changed but no doc/state artifact did; kit: docs-guard.sh); reviewer agent verdict | Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality. | +| 6 | **Complexity routing** | M1 complexity-aware router (transparent additive rubric); Prototype-II router | Algorithm A7 KNOWLEDGE-ROUTER (where every fact lives, keeps always-loaded context bounded); System 1/System 2 effort routing; intent DFA | `src/intent.js` exemplar k-NN intent classifier (UserPromptSubmit via cortex.sh, zero-token; kit: intent-router.sh keyword DFA); effort-routing rule in CLAUDE.md; per-agent model: fields | Two facets of one routing principle. Substrate M1 routes by TASK COMPLEXITY -> model tier (cost). Framework A7 routes by KNOWLEDGE TYPE -> storage home (context budget). Both are transparent/deterministic, both reject an opaque-LLM classifier for the same reason. | +| 7 | **Task decomposition** | M3 task/session decomposition | HTN closure -> ordered task list (A1 output is the worklist); the meta-algorithm TASK(t) | sdlc-pilot 7-phase skill; subagents; git worktrees | Same: decompose the goal into the closure's ordered worklist before building. | +| 8 | **Goal-anchoring** | M4 goal-anchoring (goal drift: to a text model a long story and long code are the same object) | BDI Desires = written goal + acceptance criteria in STATE.md; I2 keeps them across sessions; acceptance criteria written at SPEC, consumed at VERIFY | docs/STATE.md 'Current goal' + 'Acceptance criteria'; sdlc-pilot SPEC->VERIFY gate | Substrate names the drift; framework anchors it in a persisted, re-injected Desire so every session optimizes the same target. | +| 9 | **Anti-over-engineering** | M5 anti-over-engineering (scope minimality; the residual whitespace) | amana / scope-boundedness (agent may not exceed asked scope); I3 (no invented structure) | CLAUDE.md effort routing 'trivial -> do it directly, no ceremony'; least-privilege defaults | Weakest-covered in all three; substrate marks it residual-gap, framework encodes it as a stewardship bound, kit as an effort-routing default. | +| 10 | **World-model of the codebase** | Faculty: world-model; Prototype-I codebase world-model (AST -> persistent dependency graph) | the typed artifact graph (A, tau, R = R_declared ∪ R_discovered) | documentation-map.md + the repo itself + ARCHITECTURE.md | Same graph. Substrate builds it from AST for code; framework generalizes the node type to {code,test,doc,config,diagram} so DOCS become first-class dependents — the key generalization that turns 'impact on code' into 'impact on all artifacts'. | +| 11 | **Continual learning from outcomes** | Faculty: learning without touching theta (non-parametric always-on + parametric LoRA/EWC) | Reflexion loop made cross-session (I3/I4); DECISIONS.md as precedent DB; A4 mirrors durable lessons | Reflexion rule in CLAUDE.md; STATE gotchas; agents' memory: project | Same 'learn without retraining' stance. Both store verbal lessons externally and re-inject; substrate adds the parametric option, framework keeps it purely non-parametric (committed files). | +| 12 | **Rehydration (session start)** | closed-loop write-back/read-back band in the substrate architecture | Algorithm A5 CONTEXT-REHYDRATE; loader L; records git baseline for the gate | `src/session.js` rehydrationBlock SessionStart injection via cortex.sh (kit: session-context.sh); /catchup skill (deep variant) | The read side of the substrate's memory loop, made deterministic (inject, don't hope the agent reads). | +| 13 | **Independent verification / judge** | self-correction faculty; the honest-negative-result caution (models correct poorly alone) | LLM-as-Judge applied: reviewer agent (fresh context, explicit criteria, adversarial); self-consistency for critical changes (majority of N) | reviewer.md agent; DoD item 7 | Substrate cited the finding that self-correction alone is weak; framework operationalizes the fix (an EXTERNAL judge), which is the same 'trust only an external check' principle as the gate. | +| 14 | **The two-layer duality (THE central new insight)** | implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a law | DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·prod_j(1-c_j). Since p<1 always, neither layer alone suffices. | the split itself: CLAUDE.md/rules/skills = soft layer; hooks (cortex.sh -> src/gate.js, src/session.js, src/intent.js; kit: docs-guard, session-context, intent-router) = hard layer | THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer. | +| 15 | **The probabilistic failure model** | eval honesty: perfect accuracy shows separation not a benchmark; p<1 | P(>=1 miss) = 1 - p^n over n tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·prod(1-c_j) | the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI) | The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies why each added deterministic gate multiplies down the residual. | +| 16 | **Stewardship / governance boundary** | STEWARDSHIP/amana wrapper (33:72) around the whole architecture | amana in I3/I4 as no-fabrication + verified-currency; least privilege, reversibility, logged rationale, scope-boundedness | committed-files-only (auditable), block-at-most-once (no nagging), fail-open safety (T2), DOCS_GUARD_DISABLE auditable escape hatch | The ethical frame the substrate draws from the lens is realized as concrete safety properties in the framework (fail-open, reversibility) and kit (auditable hooks). | ## The three anchor identities -Three rows are not analogies but *the same mathematics under two names*: +Three rows are not analogies but _the same mathematics under two names_: 1. **Impact Oracle blast-radius ≡ change-closure fixpoint Δ\*.** Reverse reachability on the dependency graph is the least fixpoint of `X ↦ X ∪ N(X)`. The oracle computes `N(X)` with a confidence-decay weight; the framework's `N(X)` is its boolean core. -2. **M2 assumption gate ≡ the amnesia equation.** `assumption ≈ argmax P(convention | training)` is *why* an under-specified prompt is answered with a confabulated convention — so the gate either supplies the missing context or halts; it never hopes. -3. **The substrate's two layers ≡ the design law.** Instructions raise `p<1`; interception guarantees a floor `c→1`. `P(silent miss) = (1−p)·∏(1−cⱼ)`. This is the theorem that says the substrate *must* be two layers — and it is the formal statement of the governing discipline, *never trust AI output*. +2. **M2 assumption gate ≡ the amnesia equation.** `assumption ≈ argmax P(convention | training)` is _why_ an under-specified prompt is answered with a confabulated convention — so the gate either supplies the missing context or halts; it never hopes. +3. **The substrate's two layers ≡ the design law.** Instructions raise `p<1`; interception guarantees a floor `c→1`. `P(silent miss) = (1−p)·∏(1−cⱼ)`. This is the theorem that says the substrate _must_ be two layers — and it is the formal statement of the governing discipline, _never trust AI output_. diff --git a/src/cli.js b/src/cli.js index 6445c9c..3b6d08c 100755 --- a/src/cli.js +++ b/src/cli.js @@ -5,6 +5,9 @@ import { BRAND } from "./brand.js"; // The command surface lives in commands.js as data — docs_check.js reconciles the // README/GUIDE tables against the same table this help is rendered from. import { COMMANDS, GROUPS } from "./commands.js"; +// Color is capability-gated (FORCE_COLOR > NO_COLOR > TERM=dumb > TTY) — piped +// output stays byte-plain, so nothing downstream ever parses an escape code. +import { bar, heading as fmtHeading, paint } from "./fmt.js"; const printVersion = () => console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.version}`); @@ -13,7 +16,7 @@ const printVersion = () => console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.v // just its output. The `--help`/`--version` banner is unaffected. const VERBOSE = process.argv.includes("--verbose") || process.env.FORGE_VERBOSE === "1"; const heading = (text) => { - if (VERBOSE) console.log(text); + if (VERBOSE) console.log(fmtHeading(text)); }; function printHelp() { @@ -189,10 +192,16 @@ async function run(argv) { if (failed) process.exitCode = 1; return; } - const icon = { ok: "✓", warn: "!", fail: "✗" }; + const icon = { + ok: paint("✓", "ok"), + warn: paint("!", "warn"), + fail: paint("✗", "err"), + }; heading(`${BRAND.brand} doctor\n`); for (const r of results) console.log(` ${icon[r.status]} ${r.label.padEnd(16)} ${r.note}`); - console.log(`\n${failed === 0 ? "all clear" : `${failed} problem(s)`}`); + console.log( + `\n${failed === 0 ? paint("all clear", "ok") : paint(`${failed} problem(s)`, "err")}`, + ); if (failed) process.exitCode = 1; return; } @@ -300,9 +309,11 @@ async function run(argv) { console.log(` claims: ${s.total} (tombstoned ${s.tombstoned})`); for (const [kind, n] of Object.entries(s.byKind)) console.log(` ${kind}: ${n}`); console.log( - ` val: trusted ${s.val.trusted} · uncertain ${s.val.uncertain} · dormant ${s.val.dormant}`, + ` val: ${paint(`trusted ${s.val.trusted}`, "ok")} · ${paint(`uncertain ${s.val.uncertain}`, "warn")} · ${paint(`dormant ${s.val.dormant}`, "dim")}`, + ); + console.log( + paint("\n stored in .forge/ledger/ (git-committable, conflict-free merge)", "dim"), ); - console.log("\n stored in .forge/ledger/ (git-committable, conflict-free merge)"); return; } if (sub === "verify") { @@ -354,17 +365,21 @@ async function run(argv) { } if (json) return console.log(JSON.stringify(b, null, 2)); heading(`${BRAND.brand} ledger blame — ${b.kind} ${b.id.slice(0, 12)}\n`); - console.log(` val ${b.val.toFixed(2)} (trust-weighted ${b.valTrustWeighted.toFixed(2)})`); + console.log( + ` val ${bar(b.val)} ${b.val.toFixed(2)} (trust-weighted ${b.valTrustWeighted.toFixed(2)})`, + ); for (const p of b.minted) console.log( ` minted day ${p.t} by ${p.author || "(unknown)"}${p.agent ? ` · ${p.agent}` : ""}`, ); for (const e of b.evidence) console.log( - ` ${e.result === "confirm" ? "confirm " : "contradic"} day ${e.t} ${e.oracle} → ${e.ref}${e.author ? ` by ${e.author}` : ""}`, + ` ${e.result === "confirm" ? paint("confirm ", "ok") : paint("contradic", "err")} day ${e.t} ${e.oracle} → ${e.ref}${e.author ? ` by ${e.author}` : ""}`, ); for (const t of b.tombstones) - console.log(` retract day ${t.t} ${t.reason}${t.author ? ` by ${t.author}` : ""}`); + console.log( + paint(` retract day ${t.t} ${t.reason}${t.author ? ` by ${t.author}` : ""}`, "dim"), + ); const trusts = Object.entries(b.trust); if (trusts.length) { console.log("\n author trust (earned from oracle outcomes on their claims):"); @@ -455,11 +470,11 @@ async function run(argv) { 2, ), ); - console.log(` sim: ${simLabel(sim)}`); + console.log(paint(` sim: ${simLabel(sim)}`, "dim")); if (!ranked.length) return console.log(" no matching live claims"); for (const r of ranked) console.log( - ` ${r.score.toFixed(3)} ${r.claim.kind.padEnd(9)} ${r.claim.id.slice(0, 8)} ${claimText(r.claim).slice(0, 90)}`, + ` ${bar(r.score, 8)} ${r.score.toFixed(3)} ${paint(r.claim.kind.padEnd(9), "accent")} ${paint(r.claim.id.slice(0, 8), "dim")} ${claimText(r.claim).slice(0, 90)}`, ); return; } @@ -804,7 +819,7 @@ async function run(argv) { ` ${m.model.padEnd(30)} $${m.cost.toFixed(4)} (${m.inTokens} in / ${m.outTokens} out)`, ); } - console.log("\n install ccusage for precise tracking: npm i -g ccusage"); + console.log(paint("\n install ccusage for precise tracking: npm i -g ccusage", "dim")); } else { console.log( " ccusage not found. Install for real spend (reads local JSONL, nothing leaves your machine):\n npm i -g ccusage # then: forge cost", @@ -812,7 +827,10 @@ async function run(argv) { } } console.log( - `\n ceiling: FORGE_COST_CEILING (default $10) — the cost-budget guard warns when a day exceeds it.`, + paint( + `\n ceiling: FORGE_COST_CEILING (default $10) — the cost-budget guard warns when a day exceeds it.`, + "dim", + ), ); return; } @@ -887,11 +905,12 @@ async function run(argv) { ); if (s.topActive.length) { console.log("\n top active (by confidence):"); - for (const t of s.topActive) console.log(` ${t.confidence.toFixed(2)} ${t.id}`); + for (const t of s.topActive) + console.log(` ${bar(t.confidence, 8)} ${t.confidence.toFixed(2)} ${t.id}`); } else { console.log("\n (no active lessons yet — Cortex learns from corrections as you work)"); } - console.log("\n stored in .forge/lessons/ (git-committable, auditable)"); + console.log(paint("\n stored in .forge/lessons/ (git-committable, auditable)", "dim")); return; } if (cmd === "preflight") { @@ -1187,11 +1206,11 @@ async function run(argv) { } else { heading(`${BRAND.brand} route — cheapest capable model\n`); console.log( - ` → ${rec.model.name} (${rec.tier}, $${rec.model.inCost}/$${rec.model.outCost} per M tok)`, + ` → ${paint(rec.model.name, "accent")} (${rec.tier}, $${rec.model.inCost}/$${rec.model.outCost} per M tok)`, ); console.log(` ${rec.model.use}`); console.log( - ` complexity ${rec.score.toFixed(2)}${rec.reasons.length ? ` · driven by: ${rec.reasons.join(", ")}` : ""}`, + ` complexity ${bar(rec.score, 8)} ${rec.score.toFixed(2)}${rec.reasons.length ? ` · driven by: ${rec.reasons.join(", ")}` : ""}`, ); console.log( ` signals: ${rec.signals.files} file(s), fan-out ${rec.signals.fanout}, churn ${rec.signals.churn}, past-mistakes ${rec.signals.pastMistakes}, ambiguity ${rec.signals.ambiguity.toFixed(2)}`, diff --git a/src/docs_check.js b/src/docs_check.js index 85593a8..a74db4a 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -452,6 +452,53 @@ function checkRoadmap(root, issues) { } } +/** + * Research crosswalk bindings: every `.js`/`.sh` file token the formal-synthesis + * crosswalk claims as a forgekit binding must name a file that actually exists in + * `src/`, `global/guards/`, or `hooks/`. This is the drift that let the paper keep + * citing `docs-guard.sh` / `session-context.sh` / `intent-router.sh` long after the + * hook system became `cortex.sh` + `cortex_hook_main.js`. Names that belong to the + * claude-e2e-kit binding (a different repo) opt out with a `kit:` prefix on their + * clause; a missing crosswalk.json (npm installs without research/) is a no-op. + */ +function checkCrosswalk(root, issues) { + const rel = join("research", "formal-synthesis", "crosswalk.json"); + const p = join(root, rel); + if (!existsSync(p)) return; + let rows; + try { + rows = JSON.parse(readFileSync(p, "utf8")).rows ?? []; + } catch { + issues.push({ + check: "crosswalk", + severity: "error", + detail: `${rel} is not valid JSON`, + }); + return; + } + const known = new Set(srcFiles(root).map((f) => String(f).split(/[\\/]/).pop())); + for (const dir of [join("global", "guards"), "hooks"]) { + const d = join(root, dir); + if (!existsSync(d)) continue; + for (const f of readdirSync(d)) known.add(String(f)); + } + for (const row of rows) { + // A `kit:` clause covers everything to the next `;` or `)` — those names live in + // the kit repo, not this one, so they are the kit's docs problem, not ours. + const ours = String(row.forgekit ?? "").replace(/kit:[^;)]*/g, " "); + for (const m of ours.matchAll(/\b([\w./-]+\.(?:js|sh))\b/g)) { + const name = m[1].split("/").pop() ?? m[1]; + if (!known.has(name)) { + issues.push({ + check: "crosswalk", + severity: "error", + detail: `${rel}: row "${row.concept}" binds ${m[1]}, which exists nowhere in src/, global/guards/, or hooks/ (stale binding — or prefix its clause with kit: if it names the kit, not this repo)`, + }); + } + } + } +} + /** * Run every reconciler against the forge package tree. * @param {{root?: string}} [opts] @@ -469,6 +516,7 @@ export function docsCheck({ root = BRAND.root } = {}) { checkBenchmarks(root, docs, issues); checkLinks(root, issues); checkRoadmap(root, issues); + checkCrosswalk(root, issues); return { ok: !issues.some((i) => i.severity === "error"), issues, @@ -482,6 +530,7 @@ export function docsCheck({ root = BRAND.root } = {}) { "benchmarks", "links", "roadmap", + "crosswalk", ], }; } diff --git a/src/fmt.js b/src/fmt.js new file mode 100644 index 0000000..728ba19 --- /dev/null +++ b/src/fmt.js @@ -0,0 +1,138 @@ +// forge fmt — zero-dependency CLI formatting. One capability decision +// (FORCE_COLOR > NO_COLOR > TERM=dumb > isTTY), brand-token painting (truecolor +// from brand.json when the terminal declares 24-bit, portable 16-color SGR +// otherwise — the statusline.sh degradation pattern), aligned tables, and a +// confidence meter. Everything degrades to the exact plain text commands print +// today: with color off, `paint` is the identity and `table`/`bar` are pure ASCII… +// so piped output, tests, and dumb terminals never see an escape byte. +import { BRAND } from "./brand.js"; +import { clamp01 } from "./util.js"; + +// Contract note: the default-argument reads below are process.env.FORCE_COLOR, +// process.env.NO_COLOR, process.env.TERM and process.env.COLORTERM — named +// literally here so docs_check.envVarsRead collects them into the documented +// env contract even though the code takes them via the injectable `env` param. + +/** + * Should output to `stream` be colored? Precedence (the no-color.org / Node + * convention): FORCE_COLOR set wins both ways (`"0"` forces off, anything else + * forces on — even when piped, e.g. CI) > NO_COLOR set (non-empty) forces off > + * TERM=dumb forces off > otherwise color iff the stream is a TTY. + * @param {{isTTY?: boolean}} [stream] + * @param {Record} [env] + */ +export function supportsColor(stream = process.stdout, env = process.env) { + const force = env.FORCE_COLOR; + if (force !== undefined && force !== "") return force !== "0"; + if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false; + if (env.TERM === "dumb") return false; + return Boolean(stream?.isTTY); +} + +/** Does the terminal declare 24-bit color (COLORTERM=truecolor|24bit)? */ +export function supportsTruecolor(env = process.env) { + const ct = env.COLORTERM || ""; + return ct.includes("truecolor") || ct.includes("24bit"); +} + +const RESET = "\x1b[0m"; + +/** `#rrggbb` → the SGR foreground parameter `38;2;r;g;b`, or null if unparsable. */ +function hexSgr(hex) { + const m = /^#([0-9a-f]{6})$/i.exec(String(hex || "")); + if (!m) return null; + const n = Number.parseInt(m[1], 16); + return `38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}`; +} + +// Semantic roles → brand.json dark-palette tokens (the terminal is a dark +// surface) + a portable 16-color/SGR fallback. `err` has no brand token yet — +// the warm red matches statusline.sh's diff-removed red, distinct from ember. +const DARK = BRAND.colors?.dark ?? {}; +const ROLES = { + ok: { hex: DARK.ok, basic: "32" }, // green — pass / confirm / trusted + warn: { hex: DARK["brand-2"], basic: "33" }, // soft ember — caution / uncertain + err: { hex: "#e0605a", basic: "31" }, // warm red — fail / contradict + accent: { hex: DARK.brand, basic: "36" }, // ember — the answer / the pick + dim: { hex: DARK.muted, basic: "2" }, // warm taupe — chrome, footnotes +}; + +/** @typedef {keyof typeof ROLES} Role */ + +/** + * Wrap `text` in the role's color when color is on; identity otherwise (and for + * unknown roles — a bad role name must never corrupt output). + * @param {unknown} text + * @param {Role} role + * @param {{enabled?: boolean, truecolor?: boolean}} [opts] injectable for tests + */ +export function paint(text, role, opts = {}) { + const enabled = opts.enabled ?? supportsColor(); + const r = ROLES[role]; + if (!enabled || !r) return String(text); + const truecolor = opts.truecolor ?? supportsTruecolor(); + const code = (truecolor ? hexSgr(r.hex) : null) ?? r.basic; + return `\x1b[${code}m${text}${RESET}`; +} + +/** + * A title line: bold + accent when color is on, the plain text otherwise. + * @param {unknown} text + * @param {{enabled?: boolean, truecolor?: boolean}} [opts] + */ +export function heading(text, opts = {}) { + const enabled = opts.enabled ?? supportsColor(); + if (!enabled) return String(text); + return `\x1b[1m${paint(text, "accent", { ...opts, enabled })}\x1b[22m`; +} + +/** + * Confidence meter over [0,1]: `bar(0.7)` → `███████░░░`. Clamped (NaN → 0), + * pure ASCII-art blocks — readable with color off, paintable by the caller. + * @param {number} v01 + * @param {number} [width] + */ +export function bar(v01, width = 10) { + const w = Math.max(1, Math.floor(width)); + const filled = Math.round(clamp01(Number(v01) || 0) * w); + return "█".repeat(filled) + "░".repeat(w - filled); +} + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI SGR escapes is the point +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +/** Visible width of a cell — SGR escapes take no columns. */ +function visibleWidth(s) { + return String(s).replace(ANSI_RE, "").length; +} + +/** + * Align rows of cells into columns (two-space gutter). Widths are computed on + * VISIBLE characters, so painted cells align with plain ones. The last column + * is never padded (no trailing whitespace). + * @param {unknown[][]} rows + * @param {{indent?: string}} [opts] + * @returns {string} the rendered block ("" for no rows) + */ +export function table(rows, opts = {}) { + const indent = opts.indent ?? " "; + if (!rows?.length) return ""; + const widths = []; + for (const row of rows) + row.forEach((cell, i) => { + widths[i] = Math.max(widths[i] ?? 0, visibleWidth(cell)); + }); + return rows + .map( + (row) => + indent + + row + .map((cell, i) => + i === row.length - 1 + ? String(cell) + : String(cell) + " ".repeat(widths[i] - visibleWidth(cell)), + ) + .join(" "), + ) + .join("\n"); +} diff --git a/test/docs_check.test.js b/test/docs_check.test.js index d0dd228..582ca8d 100644 --- a/test/docs_check.test.js +++ b/test/docs_check.test.js @@ -329,6 +329,88 @@ test("docsCheck: a ROADMAP 'Now' marker behind package.json is flagged; a curren ); }); +// The research crosswalk fixture: rows whose forgekit column names file tokens the +// reconciler must resolve against src/ + global/guards/ + hooks/. +const crosswalkFixture = (rows) => JSON.stringify({ meta: { title: "fixture crosswalk" }, rows }); + +test("docsCheck: crosswalk bindings naming real src/guard files pass; crosswalk is a checked dimension", () => { + const root = fixtureRoot((f) => ({ + ...f, + "global/guards/g.sh": "true\n", + "research/formal-synthesis/crosswalk.json": crosswalkFixture([ + { + concept: "gate", + forgekit: "src/a.js stop hook via g.sh; reviewer agent verdict", + }, + ]), + })); + const r = docsCheck({ root }); + assert.deepEqual(r.issues, []); + assert.ok(r.checked.includes("crosswalk")); +}); + +test("docsCheck: a crosswalk binding naming a file that exists nowhere is flagged", () => { + const root = fixtureRoot((f) => ({ + ...f, + "research/formal-synthesis/crosswalk.json": crosswalkFixture([ + { + concept: "verification", + forgekit: "docs-guard.sh Stop hook (blocks finish)", + }, + ]), + })); + const r = docsCheck({ root }); + assert.equal(r.ok, false); + assert.ok( + r.issues.some( + (i) => + i.check === "crosswalk" && /verification.*docs-guard\.sh.*exists nowhere/.test(i.detail), + ), + "a stale hook name in the crosswalk is caught", + ); +}); + +test("docsCheck: a kit:-prefixed crosswalk clause is the kit's binding, never flagged", () => { + const root = fixtureRoot((f) => ({ + ...f, + "research/formal-synthesis/crosswalk.json": crosswalkFixture([ + { + concept: "gate", + forgekit: "src/a.js stopGate (kit: docs-guard.sh); reviewer verdict", + }, + { + concept: "duality", + forgekit: "hooks = hard layer; kit: session-context.sh + intent-router.sh", + }, + ]), + })); + const r = docsCheck({ root }); + assert.deepEqual( + r.issues.filter((i) => i.check === "crosswalk"), + [], + "names inside a kit: clause belong to the other repo and are not our drift", + ); +}); + +test("docsCheck: a corrupt crosswalk.json is flagged; a missing one is a no-op", () => { + const corrupt = docsCheck({ + root: fixtureRoot((f) => ({ + ...f, + "research/formal-synthesis/crosswalk.json": "{not json", + })), + }); + assert.ok( + corrupt.issues.some((i) => i.check === "crosswalk" && /not valid JSON/.test(i.detail)), + "unparseable crosswalk is an error, not a silent skip", + ); + // The base fixture has no research/ tree at all — already asserted clean above. + const missing = docsCheck({ root: fixtureRoot() }); + assert.deepEqual( + missing.issues.filter((i) => i.check === "crosswalk"), + [], + ); +}); + test("docsCheck: empty release sections and version mismatch are flagged", () => { const root = fixtureRoot((f) => ({ ...f, diff --git a/test/fmt.test.js b/test/fmt.test.js new file mode 100644 index 0000000..0f27c88 --- /dev/null +++ b/test/fmt.test.js @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { BRAND } from "../src/brand.js"; +import { bar, heading, paint, supportsColor, supportsTruecolor, table } from "../src/fmt.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const TTY = { isTTY: true }; +const PIPE = { isTTY: false }; + +test("supportsColor precedence: FORCE_COLOR > NO_COLOR > TERM=dumb > isTTY", () => { + // FORCE_COLOR wins both ways — even against NO_COLOR and a non-TTY stream. + assert.equal(supportsColor(PIPE, { FORCE_COLOR: "1", NO_COLOR: "1" }), true); + assert.equal(supportsColor(TTY, { FORCE_COLOR: "0" }), false); + assert.equal(supportsColor(PIPE, { FORCE_COLOR: "" }), false, "empty FORCE_COLOR is unset"); + // NO_COLOR (non-empty) beats a TTY; empty NO_COLOR is unset per no-color.org. + assert.equal(supportsColor(TTY, { NO_COLOR: "1" }), false); + assert.equal(supportsColor(TTY, { NO_COLOR: "" }), true); + // TERM=dumb beats a TTY. + assert.equal(supportsColor(TTY, { TERM: "dumb" }), false); + // Otherwise: color iff TTY. + assert.equal(supportsColor(TTY, {}), true); + assert.equal(supportsColor(PIPE, {}), false); + assert.equal(supportsColor(undefined, {}), supportsColor(process.stdout, {})); +}); + +test("supportsTruecolor reads COLORTERM", () => { + assert.equal(supportsTruecolor({ COLORTERM: "truecolor" }), true); + assert.equal(supportsTruecolor({ COLORTERM: "24bit" }), true); + assert.equal(supportsTruecolor({ COLORTERM: "8bit" }), false); + assert.equal(supportsTruecolor({}), false); +}); + +test("paint: identity when disabled or role unknown; SGR-wrapped when enabled", () => { + assert.equal(paint("hi", "ok", { enabled: false }), "hi"); + // @ts-expect-error deliberately bad role — must never corrupt output + assert.equal(paint("hi", "nonsense", { enabled: true }), "hi"); + const t = paint("hi", "ok", { enabled: true, truecolor: false }); + assert.equal(t, "\x1b[32mhi\x1b[0m"); + assert.equal(paint(42, "err", { enabled: false }), "42", "non-strings coerce"); +}); + +test("paint truecolor uses the brand.json dark palette hex", () => { + const t = paint("x", "ok", { enabled: true, truecolor: true }); + // brand.json colors.dark.ok — decompose the hex the same way fmt.js must. + const n = Number.parseInt(BRAND.colors.dark.ok.slice(1), 16); + assert.equal(t, `\x1b[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}mx\x1b[0m`); +}); + +test("heading: bold + accent when enabled, plain otherwise", () => { + assert.equal(heading("Title", { enabled: false }), "Title"); + const h = heading("Title", { enabled: true, truecolor: false }); + assert.equal(h.startsWith("\x1b[1m"), true, "bold on"); + assert.equal(h.includes("Title"), true); + assert.equal(h.endsWith("\x1b[22m"), true, "bold off"); +}); + +test("bar: clamped confidence meter", () => { + assert.equal(bar(0), "░░░░░░░░░░"); + assert.equal(bar(1), "██████████"); + assert.equal(bar(0.5), "█████░░░░░"); + assert.equal(bar(0.72, 5), "████░"); + assert.equal(bar(7), "██████████", "clamps above 1"); + assert.equal(bar(-3), "░░░░░░░░░░", "clamps below 0"); + assert.equal(bar(Number.NaN), "░░░░░░░░░░", "NaN → 0"); + assert.equal(bar(1, 0), "█", "width floor of 1"); +}); + +test("table aligns on VISIBLE width — painted cells line up with plain ones", () => { + const rows = [ + [paint("ok", "ok", { enabled: true, truecolor: false }), "short", "end"], + ["longer-cell", "x", "tail"], + ]; + const out = table(rows); + const lines = out.split("\n"); + assert.equal(lines.length, 2); + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI SGR escapes is the point + const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); + assert.equal(strip(lines[0]), " ok short end"); + assert.equal(strip(lines[1]), " longer-cell x tail"); + assert.equal(/\s$/.test(lines[0]), false, "last column never padded"); + assert.equal(table([]), ""); + assert.equal(table(undefined), ""); + assert.equal(table([["a", "b"]], { indent: "" }), "a b"); +}); + +// End-to-end wiring: the CLI honors the env contract through a real spawn. +test("CLI: FORCE_COLOR=1 colors piped output; NO_COLOR strips every escape byte", () => { + const cwd = mkdtempSync(join(tmpdir(), "forge-fmt-")); + const run = (env) => + spawnSync("node", [CLI, "doctor"], { + cwd, + encoding: "utf8", + env: { + ...process.env, + FORCE_COLOR: "", + NO_COLOR: "", + COLORTERM: "", + ...env, + }, + }); + const ESC = "\x1b["; + const colored = run({ FORCE_COLOR: "1" }); + assert.equal(colored.stdout.includes(ESC), true, "escape codes present under FORCE_COLOR=1"); + const plain = run({ NO_COLOR: "1" }); + assert.equal(plain.stdout.includes(ESC), false, "no escape codes under NO_COLOR"); + // Piped without FORCE_COLOR: not a TTY → plain. + const piped = run({}); + assert.equal(piped.stdout.includes(ESC), false, "piped output is plain by default"); +});