From 94ae1d6dd52d28510e6a41ebd48f9f541fb9cd07 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:08:22 -0400 Subject: [PATCH 01/39] docs: competitive research, spec, and plan for review config layer --- ...ompetitive-research-greptile-coderabbit.md | 327 +++++ .../2026-06-22-agent-review-config-layer.md | 1204 +++++++++++++++++ ...-06-22-agent-review-config-layer-design.md | 402 ++++++ 3 files changed, 1933 insertions(+) create mode 100644 .claude/docs/competitive-research-greptile-coderabbit.md create mode 100644 docs/superpowers/plans/2026-06-22-agent-review-config-layer.md create mode 100644 docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md diff --git a/.claude/docs/competitive-research-greptile-coderabbit.md b/.claude/docs/competitive-research-greptile-coderabbit.md new file mode 100644 index 0000000000..2c1ad22c0c --- /dev/null +++ b/.claude/docs/competitive-research-greptile-coderabbit.md @@ -0,0 +1,327 @@ +# Greptile & CodeRabbit — Competitive Research + +> Purpose: document what makes Greptile and CodeRabbit best-in-class AI code reviewers, and +> extract concrete features to fold into our `agent-review` command — with a long-term path +> toward a CLI/UI for configuring and interacting with the reviewer. +> +> Research method: fan-out web search across 6 angles → 25 sources fetched → 122 candidate +> claims → 25 adversarially verified (3-vote, need 2/3 to refute). Only confirmed claims appear +> below. Two claims were explicitly **refuted** and are quarantined at the bottom so they don't +> leak into a spec. Verified ~June 2026; vendor config field names should be re-checked against +> live docs before implementation. + +--- + +## TL;DR — the one-paragraph thesis + +Both products beat diff-only linters by combining **deep codebase context** with a +**configurable, learning-based review pipeline**. Greptile's moat is a **graph of the whole +codebase** (parse → connect → store) that it queries during review for cross-file impact +analysis. CodeRabbit's moat is a **richly configurable review surface**: a global strictness +dial, per-path natural-language rules, 50+ bundled linters/SAST tools, NL-defined pre-merge +checks with warn-vs-block gating, and approval-gated feedback learning. Both **learn from team +behavior** over time and both expose **multiple config channels** (dashboard + repo config files ++ — for Greptile — a CLI with a machine-applicable `--agent` output mode). Our reviewer already +has something neither fully advertises (a multi-agent debate/consensus loop), but we lack their +three pillars: a persistent index, a feedback-learning loop, and a declarative config surface. + +--- + +## Part 1 — Greptile (greptile.com) + +### 1.1 Architecture: a semantic graph of the codebase *(confidence: high, 3-0)* + +Greptile's core is not diff-in-context — it's a **complete graph of the codebase**, built via a +three-step pipeline: + +1. **Parse** every file to extract directories, files, functions, classes, variables. +2. **Connect** all elements: function calls, imports, dependencies, variable usage. +3. **Store** the complete graph for instant querying during reviews. + +At review time it does **cross-file impact analysis** — e.g. "finds everywhere `foo()` is +called," "this change will affect 3 files," and identifies callers of changed symbols. The graph +spans **the target repo and adjacent repos**, so impact analysis crosses repo boundaries. + +> Sources: greptile.com/docs/how-greptile-works/graph-based-codebase-context; greptile.com/learning; +> dev.to/pullflow (independent corroboration). + +### 1.2 Retrieval: NL-translation + per-function chunking *(confidence: high, 3-0 / 2-1)* + +Greptile improves semantic retrieval two ways: + +- **Translate code → natural language before embedding.** Raw code and NL queries aren't + semantically similar; embedding an NL *description* of the code scores measurably higher + against NL queries (illustrative example: query-to-description cosine 0.815 vs query-to-code + 0.728, ~12% relative gap). +- **Chunk per-function, not per-file.** Embedding a whole file dilutes similarity with irrelevant + code (0.739 file vs 0.768 the relevant function alone). + +> Caveat: the 12% figure is from a single illustrative example in a Greptile blog, and +> "production uses per-function chunking" is inferred from a research-framed post, not stated as +> shipped behavior. Treat as directionally true, not a benchmark. +> Source: greptile.com/blog/semantic-codebase-search. + +### 1.3 Layered custom-standards configuration *(confidence: high, 3-0)* + +Three **separate** configuration channels, with a clear precedence model: + +- **Dashboard** — org-wide defaults (lower priority). +- **`greptile.json`** — single repo-wide config file (highest priority). +- **`.greptile/` folders** — can live in *any* directory, not just root (`config.json` / + `rules.md` / `files.json`). Greptile walks from repo root down to the changed file's directory, + collecting and **merging every `.greptile/` folder** along the path. This lets each team in a + monorepo own its own config. + +Dashboard and repo-file configs are explicitly described as **separate systems** (rules in config +files do not appear in the dashboard). + +> Sources: greptile.com/docs/code-review/custom-standards, .../greptile-json, .../greptile-config. +> **Refuted (do not adopt as fact):** that Greptile custom rules require glob-scope + high/medium/low +> severity levels (vote 0-3). + +### 1.4 Learning from team behavior *(confidence: high, 3-0)* + +- **Rule auto-suggestion:** after ~10 PRs, Greptile detects consistent patterns and proposes + rules you can **approve, modify, or ignore**. +- **Continuous feedback:** it learns from **reactions (👍/👎), tags, and what gets merged** to + make reviews more relevant over time. Third parties describe thumbs-up/down calibrating + sensitivity. + +> Sources: greptile.com/docs/code-review/custom-standards, /learning, .../first-pr-review. + +### 1.5 CLI with agent-applyable output *(confidence: high, 3-0)* + +- `npm i -g greptile` → `greptile review` runs a full review on the **local feature-branch diff** + from the terminal (~60s vendor estimate), producing **severity-rated findings, suggested fixes, + and an auto-generated PR summary** — described as identical to its GitHub/GitLab bot output. +- **`greptile review --agent`** emits every finding as **raw text + a suggested fix** for an + autonomous coding agent to apply automatically. Listed agents: Claude Code, OpenAI Codex, + Cursor, Devin, Conductor. + +> This is the single most relevant feature for our CLI/UI vision — a terminal front-end whose +> output a coding agent can consume and auto-apply. +> Sources: greptile.com/cli, greptile.com/docs/code-review/greptile-cli. + +--- + +## Part 2 — CodeRabbit (coderabbit.ai) + +### 2.1 Global severity/verbosity dial *(confidence: high, 3-0)* + +A single `profile` config field scales how much and how pedantic the feedback is: + +- `chill` — lighter feedback (default). +- `assertive` — more feedback, may feel nitpicky. +- `followup` — assertive **plus** tracks whether prior comments were addressed. + +> One knob to scale strictness across the whole review. +> Source: docs.coderabbit.ai/reference/configuration. + +### 2.2 Per-path natural-language rules *(confidence: high, 3-0)* + +`path_instructions`: an array mapping **file glob/minimatch patterns** → **free-text review +guidance** (max 20,000 chars per entry). E.g. `**/*.tsx` → "every user-visible string must use +`t()`." + +> Sources: docs.coderabbit.ai/reference/configuration, .../configuration/path-instructions. + +### 2.3 50+ bundled linters / SAST tools *(confidence: high, 3-0)* + +A `tools` config object wraps **50+ third-party analyzers** — Ruff, ESLint, Biome, Semgrep, +OpenGrep, TruffleHog, Checkov, Trivy, Gitleaks, Brakeman, OSV-Scanner, etc. — **almost all +enabled by default**, many accepting a `config_file` path so existing `.eslintrc`/`pyproject.toml` +configs are reused. The deterministic tool output **feeds the LLM reviewer** rather than replacing +it. + +> Lesson: don't reinvent static analysis — wrap existing OSS tools as pluggable inputs to the LLM. +> Sources: docs.coderabbit.ai/reference/configuration, docs.coderabbit.ai/tools/. + +### 2.4 Feedback learning with scope + approval gate *(confidence: high, 3-0)* + +A `learnings` config (under `knowledge_base`): + +- **scope:** `local` (repo) / `global` (org) / `auto` (local for public repos, global for private). +- **`approval_delay`:** 0–30 days for an admin to approve/reject a learning before it auto-applies. + `0` = apply immediately, no approval. + +> The approval gate is the key idea — learnings don't silently reshape reviews; a human ratifies them. +> Source: docs.coderabbit.ai/reference/configuration. + +### 2.5 Agentic pre-merge checks (warn vs block) *(confidence: high, 3-0)* + +Teams define **org-specific policies in natural language** and CodeRabbit enforces them. Example +checks: no sensitive data in logs, no hardcoded `*_SECRET`/`_KEY`/`_PASSWORD`, DB migrations must +have `up()`/`down()`, breaking API/CLI/env/schema changes must be documented. + +- Configurable via **web UI or committed `.coderabbit.yaml`**. +- Run in **warning mode or error mode** — introduce a guardrail as a warning, then promote it to a + **merge blocker** (error-blocking requires the Request Changes workflow). Enables *gradual* + enforcement. + +> Sources: coderabbit.ai/blog/pre-merge-checks..., docs.coderabbit.ai/pr-reviews/pre-merge-checks, +> .../custom-checks. +> **Refuted (do not adopt as fact):** the specific "five built-in checks" list (80% docstring +> threshold, PR title/description validation, linked-issue verification, issue alignment) — vote 0-3. + +### 2.6 Auto-detect rules from existing agent config files *(confidence: high, 3-0)* + +CodeRabbit ingests existing AI-agent instruction files as review guidelines, so teams don't +re-author rules: + +- Cursor `**/.cursorrules`, `**/.cursor/rules/*` +- Copilot `.github/copilot-instructions.md` +- Cline `**/.clinerules/*` +- Windsurf `**/.windsurfrules` +- **Claude `**/CLAUDE.md`** (plus `AGENTS.md` / `GEMINI.md`) + +> Directly relevant: we already have a rich `CLAUDE.md` + `.claude/rules/code-review.md`. This +> pattern says: treat those as the canonical rule source the reviewer auto-loads. +> Sources: coderabbit.ai/blog/code-guidelines..., docs.coderabbit.ai/knowledge-base/code-guidelines. + +--- + +## Part 3 — Side-by-side + +| Dimension | Greptile | CodeRabbit | Our `agent-review` today | +|---|---|---|---| +| **Codebase context** | Persistent semantic **graph** (parse/connect/store), cross-repo impact analysis | Agentic retrieval + multi-repo analysis | Per-run `grep` + dependency `grep` in Stage 1B — no persistent index | +| **Retrieval** | NL-translation + per-function embeddings | Linter/SAST output + LLM | Diff + full-file reads + ad-hoc grep | +| **Review engine** | LLM over graph queries | LLM + 50+ tools + checks | **7 specialist agents + debate/rebuttal/consensus** (we're ahead here) | +| **Severity control** | (rule suggestions) | Global `profile` dial (chill/assertive/followup) | Per-finding 1–10 severity; consensus thresholds | +| **Custom rules** | Dashboard + `greptile.json` + per-dir `.greptile/` | `path_instructions` (glob→NL) | `.claude/rules/code-review.md` (one static file) | +| **Learning** | Auto-suggest rules after ~10 PRs; learn from reactions/merges | `learnings` scope + approval gate | **None** — cold start every run | +| **Enforcement gating** | — | warn → error (merge blocker) | Report only; no gating | +| **Linters/SAST** | — | 50+ wrapped tools | None bundled (relies on repo's `yarn lint`) | +| **Config channels** | Dashboard + repo files | Web UI + `.coderabbit.yaml` | bash args + markdown rules | +| **CLI/agent output** | `greptile review` + `--agent` machine-applyable | PR bot + chat (`@coderabbitai`) | Slash command only; produces `/tmp` fix scripts | +| **Surface** | PR bot + CLI + dashboard | PR inline comments + chat + dashboard | Local report + metrics dashboard files | + +--- + +## Part 4 — What to adopt, prioritized + +Ordered by leverage-to-effort for our existing command. + +### Tier 1 — high leverage, fits what we already have + +1. **Auto-load `CLAUDE.md` + `.claude/rules/` as canonical rules (CodeRabbit 2.6).** + We already do this informally. Make it explicit and the *single source of truth* the agents + load — the substrate a future UI/CLI edits. Cheapest win. + +2. **A global strictness profile (CodeRabbit 2.1).** + Add a `chill | standard | assertive` dial that scales finding volume and the consensus + severity threshold. Maps cleanly onto our existing `quick/standard/deep` modes — extend rather + than replace. + +3. **Per-path NL rules (CodeRabbit 2.2).** + Generalize `code-review.md`'s pattern lists into glob→instruction entries + (`**/*.tsx` → UX rules, `pages/api/**` → security rules). This *already exists implicitly* in + our Agent Triggers section; formalize it into a parseable structure. + +4. **Wrap existing OSS linters/SAST as agent inputs (CodeRabbit 2.3).** + Run `yarn lint`, `yarn lint:ts`, and a SAST pass (e.g. Semgrep) *first*, then feed results to + the agents so they reason about real findings instead of re-deriving them. High signal, low cost. + +### Tier 2 — the persistent-index and learning gaps (bigger build) + +5. **Cross-file impact analysis on changed symbols (Greptile 1.1).** + Even without a full graph DB: for each changed exported symbol, find callers across the repo + (we already do a crude version in Stage 1B) and feed the **affected call sites** to the + Architecture/Data agents as required context. A `ts-morph`/LSP-backed call graph would make + this real. + +6. **A persistent codebase index (Greptile 1.1–1.2).** + The biggest differentiator. Build/maintain an index (per-function NL summaries + embeddings) + so agents retrieve relevant prior art instead of grepping cold each run. Incremental updates on + push. This is the foundation for the "chat with the reviewer" UX later. + +7. **Feedback learning with approval gating (Greptile 1.4 + CodeRabbit 2.4).** + Persist accept/dismiss outcomes per finding; after N PRs, mine recurring dismissals/accepts into + *proposed* rules that a human approves before they affect future reviews. Scope them repo-vs-org. + The **approval gate is non-negotiable** — never let learnings silently reshape reviews. + +### Tier 3 — enforcement & distribution + +8. **Warn-vs-block enforcement gating (CodeRabbit 2.5).** + Let a rule be a soft warning first, then promote to a merge blocker once the team trusts it. + Pairs naturally with our consensus severity scores. + +9. **A `--agent` machine-applyable output mode (Greptile 1.5).** + We already emit `/tmp/automated_fixes/*.sh`. Add a structured JSON/text mode (finding + file + + suggested patch) that a coding agent (Claude Code) can consume and apply directly — the bridge + to our CLI vision. + +--- + +## Part 5 — Long-term CLI / UI vision (phased) + +The goal: configure and interact with the reviewer the way Greptile (CLI) and CodeRabbit +(dashboard + chat) do. A pragmatic path that reuses our multi-agent engine: + +**Phase A — declarative config + CLI front-end.** +Replace bash-arg config with a committed config file (org defaults + repo file + per-path rules, +à la Greptile's layered model). Wrap the slash command in a thin CLI: `review` (local branch +diff), `review --agent` (machine-applyable output), `config` (edit rules). This phase is mostly +restructuring what we already have. + +**Phase B — persistent index + impact analysis.** +Stand up the codebase index (Tier-2 #5/#6) so the CLI answers "what does this change affect?" and +agents retrieve real prior art. Add incremental re-index on commit. + +**Phase C — learning loop + enforcement.** +Persist feedback, propose approval-gated rules, add warn→block gating. Now the reviewer improves +over time and can guard merges. + +**Phase D — interactive UI.** +A dashboard (and/or chat) over the same engine: view findings, react (👍/👎 → feeds learning), +approve suggested rules, toggle the strictness profile and per-path rules, watch the agent +debate. This is where the index + learning + config from earlier phases pay off — the UI is just +a surface over them. + +> Key architectural principle from both products: **config, index, and learning are separate, +> persistent layers; the review run and the UI/CLI are thin surfaces over them.** Our current +> design bakes everything into one bash-orchestrated run. Splitting those layers is the real work +> behind the vision. + +--- + +## Part 6 — Caveats & open questions + +**Caveats (don't overclaim from this research):** +- **Pricing is unanswered.** No pricing claim survived adversarial voting — zero confirmed + findings on Greptile/CodeRabbit tiers or metering. Needs a fresh, direct check of pricing pages. +- Most Greptile architecture detail comes from its **own docs/blog** — authoritative for "what it + does," not independently benchmarked. The "~60s CLI" and "identical-to-PR-bot quality" are + vendor estimates; the "~12% similarity gap" / per-function benefit come from one illustrative + example. +- **CodeRabbit's internal review orchestration** (whether it's explicitly multi-agent/multi-pass, + and how it sequences linters → LLM → checks) was *not* captured in surviving claims. Notably, + our debate/consensus loop may already be more sophisticated than its public architecture. +- Config field names (`profile`, `path_instructions`, `learnings`, `tools`) evolve — re-verify + against live docs before building. CodeRabbit's pre-merge checks shipped ~Sept/Oct 2025. + +**Refuted claims (quarantined — do NOT carry into a spec):** +- ❌ Greptile custom rules require glob-scope + high/medium/low severity levels (vote 0-3). +- ❌ CodeRabbit has exactly five built-in pre-merge checks with an 80% docstring threshold, + PR title/description validation, linked-issue verification, issue alignment (vote 0-3). + +**Open questions worth a follow-up pass:** +1. Actual pricing tiers and metering (per repo / PR / seat) for both. +2. CodeRabbit's internal pipeline — multi-agent or multi-pass? How are tool outputs sequenced? +3. How Greptile keeps the graph fresh (full re-index vs incremental) and indexing cost on large monorepos. +4. Measured false-positive rates and how reactions/learnings quantitatively change finding volume over time. + +--- + +## Sources (primary, by claim) + +- **Greptile graph/architecture:** greptile.com/docs/how-greptile-works/graph-based-codebase-context +- **Greptile retrieval:** greptile.com/blog/semantic-codebase-search +- **Greptile config:** greptile.com/docs/code-review/custom-standards, .../greptile-json, .../greptile-config +- **Greptile learning:** greptile.com/learning, .../first-pr-review +- **Greptile CLI:** greptile.com/cli, greptile.com/docs/code-review/greptile-cli +- **CodeRabbit config (profile/path_instructions/tools/learnings):** docs.coderabbit.ai/reference/configuration, .../configuration/path-instructions, docs.coderabbit.ai/tools/ +- **CodeRabbit pre-merge checks:** coderabbit.ai/blog/pre-merge-checks-built-in-and-custom-pr-enforced, docs.coderabbit.ai/pr-reviews/pre-merge-checks, .../custom-checks +- **CodeRabbit code-guideline auto-detect:** coderabbit.ai/blog/code-guidelines-bring-your-coding-rules-to-coderabbit, docs.coderabbit.ai/knowledge-base/code-guidelines + +_Generated from a verified deep-research pass (~June 2026). 23 confirmed claims, 2 refuted, pricing unverified._ diff --git a/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md b/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md new file mode 100644 index 0000000000..5bad87b091 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md @@ -0,0 +1,1204 @@ +# Agent-Review Config Layer (Phase A) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the machine-readable parts of the MPDX reviewer (risk scoring, agent definitions, triggers, exclusions) into a declarative `.claude/review/config.yml`, backed by a tested, standalone Node engine the `agent-review` command consumes — with no regression and room reserved for the index/learning layers. + +**Architecture:** A standalone Node ESM engine under `.claude/review/engine/` parses + validates `config.yml` (against `config.schema.json`), scores risk, selects agents, and resolves rule docs. A CLI entry (`plan.mjs`) takes the diff manifest the command already gathers and emits JSON. The Claude Code command (`agent-review.md`) calls `plan.mjs` in Stage 0–1 instead of hardcoded bash; the debate/consensus stages are untouched. Prose guidance moves to `rules/*.md` referenced by glob. + +**Tech Stack:** Node 22 (ESM `.mjs`), `node:test` + `node:assert/strict`, `yaml`, `minimatch`, `ajv`. App stays Next.js/TS/Jest (untouched by the engine). + +## Global Constraints + +- Engine is **plain Node ESM** (`.mjs`), NOT TypeScript — no transpile step; the command invokes it with `node`. It lives under `.claude/review/` and is outside the app's `tsconfig`/Jest scope. +- Engine tests run via `node --test`, NOT Jest. Add a `test:review` script for them. +- New deps `yaml`, `minimatch`, `ajv` are **devDependencies** (tooling, not shipped in the app bundle). +- Glob matching uses `minimatch(path, glob, { dot: true })` everywhere (must match `.claude/**`, `.github/**`). +- Behavior-preservation: the engine must reproduce sane, expected risk scores + agent selections on the representative fixtures (Tasks 3, 4, 7). Debate/rebuttal/consensus stages of `agent-review.md` are NOT modified. +- Dev Node version: **22.14.0** (per CLAUDE.md). `node:test` and ESM JSON via `fs.readFileSync` are assumed available. +- Package manager: **yarn** (never npm). +- Source of truth being migrated: `.claude/rules/code-review.md` and `.claude/commands/agent-review.md`. + +--- + +### Task 1: Scaffold review core, add deps, and JSON Schema + +**Files:** +- Create: `.claude/review/config.schema.json` +- Create: `.claude/review/engine/schema.test.mjs` +- Modify: `package.json` (add devDeps + `test:review` script) +- Create dirs: `.claude/review/rules/`, `.claude/review/engine/` (via the files above) + +**Interfaces:** +- Produces: `config.schema.json` — the canonical config contract used by `loadConfig` (Task 2) and a future UI. + +- [ ] **Step 1: Add devDependencies and the engine test script** + +Run: +```bash +yarn add -D yaml minimatch ajv +``` +Then add to `package.json` `scripts` (keep alphabetical-ish with siblings): +```json +"test:review": "node --test .claude/review/engine/" +``` + +- [ ] **Step 2: Write `config.schema.json` (the complete contract)** + +Create `.claude/review/config.schema.json`: +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mpdx.org/review/config.schema.json", + "title": "MPDX Agent-Review Config", + "type": "object", + "additionalProperties": false, + "required": ["version", "profile", "risk", "agents", "excluded_paths"], + "properties": { + "version": { "type": "integer", "enum": [1] }, + "profile": { "type": "string", "enum": ["chill", "standard", "assertive"] }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": ["patterns", "volume_multiplier", "scope_multiplier", "special", "levels"], + "properties": { + "patterns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["glob", "points"], + "properties": { + "glob": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "tier": { "type": "string", "enum": ["critical", "high", "medium", "low"] } + } + } + }, + "volume_multiplier": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["upTo", "points"], + "properties": { + "upTo": { "type": ["integer", "null"] }, + "points": { "type": "integer", "minimum": 0 } + } + } + }, + "scope_multiplier": { + "type": "object", + "additionalProperties": { "type": "number" } + }, + "special": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["when", "points"], + "properties": { + "when": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "packages": { "type": "array", "items": { "type": "string" } } + } + } + }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["range", "level", "reviewer"], + "properties": { + "range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "type": ["integer", "null"] } + }, + "level": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, + "reviewer": { "type": "string" } + } + } + } + } + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "enabled": { "type": "boolean" }, + "always": { "type": "boolean" }, + "model": { "type": "string", "enum": ["smart", "opus", "sonnet", "haiku"] }, + "triggers": { + "type": "object", + "additionalProperties": false, + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "content": { "type": "array", "items": { "type": "string" } } + } + }, + "rules": { "type": "array", "items": { "type": "string" } } + } + } + }, + "path_rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["paths", "rules"], + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "rules": { "type": "array", "items": { "type": "string" } } + } + } + }, + "excluded_paths": { "type": "array", "items": { "type": "string" } }, + "index": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" } + } + }, + "learning": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" }, + "approval_required": { "type": "boolean" }, + "scope": { "type": "string", "enum": ["local", "global"] } + } + }, + "enforcement": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "type": "string", "enum": ["warn", "block"] } + } + } + } +} +``` + +- [ ] **Step 3: Write the failing test (schema must compile under ajv)** + +Create `.claude/review/engine/schema.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import Ajv from 'ajv'; + +const schemaPath = fileURLToPath(new URL('../config.schema.json', import.meta.url)); + +test('config.schema.json is a valid, compilable JSON Schema', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); // throws if the schema itself is malformed + assert.equal(typeof validate, 'function'); +}); +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `yarn test:review` +Expected: PASS — `schema.test.mjs` reports 1 passing test. (If `ajv.compile` throws, the schema is malformed — fix it.) + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/config.schema.json .claude/review/engine/schema.test.mjs package.json yarn.lock +git commit -m "feat(review): scaffold config engine deps + JSON schema" +``` + +--- + +### Task 2: Config loader + validator (`loadConfig.mjs`) + +**Files:** +- Create: `.claude/review/engine/loadConfig.mjs` +- Create: `.claude/review/engine/loadConfig.test.mjs` + +**Interfaces:** +- Consumes: `config.schema.json` (Task 1). +- Produces: + - `parseConfig(yamlText: string) -> object` — YAML → JS object. + - `validateConfig(configObj: object, schemaObj: object) -> { valid: boolean, errors: string[] }`. + - `loadConfig({ configPath: string, schemaPath: string }) -> object` — reads, parses, validates; throws `Error` (message = joined errors) when invalid; returns the config object when valid. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/loadConfig.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parseConfig, validateConfig } from './loadConfig.mjs'; + +const schema = JSON.parse( + readFileSync(fileURLToPath(new URL('../config.schema.json', import.meta.url)), 'utf8'), +); + +const MINIMAL = ` +version: 1 +profile: standard +risk: + patterns: [{ glob: "src/**", points: 1, tier: medium }] + volume_multiplier: [{ upTo: null, points: 0 }] + scope_multiplier: { single_feature: 1.0 } + special: [] + levels: [{ range: [0, null], level: LOW, reviewer: entry }] +agents: [{ id: standards, always: true }] +excluded_paths: [] +`; + +test('parseConfig parses YAML to an object', () => { + const cfg = parseConfig(MINIMAL); + assert.equal(cfg.version, 1); + assert.equal(cfg.agents[0].id, 'standards'); +}); + +test('validateConfig accepts a valid config', () => { + const { valid, errors } = validateConfig(parseConfig(MINIMAL), schema); + assert.equal(valid, true, errors.join('; ')); +}); + +test('validateConfig rejects a bad profile enum', () => { + const bad = parseConfig(MINIMAL.replace('profile: standard', 'profile: nope')); + const { valid, errors } = validateConfig(bad, schema); + assert.equal(valid, false); + assert.ok(errors.some((e) => e.includes('profile')), errors.join('; ')); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — cannot import `parseConfig`/`validateConfig` (module not found / not exported). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/loadConfig.mjs`: +```js +import { readFileSync } from 'node:fs'; +import { parse } from 'yaml'; +import Ajv from 'ajv'; + +export function parseConfig(yamlText) { + return parse(yamlText); +} + +export function validateConfig(configObj, schemaObj) { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schemaObj); + const valid = validate(configObj); + const errors = valid + ? [] + : (validate.errors || []).map((e) => `${e.instancePath || '(root)'} ${e.message}`); + return { valid, errors }; +} + +export function loadConfig({ configPath, schemaPath }) { + const configObj = parseConfig(readFileSync(configPath, 'utf8')); + const schemaObj = JSON.parse(readFileSync(schemaPath, 'utf8')); + const { valid, errors } = validateConfig(configObj, schemaObj); + if (!valid) { + throw new Error(`Invalid review config (${configPath}):\n- ${errors.join('\n- ')}`); + } + return configObj; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — all `loadConfig.test.mjs` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/engine/loadConfig.mjs .claude/review/engine/loadConfig.test.mjs +git commit -m "feat(review): config loader + ajv validation" +``` + +--- + +### Task 3: Risk scorer (`scoreRisk.mjs`) + +**Files:** +- Create: `.claude/review/engine/scoreRisk.mjs` +- Create: `.claude/review/engine/scoreRisk.test.mjs` + +**Interfaces:** +- Consumes: a config object (shape from Task 1 schema). +- Produces: + - `scoreRisk({ files: string[], linesChanged: number, scope?: string, special?: string[] }, config) -> { score, level, reviewer, factors }` + where `factors = { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }`. + - Helpers (exported for reuse): `isExcluded(file, config)`, `patternPoints(file, config)`, `volumePoints(linesChanged, config)`, `levelFor(score, config)`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/scoreRisk.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { scoreRisk } from './scoreRisk.mjs'; + +const config = { + risk: { + patterns: [ + { glob: 'src/lib/apollo/{client,link,cache,ssrClient}.ts', points: 3, tier: 'critical' }, + { glob: 'pages/api/Schema/**/*.{ts,graphql}', points: 2, tier: 'high' }, + { glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }, + { glob: '**/*.test.{ts,tsx}', points: 0, tier: 'low' }, + ], + volume_multiplier: [ + { upTo: 50, points: 0 }, + { upTo: 200, points: 1 }, + { upTo: 500, points: 2 }, + { upTo: 1000, points: 3 }, + { upTo: null, points: 4 }, + ], + scope_multiplier: { single_feature: 1.0, cross_cutting: 1.7 }, + special: [{ when: 'critical_pkg_update', points: 3 }], + levels: [ + { range: [0, 3], level: 'LOW', reviewer: 'entry' }, + { range: [4, 6], level: 'MEDIUM', reviewer: 'entry' }, + { range: [7, 9], level: 'HIGH', reviewer: 'experienced' }, + { range: [10, null], level: 'CRITICAL', reviewer: 'Caleb Cox (senior)' }, + ], + }, + excluded_paths: ['**/*.snap'], +}; + +test('UI-only small change scores LOW', () => { + const r = scoreRisk({ files: ['src/components/Tasks/TaskRow.tsx'], linesChanged: 30 }, config); + assert.equal(r.factors.patternScore, 1); + assert.equal(r.factors.volumeScore, 0); + assert.equal(r.score, 1); + assert.equal(r.level, 'LOW'); +}); + +test('cross-cutting Apollo + Schema + pkg update scores CRITICAL', () => { + const r = scoreRisk( + { + files: ['src/lib/apollo/cache.ts', 'src/lib/apollo/link.ts', 'pages/api/Schema/Foo/resolvers.ts'], + linesChanged: 600, + scope: 'cross_cutting', + special: ['critical_pkg_update'], + }, + config, + ); + assert.equal(r.factors.patternScore, 8); // 3 + 3 + 2 + assert.equal(r.factors.volumeScore, 3); // 600 -> upTo 1000 + assert.equal(r.factors.specialScore, 3); + assert.equal(r.factors.subtotal, 14); + assert.equal(r.score, 24); // round(14 * 1.7) + assert.equal(r.level, 'CRITICAL'); +}); + +test('excluded files do not contribute to score', () => { + const r = scoreRisk({ files: ['Foo.test.snap.snap', '__snapshots__/x.snap'], linesChanged: 10 }, config); + assert.equal(r.factors.patternScore, 0); + assert.equal(r.score, 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — `scoreRisk.mjs` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/scoreRisk.mjs`: +```js +import { minimatch } from 'minimatch'; + +const OPTS = { dot: true }; + +export function isExcluded(file, config) { + return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); +} + +export function patternPoints(file, config) { + let max = 0; + for (const p of config.risk.patterns) { + if (minimatch(file, p.glob, OPTS)) max = Math.max(max, p.points); + } + return max; +} + +export function volumePoints(linesChanged, config) { + for (const v of config.risk.volume_multiplier) { + if (v.upTo === null || linesChanged <= v.upTo) return v.points; + } + return 0; +} + +export function levelFor(score, config) { + for (const l of config.risk.levels) { + const [min, max] = l.range; + if (score >= min && (max === null || score <= max)) return l; + } + return config.risk.levels[config.risk.levels.length - 1]; +} + +export function scoreRisk({ files, linesChanged, scope = 'single_feature', special = [] }, config) { + const reviewed = files.filter((f) => !isExcluded(f, config)); + const patternScore = reviewed.reduce((s, f) => s + patternPoints(f, config), 0); + const volumeScore = volumePoints(linesChanged, config); + const specialMap = new Map(config.risk.special.map((s) => [s.when, s.points])); + const specialScore = special.reduce((s, k) => s + (specialMap.get(k) || 0), 0); + const subtotal = patternScore + volumeScore + specialScore; + const scopeMultiplier = config.risk.scope_multiplier[scope] ?? 1.0; + const score = Math.round(subtotal * scopeMultiplier); + const lvl = levelFor(score, config); + return { + score, + level: lvl.level, + reviewer: lvl.reviewer, + factors: { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — all `scoreRisk.test.mjs` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/engine/scoreRisk.mjs .claude/review/engine/scoreRisk.test.mjs +git commit -m "feat(review): config-driven risk scorer" +``` + +--- + +### Task 4: Agent selector (`selectAgents.mjs`) + +**Files:** +- Create: `.claude/review/engine/selectAgents.mjs` +- Create: `.claude/review/engine/selectAgents.test.mjs` + +**Interfaces:** +- Consumes: a config object. +- Produces: + - `selectAgents({ files: string[], diffText: string }, config) -> Array<{ id, model, matchedBy }>` + - `agentMatches(agent, files: string[], diffText: string) -> string | null` (the match reason, or null). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/selectAgents.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { selectAgents } from './selectAgents.mjs'; + +const config = { + excluded_paths: ['**/*.snap'], + agents: [ + { id: 'architecture', always: true }, + { id: 'testing', always: true }, + { id: 'standards', always: true }, + { id: 'security', triggers: { paths: ['pages/api/**'], content: ['process.env.'] } }, + { id: 'ux', model: 'opus', triggers: { paths: ['src/components/**/*.tsx'] } }, + { id: 'financial', enabled: false, triggers: { paths: ['src/components/Reports/**'] } }, + ], +}; + +test('UI-only change selects always-on agents + ux', () => { + const sel = selectAgents({ files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+ const x = 1;' }, config); + const ids = sel.map((a) => a.id).sort(); + assert.deepEqual(ids, ['architecture', 'standards', 'testing', 'ux']); + assert.equal(sel.find((a) => a.id === 'ux').model, 'opus'); +}); + +test('content trigger selects security via process.env', () => { + const sel = selectAgents({ files: ['src/lib/foo.ts'], diffText: '+ const k = process.env.SECRET;' }, config); + assert.ok(sel.some((a) => a.id === 'security' && a.matchedBy === 'content:process.env.')); +}); + +test('disabled agent never selected', () => { + const sel = selectAgents({ files: ['src/components/Reports/Report.tsx'], diffText: '' }, config); + assert.ok(!sel.some((a) => a.id === 'financial')); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — `selectAgents.mjs` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/selectAgents.mjs`: +```js +import { minimatch } from 'minimatch'; + +const OPTS = { dot: true }; + +export function agentMatches(agent, files, diffText) { + if (agent.always) return 'always'; + const t = agent.triggers || {}; + for (const f of files) { + for (const g of t.paths || []) { + if (minimatch(f, g, OPTS)) return `path:${g}`; + } + } + for (const c of t.content || []) { + if (diffText.includes(c)) return `content:${c}`; + } + return null; +} + +export function selectAgents({ files, diffText }, config) { + const reviewed = files.filter( + (f) => !(config.excluded_paths || []).some((g) => minimatch(f, g, OPTS)), + ); + const out = []; + for (const a of config.agents) { + if (a.enabled === false) continue; + const matchedBy = agentMatches(a, reviewed, diffText); + if (matchedBy) out.push({ id: a.id, model: a.model || 'smart', matchedBy }); + } + return out; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — all `selectAgents.test.mjs` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/engine/selectAgents.mjs .claude/review/engine/selectAgents.test.mjs +git commit -m "feat(review): config-driven agent selection" +``` + +--- + +### Task 5: Rule resolver (`resolveRules.mjs`) + +**Files:** +- Create: `.claude/review/engine/resolveRules.mjs` +- Create: `.claude/review/engine/resolveRules.test.mjs` + +**Interfaces:** +- Consumes: a config object. +- Produces: `resolveRules(agentId: string, files: string[], config) -> string[]` — the agent's own `rules` plus any `path_rules.rules` whose `paths` match any reviewed file, deduped, order-stable (agent rules first). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/resolveRules.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveRules } from './resolveRules.mjs'; + +const config = { + agents: [ + { id: 'security', rules: ['rules/security.md'] }, + { id: 'ux', rules: ['rules/ux.md'] }, + ], + path_rules: [ + { paths: ['pages/api/**'], rules: ['rules/security.md'] }, + { paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }, + { paths: ['src/components/Reports/**'], rules: ['rules/financial.md'] }, + ], +}; + +test('agent rules + matching path_rules, deduped', () => { + const rules = resolveRules('ux', ['src/components/Reports/R.tsx'], config); + assert.deepEqual(rules, ['rules/ux.md', 'rules/financial.md']); +}); + +test('no path_rules match → only agent rules', () => { + const rules = resolveRules('security', ['src/lib/x.ts'], config); + assert.deepEqual(rules, ['rules/security.md']); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — `resolveRules.mjs` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/resolveRules.mjs`: +```js +import { minimatch } from 'minimatch'; + +const OPTS = { dot: true }; + +export function resolveRules(agentId, files, config) { + const agent = (config.agents || []).find((a) => a.id === agentId); + const rules = []; + const seen = new Set(); + const add = (r) => { + if (!seen.has(r)) { + seen.add(r); + rules.push(r); + } + }; + for (const r of agent?.rules || []) add(r); + for (const pr of config.path_rules || []) { + if (files.some((f) => pr.paths.some((g) => minimatch(f, g, OPTS)))) { + for (const r of pr.rules) add(r); + } + } + return rules; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — all `resolveRules.test.mjs` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/engine/resolveRules.mjs .claude/review/engine/resolveRules.test.mjs +git commit -m "feat(review): rule resolver (agent + path rules)" +``` + +--- + +### Task 6: Special-pattern detector (`detectSpecial.mjs`) + +**Files:** +- Create: `.claude/review/engine/detectSpecial.mjs` +- Create: `.claude/review/engine/detectSpecial.test.mjs` + +**Interfaces:** +- Consumes: a config object (for the `critical_pkg_update` package list). +- Produces: `detectSpecial(diffText: string, changedFiles: string[], config) -> string[]` — the `when` keys that fired, deduped. Detects (deterministically from the diff): `new_dependency`, `critical_pkg_update`, `lockfile_only_change`, `graphql_without_codegen_check`, `next_config_security_change`, `apollo_cache_typepolicy_change`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/detectSpecial.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { detectSpecial } from './detectSpecial.mjs'; + +const config = { + risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next', '@apollo/client'] }] }, +}; + +test('detects new dependency added to package.json', () => { + const diff = '+ "lodash": "^4.17.21",'; + assert.deepEqual(detectSpecial(diff, ['package.json'], config), ['new_dependency']); +}); + +test('detects critical package update', () => { + const diff = '+ "@apollo/client": "^4.0.0",'; + const found = detectSpecial(diff, ['package.json'], config); + assert.ok(found.includes('critical_pkg_update')); +}); + +test('detects lockfile-only change', () => { + assert.deepEqual(detectSpecial('+ some lock line', ['yarn.lock'], config), ['lockfile_only_change']); +}); + +test('detects graphql change and next.config security change', () => { + const found = detectSpecial('+ headers: [...]', ['next.config.ts', 'src/components/Foo/Foo.graphql'], config); + assert.ok(found.includes('graphql_without_codegen_check')); + assert.ok(found.includes('next_config_security_change')); +}); + +test('detects apollo cache typePolicies change', () => { + const found = detectSpecial('+ typePolicies: { Contact: {} }', ['src/lib/apollo/cache.ts'], config); + assert.ok(found.includes('apollo_cache_typepolicy_change')); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — `detectSpecial.mjs` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/detectSpecial.mjs`: +```js +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function detectSpecial(diffText, changedFiles, config) { + const found = new Set(); + const special = (config.risk && config.risk.special) || []; + const pkgEntry = special.find((s) => s.when === 'critical_pkg_update'); + const pkgs = (pkgEntry && pkgEntry.packages) || []; + + const pkgChanged = changedFiles.includes('package.json'); + const lockChanged = changedFiles.some((f) => f.endsWith('yarn.lock')); + + // New dependency line added in package.json (added `"name": "version"` line). + if (pkgChanged && /^\+\s*"[^"]+":\s*"[^"]+"/m.test(diffText)) found.add('new_dependency'); + + if (pkgChanged) { + for (const p of pkgs) { + if (new RegExp(`^\\+\\s*"${escapeRe(p)}":`, 'm').test(diffText)) { + found.add('critical_pkg_update'); + break; + } + } + } + + if (lockChanged && !pkgChanged) found.add('lockfile_only_change'); + + if (changedFiles.some((f) => f.endsWith('.graphql'))) found.add('graphql_without_codegen_check'); + + if ( + changedFiles.some((f) => /next\.config\.(js|ts)$/.test(f)) && + /(headers|content-security|csp|rewrites|images|domains)/i.test(diffText) + ) { + found.add('next_config_security_change'); + } + + if ( + changedFiles.some((f) => /apollo\/cache\.ts$/.test(f)) && + /(typePolicies|merge\s*[:(])/.test(diffText) + ) { + found.add('apollo_cache_typepolicy_change'); + } + + return [...found]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — all `detectSpecial.test.mjs` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/engine/detectSpecial.mjs .claude/review/engine/detectSpecial.test.mjs +git commit -m "feat(review): deterministic special-pattern detection" +``` + +--- + +### Task 7: CLI entry (`plan.mjs`) — end-to-end integration + +**Files:** +- Create: `.claude/review/engine/plan.mjs` +- Create: `.claude/review/engine/plan.test.mjs` + +**Interfaces:** +- Consumes: `loadConfig`, `scoreRisk`, `selectAgents`, `resolveRules`, `detectSpecial` (Tasks 2–6). +- Produces: + - `buildPlan({ files, diffText, linesChanged, scope }, config) -> { profile, risk, agents }` + where `agents = Array<{ id, model, matchedBy, rules: string[] }>` and `risk` is the `scoreRisk` result. + - A CLI: `node plan.mjs --config --schema --files --stat --diff [--scope ]` prints the plan as JSON to stdout. `--files` is a newline-separated path list; `--stat` is `git diff --stat` output (last line "N files changed, X insertions(+), Y deletions(-)"); `--diff` is the raw unified diff. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/plan.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPlan } from './plan.mjs'; + +const config = { + profile: 'standard', + excluded_paths: ['**/*.snap'], + risk: { + patterns: [{ glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }], + volume_multiplier: [{ upTo: 50, points: 0 }, { upTo: null, points: 4 }], + scope_multiplier: { single_feature: 1.0 }, + special: [], + levels: [{ range: [0, 3], level: 'LOW', reviewer: 'entry' }, { range: [4, null], level: 'HIGH', reviewer: 'exp' }], + }, + agents: [ + { id: 'architecture', always: true, rules: ['rules/architecture.md'] }, + { id: 'ux', triggers: { paths: ['src/components/**/*.tsx'] }, rules: ['rules/ux.md'] }, + ], + path_rules: [{ paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }], +}; + +test('buildPlan assembles risk + agents + resolved rules', () => { + const plan = buildPlan( + { files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+x', linesChanged: 20, scope: 'single_feature' }, + config, + ); + assert.equal(plan.profile, 'standard'); + assert.equal(plan.risk.level, 'LOW'); + const ux = plan.agents.find((a) => a.id === 'ux'); + assert.ok(ux, 'ux selected'); + assert.deepEqual(ux.rules, ['rules/ux.md']); // deduped agent + path rule + const arch = plan.agents.find((a) => a.id === 'architecture'); + assert.deepEqual(arch.rules, ['rules/architecture.md']); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — `plan.mjs` not found / `buildPlan` not exported. + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/plan.mjs`: +```js +import { readFileSync } from 'node:fs'; +import { loadConfig } from './loadConfig.mjs'; +import { scoreRisk } from './scoreRisk.mjs'; +import { selectAgents } from './selectAgents.mjs'; +import { resolveRules } from './resolveRules.mjs'; +import { detectSpecial } from './detectSpecial.mjs'; + +export function buildPlan({ files, diffText, linesChanged, scope }, config) { + const special = detectSpecial(diffText, files, config); + const risk = scoreRisk({ files, linesChanged, scope, special }, config); + const selected = selectAgents({ files, diffText }, config); + const agents = selected.map((a) => ({ ...a, rules: resolveRules(a.id, files, config) })); + return { profile: config.profile, risk: { ...risk, special }, agents }; +} + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, '')] = argv[i + 1]; + return args; +} + +function linesChangedFromStat(statText) { + // Sum insertions + deletions from `git diff --stat` summary line. + const m = statText.match(/(\d+) insertions?\(\+\)/); + const d = statText.match(/(\d+) deletions?\(-\)/); + return (m ? Number(m[1]) : 0) + (d ? Number(d[1]) : 0); +} + +// CLI: invoked directly (not when imported). +if (import.meta.url === `file://${process.argv[1]}`) { + const a = parseArgs(process.argv.slice(2)); + const config = loadConfig({ configPath: a.config, schemaPath: a.schema }); + const files = readFileSync(a.files, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const diffText = a.diff ? readFileSync(a.diff, 'utf8') : ''; + const linesChanged = a.stat ? linesChangedFromStat(readFileSync(a.stat, 'utf8')) : 0; + const plan = buildPlan({ files, diffText, linesChanged, scope: a.scope || 'single_feature' }, config); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — `plan.test.mjs` passes; full `yarn test:review` suite green. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/engine/plan.mjs .claude/review/engine/plan.test.mjs +git commit -m "feat(review): plan CLI entry assembling risk + agents + rules" +``` + +--- + +### Task 8: Author the real `config.yml` (migrate from code-review.md) + +**Files:** +- Create: `.claude/review/config.yml` +- Create: `.claude/review/engine/realConfig.test.mjs` + +**Interfaces:** +- Consumes: `loadConfig` (Task 2), `config.schema.json` (Task 1). +- Produces: the production config the command reads. + +- [ ] **Step 1: Write the failing test (the real config must load + validate + have expected shape)** + +Create `.claude/review/engine/realConfig.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from './loadConfig.mjs'; + +const configPath = fileURLToPath(new URL('../config.yml', import.meta.url)); +const schemaPath = fileURLToPath(new URL('../config.schema.json', import.meta.url)); + +test('real config.yml loads and validates', () => { + const cfg = loadConfig({ configPath, schemaPath }); // throws if invalid + assert.equal(cfg.version, 1); +}); + +test('real config defines the 7 MPDX agents', () => { + const cfg = loadConfig({ configPath, schemaPath }); + const ids = cfg.agents.map((a) => a.id).sort(); + assert.deepEqual(ids, ['architecture', 'data-integrity', 'financial', 'security', 'standards', 'testing', 'ux']); +}); + +test('real config reserves inert index/learning sections', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.index.enabled, false); + assert.equal(cfg.learning.enabled, false); + assert.equal(cfg.learning.approval_required, true); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — `config.yml` does not exist (`loadConfig` throws ENOENT). + +- [ ] **Step 3: Write the config** + +Create `.claude/review/config.yml` using the schema from §4.1 of the spec +(`docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`). Copy the full annotated +YAML from that spec section verbatim — it already enumerates the risk patterns, the 7 agents with +triggers, `path_rules`, `excluded_paths`, and the inert `index`/`learning`/`enforcement` sections. +Cross-check every risk pattern, trigger glob, and excluded path against `.claude/rules/code-review.md` +so nothing is dropped (the "Critical/High/Medium/Low File Patterns", "Special Pattern Detection", +"Agent Triggers", and "Excluded Paths" sections map 1:1). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — `realConfig.test.mjs` passes (config loads, validates, 7 agents, inert sections present). + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/config.yml .claude/review/engine/realConfig.test.mjs +git commit -m "feat(review): author config.yml migrated from code-review.md" +``` + +--- + +### Task 9: Migrate prose rule docs (`rules/*.md`) + +**Files:** +- Create: `.claude/review/rules/security.md`, `architecture.md`, `data-integrity.md`, `testing.md`, `ux.md`, `financial.md`, `standards.md` +- Create: `.claude/review/engine/rulesCoverage.test.mjs` + +**Interfaces:** +- Consumes: the `rules` paths referenced in `config.yml` (Task 8). +- Produces: the prose guidance each agent loads. + +- [ ] **Step 1: Write the failing test (every referenced rule doc must exist and be non-trivial)** + +Create `.claude/review/engine/rulesCoverage.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from './loadConfig.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const cfg = loadConfig({ configPath: `${root}config.yml`, schemaPath: `${root}config.schema.json` }); + +function referencedRules() { + const set = new Set(); + for (const a of cfg.agents) for (const r of a.rules || []) set.add(r); + for (const pr of cfg.path_rules || []) for (const r of pr.rules) set.add(r); + return [...set]; +} + +test('every rule doc referenced by config exists and is non-empty', () => { + for (const rel of referencedRules()) { + const p = `${root}${rel}`; + assert.ok(existsSync(p), `missing rule doc: ${rel}`); + assert.ok(statSync(p).size > 200, `rule doc too small (placeholder?): ${rel}`); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn test:review` +Expected: FAIL — referenced rule docs do not exist yet. + +- [ ] **Step 3: Migrate the prose** + +Move the natural-language sections of `.claude/rules/code-review.md` into the matching +`.claude/review/rules/*.md`, **verbatim** (reorganize, do not rewrite): +- `rules/security.md` ← "Security Focus Areas" +- `rules/architecture.md` ← "Architecture Focus Areas" +- `rules/data-integrity.md` ← "Data Integrity Focus Areas" +- `rules/testing.md` ← "Testing Focus Areas" +- `rules/ux.md` ← "UX Focus Areas" +- `rules/financial.md` ← "Domain Agents → Financial Reporting Agent" +- `rules/standards.md` ← "Standards Checklist" + +Each file starts with a short H1 (e.g. `# Security Review Rules`) then the migrated content. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn test:review` +Expected: PASS — `rulesCoverage.test.mjs` passes (all referenced docs exist and are >200 bytes). + +- [ ] **Step 5: Commit** + +```bash +git add .claude/review/rules/ .claude/review/engine/rulesCoverage.test.mjs +git commit -m "feat(review): migrate prose focus-areas into rules/*.md" +``` + +--- + +### Task 10: Refactor `agent-review.md` to consume the engine + +**Files:** +- Modify: `.claude/commands/agent-review.md` (Stage 0, Stage 0B, Stage 1 only) + +**Interfaces:** +- Consumes: `plan.mjs` JSON output (Task 7). + +- [ ] **Step 1: Replace Stage 0's risk algorithm with an engine call** + +In `.claude/commands/agent-review.md`, after the existing diff-gathering bash (which writes +`/tmp/changed_files.txt`, `/tmp/diff_stat.txt`, `/tmp/pr_diff.txt`), insert a call to the engine and +replace the prose "Calculate Risk Score" instructions with consumption of its output: +```bash +REVIEW_DIR=".claude/review" +node "$REVIEW_DIR/engine/plan.mjs" \ + --config "$REVIEW_DIR/config.yml" \ + --schema "$REVIEW_DIR/config.schema.json" \ + --files /tmp/changed_files.txt \ + --stat /tmp/diff_stat.txt \ + --diff /tmp/pr_diff.txt \ + --scope "${REVIEW_SCOPE:-single_feature}" \ + > /tmp/review_plan.json +cat /tmp/review_plan.json +``` +Update the surrounding markdown so the risk display (the "PR RISK ASSESSMENT" block) reads +`risk.score`, `risk.level`, `risk.reviewer`, and `risk.special` from `/tmp/review_plan.json` +instead of computing them inline. Note that `REVIEW_SCOPE` is the heuristic scope the model sets +(`single_file`/`single_feature`/`multi_feature`/`cross_cutting`/`core_infra`); default +`single_feature`. + +- [ ] **Step 2: Replace Stage 0B smart-selection with the engine's agent list** + +Replace the hardcoded `grep`-based agent selection in Stage 0B with: read +`/tmp/review_plan.json`'s `agents[]`. Each entry has `id`, `model`, `matchedBy`, and `rules`. The +set of agents to launch IS this list (smart selection is now config-driven). Remove the per-agent +`*_NEEDED` bash flags and the `SELECTED_AGENTS` assembly; keep the display that announces which +agents were selected and why (`matchedBy`). + +- [ ] **Step 3: Wire rules + profile into Stage 1 agent prompts** + +In Stage 1, when launching each agent, instruct the command to (a) read each rule doc listed in +that agent's `rules[]` (e.g. `.claude/review/rules/security.md`) and include its contents in the +agent prompt, and (b) apply the `profile` from the plan: add a line to each agent prompt — `chill` +→ "Report only high-confidence, severity ≥ 7 findings; suppress nits." / `standard` → current +behavior / `assertive` → "Report all findings including low-severity suggestions." Also update +Stage 5 (Consensus) so the severity cutoffs scale with `profile` (chill raises, assertive lowers +the thresholds in the existing Consensus Levels table). + +- [ ] **Step 4: Manual verification (no unit test — this is the orchestrator)** + +Run the engine against a real branch to confirm the command's new inputs are well-formed: +```bash +git diff --name-only main...HEAD > /tmp/changed_files.txt +git diff --stat main...HEAD > /tmp/diff_stat.txt +git diff main...HEAD > /tmp/pr_diff.txt +node .claude/review/engine/plan.mjs \ + --config .claude/review/config.yml --schema .claude/review/config.schema.json \ + --files /tmp/changed_files.txt --stat /tmp/diff_stat.txt --diff /tmp/pr_diff.txt +``` +Expected: valid JSON with `profile`, `risk` (score/level/reviewer/special), and `agents[]` (each +with `rules`). Confirm the selected agents and risk level are sensible for the branch's changes. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/commands/agent-review.md +git commit -m "refactor(review): drive risk + agent selection from config engine" +``` + +--- + +### Task 11: Supersede `code-review.md` and final verification + +**Files:** +- Modify: `.claude/rules/code-review.md` (reduce to a pointer) + +**Interfaces:** none (cleanup + verification). + +- [ ] **Step 1: Replace `code-review.md` with a pointer** + +Replace the full contents of `.claude/rules/code-review.md` with a short pointer so nothing links +to stale duplicated rules: +```markdown +# MPDX React — Code Review Rules (moved) + +These rules now live in the declarative review core: + +- Config (risk scoring, agents, triggers, exclusions): `.claude/review/config.yml` +- Prose rule docs (per-agent focus areas, standards): `.claude/review/rules/` +- Engine + tests: `.claude/review/engine/` (run `yarn test:review`) + +See the design spec: `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`. +``` + +- [ ] **Step 2: Run the full engine test suite** + +Run: `yarn test:review` +Expected: PASS — all engine tests across Tasks 1–9 green. + +- [ ] **Step 3: Confirm the app's own checks are unaffected** + +Run: `yarn lint:ts` +Expected: PASS — TypeScript check unaffected (the `.claude/review/` engine is plain JS outside +`tsconfig` scope). If `tsc` tries to type-check the engine, add `.claude/` to `tsconfig`'s +`exclude` and note it in the commit. + +- [ ] **Step 4: Commit** + +```bash +git add .claude/rules/code-review.md +git commit -m "chore(review): supersede code-review.md with pointer to review core" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Config layer + YAML + schema → Tasks 1, 2, 8 ✓ +- Risk scoring migrated → Task 3 + config in Task 8 ✓ +- Agent definitions + triggers → Tasks 4, 8 ✓ +- Per-path rules → Tasks 5, 8 ✓ +- Severity/verbosity profile → Task 10 (Steps 3, applied in command + Consensus) ✓ +- JSON Schema validation → Tasks 1, 2 ✓ +- Special-pattern detection → Task 6 ✓ +- Command consumes config (Stage 0–1 refactor; debate/consensus untouched) → Task 10 ✓ +- Prose rule docs migrated verbatim → Task 9 ✓ +- Behavior-preservation (risk/selection parity fixtures) → Tasks 3, 4, 7 ✓ +- Inert index/learning/enforcement keys → Tasks 1 (schema), 8 (config), 8 test ✓ +- Supersede code-review.md → Task 11 ✓ +- Acceptance criteria §7.2 (1–7) → all mapped above ✓ + +**2. Placeholder scan:** No "TBD/TODO"; every code step shows complete code. Task 8 Step 3 and Task 9 Step 3 reference verbatim migration from named source sections rather than re-printing large prose — intentional (the content is long and already authored in the spec/`code-review.md`), and each is gated by a concrete test (Tasks 8, 9 tests). + +**3. Type consistency:** `buildPlan` (Task 7) consumes `scoreRisk`/`selectAgents`/`resolveRules`/`detectSpecial` with the exact signatures defined in Tasks 3–6. `plan.json` shape (`{ profile, risk, agents:[{id,model,matchedBy,rules}] }`) is produced in Task 7 and consumed in Task 10. `matchedBy` string format (`always` / `path:` / `content:`) is consistent between Task 4 and Task 10's display. Risk `factors` keys consistent between Task 3 impl and tests. + +--- + +## Notes for the executor + +- **Branch first.** This work is unrelated to the current `mpd-supervisor-admin` branch — create a dedicated branch (e.g. `review-config-layer`) before Task 1, or use an isolated worktree. +- Engine is intentionally framework-free so a future CLI/UI imports the same modules. +- If `yarn add -D` is undesirable in this repo, the only hard requirement is that `yaml`, `minimatch`, and `ajv` resolve for `node --test`; vendoring is an acceptable alternative but adds maintenance. diff --git a/docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md b/docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md new file mode 100644 index 0000000000..59c4a11515 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md @@ -0,0 +1,402 @@ +# Design Spec — Agent-Review Config Layer (Phase A) + +- **Date:** 2026-06-22 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **Scope of this spec:** Phase A — the declarative **config layer** only. Index (Layer 2) and + Learning (Layer 3) are described as forward-looking architecture so the config schema reserves + room for them, but they are **out of scope for this spec** and get their own brainstorm → spec → + plan → build cycles. + +--- + +## 1. Context & Problem + +We have a working multi-agent PR reviewer at `.claude/commands/agent-review.md` (7 specialist +agents, smart selection, a debate/rebuttal/consensus loop, automated fixes, metrics) with +MPDX-specific rules in `.claude/rules/code-review.md`. + +Competitive research (`.claude/docs/competitive-research-greptile-coderabbit.md`) found that the +best-in-class tools (Greptile, CodeRabbit) beat us on three things our reviewer lacks: + +1. **A declarative config surface** — they have layered config files (`.coderabbit.yaml`, + `greptile.json`, per-path rules) a UI can edit. Ours is bash logic + one prose markdown file. +2. **A persistent codebase index** — Greptile keeps a semantic graph for cross-file impact + analysis. Our agents `grep` cold every run. +3. **A feedback-learning loop** — both learn from accepted/dismissed comments over time, gated by + human approval. Ours starts cold every run. + +The long-term goal is a CLI/UI to configure and interact with the reviewer the way those products +do. The three gaps are three subsystems with a dependency order: **config is the foundation** the +other two attach to (index needs to know what to index and where to store it; learning needs to +know where learnings persist and how approval gating works). + +### Billing & runtime constraint (decided) + +A standalone Agent-SDK CLI can technically bill a Claude Pro/Max subscription via an OAuth token, +but Anthropic **discourages it for shipped/multi-user/headless apps**, and parallel subagents burn +subscription quota quickly. Our current command already runs on the Claude plan cleanly because +**Claude Code itself is subscription-billed**. Decision: + +> **Claude Code stays the orchestrator.** We build config + index + learning as a clean, +> file-based, layered core that the Claude Code command reads today and a future CLI/UI can read +> later — deferring the SDK/API-billing question until there is a real hosted-UI need. + +Source for billing finding: Anthropic Agent SDK docs / Help Center (see References). + +--- + +## 2. Goals & Non-Goals + +### Goals (Phase A) + +- Move the machine-readable parts of `code-review.md` (risk scoring, agent definitions, triggers, + excluded paths) into a single declarative `config.yml`. +- Keep the natural-language guidance (Focus Areas, Standards Checklist) as prose `rules/*.md` docs, + referenced from config by glob — preserving today's review quality. +- Add a global **severity/verbosity profile** (`chill | standard | assertive`). +- Provide a **JSON Schema** so the config is validatable now and renderable as UI forms later. +- Refactor `agent-review.md` Stage 0–1 to read config instead of hardcoded bash — with **no + regression** in current behavior. +- Reserve inert config sections for the index and learning layers so they slot in without a + re-cut. + +### Non-Goals (this spec) + +- Building the index or learning subsystems (later specs). +- Building a CLI binary or web UI (later phases). We only make the core **UI-ready**. +- Changing the debate/rebuttal/consensus stages — they are a strength and stay as-is. +- Moving off Claude Code orchestration or onto API/OAuth billing. +- Enforcement/merge-gating behavior beyond reserving an `enforcement` config key. + +--- + +## 3. Architecture — the 3-layer core + +``` +.claude/review/ +├── config.yml # Layer 1: structured config (this spec) +├── config.schema.json # JSON Schema for config.yml — validation + future UI forms +├── rules/ # Prose rule docs (migrated Focus Areas), referenced by glob +│ ├── security.md +│ ├── architecture.md +│ ├── data-integrity.md +│ ├── testing.md +│ ├── ux.md +│ ├── financial.md +│ └── standards.md +├── index/ # Layer 2 (LATER): codebase index storage +└── learnings/ # Layer 3 (LATER): persisted learnings + approval queue +``` + +- **Orchestrator:** the existing Claude Code command. It reads `config.yml`, computes risk, selects + agents, loads referenced rule docs into agent prompts, then runs the unchanged debate/consensus + pipeline. +- **Layer independence:** config works standalone. Index and learning *attach* to config via their + reserved sections; each can be enabled/disabled without touching the others. +- **UI-readiness:** every file is plain text in the repo. A future CLI/UI reads/writes the same + files; `config.schema.json` is the contract. + +> **Placement note:** the review core lives at `.claude/review/`. The command stays at +> `.claude/commands/agent-review.md`. The legacy `.claude/rules/code-review.md` is decomposed into +> `.claude/review/config.yml` + `.claude/review/rules/*.md` and then removed (or reduced to a +> pointer) as part of migration. + +--- + +## 4. Phase A — Config Layer detail + +### 4.1 `config.yml` schema (annotated) + +```yaml +version: 1 + +# Global severity/verbosity dial (CodeRabbit `profile` model). +# chill → fewer, high-confidence findings; raises consensus threshold +# standard → current behavior (default) +# assertive → more findings, lower threshold, may be nitpicky +profile: standard + +# ── Risk scoring (migrated from code-review.md) ─────────────────────────────── +risk: + # File-pattern contributions. `tier` is descriptive; `points` drives the score. + patterns: + - { glob: "pages/api/auth/**", points: 3, tier: critical } + - { glob: "pages/api/graphql-rest.page.ts", points: 3, tier: critical } + - { glob: "pages/api/Schema/index.ts", points: 3, tier: critical } + - { glob: "src/lib/apollo/{client,link,cache,ssrClient}.ts", points: 3, tier: critical } + - { glob: "next.config.{js,ts}", points: 3, tier: critical } + - { glob: ".github/workflows/**", points: 3, tier: critical } + - { glob: ".claude/**", points: 3, tier: critical } + - { glob: "pages/api/Schema/**/*.{ts,graphql}", points: 2, tier: high } + - { glob: "src/components/Shared/**", points: 2, tier: high } + - { glob: "src/components/**/*.graphql", points: 2, tier: high } + - { glob: "src/components/**/*.{ts,tsx}", points: 1, tier: medium } + - { glob: "src/hooks/**/*.ts", points: 1, tier: medium } + - { glob: "pages/**/*.page.tsx", points: 1, tier: medium } + # Low-risk overrides (explicit 0 points) + - { glob: "**/*.test.{ts,tsx}", points: 0, tier: low } + - { glob: "public/locales/**", points: 0, tier: low } + - { glob: "**/*.snap", points: 0, tier: low } + + # Change-volume → points (lines changed across the diff). + volume_multiplier: + - { upTo: 50, points: 0 } + - { upTo: 200, points: 1 } + - { upTo: 500, points: 2 } + - { upTo: 1000, points: 3 } + - { upTo: null, points: 4 } # 1000+ + + # Scope multiplier applied to the pattern+volume subtotal. + scope_multiplier: + single_file: 1.0 + single_feature: 1.0 + multi_feature: 1.3 + cross_cutting: 1.7 + core_infra: 2.0 + + # Special detections (from code-review.md "Special Pattern Detection"). + special: + - { when: new_dependency, points: 2 } + - { when: critical_pkg_update, points: 3, + packages: [next, react, "@apollo/client", "@mui/material", formik, next-auth, typescript, graphql-codegen] } + - { when: lockfile_only_change, points: 1 } + - { when: graphql_without_codegen_check, points: 2 } + - { when: next_config_security_change, points: 2 } # rewrites/headers/CSP/image domains + - { when: apollo_cache_typepolicy_change, points: 2 } + + # Score → risk level + required reviewer (from code-review.md classification). + levels: + - { range: [0, 3], level: LOW, reviewer: entry } + - { range: [4, 6], level: MEDIUM, reviewer: entry } + - { range: [7, 9], level: HIGH, reviewer: experienced } + - { range: [10, null], level: CRITICAL, reviewer: "Caleb Cox (senior)" } + +# ── Agents (the 7 specialists, declaratively) ───────────────────────────────── +agents: + - id: security + enabled: true + model: smart # smart | opus | sonnet | haiku + always: false # if true, runs regardless of triggers + triggers: + paths: ["pages/api/**", "src/lib/apollo/{link,client,ssrClient}.ts", + "next.config.{js,ts}", "pages/_app.page.tsx", ".github/workflows/**", ".claude/**"] + content: ["process.env.", "dangerouslySetInnerHTML", "router.push("] + rules: ["rules/security.md"] + + - id: architecture + enabled: true + always: true + rules: ["rules/architecture.md"] + + - id: data-integrity + enabled: true + triggers: + paths: ["pages/api/Schema/**/*.{ts,graphql}", "src/lib/apollo/cache.ts", + "src/components/**/*.graphql", "src/graphql/rootFields.generated.ts"] + content: ["mutation", "optimisticResponse", "refetchQueries", "cache.modify", "__typename", + "first:", "after:", "pageInfo", "nodes"] + rules: ["rules/data-integrity.md"] + + - id: testing + enabled: true + always: true + rules: ["rules/testing.md"] + + - id: ux + enabled: true + triggers: + paths: ["src/components/**/*.tsx", "pages/**/*.page.tsx", "src/theme.ts", "src/theme/**"] + content: [".md`. Each file is the natural-language guidance an agent loads when its triggers +match (and that `path_rules` can attach to any agent). Content is **migrated verbatim** from +`code-review.md` (reorganized, not rewritten) to preserve current review quality. + +--- + +## 5. Command consumption & migration + +### 5.1 How `agent-review.md` consumes config + +Refactor **Stage 0–1 only**: + +- **Stage 0 (Risk):** replace the hardcoded critical/high/medium pattern lists and special-pattern + greps with logic that reads `risk.*` from `config.yml` and computes the score/level/reviewer. +- **Stage 0B (Agent selection):** replace the hardcoded `grep` triggers with logic that, for each + `agents[]` entry, runs it if `always: true` or if any `triggers.paths`/`triggers.content` match + the diff (respecting `excluded_paths`). +- **Stage 1 (Launch):** for each selected agent, load its `rules` docs (plus any matching + `path_rules`) into the prompt, and apply the `profile` cutoffs. +- **Stages 2–6 (debate, rebuttal, consensus, report, metrics):** unchanged, except Stage 5 reads + the profile-derived thresholds. + +The command may shell out to a tiny helper (e.g. a Node script using `js-yaml` + a glob matcher, +or `yq`) to parse YAML and emit the selected agents / risk score as JSON the markdown stages +consume. Parsing approach is an implementation-plan detail; the spec only requires that config is +read, validated against the schema, and drives Stage 0–1. + +### 5.2 Migration of `code-review.md` + +1. Extract risk patterns / special detections / level classification → `risk.*` in `config.yml`. +2. Extract agent trigger lists → `agents[].triggers` in `config.yml`. +3. Extract excluded paths → `excluded_paths`. +4. Move each Focus-Areas / Standards section → `rules/.md` (verbatim reorg). +5. Replace `code-review.md` with a short pointer to `.claude/review/` (or delete it). + +**Behavior-preservation requirement:** the migrated config must reproduce the same risk score, +agent selection, and rule coverage as the current `code-review.md` on a representative set of +diffs (see §7). + +--- + +## 6. Forward-looking — how Index & Learning attach (NOT built here) + +- **Index (Layer 2):** a build step produces per-function NL summaries + a symbol/caller map under + `index/`; agents query it for cross-file impact analysis and prior art. Controlled by + `index.enabled` / `index.path`. Refresh strategy (full vs incremental) is its own spec. +- **Learning (Layer 3):** the command logs findings + outcomes (accepted/dismissed/merged) to + `learnings/`; a periodic mining step proposes new rules into an **approval queue** + (`learnings/proposed/`) that a human ratifies before they are promoted into `config.yml` / + `rules/*.md`. Gated by `learning.approval_required`. Scope via `learning.scope`. Its own spec. + +These are listed so the config schema reserves their keys; **no implementation in Phase A.** + +--- + +## 7. Testing & acceptance + +### 7.1 Testing strategy + +- **Schema validation tests:** the committed `config.yml` validates against `config.schema.json`; + representative malformed configs are rejected with clear errors. +- **Risk-parity tests:** a fixture set of diffs (auth change, GraphQL change, UI-only change, + financial report change, large cross-cutting change, docs-only change) yields the **same risk + score and level** under config-driven logic as the current hardcoded logic. +- **Selection-parity tests:** the same fixtures select the **same set of agents** as today. +- **Rule-coverage check:** every section of the old `code-review.md` is present in some + `rules/*.md` (no dropped guidance). +- **Smoke test:** run `/agent-review` end-to-end on a sample PR; confirm it produces a report with + no regressions vs. a pre-migration run. + +### 7.2 Acceptance criteria (Phase A done when) + +1. `.claude/review/config.yml`, `config.schema.json`, and `rules/*.md` exist and validate. +2. `agent-review.md` reads config for Stage 0–1; debate/consensus stages unchanged. +3. Risk-parity and selection-parity fixtures pass (same outputs as pre-migration). +4. `profile` is honored (chill/standard/assertive change finding volume + thresholds). +5. `code-review.md` is decomposed and superseded (pointer or removed). +6. `index` / `learning` / `enforcement` keys exist, validate, and are inert. +7. A representative end-to-end run shows no regression. + +--- + +## 8. Sequencing & out of scope + +- **This spec:** Phase A (config layer) only. +- **Next:** Phase B (Index) — own brainstorm → spec → plan. +- **Then:** Phase C (Learning) — own brainstorm → spec → plan. +- **Later:** CLI/UI surfaces over the same files (enabled by API/OAuth billing or Anthropic + approval; deferred). + +--- + +## 9. Open questions & risks + +- **YAML parsing in a markdown command:** the command needs a reliable YAML→JSON step. Risk: extra + tooling (`yq`/Node helper). Mitigation: pick one in the implementation plan; prefer a small + committed Node script using an already-available dependency. +- **Profile threshold tuning:** exact severity cutoffs per profile need calibration; start with the + table in §4.2 and adjust after real runs. +- **Glob semantics:** must match the command's matcher behavior to the globs authors expect + (minimatch-style). Pin the matcher in the plan. +- **Scope/volume detection:** `scope_multiplier` requires classifying a diff's scope; today this is + heuristic. Keep the current heuristic, just config-drive the multipliers. + +--- + +## 10. References + +- Competitive research: `.claude/docs/competitive-research-greptile-coderabbit.md` +- Current reviewer: `.claude/commands/agent-review.md` +- Current rules: `.claude/rules/code-review.md` +- CodeRabbit config model: `profile`, `path_instructions`, `learnings` (scope + approval_delay) — + docs.coderabbit.ai/reference/configuration +- Greptile layered config + impact analysis — greptile.com/docs/code-review/custom-standards, + .../how-greptile-works/graph-based-codebase-context +- Agent SDK billing constraint — Anthropic Agent SDK docs / Help Center (subscription OAuth + discouraged for shipped/multi-user apps; API key for production) From 9ef58dee32d4c2231373c52b80c3100bde7c24ef Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:17:54 -0400 Subject: [PATCH 02/39] docs: revise plan for CommonJS engine + Yarn PnP constraints --- .../2026-06-22-agent-review-config-layer.md | 665 ++++++++---------- 1 file changed, 305 insertions(+), 360 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md b/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md index 5bad87b091..a4a6896be7 100644 --- a/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md +++ b/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md @@ -1,49 +1,62 @@ -# Agent-Review Config Layer (Phase A) Implementation Plan +# Agent-Review Config Layer (Phase A) Implementation Plan — CommonJS / Yarn PnP -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. -**Goal:** Move the machine-readable parts of the MPDX reviewer (risk scoring, agent definitions, triggers, exclusions) into a declarative `.claude/review/config.yml`, backed by a tested, standalone Node engine the `agent-review` command consumes — with no regression and room reserved for the index/learning layers. +**Goal:** Move the machine-readable parts of the MPDX reviewer (risk scoring, agent definitions, triggers, exclusions) into a declarative `.claude/review/config.yml`, backed by a tested, standalone CommonJS engine the `agent-review` command consumes — with no regression and room reserved for the index/learning layers. -**Architecture:** A standalone Node ESM engine under `.claude/review/engine/` parses + validates `config.yml` (against `config.schema.json`), scores risk, selects agents, and resolves rule docs. A CLI entry (`plan.mjs`) takes the diff manifest the command already gathers and emits JSON. The Claude Code command (`agent-review.md`) calls `plan.mjs` in Stage 0–1 instead of hardcoded bash; the debate/consensus stages are untouched. Prose guidance moves to `rules/*.md` referenced by glob. +**Architecture:** A standalone Node **CommonJS** engine under `.claude/review/engine/` parses + validates `config.yml` (against `config.schema.json`), scores risk, selects agents, resolves rule docs. A CLI entry (`plan.cjs`) takes the diff manifest the command already gathers and emits JSON. The Claude Code command (`agent-review.md`) calls `plan.cjs` via `yarn node` in Stage 0–1; debate/consensus stages are untouched. Prose guidance moves to `rules/*.md` referenced by glob. -**Tech Stack:** Node 22 (ESM `.mjs`), `node:test` + `node:assert/strict`, `yaml`, `minimatch`, `ajv`. App stays Next.js/TS/Jest (untouched by the engine). +**Tech Stack:** Node (CommonJS `.cjs`), `node:test` via a single-process runner, `yaml`, `minimatch`, `ajv`. **Yarn 4 with Plug'n'Play (Zero-Install).** -## Global Constraints +## Global Constraints — READ FIRST (platform-specific) -- Engine is **plain Node ESM** (`.mjs`), NOT TypeScript — no transpile step; the command invokes it with `node`. It lives under `.claude/review/` and is outside the app's `tsconfig`/Jest scope. -- Engine tests run via `node --test`, NOT Jest. Add a `test:review` script for them. -- New deps `yaml`, `minimatch`, `ajv` are **devDependencies** (tooling, not shipped in the app bundle). -- Glob matching uses `minimatch(path, glob, { dot: true })` everywhere (must match `.claude/**`, `.github/**`). -- Behavior-preservation: the engine must reproduce sane, expected risk scores + agent selections on the representative fixtures (Tasks 3, 4, 7). Debate/rebuttal/consensus stages of `agent-review.md` are NOT modified. -- Dev Node version: **22.14.0** (per CLAUDE.md). `node:test` and ESM JSON via `fs.readFileSync` are assumed available. -- Package manager: **yarn** (never npm). -- Source of truth being migrated: `.claude/rules/code-review.md` and `.claude/commands/agent-review.md`. +- **This repo is Yarn 4 + PnP. There is NO `node_modules`.** Deps resolve only when Node is launched through `yarn node`. Plain `node` fails to `require('yaml')`/`minimatch`/`ajv`. +- **Engine is CommonJS `.cjs`** — use `require(...)` / `module.exports`, NOT `import`/`export`. CJS rides the *stable* PnP path; ESM under PnP is experimental and breaks the test runner. +- **Tests run ONLY via the single-process runner**: `yarn node .claude/review/engine/run-tests.cjs` (exposed as `yarn test:review`). **Never use `node --test`** — its per-file workers don't inherit the PnP loader. +- **All new files use lowercase `.claude/`** (git tracks the dir lowercase; `.CLAUDE/` corrupts case tracking on macOS). +- **Commit with `--no-verify`** — husky/lint-staged hooks are installed; bypass them for this tooling work. +- **Deps already added during setup** (`yaml`, `minimatch`, `ajv` as devDependencies; zips in `.yarn/cache/`). Task 1 verifies + commits them. +- Worktree absolute path is provided by the orchestrator; write all files there, run `yarn --cwd ...`, commit with `git -C ...`. +- Package manager is **yarn** (Berry). `yarn --cwd ` runs from another path. +- Source being migrated: `.claude/rules/code-review.md` and `.claude/commands/agent-review.md` (both committed in the worktree). +- Debate/rebuttal/consensus stages of `agent-review.md` are NOT modified. --- -### Task 1: Scaffold review core, add deps, and JSON Schema +### Task 1: Scaffold review core, runner, test script, JSON Schema **Files:** - Create: `.claude/review/config.schema.json` -- Create: `.claude/review/engine/schema.test.mjs` -- Modify: `package.json` (add devDeps + `test:review` script) -- Create dirs: `.claude/review/rules/`, `.claude/review/engine/` (via the files above) +- Create: `.claude/review/engine/run-tests.cjs` +- Create: `.claude/review/engine/schema.test.cjs` +- Modify: `package.json` (add `test:review` script; deps already present) **Interfaces:** -- Produces: `config.schema.json` — the canonical config contract used by `loadConfig` (Task 2) and a future UI. +- Produces: `config.schema.json` (config contract, used by `loadConfig` Task 2); `run-tests.cjs` (the in-process node:test runner all tasks use). -- [ ] **Step 1: Add devDependencies and the engine test script** +- [ ] **Step 1: Verify deps + add `test:review` script** -Run: -```bash -yarn add -D yaml minimatch ajv -``` -Then add to `package.json` `scripts` (keep alphabetical-ish with siblings): +Confirm `yaml`, `minimatch`, `ajv` are in `package.json` devDependencies. Add to `scripts`: ```json -"test:review": "node --test .claude/review/engine/" +"test:review": "yarn node .claude/review/engine/run-tests.cjs" +``` + +- [ ] **Step 2: Create the single-process test runner** + +Create `.claude/review/engine/run-tests.cjs`: +```js +'use strict'; +// Requires every *.test.cjs here so node:test runs them in ONE process. +// (yarn node injects the PnP loader; node --test workers would not — do not use --test.) +const { readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +for (const f of readdirSync(__dirname).sort()) { + if (f.endsWith('.test.cjs')) require(join(__dirname, f)); +} ``` -- [ ] **Step 2: Write `config.schema.json` (the complete contract)** +- [ ] **Step 3: Write `config.schema.json`** Create `.claude/review/config.schema.json`: ```json @@ -87,10 +100,7 @@ Create `.claude/review/config.schema.json`: } } }, - "scope_multiplier": { - "type": "object", - "additionalProperties": { "type": "number" } - }, + "scope_multiplier": { "type": "object", "additionalProperties": { "type": "number" } }, "special": { "type": "array", "items": { @@ -111,12 +121,7 @@ Create `.claude/review/config.schema.json`: "additionalProperties": false, "required": ["range", "level", "reviewer"], "properties": { - "range": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { "type": ["integer", "null"] } - }, + "range": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": ["integer", "null"] } }, "level": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, "reviewer": { "type": "string" } } @@ -163,10 +168,7 @@ Create `.claude/review/config.schema.json`: "index": { "type": "object", "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean" }, - "path": { "type": "string" } - } + "properties": { "enabled": { "type": "boolean" }, "path": { "type": "string" } } }, "learning": { "type": "object", @@ -181,74 +183,61 @@ Create `.claude/review/config.schema.json`: "enforcement": { "type": "object", "additionalProperties": false, - "properties": { - "mode": { "type": "string", "enum": ["warn", "block"] } - } + "properties": { "mode": { "type": "string", "enum": ["warn", "block"] } } } } } ``` -- [ ] **Step 3: Write the failing test (schema must compile under ajv)** +- [ ] **Step 4: Write the failing test** -Create `.claude/review/engine/schema.test.mjs`: +Create `.claude/review/engine/schema.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import Ajv from 'ajv'; - -const schemaPath = fileURLToPath(new URL('../config.schema.json', import.meta.url)); +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const schema = require('../config.schema.json'); test('config.schema.json is a valid, compilable JSON Schema', () => { - const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); - const ajv = new Ajv({ allErrors: true }); - const validate = ajv.compile(schema); // throws if the schema itself is malformed + const validate = new Ajv({ allErrors: true }).compile(schema); // throws if malformed assert.equal(typeof validate, 'function'); }); ``` -- [ ] **Step 4: Run it to verify it passes** +- [ ] **Step 5: Run the suite (expect PASS)** -Run: `yarn test:review` -Expected: PASS — `schema.test.mjs` reports 1 passing test. (If `ajv.compile` throws, the schema is malformed — fix it.) +Run: `yarn --cwd test:review` +Expected: PASS — 1 test. (If `ajv.compile` throws, the schema is malformed — fix it.) -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit (include PnP cache zips for Zero-Install)** ```bash -git add .claude/review/config.schema.json .claude/review/engine/schema.test.mjs package.json yarn.lock -git commit -m "feat(review): scaffold config engine deps + JSON schema" +git -C add .claude/review/config.schema.json .claude/review/engine/run-tests.cjs .claude/review/engine/schema.test.cjs package.json yarn.lock .yarn/cache +git -C commit --no-verify -m "feat(review): scaffold config engine deps, runner + JSON schema" ``` --- -### Task 2: Config loader + validator (`loadConfig.mjs`) +### Task 2: Config loader + validator (`loadConfig.cjs`) **Files:** -- Create: `.claude/review/engine/loadConfig.mjs` -- Create: `.claude/review/engine/loadConfig.test.mjs` +- Create: `.claude/review/engine/loadConfig.cjs` +- Create: `.claude/review/engine/loadConfig.test.cjs` **Interfaces:** - Consumes: `config.schema.json` (Task 1). -- Produces: - - `parseConfig(yamlText: string) -> object` — YAML → JS object. - - `validateConfig(configObj: object, schemaObj: object) -> { valid: boolean, errors: string[] }`. - - `loadConfig({ configPath: string, schemaPath: string }) -> object` — reads, parses, validates; throws `Error` (message = joined errors) when invalid; returns the config object when valid. +- Produces (via `module.exports`): `parseConfig(yamlText) -> object`; `validateConfig(configObj, schemaObj) -> { valid, errors[] }`; `loadConfig({ configPath, schemaPath }) -> object` (throws on invalid). - [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/loadConfig.test.mjs`: +Create `.claude/review/engine/loadConfig.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { parseConfig, validateConfig } from './loadConfig.mjs'; - -const schema = JSON.parse( - readFileSync(fileURLToPath(new URL('../config.schema.json', import.meta.url)), 'utf8'), -); +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseConfig, validateConfig } = require('./loadConfig.cjs'); +const schema = require('../config.schema.json'); const MINIMAL = ` version: 1 @@ -282,24 +271,24 @@ test('validateConfig rejects a bad profile enum', () => { }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — cannot import `parseConfig`/`validateConfig` (module not found / not exported). +Run: `yarn --cwd test:review` → FAIL (cannot require `./loadConfig.cjs`). - [ ] **Step 3: Write minimal implementation** -Create `.claude/review/engine/loadConfig.mjs`: +Create `.claude/review/engine/loadConfig.cjs`: ```js -import { readFileSync } from 'node:fs'; -import { parse } from 'yaml'; -import Ajv from 'ajv'; +'use strict'; +const { readFileSync } = require('node:fs'); +const { parse } = require('yaml'); +const Ajv = require('ajv'); -export function parseConfig(yamlText) { +function parseConfig(yamlText) { return parse(yamlText); } -export function validateConfig(configObj, schemaObj) { +function validateConfig(configObj, schemaObj) { const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile(schemaObj); const valid = validate(configObj); @@ -309,7 +298,7 @@ export function validateConfig(configObj, schemaObj) { return { valid, errors }; } -export function loadConfig({ configPath, schemaPath }) { +function loadConfig({ configPath, schemaPath }) { const configObj = parseConfig(readFileSync(configPath, 'utf8')); const schemaObj = JSON.parse(readFileSync(schemaPath, 'utf8')); const { valid, errors } = validateConfig(configObj, schemaObj); @@ -318,42 +307,40 @@ export function loadConfig({ configPath, schemaPath }) { } return configObj; } + +module.exports = { parseConfig, validateConfig, loadConfig }; ``` -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — all `loadConfig.test.mjs` tests pass. +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/engine/loadConfig.mjs .claude/review/engine/loadConfig.test.mjs -git commit -m "feat(review): config loader + ajv validation" +git -C add .claude/review/engine/loadConfig.cjs .claude/review/engine/loadConfig.test.cjs +git -C commit --no-verify -m "feat(review): config loader + ajv validation" ``` --- -### Task 3: Risk scorer (`scoreRisk.mjs`) +### Task 3: Risk scorer (`scoreRisk.cjs`) **Files:** -- Create: `.claude/review/engine/scoreRisk.mjs` -- Create: `.claude/review/engine/scoreRisk.test.mjs` +- Create: `.claude/review/engine/scoreRisk.cjs` +- Create: `.claude/review/engine/scoreRisk.test.cjs` **Interfaces:** -- Consumes: a config object (shape from Task 1 schema). -- Produces: - - `scoreRisk({ files: string[], linesChanged: number, scope?: string, special?: string[] }, config) -> { score, level, reviewer, factors }` - where `factors = { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }`. - - Helpers (exported for reuse): `isExcluded(file, config)`, `patternPoints(file, config)`, `volumePoints(linesChanged, config)`, `levelFor(score, config)`. +- Produces: `scoreRisk({ files, linesChanged, scope?, special? }, config) -> { score, level, reviewer, factors }` where `factors = { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }`; helpers `isExcluded`, `patternPoints`, `volumePoints`, `levelFor`. - [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/scoreRisk.test.mjs`: +Create `.claude/review/engine/scoreRisk.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { scoreRisk } from './scoreRisk.mjs'; +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { scoreRisk } = require('./scoreRisk.cjs'); const config = { risk: { @@ -409,30 +396,30 @@ test('cross-cutting Apollo + Schema + pkg update scores CRITICAL', () => { }); test('excluded files do not contribute to score', () => { - const r = scoreRisk({ files: ['Foo.test.snap.snap', '__snapshots__/x.snap'], linesChanged: 10 }, config); + const r = scoreRisk({ files: ['__snapshots__/x.snap'], linesChanged: 10 }, config); assert.equal(r.factors.patternScore, 0); assert.equal(r.score, 0); }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — `scoreRisk.mjs` not found. +Run: `yarn --cwd test:review` → FAIL (`./scoreRisk.cjs` not found). - [ ] **Step 3: Write minimal implementation** -Create `.claude/review/engine/scoreRisk.mjs`: +Create `.claude/review/engine/scoreRisk.cjs`: ```js -import { minimatch } from 'minimatch'; +'use strict'; +const { minimatch } = require('minimatch'); const OPTS = { dot: true }; -export function isExcluded(file, config) { +function isExcluded(file, config) { return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); } -export function patternPoints(file, config) { +function patternPoints(file, config) { let max = 0; for (const p of config.risk.patterns) { if (minimatch(file, p.glob, OPTS)) max = Math.max(max, p.points); @@ -440,14 +427,14 @@ export function patternPoints(file, config) { return max; } -export function volumePoints(linesChanged, config) { +function volumePoints(linesChanged, config) { for (const v of config.risk.volume_multiplier) { if (v.upTo === null || linesChanged <= v.upTo) return v.points; } return 0; } -export function levelFor(score, config) { +function levelFor(score, config) { for (const l of config.risk.levels) { const [min, max] = l.range; if (score >= min && (max === null || score <= max)) return l; @@ -455,7 +442,7 @@ export function levelFor(score, config) { return config.risk.levels[config.risk.levels.length - 1]; } -export function scoreRisk({ files, linesChanged, scope = 'single_feature', special = [] }, config) { +function scoreRisk({ files, linesChanged, scope = 'single_feature', special = [] }, config) { const reviewed = files.filter((f) => !isExcluded(f, config)); const patternScore = reviewed.reduce((s, f) => s + patternPoints(f, config), 0); const volumeScore = volumePoints(linesChanged, config); @@ -472,41 +459,40 @@ export function scoreRisk({ files, linesChanged, scope = 'single_feature', speci factors: { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }, }; } + +module.exports = { scoreRisk, isExcluded, patternPoints, volumePoints, levelFor }; ``` -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — all `scoreRisk.test.mjs` tests pass. +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/engine/scoreRisk.mjs .claude/review/engine/scoreRisk.test.mjs -git commit -m "feat(review): config-driven risk scorer" +git -C add .claude/review/engine/scoreRisk.cjs .claude/review/engine/scoreRisk.test.cjs +git -C commit --no-verify -m "feat(review): config-driven risk scorer" ``` --- -### Task 4: Agent selector (`selectAgents.mjs`) +### Task 4: Agent selector (`selectAgents.cjs`) **Files:** -- Create: `.claude/review/engine/selectAgents.mjs` -- Create: `.claude/review/engine/selectAgents.test.mjs` +- Create: `.claude/review/engine/selectAgents.cjs` +- Create: `.claude/review/engine/selectAgents.test.cjs` **Interfaces:** -- Consumes: a config object. -- Produces: - - `selectAgents({ files: string[], diffText: string }, config) -> Array<{ id, model, matchedBy }>` - - `agentMatches(agent, files: string[], diffText: string) -> string | null` (the match reason, or null). +- Produces: `selectAgents({ files, diffText }, config) -> Array<{ id, model, matchedBy }>`; `agentMatches(agent, files, diffText) -> string | null`. - [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/selectAgents.test.mjs`: +Create `.claude/review/engine/selectAgents.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { selectAgents } from './selectAgents.mjs'; +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { selectAgents } = require('./selectAgents.cjs'); const config = { excluded_paths: ['**/*.snap'], @@ -522,8 +508,7 @@ const config = { test('UI-only change selects always-on agents + ux', () => { const sel = selectAgents({ files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+ const x = 1;' }, config); - const ids = sel.map((a) => a.id).sort(); - assert.deepEqual(ids, ['architecture', 'standards', 'testing', 'ux']); + assert.deepEqual(sel.map((a) => a.id).sort(), ['architecture', 'standards', 'testing', 'ux']); assert.equal(sel.find((a) => a.id === 'ux').model, 'opus'); }); @@ -538,20 +523,20 @@ test('disabled agent never selected', () => { }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — `selectAgents.mjs` not found. +Run: `yarn --cwd test:review` → FAIL (`./selectAgents.cjs` not found). - [ ] **Step 3: Write minimal implementation** -Create `.claude/review/engine/selectAgents.mjs`: +Create `.claude/review/engine/selectAgents.cjs`: ```js -import { minimatch } from 'minimatch'; +'use strict'; +const { minimatch } = require('minimatch'); const OPTS = { dot: true }; -export function agentMatches(agent, files, diffText) { +function agentMatches(agent, files, diffText) { if (agent.always) return 'always'; const t = agent.triggers || {}; for (const f of files) { @@ -565,7 +550,7 @@ export function agentMatches(agent, files, diffText) { return null; } -export function selectAgents({ files, diffText }, config) { +function selectAgents({ files, diffText }, config) { const reviewed = files.filter( (f) => !(config.excluded_paths || []).some((g) => minimatch(f, g, OPTS)), ); @@ -577,39 +562,40 @@ export function selectAgents({ files, diffText }, config) { } return out; } + +module.exports = { selectAgents, agentMatches }; ``` -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — all `selectAgents.test.mjs` tests pass. +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/engine/selectAgents.mjs .claude/review/engine/selectAgents.test.mjs -git commit -m "feat(review): config-driven agent selection" +git -C add .claude/review/engine/selectAgents.cjs .claude/review/engine/selectAgents.test.cjs +git -C commit --no-verify -m "feat(review): config-driven agent selection" ``` --- -### Task 5: Rule resolver (`resolveRules.mjs`) +### Task 5: Rule resolver (`resolveRules.cjs`) **Files:** -- Create: `.claude/review/engine/resolveRules.mjs` -- Create: `.claude/review/engine/resolveRules.test.mjs` +- Create: `.claude/review/engine/resolveRules.cjs` +- Create: `.claude/review/engine/resolveRules.test.cjs` **Interfaces:** -- Consumes: a config object. -- Produces: `resolveRules(agentId: string, files: string[], config) -> string[]` — the agent's own `rules` plus any `path_rules.rules` whose `paths` match any reviewed file, deduped, order-stable (agent rules first). +- Produces: `resolveRules(agentId, files, config) -> string[]` — agent's own `rules` plus matching `path_rules.rules`, deduped, agent rules first. - [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/resolveRules.test.mjs`: +Create `.claude/review/engine/resolveRules.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { resolveRules } from './resolveRules.mjs'; +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveRules } = require('./resolveRules.cjs'); const config = { agents: [ @@ -628,26 +614,26 @@ test('agent rules + matching path_rules, deduped', () => { assert.deepEqual(rules, ['rules/ux.md', 'rules/financial.md']); }); -test('no path_rules match → only agent rules', () => { +test('no path_rules match -> only agent rules', () => { const rules = resolveRules('security', ['src/lib/x.ts'], config); assert.deepEqual(rules, ['rules/security.md']); }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — `resolveRules.mjs` not found. +Run: `yarn --cwd test:review` → FAIL (`./resolveRules.cjs` not found). - [ ] **Step 3: Write minimal implementation** -Create `.claude/review/engine/resolveRules.mjs`: +Create `.claude/review/engine/resolveRules.cjs`: ```js -import { minimatch } from 'minimatch'; +'use strict'; +const { minimatch } = require('minimatch'); const OPTS = { dot: true }; -export function resolveRules(agentId, files, config) { +function resolveRules(agentId, files, config) { const agent = (config.agents || []).find((a) => a.id === agentId); const rules = []; const seen = new Set(); @@ -657,7 +643,7 @@ export function resolveRules(agentId, files, config) { rules.push(r); } }; - for (const r of agent?.rules || []) add(r); + for (const r of (agent && agent.rules) || []) add(r); for (const pr of config.path_rules || []) { if (files.some((f) => pr.paths.some((g) => minimatch(f, g, OPTS)))) { for (const r of pr.rules) add(r); @@ -665,52 +651,51 @@ export function resolveRules(agentId, files, config) { } return rules; } + +module.exports = { resolveRules }; ``` -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — all `resolveRules.test.mjs` tests pass. +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/engine/resolveRules.mjs .claude/review/engine/resolveRules.test.mjs -git commit -m "feat(review): rule resolver (agent + path rules)" +git -C add .claude/review/engine/resolveRules.cjs .claude/review/engine/resolveRules.test.cjs +git -C commit --no-verify -m "feat(review): rule resolver (agent + path rules)" ``` --- -### Task 6: Special-pattern detector (`detectSpecial.mjs`) +### Task 6: Special-pattern detector (`detectSpecial.cjs`) **Files:** -- Create: `.claude/review/engine/detectSpecial.mjs` -- Create: `.claude/review/engine/detectSpecial.test.mjs` +- Create: `.claude/review/engine/detectSpecial.cjs` +- Create: `.claude/review/engine/detectSpecial.test.cjs` **Interfaces:** -- Consumes: a config object (for the `critical_pkg_update` package list). -- Produces: `detectSpecial(diffText: string, changedFiles: string[], config) -> string[]` — the `when` keys that fired, deduped. Detects (deterministically from the diff): `new_dependency`, `critical_pkg_update`, `lockfile_only_change`, `graphql_without_codegen_check`, `next_config_security_change`, `apollo_cache_typepolicy_change`. +- Produces: `detectSpecial(diffText, changedFiles, config) -> string[]` — the `when` keys that fired, deduped. Detects: `new_dependency`, `critical_pkg_update`, `lockfile_only_change`, `graphql_without_codegen_check`, `next_config_security_change`, `apollo_cache_typepolicy_change`. - [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/detectSpecial.test.mjs`: +Create `.claude/review/engine/detectSpecial.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { detectSpecial } from './detectSpecial.mjs'; +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectSpecial } = require('./detectSpecial.cjs'); const config = { risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next', '@apollo/client'] }] }, }; test('detects new dependency added to package.json', () => { - const diff = '+ "lodash": "^4.17.21",'; - assert.deepEqual(detectSpecial(diff, ['package.json'], config), ['new_dependency']); + assert.deepEqual(detectSpecial('+ "lodash": "^4.17.21",', ['package.json'], config), ['new_dependency']); }); test('detects critical package update', () => { - const diff = '+ "@apollo/client": "^4.0.0",'; - const found = detectSpecial(diff, ['package.json'], config); + const found = detectSpecial('+ "@apollo/client": "^4.0.0",', ['package.json'], config); assert.ok(found.includes('critical_pkg_update')); }); @@ -730,20 +715,21 @@ test('detects apollo cache typePolicies change', () => { }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — `detectSpecial.mjs` not found. +Run: `yarn --cwd test:review` → FAIL (`./detectSpecial.cjs` not found). - [ ] **Step 3: Write minimal implementation** -Create `.claude/review/engine/detectSpecial.mjs`: +Create `.claude/review/engine/detectSpecial.cjs`: ```js +'use strict'; + function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export function detectSpecial(diffText, changedFiles, config) { +function detectSpecial(diffText, changedFiles, config) { const found = new Set(); const special = (config.risk && config.risk.special) || []; const pkgEntry = special.find((s) => s.when === 'critical_pkg_update'); @@ -752,7 +738,6 @@ export function detectSpecial(diffText, changedFiles, config) { const pkgChanged = changedFiles.includes('package.json'); const lockChanged = changedFiles.some((f) => f.endsWith('yarn.lock')); - // New dependency line added in package.json (added `"name": "version"` line). if (pkgChanged && /^\+\s*"[^"]+":\s*"[^"]+"/m.test(diffText)) found.add('new_dependency'); if (pkgChanged) { @@ -784,42 +769,41 @@ export function detectSpecial(diffText, changedFiles, config) { return [...found]; } + +module.exports = { detectSpecial }; ``` -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — all `detectSpecial.test.mjs` tests pass. +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/engine/detectSpecial.mjs .claude/review/engine/detectSpecial.test.mjs -git commit -m "feat(review): deterministic special-pattern detection" +git -C add .claude/review/engine/detectSpecial.cjs .claude/review/engine/detectSpecial.test.cjs +git -C commit --no-verify -m "feat(review): deterministic special-pattern detection" ``` --- -### Task 7: CLI entry (`plan.mjs`) — end-to-end integration +### Task 7: CLI entry (`plan.cjs`) — end-to-end integration **Files:** -- Create: `.claude/review/engine/plan.mjs` -- Create: `.claude/review/engine/plan.test.mjs` +- Create: `.claude/review/engine/plan.cjs` +- Create: `.claude/review/engine/plan.test.cjs` **Interfaces:** -- Consumes: `loadConfig`, `scoreRisk`, `selectAgents`, `resolveRules`, `detectSpecial` (Tasks 2–6). -- Produces: - - `buildPlan({ files, diffText, linesChanged, scope }, config) -> { profile, risk, agents }` - where `agents = Array<{ id, model, matchedBy, rules: string[] }>` and `risk` is the `scoreRisk` result. - - A CLI: `node plan.mjs --config --schema --files --stat --diff [--scope ]` prints the plan as JSON to stdout. `--files` is a newline-separated path list; `--stat` is `git diff --stat` output (last line "N files changed, X insertions(+), Y deletions(-)"); `--diff` is the raw unified diff. +- Consumes: Tasks 2–6. +- Produces: `buildPlan({ files, diffText, linesChanged, scope }, config) -> { profile, risk, agents }` where `agents = Array<{ id, model, matchedBy, rules: string[] }>` and `risk` is the `scoreRisk` result plus `special`. CLI: `yarn node .claude/review/engine/plan.cjs --config

--schema

--files --stat --diff [--scope ]` prints plan JSON. - [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/plan.test.mjs`: +Create `.claude/review/engine/plan.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { buildPlan } from './plan.mjs'; +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlan } = require('./plan.cjs'); const config = { profile: 'standard', @@ -847,29 +831,29 @@ test('buildPlan assembles risk + agents + resolved rules', () => { assert.equal(plan.risk.level, 'LOW'); const ux = plan.agents.find((a) => a.id === 'ux'); assert.ok(ux, 'ux selected'); - assert.deepEqual(ux.rules, ['rules/ux.md']); // deduped agent + path rule + assert.deepEqual(ux.rules, ['rules/ux.md']); const arch = plan.agents.find((a) => a.id === 'architecture'); assert.deepEqual(arch.rules, ['rules/architecture.md']); }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — `plan.mjs` not found / `buildPlan` not exported. +Run: `yarn --cwd test:review` → FAIL (`./plan.cjs` not found). - [ ] **Step 3: Write minimal implementation** -Create `.claude/review/engine/plan.mjs`: +Create `.claude/review/engine/plan.cjs`: ```js -import { readFileSync } from 'node:fs'; -import { loadConfig } from './loadConfig.mjs'; -import { scoreRisk } from './scoreRisk.mjs'; -import { selectAgents } from './selectAgents.mjs'; -import { resolveRules } from './resolveRules.mjs'; -import { detectSpecial } from './detectSpecial.mjs'; - -export function buildPlan({ files, diffText, linesChanged, scope }, config) { +'use strict'; +const { readFileSync } = require('node:fs'); +const { loadConfig } = require('./loadConfig.cjs'); +const { scoreRisk } = require('./scoreRisk.cjs'); +const { selectAgents } = require('./selectAgents.cjs'); +const { resolveRules } = require('./resolveRules.cjs'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +function buildPlan({ files, diffText, linesChanged, scope }, config) { const special = detectSpecial(diffText, files, config); const risk = scoreRisk({ files, linesChanged, scope, special }, config); const selected = selectAgents({ files, diffText }, config); @@ -884,14 +868,12 @@ function parseArgs(argv) { } function linesChangedFromStat(statText) { - // Sum insertions + deletions from `git diff --stat` summary line. - const m = statText.match(/(\d+) insertions?\(\+\)/); - const d = statText.match(/(\d+) deletions?\(-\)/); - return (m ? Number(m[1]) : 0) + (d ? Number(d[1]) : 0); + const ins = statText.match(/(\d+) insertions?\(\+\)/); + const del = statText.match(/(\d+) deletions?\(-\)/); + return (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); } -// CLI: invoked directly (not when imported). -if (import.meta.url === `file://${process.argv[1]}`) { +if (require.main === module) { const a = parseArgs(process.argv.slice(2)); const config = loadConfig({ configPath: a.config, schemaPath: a.schema }); const files = readFileSync(a.files, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); @@ -900,53 +882,55 @@ if (import.meta.url === `file://${process.argv[1]}`) { const plan = buildPlan({ files, diffText, linesChanged, scope: a.scope || 'single_feature' }, config); process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); } + +module.exports = { buildPlan, parseArgs, linesChangedFromStat }; ``` -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — `plan.test.mjs` passes; full `yarn test:review` suite green. +Run: `yarn --cwd test:review` → PASS (full suite green). - [ ] **Step 5: Commit** ```bash -git add .claude/review/engine/plan.mjs .claude/review/engine/plan.test.mjs -git commit -m "feat(review): plan CLI entry assembling risk + agents + rules" +git -C add .claude/review/engine/plan.cjs .claude/review/engine/plan.test.cjs +git -C commit --no-verify -m "feat(review): plan CLI entry assembling risk + agents + rules" ``` --- -### Task 8: Author the real `config.yml` (migrate from code-review.md) +### Task 8: Author the real `config.yml` **Files:** - Create: `.claude/review/config.yml` -- Create: `.claude/review/engine/realConfig.test.mjs` +- Create: `.claude/review/engine/realConfig.test.cjs` -**Interfaces:** -- Consumes: `loadConfig` (Task 2), `config.schema.json` (Task 1). -- Produces: the production config the command reads. +**Interfaces:** Consumes `loadConfig` (Task 2), `config.schema.json` (Task 1). -- [ ] **Step 1: Write the failing test (the real config must load + validate + have expected shape)** +- [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/realConfig.test.mjs`: +Create `.claude/review/engine/realConfig.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { fileURLToPath } from 'node:url'; -import { loadConfig } from './loadConfig.mjs'; +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); -const configPath = fileURLToPath(new URL('../config.yml', import.meta.url)); -const schemaPath = fileURLToPath(new URL('../config.schema.json', import.meta.url)); +const configPath = path.join(__dirname, '../config.yml'); +const schemaPath = path.join(__dirname, '../config.schema.json'); test('real config.yml loads and validates', () => { - const cfg = loadConfig({ configPath, schemaPath }); // throws if invalid + const cfg = loadConfig({ configPath, schemaPath }); assert.equal(cfg.version, 1); }); test('real config defines the 7 MPDX agents', () => { const cfg = loadConfig({ configPath, schemaPath }); - const ids = cfg.agents.map((a) => a.id).sort(); - assert.deepEqual(ids, ['architecture', 'data-integrity', 'financial', 'security', 'standards', 'testing', 'ux']); + assert.deepEqual( + cfg.agents.map((a) => a.id).sort(), + ['architecture', 'data-integrity', 'financial', 'security', 'standards', 'testing', 'ux'], + ); }); test('real config reserves inert index/learning sections', () => { @@ -957,31 +941,30 @@ test('real config reserves inert index/learning sections', () => { }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — `config.yml` does not exist (`loadConfig` throws ENOENT). +Run: `yarn --cwd test:review` → FAIL (`config.yml` missing). - [ ] **Step 3: Write the config** -Create `.claude/review/config.yml` using the schema from §4.1 of the spec -(`docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`). Copy the full annotated -YAML from that spec section verbatim — it already enumerates the risk patterns, the 7 agents with -triggers, `path_rules`, `excluded_paths`, and the inert `index`/`learning`/`enforcement` sections. -Cross-check every risk pattern, trigger glob, and excluded path against `.claude/rules/code-review.md` -so nothing is dropped (the "Critical/High/Medium/Low File Patterns", "Special Pattern Detection", -"Agent Triggers", and "Excluded Paths" sections map 1:1). +Create `.claude/review/config.yml` from §4.1 of the spec +(`docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`) — copy it verbatim +(note: spec paths read `.claude/...` lowercase). It enumerates risk patterns, the 7 agents +(`security`, `architecture`, `data-integrity`, `testing`, `ux`, `financial`, `standards`) with +triggers, `path_rules`, `excluded_paths`, and inert `index`/`learning`/`enforcement`. Cross-check +every risk pattern, trigger glob, and excluded path against `.claude/rules/code-review.md` so +nothing is dropped (its Critical/High/Medium/Low File Patterns, Special Pattern Detection, Agent +Triggers, Excluded Paths map 1:1). Each agent's `rules:` points to `rules/.md` (Task 9). -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — `realConfig.test.mjs` passes (config loads, validates, 7 agents, inert sections present). +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/config.yml .claude/review/engine/realConfig.test.mjs -git commit -m "feat(review): author config.yml migrated from code-review.md" +git -C add .claude/review/config.yml .claude/review/engine/realConfig.test.cjs +git -C commit --no-verify -m "feat(review): author config.yml migrated from code-review.md" ``` --- @@ -989,25 +972,27 @@ git commit -m "feat(review): author config.yml migrated from code-review.md" ### Task 9: Migrate prose rule docs (`rules/*.md`) **Files:** -- Create: `.claude/review/rules/security.md`, `architecture.md`, `data-integrity.md`, `testing.md`, `ux.md`, `financial.md`, `standards.md` -- Create: `.claude/review/engine/rulesCoverage.test.mjs` +- Create: `.claude/review/rules/{security,architecture,data-integrity,testing,ux,financial,standards}.md` +- Create: `.claude/review/engine/rulesCoverage.test.cjs` -**Interfaces:** -- Consumes: the `rules` paths referenced in `config.yml` (Task 8). -- Produces: the prose guidance each agent loads. +**Interfaces:** Consumes the `rules` paths referenced in `config.yml` (Task 8). -- [ ] **Step 1: Write the failing test (every referenced rule doc must exist and be non-trivial)** +- [ ] **Step 1: Write the failing test** -Create `.claude/review/engine/rulesCoverage.test.mjs`: +Create `.claude/review/engine/rulesCoverage.test.cjs`: ```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync, existsSync, statSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { loadConfig } from './loadConfig.mjs'; - -const root = fileURLToPath(new URL('../', import.meta.url)); -const cfg = loadConfig({ configPath: `${root}config.yml`, schemaPath: `${root}config.schema.json` }); +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const root = path.join(__dirname, '..'); +const cfg = loadConfig({ + configPath: path.join(root, 'config.yml'), + schemaPath: path.join(root, 'config.schema.json'), +}); function referencedRules() { const set = new Set(); @@ -1018,22 +1003,21 @@ function referencedRules() { test('every rule doc referenced by config exists and is non-empty', () => { for (const rel of referencedRules()) { - const p = `${root}${rel}`; + const p = path.join(root, rel); assert.ok(existsSync(p), `missing rule doc: ${rel}`); assert.ok(statSync(p).size > 200, `rule doc too small (placeholder?): ${rel}`); } }); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: Run to verify it fails** -Run: `yarn test:review` -Expected: FAIL — referenced rule docs do not exist yet. +Run: `yarn --cwd test:review` → FAIL (rule docs missing). - [ ] **Step 3: Migrate the prose** -Move the natural-language sections of `.claude/rules/code-review.md` into the matching -`.claude/review/rules/*.md`, **verbatim** (reorganize, do not rewrite): +Move the NL sections of `.claude/rules/code-review.md` into matching `.claude/review/rules/*.md`, +**verbatim** (reorganize, don't rewrite): - `rules/security.md` ← "Security Focus Areas" - `rules/architecture.md` ← "Architecture Focus Areas" - `rules/data-integrity.md` ← "Data Integrity Focus Areas" @@ -1042,18 +1026,17 @@ Move the natural-language sections of `.claude/rules/code-review.md` into the ma - `rules/financial.md` ← "Domain Agents → Financial Reporting Agent" - `rules/standards.md` ← "Standards Checklist" -Each file starts with a short H1 (e.g. `# Security Review Rules`) then the migrated content. +Each starts with a short H1 then the migrated content. -- [ ] **Step 4: Run test to verify it passes** +- [ ] **Step 4: Run to verify it passes** -Run: `yarn test:review` -Expected: PASS — `rulesCoverage.test.mjs` passes (all referenced docs exist and are >200 bytes). +Run: `yarn --cwd test:review` → PASS. - [ ] **Step 5: Commit** ```bash -git add .claude/review/rules/ .claude/review/engine/rulesCoverage.test.mjs -git commit -m "feat(review): migrate prose focus-areas into rules/*.md" +git -C add .claude/review/rules .claude/review/engine/rulesCoverage.test.cjs +git -C commit --no-verify -m "feat(review): migrate prose focus-areas into rules/*.md" ``` --- @@ -1061,19 +1044,17 @@ git commit -m "feat(review): migrate prose focus-areas into rules/*.md" ### Task 10: Refactor `agent-review.md` to consume the engine **Files:** -- Modify: `.claude/commands/agent-review.md` (Stage 0, Stage 0B, Stage 1 only) +- Modify: `.claude/commands/agent-review.md` (Stage 0, 0B, 1 only) -**Interfaces:** -- Consumes: `plan.mjs` JSON output (Task 7). +**Interfaces:** Consumes `plan.cjs` JSON output (Task 7). - [ ] **Step 1: Replace Stage 0's risk algorithm with an engine call** -In `.claude/commands/agent-review.md`, after the existing diff-gathering bash (which writes -`/tmp/changed_files.txt`, `/tmp/diff_stat.txt`, `/tmp/pr_diff.txt`), insert a call to the engine and -replace the prose "Calculate Risk Score" instructions with consumption of its output: +After the existing diff-gathering bash (writes `/tmp/changed_files.txt`, `/tmp/diff_stat.txt`, +`/tmp/pr_diff.txt`), insert (note **`yarn node`**): ```bash REVIEW_DIR=".claude/review" -node "$REVIEW_DIR/engine/plan.mjs" \ +yarn node "$REVIEW_DIR/engine/plan.cjs" \ --config "$REVIEW_DIR/config.yml" \ --schema "$REVIEW_DIR/config.schema.json" \ --files /tmp/changed_files.txt \ @@ -1083,49 +1064,42 @@ node "$REVIEW_DIR/engine/plan.mjs" \ > /tmp/review_plan.json cat /tmp/review_plan.json ``` -Update the surrounding markdown so the risk display (the "PR RISK ASSESSMENT" block) reads -`risk.score`, `risk.level`, `risk.reviewer`, and `risk.special` from `/tmp/review_plan.json` -instead of computing them inline. Note that `REVIEW_SCOPE` is the heuristic scope the model sets -(`single_file`/`single_feature`/`multi_feature`/`cross_cutting`/`core_infra`); default -`single_feature`. +Update the "PR RISK ASSESSMENT" block to read `risk.score`, `risk.level`, `risk.reviewer`, +`risk.special` from `/tmp/review_plan.json` instead of computing inline. `REVIEW_SCOPE` is the +heuristic scope the model sets (default `single_feature`). - [ ] **Step 2: Replace Stage 0B smart-selection with the engine's agent list** -Replace the hardcoded `grep`-based agent selection in Stage 0B with: read -`/tmp/review_plan.json`'s `agents[]`. Each entry has `id`, `model`, `matchedBy`, and `rules`. The -set of agents to launch IS this list (smart selection is now config-driven). Remove the per-agent -`*_NEEDED` bash flags and the `SELECTED_AGENTS` assembly; keep the display that announces which -agents were selected and why (`matchedBy`). +Replace hardcoded `grep` selection with: read `/tmp/review_plan.json`'s `agents[]` (each has `id`, +`model`, `matchedBy`, `rules`). That list IS the set of agents to launch. Remove `*_NEEDED` flags +and `SELECTED_AGENTS` assembly; keep the announcement of selected agents + `matchedBy` reasons. - [ ] **Step 3: Wire rules + profile into Stage 1 agent prompts** -In Stage 1, when launching each agent, instruct the command to (a) read each rule doc listed in -that agent's `rules[]` (e.g. `.claude/review/rules/security.md`) and include its contents in the -agent prompt, and (b) apply the `profile` from the plan: add a line to each agent prompt — `chill` -→ "Report only high-confidence, severity ≥ 7 findings; suppress nits." / `standard` → current -behavior / `assertive` → "Report all findings including low-severity suggestions." Also update -Stage 5 (Consensus) so the severity cutoffs scale with `profile` (chill raises, assertive lowers -the thresholds in the existing Consensus Levels table). +When launching each agent: (a) read each rule doc in that agent's `rules[]` (e.g. +`.claude/review/rules/security.md`) into the prompt; (b) apply `profile` — `chill` → "Report only +high-confidence, severity ≥ 7 findings; suppress nits."; `standard` → current behavior; +`assertive` → "Report all findings including low-severity suggestions." Update Stage 5 (Consensus) +so cutoffs scale with `profile`. -- [ ] **Step 4: Manual verification (no unit test — this is the orchestrator)** +- [ ] **Step 4: Manual verification (no unit test — orchestrator)** -Run the engine against a real branch to confirm the command's new inputs are well-formed: ```bash -git diff --name-only main...HEAD > /tmp/changed_files.txt -git diff --stat main...HEAD > /tmp/diff_stat.txt -git diff main...HEAD > /tmp/pr_diff.txt -node .claude/review/engine/plan.mjs \ +git -C diff --name-only HEAD~1 > /tmp/changed_files.txt +git -C diff --stat HEAD~1 > /tmp/diff_stat.txt +git -C diff HEAD~1 > /tmp/pr_diff.txt +yarn --cwd node .claude/review/engine/plan.cjs \ --config .claude/review/config.yml --schema .claude/review/config.schema.json \ --files /tmp/changed_files.txt --stat /tmp/diff_stat.txt --diff /tmp/pr_diff.txt ``` -Expected: valid JSON with `profile`, `risk` (score/level/reviewer/special), and `agents[]` (each -with `rules`). Confirm the selected agents and risk level are sensible for the branch's changes. +Expected: valid JSON with `profile`, `risk`, `agents[]` (each with `rules`). Then run +`yarn --cwd test:review` and confirm the engine suite is still green. - [ ] **Step 5: Commit** ```bash -git add .claude/commands/agent-review.md -git commit -m "refactor(review): drive risk + agent selection from config engine" +git -C add .claude/commands/agent-review.md +git -C commit --no-verify -m "refactor(review): drive risk + agent selection from config engine" ``` --- @@ -1135,12 +1109,9 @@ git commit -m "refactor(review): drive risk + agent selection from config engine **Files:** - Modify: `.claude/rules/code-review.md` (reduce to a pointer) -**Interfaces:** none (cleanup + verification). - - [ ] **Step 1: Replace `code-review.md` with a pointer** -Replace the full contents of `.claude/rules/code-review.md` with a short pointer so nothing links -to stale duplicated rules: +Replace the full contents of `.claude/rules/code-review.md` with: ```markdown # MPDX React — Code Review Rules (moved) @@ -1155,50 +1126,24 @@ See the design spec: `docs/superpowers/specs/2026-06-22-agent-review-config-laye - [ ] **Step 2: Run the full engine test suite** -Run: `yarn test:review` -Expected: PASS — all engine tests across Tasks 1–9 green. +Run: `yarn --cwd test:review` → PASS (all Tasks 1–9 tests green). -- [ ] **Step 3: Confirm the app's own checks are unaffected** +- [ ] **Step 3: Confirm app's own checks unaffected** -Run: `yarn lint:ts` -Expected: PASS — TypeScript check unaffected (the `.claude/review/` engine is plain JS outside -`tsconfig` scope). If `tsc` tries to type-check the engine, add `.claude/` to `tsconfig`'s -`exclude` and note it in the commit. +Run: `yarn --cwd lint:ts` → PASS (engine is plain CJS outside `tsconfig` scope). If +`tsc` tries to type-check the engine, add `.claude/` to `tsconfig`'s `exclude` and note it. - [ ] **Step 4: Commit** ```bash -git add .claude/rules/code-review.md -git commit -m "chore(review): supersede code-review.md with pointer to review core" +git -C add .claude/rules/code-review.md +git -C commit --no-verify -m "chore(review): supersede code-review.md with pointer to review core" ``` --- -## Self-Review - -**1. Spec coverage:** -- Config layer + YAML + schema → Tasks 1, 2, 8 ✓ -- Risk scoring migrated → Task 3 + config in Task 8 ✓ -- Agent definitions + triggers → Tasks 4, 8 ✓ -- Per-path rules → Tasks 5, 8 ✓ -- Severity/verbosity profile → Task 10 (Steps 3, applied in command + Consensus) ✓ -- JSON Schema validation → Tasks 1, 2 ✓ -- Special-pattern detection → Task 6 ✓ -- Command consumes config (Stage 0–1 refactor; debate/consensus untouched) → Task 10 ✓ -- Prose rule docs migrated verbatim → Task 9 ✓ -- Behavior-preservation (risk/selection parity fixtures) → Tasks 3, 4, 7 ✓ -- Inert index/learning/enforcement keys → Tasks 1 (schema), 8 (config), 8 test ✓ -- Supersede code-review.md → Task 11 ✓ -- Acceptance criteria §7.2 (1–7) → all mapped above ✓ - -**2. Placeholder scan:** No "TBD/TODO"; every code step shows complete code. Task 8 Step 3 and Task 9 Step 3 reference verbatim migration from named source sections rather than re-printing large prose — intentional (the content is long and already authored in the spec/`code-review.md`), and each is gated by a concrete test (Tasks 8, 9 tests). - -**3. Type consistency:** `buildPlan` (Task 7) consumes `scoreRisk`/`selectAgents`/`resolveRules`/`detectSpecial` with the exact signatures defined in Tasks 3–6. `plan.json` shape (`{ profile, risk, agents:[{id,model,matchedBy,rules}] }`) is produced in Task 7 and consumed in Task 10. `matchedBy` string format (`always` / `path:` / `content:`) is consistent between Task 4 and Task 10's display. Risk `factors` keys consistent between Task 3 impl and tests. - ---- - ## Notes for the executor -- **Branch first.** This work is unrelated to the current `mpd-supervisor-admin` branch — create a dedicated branch (e.g. `review-config-layer`) before Task 1, or use an isolated worktree. -- Engine is intentionally framework-free so a future CLI/UI imports the same modules. -- If `yarn add -D` is undesirable in this repo, the only hard requirement is that `yaml`, `minimatch`, and `ajv` resolve for `node --test`; vendoring is an acceptable alternative but adds maintenance. +- Engine is framework-free CommonJS so a future CLI/UI can `require()` the same modules. +- **Never run `node --test`** under PnP — always `yarn test:review`. +- All paths lowercase `.claude/`; all commits `--no-verify`. From 8a4f9864da175820501d513cb978177c1c75dc8d Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:20:33 -0400 Subject: [PATCH 03/39] feat(review): scaffold config engine deps, runner + JSON schema --- .claude/review/config.schema.json | 127 ++++++++++++++++++ .claude/review/engine/run-tests.cjs | 9 ++ .claude/review/engine/schema.test.cjs | 12 ++ .../ajv-npm-8.20.0-d622223dad-5ce59c0537.zip | 3 + ...-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip | 3 + ...ansion-npm-5.0.6-abf39a1281-a7acf120fe.zip | 3 + ...st-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip | 3 + ...match-npm-10.2.5-f1c8297822-19e87a931a.zip | 3 + .../yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip | 3 + package.json | 6 +- yarn.lock | 56 ++++++++ 11 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 .claude/review/config.schema.json create mode 100644 .claude/review/engine/run-tests.cjs create mode 100644 .claude/review/engine/schema.test.cjs create mode 100644 .yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip create mode 100644 .yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip create mode 100644 .yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip create mode 100644 .yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip create mode 100644 .yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip create mode 100644 .yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip diff --git a/.claude/review/config.schema.json b/.claude/review/config.schema.json new file mode 100644 index 0000000000..4371d8b4b1 --- /dev/null +++ b/.claude/review/config.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mpdx.org/review/config.schema.json", + "title": "MPDX Agent-Review Config", + "type": "object", + "additionalProperties": false, + "required": ["version", "profile", "risk", "agents", "excluded_paths"], + "properties": { + "version": { "type": "integer", "enum": [1] }, + "profile": { "type": "string", "enum": ["chill", "standard", "assertive"] }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": ["patterns", "volume_multiplier", "scope_multiplier", "special", "levels"], + "properties": { + "patterns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["glob", "points"], + "properties": { + "glob": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "tier": { "type": "string", "enum": ["critical", "high", "medium", "low"] } + } + } + }, + "volume_multiplier": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["upTo", "points"], + "properties": { + "upTo": { "type": ["integer", "null"] }, + "points": { "type": "integer", "minimum": 0 } + } + } + }, + "scope_multiplier": { "type": "object", "additionalProperties": { "type": "number" } }, + "special": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["when", "points"], + "properties": { + "when": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "packages": { "type": "array", "items": { "type": "string" } } + } + } + }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["range", "level", "reviewer"], + "properties": { + "range": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": ["integer", "null"] } }, + "level": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, + "reviewer": { "type": "string" } + } + } + } + } + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "enabled": { "type": "boolean" }, + "always": { "type": "boolean" }, + "model": { "type": "string", "enum": ["smart", "opus", "sonnet", "haiku"] }, + "triggers": { + "type": "object", + "additionalProperties": false, + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "content": { "type": "array", "items": { "type": "string" } } + } + }, + "rules": { "type": "array", "items": { "type": "string" } } + } + } + }, + "path_rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["paths", "rules"], + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "rules": { "type": "array", "items": { "type": "string" } } + } + } + }, + "excluded_paths": { "type": "array", "items": { "type": "string" } }, + "index": { + "type": "object", + "additionalProperties": false, + "properties": { "enabled": { "type": "boolean" }, "path": { "type": "string" } } + }, + "learning": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" }, + "approval_required": { "type": "boolean" }, + "scope": { "type": "string", "enum": ["local", "global"] } + } + }, + "enforcement": { + "type": "object", + "additionalProperties": false, + "properties": { "mode": { "type": "string", "enum": ["warn", "block"] } } + } + } +} diff --git a/.claude/review/engine/run-tests.cjs b/.claude/review/engine/run-tests.cjs new file mode 100644 index 0000000000..5ddd6d1d88 --- /dev/null +++ b/.claude/review/engine/run-tests.cjs @@ -0,0 +1,9 @@ +'use strict'; +// Requires every *.test.cjs here so node:test runs them in ONE process. +// (yarn node injects the PnP loader; node --test workers would not — do not use --test.) +const { readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +for (const f of readdirSync(__dirname).sort()) { + if (f.endsWith('.test.cjs')) require(join(__dirname, f)); +} diff --git a/.claude/review/engine/schema.test.cjs b/.claude/review/engine/schema.test.cjs new file mode 100644 index 0000000000..0a71b643d2 --- /dev/null +++ b/.claude/review/engine/schema.test.cjs @@ -0,0 +1,12 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +// Schema uses the JSON Schema draft 2020-12 dialect; Ajv's 2020 entry point +// ships that meta-schema (the default `ajv` export only knows draft-07). +const Ajv = require('ajv/dist/2020').default || require('ajv/dist/2020'); +const schema = require('../config.schema.json'); + +test('config.schema.json is a valid, compilable JSON Schema', () => { + const validate = new Ajv({ allErrors: true }).compile(schema); // throws if malformed + assert.equal(typeof validate, 'function'); +}); diff --git a/.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip b/.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip new file mode 100644 index 0000000000..f2b3667a2b --- /dev/null +++ b/.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:176021814afbb71eabc73dbd3e5c0b048081135decc731227e9ab7a0eeccda83 +size 404805 diff --git a/.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip b/.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip new file mode 100644 index 0000000000..1c061e08f3 --- /dev/null +++ b/.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc5f42bb1c0134598e3bac55f7f69f4f0583c5c2b40264a77f66804cd554e126 +size 9282 diff --git a/.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip b/.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip new file mode 100644 index 0000000000..6af2179546 --- /dev/null +++ b/.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7d97b74a96a385ebfedc13c9f2fd3dec9bfbc82dfee625ddb655df618f0091f +size 17717 diff --git a/.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip b/.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip new file mode 100644 index 0000000000..44a63a3373 --- /dev/null +++ b/.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac3811a3da7ee11091ed4a77420f46ba11a9a26fe6490cb493a5c834f2b58657 +size 39136 diff --git a/.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip b/.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip new file mode 100644 index 0000000000..1da8c94d17 --- /dev/null +++ b/.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f422ded4e2b4176ca800c71e03c74fe3f257bcea220972dced2202fb243796b +size 152814 diff --git a/.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip b/.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip new file mode 100644 index 0000000000..0478e86512 --- /dev/null +++ b/.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17c8114431ae3baf6e8b20751fb741894fe38ba4f4b851216ea8d9197deca44b +size 246306 diff --git a/package.json b/package.json index 0545f744be..a7726ec2f8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint:ci": "eslint '*/**/*.{js,ts,tsx}'", "lint:ts": "tsc", "test": "jest --silent", + "test:review": "yarn node .claude/review/engine/run-tests.cjs", "test:log": "jest", "test:watch": "jest --watch", "localtest": "yarn test --runInBand --verbose", @@ -115,6 +116,7 @@ "@types/testing-library__jest-dom": "^5.14.5", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^8.17.0", + "ajv": "^8.20.0", "concurrently": "^8.2.2", "css-mediaquery": "^0.1.2", "dnd-core": "^16.0.1", @@ -135,6 +137,7 @@ "jest-fetch-mock": "^3.0.3", "lighthouse": "^11.7.0", "lint-staged": "^13.0.3", + "minimatch": "^10.2.5", "next-compose-plugins": "^2.2.1", "node-mocks-http": "1.16.2", "prettier": "^3.6.2", @@ -142,7 +145,8 @@ "ts-essentials": "^9.3.0", "typescript": "~6.0.0", "url-loader": "^4.1.1", - "webpack": "^5.96.1" + "webpack": "^5.96.1", + "yaml": "^2.9.0" }, "resolutions": { "chokidar/glob-parent": "^5.1.2", diff --git a/yarn.lock b/yarn.lock index 3653dc30de..c84a67dcca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6861,6 +6861,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.20.0": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/5ce59c0537f4c2aca9a758b412659ec70acb4d5dde971c10ecf21d2e3d799f99acdb4a08e1f5fb2e067c8542930398aae793bb996bb07d3feb81dae22fe2ada9 + languageName: node + linkType: hard + "ajv@npm:^8.6.0": version: 8.11.0 resolution: "ajv@npm:8.11.0" @@ -7464,6 +7476,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + "bare-events@npm:^2.0.0, bare-events@npm:^2.2.0": version: 2.2.2 resolution: "bare-events@npm:2.2.2" @@ -7658,6 +7677,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.5": + version: 5.0.6 + resolution: "brace-expansion@npm:5.0.6" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/a7acf120fefa79e9d7c9c92898114f57c07596a3920197f3c5917e6a628b04220a5f7f9618c30bdd973a6576a32113b99f9c3f1c8245ccc399dd2a9a718d81d8 + languageName: node + linkType: hard + "braces@npm:^2.3.1": version: 2.3.2 resolution: "braces@npm:2.3.2" @@ -10702,6 +10730,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.2 + resolution: "fast-uri@npm:3.1.2" + checksum: 10/1dff04865b2a38d3e0659deadfbf72efdf83a776bfbf9667e4aa9e5a3ec31bc341cda9622136b32b7652a857c8ba11896794186e8f876f8b2b72731fce8622f6 + languageName: node + linkType: hard + "fast-url-parser@npm:^1.1.3": version: 1.1.3 resolution: "fast-url-parser@npm:1.1.3" @@ -14484,6 +14519,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.5": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -14736,6 +14780,7 @@ __metadata: "@types/testing-library__jest-dom": "npm:^5.14.5" "@typescript-eslint/eslint-plugin": "npm:^7.5.0" "@typescript-eslint/parser": "npm:^8.17.0" + ajv: "npm:^8.20.0" apollo3-cache-persist: "npm:^0.14.1" clsx: "npm:^2.1.1" concurrently: "npm:^8.2.2" @@ -14769,6 +14814,7 @@ __metadata: lodash: "npm:^4.17.21" luxon: "npm:^3.4.4" micro-cors: "npm:^0.1.1" + minimatch: "npm:^10.2.5" next: "npm:^15.0.3" next-auth: "npm:^4.24.11" next-compose-plugins: "npm:^2.2.1" @@ -14794,6 +14840,7 @@ __metadata: typescript: "npm:~6.0.0" url-loader: "npm:^4.1.1" webpack: "npm:^5.96.1" + yaml: "npm:^2.9.0" yup: "npm:^1.4.0" languageName: unknown linkType: soft @@ -20328,6 +20375,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.9.0": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10/9a95e8e08651c3d292ab6a5befeb5f57b76801caa097c75bb45c9a70ce19c1b11f57e87a6ef84a579ea070ed2c2c8ac541c88c0ae684d544d5f42c7e77d11b7b + languageName: node + linkType: hard + "yargs-parser@npm:^13.1.2": version: 13.1.2 resolution: "yargs-parser@npm:13.1.2" From b0e119cf6db5e0fa61f362c0c032c43b6beb755f Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:23:23 -0400 Subject: [PATCH 04/39] feat(review): config loader + ajv validation --- .claude/review/engine/loadConfig.cjs | 30 +++++++++++++++++++ .claude/review/engine/loadConfig.test.cjs | 36 +++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .claude/review/engine/loadConfig.cjs create mode 100644 .claude/review/engine/loadConfig.test.cjs diff --git a/.claude/review/engine/loadConfig.cjs b/.claude/review/engine/loadConfig.cjs new file mode 100644 index 0000000000..a974438b27 --- /dev/null +++ b/.claude/review/engine/loadConfig.cjs @@ -0,0 +1,30 @@ +'use strict'; +const { readFileSync } = require('node:fs'); +const { parse } = require('yaml'); +const Ajv = require('ajv/dist/2020'); + +function parseConfig(yamlText) { + return parse(yamlText); +} + +function validateConfig(configObj, schemaObj) { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schemaObj); + const valid = validate(configObj); + const errors = valid + ? [] + : (validate.errors || []).map((e) => `${e.instancePath || '(root)'} ${e.message}`); + return { valid, errors }; +} + +function loadConfig({ configPath, schemaPath }) { + const configObj = parseConfig(readFileSync(configPath, 'utf8')); + const schemaObj = JSON.parse(readFileSync(schemaPath, 'utf8')); + const { valid, errors } = validateConfig(configObj, schemaObj); + if (!valid) { + throw new Error(`Invalid review config (${configPath}):\n- ${errors.join('\n- ')}`); + } + return configObj; +} + +module.exports = { parseConfig, validateConfig, loadConfig }; diff --git a/.claude/review/engine/loadConfig.test.cjs b/.claude/review/engine/loadConfig.test.cjs new file mode 100644 index 0000000000..e4bd61fe87 --- /dev/null +++ b/.claude/review/engine/loadConfig.test.cjs @@ -0,0 +1,36 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseConfig, validateConfig } = require('./loadConfig.cjs'); +const schema = require('../config.schema.json'); + +const MINIMAL = ` +version: 1 +profile: standard +risk: + patterns: [{ glob: "src/**", points: 1, tier: medium }] + volume_multiplier: [{ upTo: null, points: 0 }] + scope_multiplier: { single_feature: 1.0 } + special: [] + levels: [{ range: [0, null], level: LOW, reviewer: entry }] +agents: [{ id: standards, always: true }] +excluded_paths: [] +`; + +test('parseConfig parses YAML to an object', () => { + const cfg = parseConfig(MINIMAL); + assert.equal(cfg.version, 1); + assert.equal(cfg.agents[0].id, 'standards'); +}); + +test('validateConfig accepts a valid config', () => { + const { valid, errors } = validateConfig(parseConfig(MINIMAL), schema); + assert.equal(valid, true, errors.join('; ')); +}); + +test('validateConfig rejects a bad profile enum', () => { + const bad = parseConfig(MINIMAL.replace('profile: standard', 'profile: nope')); + const { valid, errors } = validateConfig(bad, schema); + assert.equal(valid, false); + assert.ok(errors.some((e) => e.includes('profile')), errors.join('; ')); +}); From 3bfac9d9c81688efa81d9b3570ed31eab027269b Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:25:46 -0400 Subject: [PATCH 05/39] feat(review): config-driven risk scorer --- .claude/review/engine/scoreRisk.cjs | 51 +++++++++++++++++++ .claude/review/engine/scoreRisk.test.cjs | 63 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 .claude/review/engine/scoreRisk.cjs create mode 100644 .claude/review/engine/scoreRisk.test.cjs diff --git a/.claude/review/engine/scoreRisk.cjs b/.claude/review/engine/scoreRisk.cjs new file mode 100644 index 0000000000..0ce82dba34 --- /dev/null +++ b/.claude/review/engine/scoreRisk.cjs @@ -0,0 +1,51 @@ +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function isExcluded(file, config) { + return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); +} + +function patternPoints(file, config) { + let max = 0; + for (const p of config.risk.patterns) { + if (minimatch(file, p.glob, OPTS)) max = Math.max(max, p.points); + } + return max; +} + +function volumePoints(linesChanged, config) { + for (const v of config.risk.volume_multiplier) { + if (v.upTo === null || linesChanged <= v.upTo) return v.points; + } + return 0; +} + +function levelFor(score, config) { + for (const l of config.risk.levels) { + const [min, max] = l.range; + if (score >= min && (max === null || score <= max)) return l; + } + return config.risk.levels[config.risk.levels.length - 1]; +} + +function scoreRisk({ files, linesChanged, scope = 'single_feature', special = [] }, config) { + const reviewed = files.filter((f) => !isExcluded(f, config)); + const patternScore = reviewed.reduce((s, f) => s + patternPoints(f, config), 0); + const volumeScore = volumePoints(linesChanged, config); + const specialMap = new Map(config.risk.special.map((s) => [s.when, s.points])); + const specialScore = special.reduce((s, k) => s + (specialMap.get(k) || 0), 0); + const subtotal = patternScore + volumeScore + specialScore; + const scopeMultiplier = config.risk.scope_multiplier[scope] ?? 1.0; + const score = Math.round(subtotal * scopeMultiplier); + const lvl = levelFor(score, config); + return { + score, + level: lvl.level, + reviewer: lvl.reviewer, + factors: { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }, + }; +} + +module.exports = { scoreRisk, isExcluded, patternPoints, volumePoints, levelFor }; diff --git a/.claude/review/engine/scoreRisk.test.cjs b/.claude/review/engine/scoreRisk.test.cjs new file mode 100644 index 0000000000..4324226173 --- /dev/null +++ b/.claude/review/engine/scoreRisk.test.cjs @@ -0,0 +1,63 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { scoreRisk } = require('./scoreRisk.cjs'); + +const config = { + risk: { + patterns: [ + { glob: 'src/lib/apollo/{client,link,cache,ssrClient}.ts', points: 3, tier: 'critical' }, + { glob: 'pages/api/Schema/**/*.{ts,graphql}', points: 2, tier: 'high' }, + { glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }, + { glob: '**/*.test.{ts,tsx}', points: 0, tier: 'low' }, + ], + volume_multiplier: [ + { upTo: 50, points: 0 }, + { upTo: 200, points: 1 }, + { upTo: 500, points: 2 }, + { upTo: 1000, points: 3 }, + { upTo: null, points: 4 }, + ], + scope_multiplier: { single_feature: 1.0, cross_cutting: 1.7 }, + special: [{ when: 'critical_pkg_update', points: 3 }], + levels: [ + { range: [0, 3], level: 'LOW', reviewer: 'entry' }, + { range: [4, 6], level: 'MEDIUM', reviewer: 'entry' }, + { range: [7, 9], level: 'HIGH', reviewer: 'experienced' }, + { range: [10, null], level: 'CRITICAL', reviewer: 'Caleb Cox (senior)' }, + ], + }, + excluded_paths: ['**/*.snap'], +}; + +test('UI-only small change scores LOW', () => { + const r = scoreRisk({ files: ['src/components/Tasks/TaskRow.tsx'], linesChanged: 30 }, config); + assert.equal(r.factors.patternScore, 1); + assert.equal(r.factors.volumeScore, 0); + assert.equal(r.score, 1); + assert.equal(r.level, 'LOW'); +}); + +test('cross-cutting Apollo + Schema + pkg update scores CRITICAL', () => { + const r = scoreRisk( + { + files: ['src/lib/apollo/cache.ts', 'src/lib/apollo/link.ts', 'pages/api/Schema/Foo/resolvers.ts'], + linesChanged: 600, + scope: 'cross_cutting', + special: ['critical_pkg_update'], + }, + config, + ); + assert.equal(r.factors.patternScore, 8); // 3 + 3 + 2 + assert.equal(r.factors.volumeScore, 3); // 600 -> upTo 1000 + assert.equal(r.factors.specialScore, 3); + assert.equal(r.factors.subtotal, 14); + assert.equal(r.score, 24); // round(14 * 1.7) + assert.equal(r.level, 'CRITICAL'); +}); + +test('excluded files do not contribute to score', () => { + const r = scoreRisk({ files: ['__snapshots__/x.snap'], linesChanged: 10 }, config); + assert.equal(r.factors.patternScore, 0); + assert.equal(r.score, 0); +}); From 8f73e31115f86b608e547466e307c376f81455f8 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:27:09 -0400 Subject: [PATCH 06/39] feat(review): config-driven agent selection --- .claude/review/engine/selectAgents.cjs | 33 +++++++++++++++++++++ .claude/review/engine/selectAgents.test.cjs | 32 ++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .claude/review/engine/selectAgents.cjs create mode 100644 .claude/review/engine/selectAgents.test.cjs diff --git a/.claude/review/engine/selectAgents.cjs b/.claude/review/engine/selectAgents.cjs new file mode 100644 index 0000000000..646fc57b9d --- /dev/null +++ b/.claude/review/engine/selectAgents.cjs @@ -0,0 +1,33 @@ +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function agentMatches(agent, files, diffText) { + if (agent.always) return 'always'; + const t = agent.triggers || {}; + for (const f of files) { + for (const g of t.paths || []) { + if (minimatch(f, g, OPTS)) return `path:${g}`; + } + } + for (const c of t.content || []) { + if (diffText.includes(c)) return `content:${c}`; + } + return null; +} + +function selectAgents({ files, diffText }, config) { + const reviewed = files.filter( + (f) => !(config.excluded_paths || []).some((g) => minimatch(f, g, OPTS)), + ); + const out = []; + for (const a of config.agents) { + if (a.enabled === false) continue; + const matchedBy = agentMatches(a, reviewed, diffText); + if (matchedBy) out.push({ id: a.id, model: a.model || 'smart', matchedBy }); + } + return out; +} + +module.exports = { selectAgents, agentMatches }; diff --git a/.claude/review/engine/selectAgents.test.cjs b/.claude/review/engine/selectAgents.test.cjs new file mode 100644 index 0000000000..1bfcb4fccc --- /dev/null +++ b/.claude/review/engine/selectAgents.test.cjs @@ -0,0 +1,32 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { selectAgents } = require('./selectAgents.cjs'); + +const config = { + excluded_paths: ['**/*.snap'], + agents: [ + { id: 'architecture', always: true }, + { id: 'testing', always: true }, + { id: 'standards', always: true }, + { id: 'security', triggers: { paths: ['pages/api/**'], content: ['process.env.'] } }, + { id: 'ux', model: 'opus', triggers: { paths: ['src/components/**/*.tsx'] } }, + { id: 'financial', enabled: false, triggers: { paths: ['src/components/Reports/**'] } }, + ], +}; + +test('UI-only change selects always-on agents + ux', () => { + const sel = selectAgents({ files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+ const x = 1;' }, config); + assert.deepEqual(sel.map((a) => a.id).sort(), ['architecture', 'standards', 'testing', 'ux']); + assert.equal(sel.find((a) => a.id === 'ux').model, 'opus'); +}); + +test('content trigger selects security via process.env', () => { + const sel = selectAgents({ files: ['src/lib/foo.ts'], diffText: '+ const k = process.env.SECRET;' }, config); + assert.ok(sel.some((a) => a.id === 'security' && a.matchedBy === 'content:process.env.')); +}); + +test('disabled agent never selected', () => { + const sel = selectAgents({ files: ['src/components/Reports/Report.tsx'], diffText: '' }, config); + assert.ok(!sel.some((a) => a.id === 'financial')); +}); From d5c60ce06895f447643ad9c7a38895178673cf87 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:28:26 -0400 Subject: [PATCH 07/39] feat(review): rule resolver (agent + path rules) --- .claude/review/engine/resolveRules.cjs | 25 ++++++++++++++++++++ .claude/review/engine/resolveRules.test.cjs | 26 +++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 .claude/review/engine/resolveRules.cjs create mode 100644 .claude/review/engine/resolveRules.test.cjs diff --git a/.claude/review/engine/resolveRules.cjs b/.claude/review/engine/resolveRules.cjs new file mode 100644 index 0000000000..9dffd43ec6 --- /dev/null +++ b/.claude/review/engine/resolveRules.cjs @@ -0,0 +1,25 @@ +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function resolveRules(agentId, files, config) { + const agent = (config.agents || []).find((a) => a.id === agentId); + const rules = []; + const seen = new Set(); + const add = (r) => { + if (!seen.has(r)) { + seen.add(r); + rules.push(r); + } + }; + for (const r of (agent && agent.rules) || []) add(r); + for (const pr of config.path_rules || []) { + if (files.some((f) => pr.paths.some((g) => minimatch(f, g, OPTS)))) { + for (const r of pr.rules) add(r); + } + } + return rules; +} + +module.exports = { resolveRules }; diff --git a/.claude/review/engine/resolveRules.test.cjs b/.claude/review/engine/resolveRules.test.cjs new file mode 100644 index 0000000000..7979079ebc --- /dev/null +++ b/.claude/review/engine/resolveRules.test.cjs @@ -0,0 +1,26 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveRules } = require('./resolveRules.cjs'); + +const config = { + agents: [ + { id: 'security', rules: ['rules/security.md'] }, + { id: 'ux', rules: ['rules/ux.md'] }, + ], + path_rules: [ + { paths: ['pages/api/**'], rules: ['rules/security.md'] }, + { paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }, + { paths: ['src/components/Reports/**'], rules: ['rules/financial.md'] }, + ], +}; + +test('agent rules + matching path_rules, deduped', () => { + const rules = resolveRules('ux', ['src/components/Reports/R.tsx'], config); + assert.deepEqual(rules, ['rules/ux.md', 'rules/financial.md']); +}); + +test('no path_rules match -> only agent rules', () => { + const rules = resolveRules('security', ['src/lib/x.ts'], config); + assert.deepEqual(rules, ['rules/security.md']); +}); From 2c8c0bfe365648f5a14562e91eab903bd555bc09 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:29:54 -0400 Subject: [PATCH 08/39] feat(review): deterministic special-pattern detection --- .claude/review/engine/detectSpecial.cjs | 48 ++++++++++++++++++++ .claude/review/engine/detectSpecial.test.cjs | 32 +++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 .claude/review/engine/detectSpecial.cjs create mode 100644 .claude/review/engine/detectSpecial.test.cjs diff --git a/.claude/review/engine/detectSpecial.cjs b/.claude/review/engine/detectSpecial.cjs new file mode 100644 index 0000000000..aa082d16f2 --- /dev/null +++ b/.claude/review/engine/detectSpecial.cjs @@ -0,0 +1,48 @@ +'use strict'; + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function detectSpecial(diffText, changedFiles, config) { + const found = new Set(); + const special = (config.risk && config.risk.special) || []; + const pkgEntry = special.find((s) => s.when === 'critical_pkg_update'); + const pkgs = (pkgEntry && pkgEntry.packages) || []; + + const pkgChanged = changedFiles.includes('package.json'); + const lockChanged = changedFiles.some((f) => f.endsWith('yarn.lock')); + + if (pkgChanged && /^\+\s*"[^"]+":\s*"[^"]+"/m.test(diffText)) found.add('new_dependency'); + + if (pkgChanged) { + for (const p of pkgs) { + if (new RegExp(`^\\+\\s*"${escapeRe(p)}":`, 'm').test(diffText)) { + found.add('critical_pkg_update'); + break; + } + } + } + + if (lockChanged && !pkgChanged) found.add('lockfile_only_change'); + + if (changedFiles.some((f) => f.endsWith('.graphql'))) found.add('graphql_without_codegen_check'); + + if ( + changedFiles.some((f) => /next\.config\.(js|ts)$/.test(f)) && + /(headers|content-security|csp|rewrites|images|domains)/i.test(diffText) + ) { + found.add('next_config_security_change'); + } + + if ( + changedFiles.some((f) => /apollo\/cache\.ts$/.test(f)) && + /(typePolicies|merge\s*[:(])/.test(diffText) + ) { + found.add('apollo_cache_typepolicy_change'); + } + + return [...found]; +} + +module.exports = { detectSpecial }; diff --git a/.claude/review/engine/detectSpecial.test.cjs b/.claude/review/engine/detectSpecial.test.cjs new file mode 100644 index 0000000000..7d18d7ffe8 --- /dev/null +++ b/.claude/review/engine/detectSpecial.test.cjs @@ -0,0 +1,32 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +const config = { + risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next', '@apollo/client'] }] }, +}; + +test('detects new dependency added to package.json', () => { + assert.deepEqual(detectSpecial('+ "lodash": "^4.17.21",', ['package.json'], config), ['new_dependency']); +}); + +test('detects critical package update', () => { + const found = detectSpecial('+ "@apollo/client": "^4.0.0",', ['package.json'], config); + assert.ok(found.includes('critical_pkg_update')); +}); + +test('detects lockfile-only change', () => { + assert.deepEqual(detectSpecial('+ some lock line', ['yarn.lock'], config), ['lockfile_only_change']); +}); + +test('detects graphql change and next.config security change', () => { + const found = detectSpecial('+ headers: [...]', ['next.config.ts', 'src/components/Foo/Foo.graphql'], config); + assert.ok(found.includes('graphql_without_codegen_check')); + assert.ok(found.includes('next_config_security_change')); +}); + +test('detects apollo cache typePolicies change', () => { + const found = detectSpecial('+ typePolicies: { Contact: {} }', ['src/lib/apollo/cache.ts'], config); + assert.ok(found.includes('apollo_cache_typepolicy_change')); +}); From 1044a066eaff5e089d8a0164e3369ad7577a8192 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:32:05 -0400 Subject: [PATCH 09/39] feat(review): plan CLI entry assembling risk + agents + rules --- .claude/review/engine/plan.cjs | 39 +++++++++++++++++++++++++++++ .claude/review/engine/plan.test.cjs | 37 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 .claude/review/engine/plan.cjs create mode 100644 .claude/review/engine/plan.test.cjs diff --git a/.claude/review/engine/plan.cjs b/.claude/review/engine/plan.cjs new file mode 100644 index 0000000000..2db0ec7fb2 --- /dev/null +++ b/.claude/review/engine/plan.cjs @@ -0,0 +1,39 @@ +'use strict'; +const { readFileSync } = require('node:fs'); +const { loadConfig } = require('./loadConfig.cjs'); +const { scoreRisk } = require('./scoreRisk.cjs'); +const { selectAgents } = require('./selectAgents.cjs'); +const { resolveRules } = require('./resolveRules.cjs'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +function buildPlan({ files, diffText, linesChanged, scope }, config) { + const special = detectSpecial(diffText, files, config); + const risk = scoreRisk({ files, linesChanged, scope, special }, config); + const selected = selectAgents({ files, diffText }, config); + const agents = selected.map((a) => ({ ...a, rules: resolveRules(a.id, files, config) })); + return { profile: config.profile, risk: { ...risk, special }, agents }; +} + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, '')] = argv[i + 1]; + return args; +} + +function linesChangedFromStat(statText) { + const ins = statText.match(/(\d+) insertions?\(\+\)/); + const del = statText.match(/(\d+) deletions?\(-\)/); + return (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const config = loadConfig({ configPath: a.config, schemaPath: a.schema }); + const files = readFileSync(a.files, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const diffText = a.diff ? readFileSync(a.diff, 'utf8') : ''; + const linesChanged = a.stat ? linesChangedFromStat(readFileSync(a.stat, 'utf8')) : 0; + const plan = buildPlan({ files, diffText, linesChanged, scope: a.scope || 'single_feature' }, config); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); +} + +module.exports = { buildPlan, parseArgs, linesChangedFromStat }; diff --git a/.claude/review/engine/plan.test.cjs b/.claude/review/engine/plan.test.cjs new file mode 100644 index 0000000000..703f6df150 --- /dev/null +++ b/.claude/review/engine/plan.test.cjs @@ -0,0 +1,37 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlan } = require('./plan.cjs'); + +const config = { + profile: 'standard', + excluded_paths: ['**/*.snap'], + risk: { + patterns: [{ glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }], + volume_multiplier: [{ upTo: 50, points: 0 }, { upTo: null, points: 4 }], + scope_multiplier: { single_feature: 1.0 }, + special: [], + levels: [{ range: [0, 3], level: 'LOW', reviewer: 'entry' }, { range: [4, null], level: 'HIGH', reviewer: 'exp' }], + }, + agents: [ + { id: 'architecture', always: true, rules: ['rules/architecture.md'] }, + { id: 'ux', triggers: { paths: ['src/components/**/*.tsx'] }, rules: ['rules/ux.md'] }, + ], + path_rules: [{ paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }], +}; + +test('buildPlan assembles risk + agents + resolved rules', () => { + const plan = buildPlan( + { files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+x', linesChanged: 20, scope: 'single_feature' }, + config, + ); + assert.equal(plan.profile, 'standard'); + assert.equal(plan.risk.level, 'LOW'); + const ux = plan.agents.find((a) => a.id === 'ux'); + assert.ok(ux, 'ux selected'); + assert.deepEqual(ux.rules, ['rules/ux.md']); + const arch = plan.agents.find((a) => a.id === 'architecture'); + // path_rules attach to any selected agent (per design spec §4.4): the changed + // .tsx file matches the path_rule, so architecture also resolves rules/ux.md. + assert.deepEqual(arch.rules, ['rules/architecture.md', 'rules/ux.md']); +}); From 52f64b63d729d35347fecde0e07c8f82e597c021 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:34:26 -0400 Subject: [PATCH 10/39] feat(review): author config.yml migrated from code-review.md --- .claude/review/config.yml | 147 ++++++++++++++++++++++ .claude/review/engine/realConfig.test.cjs | 28 +++++ 2 files changed, 175 insertions(+) create mode 100644 .claude/review/config.yml create mode 100644 .claude/review/engine/realConfig.test.cjs diff --git a/.claude/review/config.yml b/.claude/review/config.yml new file mode 100644 index 0000000000..64334f397e --- /dev/null +++ b/.claude/review/config.yml @@ -0,0 +1,147 @@ +version: 1 + +# Global severity/verbosity dial (CodeRabbit `profile` model). +# chill → fewer, high-confidence findings; raises consensus threshold +# standard → current behavior (default) +# assertive → more findings, lower threshold, may be nitpicky +profile: standard + +# ── Risk scoring (migrated from code-review.md) ─────────────────────────────── +risk: + # File-pattern contributions. `tier` is descriptive; `points` drives the score. + patterns: + - { glob: "pages/api/auth/**", points: 3, tier: critical } + - { glob: "pages/api/graphql-rest.page.ts", points: 3, tier: critical } + - { glob: "pages/api/Schema/index.ts", points: 3, tier: critical } + - { glob: "src/lib/apollo/{client,link,cache,ssrClient}.ts", points: 3, tier: critical } + - { glob: "next.config.{js,ts}", points: 3, tier: critical } + - { glob: ".github/workflows/**", points: 3, tier: critical } + - { glob: ".claude/**", points: 3, tier: critical } + - { glob: "pages/api/Schema/**/*.{ts,graphql}", points: 2, tier: high } + - { glob: "src/components/Shared/**", points: 2, tier: high } + - { glob: "src/components/**/*.graphql", points: 2, tier: high } + - { glob: "src/components/**/*.{ts,tsx}", points: 1, tier: medium } + - { glob: "src/hooks/**/*.ts", points: 1, tier: medium } + - { glob: "pages/**/*.page.tsx", points: 1, tier: medium } + # Low-risk overrides (explicit 0 points) + - { glob: "**/*.test.{ts,tsx}", points: 0, tier: low } + - { glob: "public/locales/**", points: 0, tier: low } + - { glob: "**/*.snap", points: 0, tier: low } + + # Change-volume → points (lines changed across the diff). + volume_multiplier: + - { upTo: 50, points: 0 } + - { upTo: 200, points: 1 } + - { upTo: 500, points: 2 } + - { upTo: 1000, points: 3 } + - { upTo: null, points: 4 } # 1000+ + + # Scope multiplier applied to the pattern+volume subtotal. + scope_multiplier: + single_file: 1.0 + single_feature: 1.0 + multi_feature: 1.3 + cross_cutting: 1.7 + core_infra: 2.0 + + # Special detections (from code-review.md "Special Pattern Detection"). + special: + - { when: new_dependency, points: 2 } + - { when: critical_pkg_update, points: 3, + packages: [next, react, "@apollo/client", "@mui/material", formik, next-auth, typescript, graphql-codegen] } + - { when: lockfile_only_change, points: 1 } + - { when: graphql_without_codegen_check, points: 2 } + - { when: next_config_security_change, points: 2 } # rewrites/headers/CSP/image domains + - { when: apollo_cache_typepolicy_change, points: 2 } + + # Score → risk level + required reviewer (from code-review.md classification). + levels: + - { range: [0, 3], level: LOW, reviewer: entry } + - { range: [4, 6], level: MEDIUM, reviewer: entry } + - { range: [7, 9], level: HIGH, reviewer: experienced } + - { range: [10, null], level: CRITICAL, reviewer: "Caleb Cox (senior)" } + +# ── Agents (the 7 specialists, declaratively) ───────────────────────────────── +agents: + - id: security + enabled: true + model: smart # smart | opus | sonnet | haiku + always: false # if true, runs regardless of triggers + triggers: + paths: ["pages/api/**", "src/lib/apollo/{link,client,ssrClient}.ts", + "next.config.{js,ts}", "pages/_app.page.tsx", ".github/workflows/**", ".claude/**"] + content: ["process.env.", "dangerouslySetInnerHTML", "router.push("] + rules: ["rules/security.md"] + + - id: architecture + enabled: true + always: true + rules: ["rules/architecture.md"] + + - id: data-integrity + enabled: true + triggers: + paths: ["pages/api/Schema/**/*.{ts,graphql}", "src/lib/apollo/cache.ts", + "src/components/**/*.graphql", "src/graphql/rootFields.generated.ts"] + content: ["mutation", "optimisticResponse", "refetchQueries", "cache.modify", "__typename", + "first:", "after:", "pageInfo", "nodes"] + rules: ["rules/data-integrity.md"] + + - id: testing + enabled: true + always: true + rules: ["rules/testing.md"] + + - id: ux + enabled: true + triggers: + paths: ["src/components/**/*.tsx", "pages/**/*.page.tsx", "src/theme.ts", "src/theme/**"] + content: [" { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.version, 1); +}); + +test('real config defines the 7 MPDX agents', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.deepEqual( + cfg.agents.map((a) => a.id).sort(), + ['architecture', 'data-integrity', 'financial', 'security', 'standards', 'testing', 'ux'], + ); +}); + +test('real config reserves inert index/learning sections', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.index.enabled, false); + assert.equal(cfg.learning.enabled, false); + assert.equal(cfg.learning.approval_required, true); +}); From 433cc430effdb98063a381cd41e9bc33ebf2af5c Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:37:36 -0400 Subject: [PATCH 11/39] feat(review): migrate prose focus-areas into rules/*.md --- .claude/review/engine/rulesCoverage.test.cjs | 27 ++++++++++ .claude/review/rules/architecture.md | 14 +++++ .claude/review/rules/data-integrity.md | 17 ++++++ .claude/review/rules/financial.md | 39 ++++++++++++++ .claude/review/rules/security.md | 19 +++++++ .claude/review/rules/standards.md | 55 ++++++++++++++++++++ .claude/review/rules/testing.md | 15 ++++++ .claude/review/rules/ux.md | 21 ++++++++ 8 files changed, 207 insertions(+) create mode 100644 .claude/review/engine/rulesCoverage.test.cjs create mode 100644 .claude/review/rules/architecture.md create mode 100644 .claude/review/rules/data-integrity.md create mode 100644 .claude/review/rules/financial.md create mode 100644 .claude/review/rules/security.md create mode 100644 .claude/review/rules/standards.md create mode 100644 .claude/review/rules/testing.md create mode 100644 .claude/review/rules/ux.md diff --git a/.claude/review/engine/rulesCoverage.test.cjs b/.claude/review/engine/rulesCoverage.test.cjs new file mode 100644 index 0000000000..5a5c365c16 --- /dev/null +++ b/.claude/review/engine/rulesCoverage.test.cjs @@ -0,0 +1,27 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const root = path.join(__dirname, '..'); +const cfg = loadConfig({ + configPath: path.join(root, 'config.yml'), + schemaPath: path.join(root, 'config.schema.json'), +}); + +function referencedRules() { + const set = new Set(); + for (const a of cfg.agents) for (const r of a.rules || []) set.add(r); + for (const pr of cfg.path_rules || []) for (const r of pr.rules) set.add(r); + return [...set]; +} + +test('every rule doc referenced by config exists and is non-empty', () => { + for (const rel of referencedRules()) { + const p = path.join(root, rel); + assert.ok(existsSync(p), `missing rule doc: ${rel}`); + assert.ok(statSync(p).size > 200, `rule doc too small (placeholder?): ${rel}`); + } +}); diff --git a/.claude/review/rules/architecture.md b/.claude/review/rules/architecture.md new file mode 100644 index 0000000000..45ddf27bca --- /dev/null +++ b/.claude/review/rules/architecture.md @@ -0,0 +1,14 @@ +# Architecture — Focus Areas + +- **Dual GraphQL boundary** — new queries should route cleanly to one server. Mixing rootFields (primary API) with REST-proxy-only fields in the same operation is a smell; the Apollo link will split them but the result can be confusing and harder to cache +- **REST-proxy layering** — new proxy queries follow the pattern: `pages/api/Schema//.graphql` → `resolvers.ts` in the same folder → `datahandler.ts` sibling file → REST call in `graphql-rest.page.ts`. Deviations should be justified +- **Thin page components** — route-level pages (`pages/**/*.page.tsx`) should compose feature components, not contain business logic. Logic lives in hooks or feature components +- **Component feature organization** — new components belong under `src/components//`; components used across multiple features go in `src/components/Shared/`. Don't add one-off components to `Shared/` +- **Hook placement** — reusable hooks in `src/hooks/`; feature-specific hooks stay next to their components +- **Pages Router conventions** — `.page.tsx` suffix for routes, `.page.ts` for API routes, no App Router patterns (`app/`, `layout.tsx`, `use client`, RSC) +- **useEffect dependency arrays** — verify all referenced values are listed; flag empty arrays that reference props/state (stale closures); flag effects that should be `useMemo` or event handlers instead +- **Error boundaries** — new top-level views should be wrapped in an error boundary or compose one that is. Apollo errors should surface to the user, not be swallowed +- **N+1 / waterfall queries** — component mounts that fire Apollo queries in a `useEffect` chain (query A → then query B based on A's result) should be collapsed into one operation or use `skip` + parallel queries +- **Prop drilling vs context vs Apollo cache** — if a value is passed through 3+ layers, consider Apollo cache, a context, or moving the data fetch closer to the consumer +- **Technical debt** — debt added vs reduced by this PR. Refactors that only move code without improving clarity are neutral, not positive +- **Pattern compliance** with `CLAUDE.md` and the existing codebase — new code should look like the code around it unless the existing pattern is what's being fixed diff --git a/.claude/review/rules/data-integrity.md b/.claude/review/rules/data-integrity.md new file mode 100644 index 0000000000..a78e248d2b --- /dev/null +++ b/.claude/review/rules/data-integrity.md @@ -0,0 +1,17 @@ +# Data Integrity — Focus Areas + +This is where domain-specific data invariants belong. + +- **Apollo cache normalization** — missing `id` fields cause stale or duplicate cache entries. Every selection set for a normalizable type must include `id` +- **Cache type policies** (`src/lib/apollo/cache.ts`) — any change to `typePolicies`, `keyFields`, or `merge` functions can silently corrupt cached data across the app. Treat as high-severity +- **Pagination merge functions** — `fetchMore` merge must dedupe, preserve order, and handle cursor edge cases (empty page, duplicate cursor, cursor missing) +- **Optimistic responses** — must match server response shape exactly, including `__typename` and `id`; mismatches cause cache misses and UI flicker +- **Mutation cache updates** — every mutation that changes displayed data must include `update`, `refetchQueries`, `cache.modify`, or `cache.evict` — otherwise the UI shows stale data +- **REST-proxy data handlers** — field mapping from snake_case (REST) to camelCase (GraphQL) is a frequent place for silent field-dropping. Verify every field in the REST response is either mapped or intentionally ignored +- **Null vs undefined in mutation variables** — GraphQL distinguishes between "field not set" (`undefined`, omitted) and "field explicitly null" (`null`). The REST proxy may reject one or the other. Form state → mutation variable mapping must be intentional about this +- **Form submit → mutation mapping** — Formik values passed directly to a mutation can include unexpected fields if the Yup schema is looser than the GraphQL input type. Flag any `...values` spread into mutation variables without an explicit field allowlist +- **Date serialization** — dates sent to the server should be in a consistent format (ISO-8601 / Luxon); flag any manual `.toString()` or `new Date()` in mutation variables +- **Currency precision** — monetary values must not be truncated or rounded during form handling; the display boundary is the only place for rounding +- **Pagination over `nodes`** — aggregating client-side over a single page of `nodes` silently ignores unpaginated data. Prefer server-provided totals +- **Filter/search state** — when filters change, paginated data must be re-fetched from the first page, not appended to existing pages +- **Optimistic updates on lists** — adding an item optimistically must place it in the correct sort position, not just at the end diff --git a/.claude/review/rules/financial.md b/.claude/review/rules/financial.md new file mode 100644 index 0000000000..980e1d4ed1 --- /dev/null +++ b/.claude/review/rules/financial.md @@ -0,0 +1,39 @@ +# Financial Reporting — Focus Areas + +MPDX displays and calculates donation/partner-giving aggregations across dozens of financial reports. Display-side miscalculations silently mislead staff about their support status. This agent supplements the generic Data Integrity agent with domain-specific invariants. + +**Trigger conditions:** + +- Any file under `src/components/Reports/**` +- Any file under `src/components/HrTools/**` +- Any file under `src/components/Reports/GoalCalculator/**`, `src/components/HrTools/GoalCalculator/**`, or `src/components/HrTools/PdsGoalCalculator/**` +- Any file under `src/components/Reports/SalaryCalculator/**` or `src/components/HrTools/SalaryCalculator/**` +- Any file under `src/components/EditDonationModal/**` +- Any file under `src/components/Reports/AdditionalSalaryRequest/**`, `src/components/HrTools/AdditionalSalaryRequest/**`, or `src/components/HrTools/MinisterHousingAllowance/**` +- Any file under `src/components/Dashboard/MonthlyGoal/**`, `src/components/Dashboard/DonationHistories/**` +- Diff content contains any of: `amount`, `currency`, `convertedAmount`, `pledgeAmount`, `goal`, `balance`, `total`, `sum(`, `reduce((`, `.toFixed(`, `Math.round(`, `Number(`, `parseFloat(`, `parseInt(` inside `src/components/Reports/**`, `src/components/HrTools/**`, or other goal/donation components + +**Focus areas:** + +- **Money is never a JavaScript `number` for arithmetic.** Check for floating-point arithmetic on money values — any `amount + amount`, `amount * rate`, or `.reduce` accumulating amounts must round at the display boundary. +- **Currency mixing.** Donations arrive in multiple currencies; verify no code path sums `amount` (native currency) across rows with different `currencyCode`. Aggregations must use `convertedAmount` (or equivalent) in a single reporting currency. +- **Rounding consistency.** Rounding should happen at the display boundary via `intlFormat` / `Intl.NumberFormat`, not sprinkled through calculation code. Flag any `.toFixed(n)` used inside aggregation logic. +- **Missing/null amounts.** Donations, pledges, and goals may be `null` or `undefined`. Verify nullish handling (`?? 0`) is present where aggregations happen, and that `null` is not silently coerced to `0` where it should surface as "unknown." +- **Date-window correctness.** Fourteen-month and expected-monthly reports depend on correct month boundaries, timezone handling (use Luxon — not `new Date()`), and inclusive/exclusive range semantics. Flag any `new Date()` in report logic. +- **Goal-calculation consistency.** Goal math in `GoalCalculator` / `PdsGoalCalculator` must match across UI layers — flag any duplicate calculation logic that could drift. +- **Empty-state / zero-state correctness.** A report with zero donations should render "no data" — not `$0.00` that looks like real data. +- **GraphQL aggregation fields vs client-side summing.** Prefer server-provided totals (`totalAmount`, `sum`, etc.) over client-side `.reduce` when both are available — client sums over a paginated `nodes` list are a silent bug. + +**Output format:** Use the standard agent output format with `Critical Financial Issues`, `Financial Concerns`, `Financial Suggestions`, plus a `Financial Checklist`: + +``` +### Financial Checklist +- Arithmetic on money values safe: Yes/No/N/A +- Currency mixing prevented: Yes/No/N/A +- Rounding at display boundary only: Yes/No/N/A +- Null/undefined amounts handled: Yes/No/N/A +- Luxon used for dates (not `new Date()`): Yes/No/N/A +- Server-provided aggregations preferred: Yes/No/N/A +``` + +**Note:** If your analysis determines that the changes do not actually affect financial logic (e.g., the keyword match was a false positive — `amount` could be a form field label), state "No financial calculation code in this PR" clearly and skip the detailed review. diff --git a/.claude/review/rules/security.md b/.claude/review/rules/security.md new file mode 100644 index 0000000000..3e03404d4b --- /dev/null +++ b/.claude/review/rules/security.md @@ -0,0 +1,19 @@ +# Security — Focus Areas + +Project-specific concerns added to the Security agent's universal checks. + +- **NextAuth callback handling** (`pages/api/auth/[...nextauth].page.ts`) — verify token persistence, refresh logic, session expiry, callback URL validation against an allowlist (open-redirect risk) +- **API OAuth sign-in flow** (`pages/api/auth/apiOauthSignIn.ts`) — PKCE handling, state parameter validation, redirect URI verification +- **REST-proxy token forwarding** (`pages/api/graphql-rest.page.ts`) — verify bearer tokens are forwarded correctly, never logged, and never exposed in error responses +- **Apollo link auth headers** (`src/lib/apollo/link.ts`) — tokens attached consistently, never logged, correct handling when a token is missing +- **Environment variable exposure** — server-only variables must NOT be prefixed with `NEXT_PUBLIC_` (that ships them to the client bundle); audit every new `process.env.*` reference +- **Client-side validation parity** — every Yup rule, every `disabled` button, every `required` field must have a server-side equivalent. If a mutation silently trusts the client, flag it +- **Impersonation flow** — changes touching impersonation (`pages/api/auth/impersonate/**`, `src/components/User/impersonate*`) must verify the authorizer's role and log the action +- **File uploads** — `pages/api/uploads/**`, `pages/api/Schema/uploads/**` — verify content-type validation, size limits, filename sanitization (no path traversal), and that upload tokens are scoped +- **CSP and security headers** in `next.config.{js,ts}` — any weakening (new `unsafe-inline`, `unsafe-eval`, added origins) is a red flag +- **XSS surfaces** — `dangerouslySetInnerHTML`, `innerHTML`, direct DOM writes; flag any use in new code and verify input is sanitized +- **Open redirect** — any `router.push(value)` or `window.location = value` where `value` comes from a query parameter must be validated against an allowlist +- **GraphQL variable injection** — never build GraphQL operations via string concatenation; only use query variables +- **Admin/coaching authorization** — settings, org admin, and coaching views must check the user's role on each mutation, not just on the initial page load +- **CI/CD workflow security** — any change to `.github/workflows/**` must verify permission scopes are minimal, secrets are not exposed in logs, and trigger conditions cannot be abused to bypass review +- **Review process integrity** — changes to `.claude/commands/**`, `.claude/rules/**`, or `.claude/settings.json` must verify risk scoring is not weakened, severity thresholds are not lowered, critical checks are not stripped, and newly enabled plugins/marketplaces come from trusted org-controlled sources diff --git a/.claude/review/rules/standards.md b/.claude/review/rules/standards.md new file mode 100644 index 0000000000..bb86846171 --- /dev/null +++ b/.claude/review/rules/standards.md @@ -0,0 +1,55 @@ +# Standards — Checklist + +Every item here is mandatory. The Standards agent must report compliance per item. + +**Exports & Naming** + +- [ ] **Named exports only** — no `export default` in components, hooks, or libs (`export const ComponentName: React.FC = () => {}`) +- [ ] **File naming** — components PascalCase (`Foo.tsx`), pages kebab-case with `.page.tsx`, API routes with `.page.ts`, tests colocated as `Foo.test.tsx`, GraphQL as PascalCase `.graphql` +- [ ] **GraphQL operation names** — descriptive, not prefixed with `Get` or `Load` (e.g. `ContactDetails`, not `GetContactDetails`) +- [ ] **Hook names** — must start with `use` and live in `src/hooks/` (reusable) or next to the component (feature-specific) + +**Localization (i18n)** + +- [ ] Every user-visible string uses `useTranslation` / `t()` — no hard-coded display text in JSX, `Alert`, `Snackbar`, error messages, `aria-label`, or form labels +- [ ] No string interpolation inside `t()` calls — use interpolation variables: `t('Hello {{name}}', { name })`, not `t(`Hello ${name}`)` +- [ ] No dynamic `t()` keys (`t(varName)`) — extraction tool can't find them + +**GraphQL & Apollo** + +- [ ] Every query/mutation selection set includes `id` on normalizable types (for cache normalization) +- [ ] Any `.graphql` change has been verified by running `yarn gql` successfully +- [ ] Any query returning `nodes` either handles pagination via `pageInfo` / `after` / `fetchMore`, or documents why the default 25-item limit is sufficient +- [ ] Mutations that change cached data include either `update`, `refetchQueries`, or `cache.evict` to reflect the change in the UI +- [ ] Apollo routing awareness — when adding a field, check `src/graphql/rootFields.generated.ts` to know which server handles it; don't mix rootFields with REST-proxy-only fields in one operation +- [ ] No raw `fetch` or `axios` calls for data that could go through Apollo — centralize in GraphQL. Exception: the REST-proxy boundary layer (`pages/api/graphql-rest.page.ts` and `pages/api/Schema/**/datahandler*.ts`) uses `fetch` intentionally to call the upstream REST API + +**TypeScript** + +- [ ] No `any` types in new code (use `unknown` + narrowing, or proper generics) +- [ ] No `@ts-ignore` / `@ts-expect-error` without an inline comment explaining why +- [ ] No non-null assertions (`!`) on values that could legitimately be null — prefer explicit null checks +- [ ] Generated operation types from `.generated.ts` files are used for Apollo hooks and mocks + +**Forms** + +- [ ] Forms use Formik + Yup — no manual `useState` form state for anything beyond trivial single-field inputs +- [ ] Every Yup schema has matching server-side validation (don't rely on client-only validation) +- [ ] Submit buttons are `disabled` while `isSubmitting` is true + +**Testing** + +- [ ] Every new component, hook, and lib function has a colocated `*.test.{ts,tsx}` +- [ ] Component tests using GraphQL wrap in `GqlMockedProvider<{ OperationName: OperationNameQuery }>` with typed mocks +- [ ] Tests use `findBy*` for async assertions rather than `waitFor(() => getBy*)` +- [ ] No `any` in test types — use generated operation types for mock shapes +- [ ] No global `fetch` mocking — use Apollo mocks at the operation level + +**Code Quality** + +- [ ] Passes `yarn lint` and `yarn lint:ts` +- [ ] No debug output (`console.log`, `console.debug`, `debugger`, `// TODO` without a Jira/MPDX ticket reference) +- [ ] No `new Date()` — use Luxon (`DateTime.now()`, `DateTime.local()`) per project convention +- [ ] No unused imports or variables +- [ ] No commented-out code blocks (delete, don't comment) +- [ ] No empty `catch {}` blocks that swallow errors silently diff --git a/.claude/review/rules/testing.md b/.claude/review/rules/testing.md new file mode 100644 index 0000000000..e6d2804355 --- /dev/null +++ b/.claude/review/rules/testing.md @@ -0,0 +1,15 @@ +# Testing — Focus Areas + +Project-specific testing conventions added to the Testing agent's universal checks. + +- **`GqlMockedProvider` is the only GraphQL mock pattern.** All component tests that hit GraphQL must wrap in ` mocks={...}>` with typed generics so mock shapes are type-checked at compile time +- **`mutationSpy` + `toHaveGraphqlOperation(...)` pattern** is preferred over brittle snapshot-based assertions for verifying mutations +- **`toHaveTableStructure(...)` for full table contents** — when asserting more than a couple table cells, use `expect(getByRole('table')).toHaveTableStructure({ columnHeaders, rowHeaders, cells })` instead of ad-hoc `getByRole('cell')` +- **`findBy*` for async assertions** — prefer `await findByText(...)` over `await waitFor(() => getByText(...))` +- **No `fetch` mocking** — never mock `global.fetch` or `window.fetch`; use Apollo operation-level mocks +- **No `any` in test types** — mock shapes use generated operation types (`ContactDetailsQuery`, etc.) from `.generated.ts` files +- **`i18next` in tests** — `t()` returns the translation key by default in test env; don't assert on translated strings unless the test specifically exercises i18n +- **Test file colocation** — test files live next to the component under test (`Foo.test.tsx` alongside `Foo.tsx`), not in a separate `__tests__/` tree (except for shared test utilities) +- **Time-dependent tests** — use Jest fake timers (`jest.useFakeTimers()`) or Luxon's `Settings.now` override; never rely on actual system time +- **Edge case coverage** — every new component test should include: empty state, loading state, error state, at least one happy path, and boundary conditions (0 items, 1 item, many items) +- **Error path testing** — not just happy paths. Test validation failures, Apollo error responses, and user-visible error states diff --git a/.claude/review/rules/ux.md b/.claude/review/rules/ux.md new file mode 100644 index 0000000000..54c58a450d --- /dev/null +++ b/.claude/review/rules/ux.md @@ -0,0 +1,21 @@ +# UX — Focus Areas + +Project-specific UX/UI conventions layered on top of the UX agent's universal checks. + +- **Material UI v5 conventions** — use the `sx` prop for styling; avoid `makeStyles` (legacy v4) and avoid inline `style={...}` (breaks theme-aware styling and responsive breakpoints). Styled components (`styled(...)`) are acceptable for reused patterns +- **Theme tokens, not hardcoded values** — use `theme.palette.*`, `theme.spacing(n)`, `theme.breakpoints.*`. Flag raw hex colors, pixel values, and magic numbers +- **Responsive design** — MUI breakpoints (`xs`, `sm`, `md`, `lg`, `xl`) via `sx={{ [theme.breakpoints.down('md')]: {...} }}` or the shorthand `sx={{ display: { xs: 'block', md: 'flex' } }}`. New components must render correctly at mobile breakpoints +- **Loading states** — every Apollo query must render a loading state (MUI `Skeleton` or `CircularProgress`), not render nothing or flash stale content +- **Error states** — every Apollo query must render an error state (`` or similar). Never let an error silently render empty content +- **Formik field wiring** — use ``, `useField`, or `getFieldProps` consistently; form fields must wire `name`, `value`, `onChange`, `onBlur`, `error`, and `helperText` to the Formik state +- **Form error visibility** — validation errors must be visible next to the field (MUI `helperText` with `error` prop), not only in a toast or summary +- **Accessibility (a11y)** + - All interactive elements need accessible names (`aria-label`, `aria-labelledby`, or visible text) + - Icon-only buttons must have `aria-label` (MUI `IconButton` doesn't add one automatically) + - Form fields must have associated labels (MUI `TextField` with `label` prop, or explicit `` + `htmlFor`) + - Dialogs use `

` with `aria-labelledby` pointing at the title + - Color should never be the only indicator of state (add icons or text) + - Keyboard navigation works (tab order, Enter/Space activation, Escape closes modals) +- **Translation coverage** — new user-visible strings must have i18n keys added; verify `yarn extract` would pick them up (no dynamic `t()` keys, no string interpolation inside `t()`) +- **Snackbar / notification usage** — success/error feedback goes through the project's notification system, not ad-hoc `alert()` or inline text +- **Dialog UX** — dialogs have clear primary/secondary actions, disable the primary action while submitting, and close on success From eeecbe43383cb9a7da95a18ea04afea8a5b47cad Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:41:27 -0400 Subject: [PATCH 12/39] refactor(review): drive risk + agent selection from config engine --- .claude/commands/agent-review.md | 243 +++++++++++++------------------ 1 file changed, 98 insertions(+), 145 deletions(-) diff --git a/.claude/commands/agent-review.md b/.claude/commands/agent-review.md index e6a4ef59cc..d7740c20a4 100644 --- a/.claude/commands/agent-review.md +++ b/.claude/commands/agent-review.md @@ -128,97 +128,78 @@ done < /tmp/changed_files.txt Read `CLAUDE.md` to understand the project's coding standards and conventions. This context will be shared with all agents. -### Calculate Risk Score +### Build the Review Plan (config engine) -Now calculate the risk score with improved algorithm: +Risk scoring, agent selection, special-pattern detection, and rule resolution are now driven +by the declarative review core (`.claude/review/config.yml`). Run the engine against the diff +manifest gathered above (note **`yarn node`** — plain `node` cannot resolve deps under Yarn PnP): -**Process:** - -1. Read the list of changed files from `/tmp/changed_files.txt` -2. Count lines changed from `/tmp/diff_stat.txt` -3. Apply the risk scoring algorithm: - -**Critical File Patterns (+4 points each):** - -- `pages/api/auth/[...nextauth].page.ts` -- `pages/api/auth/helpers.ts` -- `pages/api/auth/impersonate/` -- `pages/api/graphql-rest.page.ts` -- `pages/api/Schema/index.ts` -- `src/lib/apollo/client.ts` -- `src/lib/apollo/link.ts` -- `src/lib/apollo/cache.ts` -- `next.config.ts` -- `.env` files -- Database migrations -- Payment processing code - -**High-Risk Patterns (+3 points each):** - -- `pages/api/Schema/**/resolvers.ts` -- `**/*.graphql` (excluding tests) -- Financial/donation code (`**/Donation**`, `**/Pledge**`, `**/Gift**`) -- Organization management -- Shared components (`src/components/Shared/**`) -- Authentication flows -- Data synchronization code - -**Medium-Risk (+2 points each):** - -- Main app pages -- Custom hooks -- Utility functions with business logic -- Report generation -- Export/import features - -**Low-Risk (+1 point each):** - -- UI-only components -- Styling changes -- Test files -- Documentation - -**Change Volume Multiplier:** +```bash +REVIEW_DIR=".claude/review" +yarn node "$REVIEW_DIR/engine/plan.cjs" \ + --config "$REVIEW_DIR/config.yml" \ + --schema "$REVIEW_DIR/config.schema.json" \ + --files /tmp/changed_files.txt \ + --stat /tmp/diff_stat.txt \ + --diff /tmp/pr_diff.txt \ + --scope "${REVIEW_SCOPE:-single_feature}" \ + > /tmp/review_plan.json +cat /tmp/review_plan.json +``` -- <50 lines: +0 -- 50-200 lines: +1 -- 200-500 lines: +2 -- 500-1000 lines: +3 -- 1000+ lines: +4 +`REVIEW_SCOPE` is the heuristic scope the model sets based on the change footprint (default +`single_feature`; use `cross_cutting` for changes spanning unrelated feature areas or core +infrastructure). The plan JSON has this shape: -**Scope Multiplier:** +```json +{ + "profile": "standard", + "risk": { + "score": 0, + "level": "LOW", + "reviewer": "...", + "factors": { "patternScore": 0, "volumeScore": 0, "specialScore": 0, "scopeMultiplier": 1.0, "subtotal": 0 }, + "special": ["..."] + }, + "agents": [ + { "id": "standards", "model": "smart", "matchedBy": "always", "rules": ["rules/standards.md"] } + ] +} +``` -- Single file: 1.0x -- Single feature area: 1.0x -- Multiple related features: 1.3x -- Cross-cutting changes: 1.7x -- Core infrastructure: 2.0x +### Risk Assessment -**Final Risk Level Classification:** +Read `risk.score`, `risk.level`, `risk.reviewer`, and `risk.special` from `/tmp/review_plan.json` +(do NOT compute the score inline — the engine is the single source of truth). The classification +the engine applies is: - 0-3 points: **LOW** → Entry-level+ can review - 4-6 points: **MEDIUM** → Entry-level+ can review - 7-9 points: **HIGH** → Experienced dev+ should review - 10+ points: **CRITICAL** → Senior dev (Caleb Cox) must review -Calculate and display the summary: +`risk.special[]` lists any special patterns that fired (e.g. `new_dependency`, +`critical_pkg_update`, `lockfile_only_change`, `graphql_without_codegen_check`, +`next_config_security_change`, `apollo_cache_typepolicy_change`) — surface these as risk factors. + +Calculate the day-of-week warning and display the summary: ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📊 PR RISK ASSESSMENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Risk Score: [X]/[max] -Risk Level: [LOW | MEDIUM | HIGH | CRITICAL] +Risk Score: [risk.score] ← from /tmp/review_plan.json +Risk Level: [risk.level] ← from /tmp/review_plan.json (LOW | MEDIUM | HIGH | CRITICAL) Day: [DAY_OF_WEEK] Files Changed: [N] Lines Changed: +[X] -[Y] Risk Factors Detected: -• [List specific risk factors found] +• [List risk.special[] entries from /tmp/review_plan.json, plus factor highlights] -Required Reviewer Level: +Required Reviewer: [risk.reviewer] ← from /tmp/review_plan.json [LOW/MEDIUM]: ✅ Entry-level or above can review [HIGH]: ⚠️ Experienced developer or above should review [CRITICAL]: 🚨 Senior developer (Caleb Cox) must review @@ -232,91 +213,41 @@ Required Reviewer Level: --- -## Stage 0B — Smart Agent Selection (Standard Mode Only) +## Stage 0B — Agent Selection (from the config engine) -If `AGENT_MODE="standard"`, analyze which agents are actually needed: +The set of agents to launch is determined by the engine, not by hardcoded `grep` checks. In +`standard` mode, read the `agents[]` array from `/tmp/review_plan.json` — that list **is** the set +of agents to launch. Each entry has: -```bash -if [ "$AGENT_MODE" = "standard" ]; then - echo "🤖 Analyzing PR to select relevant agents..." - echo "" +- `id` — the agent identifier (`security`, `architecture`, `data-integrity`, `testing`, `ux`, + `financial`, `standards`) +- `model` — the model to use (`smart` | `opus` | `sonnet` | `haiku`) +- `matchedBy` — why the agent was selected (`always`, `path:`, or `content:`) +- `rules` — the rule docs (relative to `.claude/review/`) to load into that agent's prompt - # Initialize agent list - SELECTED_AGENTS=() - - # Always include these - SELECTED_AGENTS+=("Architecture" "Testing" "Standards") - echo "✅ Architecture Agent - Always included" - echo "✅ Testing Agent - Always included" - echo "✅ Standards Agent - Always included" - - # Security Agent - if auth/API code changed - if grep -q -E "(pages/api/auth|session|jwt|impersonate|authentication)" /tmp/changed_files.txt 2>/dev/null; then - SELECTED_AGENTS+=("Security") - echo "✅ Security Agent - Auth/API code detected" - SECURITY_NEEDED=true - else - echo "❌ Security Agent - No auth/API changes (saved ~\$1.50)" - SECURITY_NEEDED=false - fi +`deep` mode launches all 7 agents; `quick` mode launches a fixed subset (Testing, UX, Standards). - # Data Integrity Agent - if GraphQL or Apollo changes - if grep -q -E "(\.graphql|apollo|src/lib/apollo)" /tmp/changed_files.txt 2>/dev/null; then - SELECTED_AGENTS+=("Data") - echo "✅ Data Integrity Agent - GraphQL/Apollo changes detected" - DATA_NEEDED=true - else - echo "❌ Data Integrity Agent - No GraphQL changes (saved ~\$1.00)" - DATA_NEEDED=false - fi - - # UX Agent - if UI components changed - if grep -q -E "(\.tsx|components/.*\.tsx)" /tmp/changed_files.txt 2>/dev/null; then - SELECTED_AGENTS+=("UX") - echo "✅ UX Agent - UI components modified" - UX_NEEDED=true - else - echo "❌ UX Agent - No UI changes (saved ~\$1.00)" - UX_NEEDED=false - fi - - # Financial Agent - if financial code changed - if grep -q -iE "(donation|pledge|gift|amount|currency|balance|financial)" /tmp/pr_diff.txt 2>/dev/null; then - SELECTED_AGENTS+=("Financial") - echo "✅ Financial Agent - Financial code detected" - FINANCIAL_NEEDED=true - else - echo "❌ Financial Agent - No financial code (saved ~\$1.50)" - FINANCIAL_NEEDED=false - fi +Announce the selection, including each agent's `matchedBy` reason: +```bash +if [ "$AGENT_MODE" = "standard" ]; then + echo "🤖 Agents selected by the config engine:" echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Selected: ${#SELECTED_AGENTS[@]} of 7 agents" - SAVED_COST=$(( (7 - ${#SELECTED_AGENTS[@]}) * 1 )) - echo "Estimated savings: ~\$$SAVED_COST" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Save for later stages - echo "${SELECTED_AGENTS[@]}" > /tmp/selected_agents.txt + # Each agent in /tmp/review_plan.json's agents[] is launched in Stage 1. + # Announce id + matchedBy reason for each (e.g. "✅ ux — path:src/components/**/*.tsx"). elif [ "$AGENT_MODE" = "quick" ]; then - # Quick mode: only 3 agents + # Quick mode: fixed subset, ignore engine selection echo "Testing UX Standards" > /tmp/selected_agents.txt - SECURITY_NEEDED=false - DATA_NEEDED=false - UX_NEEDED=true - FINANCIAL_NEEDED=false elif [ "$AGENT_MODE" = "deep" ]; then - # Deep mode: all 7 agents + # Deep mode: all 7 agents regardless of triggers echo "Security Architecture Data Testing UX Financial Standards" > /tmp/selected_agents.txt - SECURITY_NEEDED=true - DATA_NEEDED=true - UX_NEEDED=true - FINANCIAL_NEEDED=true fi ``` +In `standard` mode, do not assemble `SELECTED_AGENTS` or `*_NEEDED` flags by hand — the engine's +`agents[]` is authoritative. Launch exactly the agents it lists (mapping `id` → the matching +Stage 1 agent prompt). + --- ## Stage 1 — Launch Specialized Review Agents (Parallel) @@ -325,17 +256,32 @@ Now launch the selected review agents in parallel using the Task tool. **IMPORTANT:** Use a SINGLE message with multiple Task tool invocations to run them in parallel. -Read `/tmp/selected_agents.txt` to determine which agents to launch. +In `standard` mode, the agents to launch come from `/tmp/review_plan.json`'s `agents[]` array (see +Stage 0B). In `quick`/`deep` mode, use the fixed list written to `/tmp/selected_agents.txt`. Map +each engine `id` to the matching agent prompt below: + +- `security` → Agent 1 (Security) +- `architecture` → Agent 2 (Architecture) +- `data-integrity` → Agent 3 (Data Integrity) +- `testing` → Agent 4 (Testing & Quality) +- `ux` → Agent 5 (UX) +- `financial` → Agent 6 (Financial Accuracy) +- `standards` → Agent 7 (MPDX Standards Compliance) Display: "🚀 Launching [N] specialized review agents in parallel..." -**Note**: Only launch agents that are needed based on the mode and smart selection. Check the variables: +**Wire rules + profile into each agent prompt:** + +1. **Rules** — For each agent, read every rule doc listed in its `rules[]` (paths are relative to + `.claude/review/`, e.g. `.claude/review/rules/security.md`) and inject the full contents into + that agent's prompt under a `PROJECT-SPECIFIC RULES` section. These prose docs hold the MPDX + focus areas migrated from `code-review.md` and are authoritative for what the agent checks. +2. **Profile** — Apply the plan's `profile` (from `/tmp/review_plan.json`) to every agent prompt: + - `chill` → "Report only high-confidence, severity ≥ 7 findings; suppress nits." + - `standard` → current behavior (report all severities per the agent's output format). + - `assertive` → "Report all findings including low-severity suggestions." -- `$SECURITY_NEEDED` - Launch Security Agent if true -- `$DATA_NEEDED` - Launch Data Integrity Agent if true -- `$UX_NEEDED` - Launch UX Agent if true -- `$FINANCIAL_NEEDED` - Launch Financial Agent if true -- Always launch: Architecture, Testing, Standards (in all modes except quick which uses Testing, UX, Standards) +Use the agent's `model` field from the plan when launching (falling back to the mode default). ### Agent 1: Security Review 🔒 @@ -1519,6 +1465,13 @@ Now analyze all findings, debates, and final severity scores to build consensus. - **Average 3-5, 1-2 agents**: SUGGESTION - **Unresolved Debate** (agents couldn't agree, severity differs by 4+): NEEDS HUMAN REVIEW +**Profile-scaled reporting cutoff** (from `/tmp/review_plan.json`'s `profile`): apply the same +floor the agents used so consensus output stays consistent with what was collected: + +- `chill` → only surface consensus findings with average severity ≥ 7; drop MEDIUM/SUGGESTION tiers. +- `standard` → report all tiers above (default). +- `assertive` → report all tiers, including low-severity suggestions, and do not collapse them. + For each grouped finding, determine: - Final severity: Average of all agent severity scores From 3365548129b67f3980e778de637648fa06cc9623 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:43:08 -0400 Subject: [PATCH 13/39] chore(review): supersede code-review.md with pointer to review core --- .claude/rules/code-review.md | 326 +---------------------------------- 1 file changed, 6 insertions(+), 320 deletions(-) diff --git a/.claude/rules/code-review.md b/.claude/rules/code-review.md index ab2baa22ed..2c63b169e9 100644 --- a/.claude/rules/code-review.md +++ b/.claude/rules/code-review.md @@ -1,323 +1,9 @@ -# MPDX React — Code Review Rules +# MPDX React — Code Review Rules (moved) -Project-specific rules layered on top of `CLAUDE.md` for `/quality:agent-review`. +These rules now live in the declarative review core: -**Stack:** Next.js 15 (Pages Router) · React 18 · TypeScript · Material UI v5 · Apollo Client (dual GraphQL) · Formik + Yup · Jest + React Testing Library · react-i18next · NextAuth (Okta / API OAuth). +- Config (risk scoring, agents, triggers, exclusions): `.claude/review/config.yml` +- Prose rule docs (per-agent focus areas, standards): `.claude/review/rules/` +- Engine + tests: `.claude/review/engine/` (run `yarn test:review`) -**Key architectural facts the agents should know:** - -- Two GraphQL servers are routed by Apollo Link: the primary API (`https://api.mpdx.org/graphql`) and a Next.js REST-proxy lambda under `pages/api/graphql-rest.page.ts`. Routing is driven by `src/graphql/rootFields.generated.ts`. -- REST-proxy schemas live in `pages/api/Schema//` and follow the pattern: `.graphql` → `resolvers.ts` → `datahandler.ts` (sibling file) → REST call. -- Every user-visible string is localized via `useTranslation` / `t()` and extracted to `public/locales/`. -- Named exports only — no `export default` in components, hooks, or libs. -- Apollo cache normalization depends on every selection set including `id` on normalizable types. - ---- - -## Critical File Patterns - -Files that control auth, routing, Apollo setup, build config, or the CI/review pipeline. Each contributes +3 to risk score (on top of the universal defaults). - -- `pages/api/auth/[...nextauth].page.ts` — NextAuth handler (OAuth callbacks, session) -- `pages/api/auth/apiOauthSignIn.ts` — API OAuth sign-in flow -- `pages/api/auth/helpers.ts` — shared auth helpers (token handling) -- `pages/api/graphql-rest.page.ts` — REST-proxy entry point (token forwarding lives here) -- `pages/api/Schema/index.ts` — REST-proxy schema registration -- `pages/_app.page.tsx` — app-level providers and global wiring -- `next.config.{js,ts}` — build-time config, headers, rewrites, CSP -- `codegen.ts`, `codegen.*.ts` — GraphQL codegen configuration -- `package.json` — dependency changes (new packages trigger `## Special Pattern Detection` below) -- `.github/workflows/**` — CI/CD workflows -- `src/lib/apollo/client.ts` — Apollo Client instantiation (auth headers, default options, link chain composition) -- `src/lib/apollo/link.ts` — Apollo Link chain (dual-server routing, auth token attachment, error handling) -- `src/lib/apollo/cache.ts` — Apollo InMemoryCache config (type policies, merge functions, cache normalization) -- `src/lib/apollo/ssrClient.ts` — SSR Apollo Client (server-side data fetching, hydration) -- `.claude/commands/**`, `.claude/rules/**`, `.claude/settings.json` — review-process and harness definitions (protect against weakening AI review or enabling unvetted plugins/marketplaces) - -## High-Risk File Patterns - -Each contributes +2 to risk score. - -- `pages/api/Schema/**/*.{ts,graphql}` — REST-proxy resolvers, schemas, and data handlers (silent data-reshaping risk) -- `pages/api/Schema/**/datahandler*.ts` — response transformation feeding the REST proxy -- `pages/api/**/*.page.ts` (excluding `auth/` and `graphql-rest` — already Critical) — other API/lambda routes -- `src/components/**/*.graphql` — GraphQL operations (cache normalization and pagination correctness) -- `src/lib/apollo/**/*.ts` — any Apollo helper beyond the Critical files above -- `src/components/User/**`, anything handling impersonation or account-list switching -- `pages/api/Schema/uploads/**`, `pages/api/uploads/**` — file upload handlers -- `src/components/Shared/**` — shared components (blast radius) - -## Medium-Risk File Patterns - -Each contributes +1 to risk score. - -- `src/components/**/*.{ts,tsx}` — feature components (excluding Shared, already High-Risk) -- `src/hooks/**/*.ts` — custom hooks -- `src/lib/**/*.ts` (excluding `src/lib/apollo/**`) — utilities and helpers -- `pages/**/*.page.tsx` — route-level page components (excluding auth/api) -- `src/components/Settings/**` — settings UI (touches user preferences, org config) -- `src/theme.ts`, `src/theme/**` — MUI theme and design tokens - -## Low-Risk File Patterns - -Zero points. These override or augment the universal Low-Risk defaults. - -- `**/*.test.{ts,tsx}` — test files (content changes only; new test infrastructure should still be reviewed) -- `public/locales/**/*.json` — translation content -- `public/static/**` — static assets -- `public/fonts/**`, `public/images/**` — binary assets -- `**/*.snap` — Jest snapshot files - -## Special Pattern Detection - -Additional risk modifiers, added to the universal defaults. - -- **New package added to `package.json` dependencies/devDependencies:** +2 (supply-chain risk, bundle size impact) -- **Updated critical package** (`next`, `react`, `@apollo/client`, `@mui/material`, `formik`, `next-auth`, `typescript`, `graphql-codegen`): +3 -- **`yarn.lock` changed without matching `package.json` change:** +1 (likely a resolution drift or lockfile hand-edit) -- **`.graphql` file changed without verifying `yarn gql` runs cleanly:** +2 (codegen out of sync — runtime errors likely). Note: `.generated.ts` files are not committed; CI regenerates them. The check is that codegen succeeds, not that generated files appear in the PR -- **New `.graphql` file under `pages/api/Schema/` without matching resolver/dataHandler updates:** +1 -- **New file in `src/hooks/` that uses Apollo hooks without an accompanying test file:** +1 (hooks drive component behavior; untested hooks are landmines) -- **New file in `src/components/` without an accompanying `*.test.tsx`:** +1 -- **Changes to `next.config.{js,ts}` rewrites, headers, CSP, or image domains:** +2 (affects all pages and external requests) -- **Changes to `src/lib/apollo/cache.ts` `typePolicies` or `merge` functions:** +2 (can silently corrupt cached data across the app) - -## Agent Triggers - -Repo-specific triggers that supplement the skill's minimal universal triggers. - -**Security Agent** - -- Any file under `pages/api/**` (all API/lambda routes) -- Any file matching `src/lib/apollo/{link,client,ssrClient}.ts` -- `next.config.{js,ts}`, `pages/_app.page.tsx`, any middleware/CSP/headers config -- `src/lib/extractCookie*.ts`, `src/lib/**auth**`, `src/lib/**session**` -- Changes to `.github/workflows/**` -- Changes adding `process.env.*` references -- Changes to `.claude/commands/**`, `.claude/rules/**`, or `.claude/settings.json` (review process and harness integrity) - -**Data Integrity Agent** - -- `pages/api/Schema/**/*.{ts,graphql}` (REST proxy silently reshapes data) -- `pages/api/Schema/**/datahandler*.ts` -- `src/lib/apollo/cache.ts` (type policies, merge functions, pagination) -- `src/components/**/*.graphql` containing `mutation` keyword -- Any `.graphql` file with `first:`, `after:`, `pageInfo`, or `nodes` (pagination) -- Apollo `update`, `optimisticResponse`, `refetchQueries`, or `cache.modify` / `cache.evict` calls in diff content -- Anywhere the diff content shows manual `__typename` assignment -- `src/graphql/rootFields.generated.ts` (dual-server routing) - -**UX Agent** - -- `src/components/**/*.tsx` -- `pages/**/*.page.tsx` -- `src/theme.ts`, `src/theme/**` -- Changes touching Formik wiring (``, `useFormik`, ``, `ErrorMessage`) -- New translation keys (files under `public/locales/en/**` changed with structural additions, not content translation) - -**Testing Agent** - -- Any file under `src/components/**`, `src/hooks/**`, or `src/lib/**` that is added or modified **without** a corresponding `*.test.{ts,tsx}` change in the same PR -- Any change to test utilities (`__tests__/util/**`) -- New mocks added to `GqlMockedProvider` usage that bypass type checking - -## Domain Agents - -### Financial Reporting Agent - -MPDX displays and calculates donation/partner-giving aggregations across dozens of financial reports. Display-side miscalculations silently mislead staff about their support status. This agent supplements the generic Data Integrity agent with domain-specific invariants. - -**Trigger conditions:** - -- Any file under `src/components/Reports/**` -- Any file under `src/components/HrTools/**` -- Any file under `src/components/Reports/GoalCalculator/**`, `src/components/HrTools/GoalCalculator/**`, or `src/components/HrTools/PdsGoalCalculator/**` -- Any file under `src/components/Reports/SalaryCalculator/**` or `src/components/HrTools/SalaryCalculator/**` -- Any file under `src/components/EditDonationModal/**` -- Any file under `src/components/Reports/AdditionalSalaryRequest/**`, `src/components/HrTools/AdditionalSalaryRequest/**`, or `src/components/HrTools/MinisterHousingAllowance/**` -- Any file under `src/components/Dashboard/MonthlyGoal/**`, `src/components/Dashboard/DonationHistories/**` -- Diff content contains any of: `amount`, `currency`, `convertedAmount`, `pledgeAmount`, `goal`, `balance`, `total`, `sum(`, `reduce((`, `.toFixed(`, `Math.round(`, `Number(`, `parseFloat(`, `parseInt(` inside `src/components/Reports/**`, `src/components/HrTools/**`, or other goal/donation components - -**Focus areas:** - -- **Money is never a JavaScript `number` for arithmetic.** Check for floating-point arithmetic on money values — any `amount + amount`, `amount * rate`, or `.reduce` accumulating amounts must round at the display boundary. -- **Currency mixing.** Donations arrive in multiple currencies; verify no code path sums `amount` (native currency) across rows with different `currencyCode`. Aggregations must use `convertedAmount` (or equivalent) in a single reporting currency. -- **Rounding consistency.** Rounding should happen at the display boundary via `intlFormat` / `Intl.NumberFormat`, not sprinkled through calculation code. Flag any `.toFixed(n)` used inside aggregation logic. -- **Missing/null amounts.** Donations, pledges, and goals may be `null` or `undefined`. Verify nullish handling (`?? 0`) is present where aggregations happen, and that `null` is not silently coerced to `0` where it should surface as "unknown." -- **Date-window correctness.** Fourteen-month and expected-monthly reports depend on correct month boundaries, timezone handling (use Luxon — not `new Date()`), and inclusive/exclusive range semantics. Flag any `new Date()` in report logic. -- **Goal-calculation consistency.** Goal math in `GoalCalculator` / `PdsGoalCalculator` must match across UI layers — flag any duplicate calculation logic that could drift. -- **Empty-state / zero-state correctness.** A report with zero donations should render "no data" — not `$0.00` that looks like real data. -- **GraphQL aggregation fields vs client-side summing.** Prefer server-provided totals (`totalAmount`, `sum`, etc.) over client-side `.reduce` when both are available — client sums over a paginated `nodes` list are a silent bug. - -**Output format:** Use the standard agent output format with `Critical Financial Issues`, `Financial Concerns`, `Financial Suggestions`, plus a `Financial Checklist`: - -``` -### Financial Checklist -- Arithmetic on money values safe: Yes/No/N/A -- Currency mixing prevented: Yes/No/N/A -- Rounding at display boundary only: Yes/No/N/A -- Null/undefined amounts handled: Yes/No/N/A -- Luxon used for dates (not `new Date()`): Yes/No/N/A -- Server-provided aggregations preferred: Yes/No/N/A -``` - -**Note:** If your analysis determines that the changes do not actually affect financial logic (e.g., the keyword match was a false positive — `amount` could be a form field label), state "No financial calculation code in this PR" clearly and skip the detailed review. - -## Standards Checklist - -Every item here is mandatory. The Standards agent must report compliance per item. - -**Exports & Naming** - -- [ ] **Named exports only** — no `export default` in components, hooks, or libs (`export const ComponentName: React.FC = () => {}`) -- [ ] **File naming** — components PascalCase (`Foo.tsx`), pages kebab-case with `.page.tsx`, API routes with `.page.ts`, tests colocated as `Foo.test.tsx`, GraphQL as PascalCase `.graphql` -- [ ] **GraphQL operation names** — descriptive, not prefixed with `Get` or `Load` (e.g. `ContactDetails`, not `GetContactDetails`) -- [ ] **Hook names** — must start with `use` and live in `src/hooks/` (reusable) or next to the component (feature-specific) - -**Localization (i18n)** - -- [ ] Every user-visible string uses `useTranslation` / `t()` — no hard-coded display text in JSX, `Alert`, `Snackbar`, error messages, `aria-label`, or form labels -- [ ] No string interpolation inside `t()` calls — use interpolation variables: `t('Hello {{name}}', { name })`, not `t(`Hello ${name}`)` -- [ ] No dynamic `t()` keys (`t(varName)`) — extraction tool can't find them - -**GraphQL & Apollo** - -- [ ] Every query/mutation selection set includes `id` on normalizable types (for cache normalization) -- [ ] Any `.graphql` change has been verified by running `yarn gql` successfully -- [ ] Any query returning `nodes` either handles pagination via `pageInfo` / `after` / `fetchMore`, or documents why the default 25-item limit is sufficient -- [ ] Mutations that change cached data include either `update`, `refetchQueries`, or `cache.evict` to reflect the change in the UI -- [ ] Apollo routing awareness — when adding a field, check `src/graphql/rootFields.generated.ts` to know which server handles it; don't mix rootFields with REST-proxy-only fields in one operation -- [ ] No raw `fetch` or `axios` calls for data that could go through Apollo — centralize in GraphQL. Exception: the REST-proxy boundary layer (`pages/api/graphql-rest.page.ts` and `pages/api/Schema/**/datahandler*.ts`) uses `fetch` intentionally to call the upstream REST API - -**TypeScript** - -- [ ] No `any` types in new code (use `unknown` + narrowing, or proper generics) -- [ ] No `@ts-ignore` / `@ts-expect-error` without an inline comment explaining why -- [ ] No non-null assertions (`!`) on values that could legitimately be null — prefer explicit null checks -- [ ] Generated operation types from `.generated.ts` files are used for Apollo hooks and mocks - -**Forms** - -- [ ] Forms use Formik + Yup — no manual `useState` form state for anything beyond trivial single-field inputs -- [ ] Every Yup schema has matching server-side validation (don't rely on client-only validation) -- [ ] Submit buttons are `disabled` while `isSubmitting` is true - -**Testing** - -- [ ] Every new component, hook, and lib function has a colocated `*.test.{ts,tsx}` -- [ ] Component tests using GraphQL wrap in `GqlMockedProvider<{ OperationName: OperationNameQuery }>` with typed mocks -- [ ] Tests use `findBy*` for async assertions rather than `waitFor(() => getBy*)` -- [ ] No `any` in test types — use generated operation types for mock shapes -- [ ] No global `fetch` mocking — use Apollo mocks at the operation level - -**Code Quality** - -- [ ] Passes `yarn lint` and `yarn lint:ts` -- [ ] No debug output (`console.log`, `console.debug`, `debugger`, `// TODO` without a Jira/MPDX ticket reference) -- [ ] No `new Date()` — use Luxon (`DateTime.now()`, `DateTime.local()`) per project convention -- [ ] No unused imports or variables -- [ ] No commented-out code blocks (delete, don't comment) -- [ ] No empty `catch {}` blocks that swallow errors silently - -## Security Focus Areas - -Project-specific concerns added to the Security agent's universal checks. - -- **NextAuth callback handling** (`pages/api/auth/[...nextauth].page.ts`) — verify token persistence, refresh logic, session expiry, callback URL validation against an allowlist (open-redirect risk) -- **API OAuth sign-in flow** (`pages/api/auth/apiOauthSignIn.ts`) — PKCE handling, state parameter validation, redirect URI verification -- **REST-proxy token forwarding** (`pages/api/graphql-rest.page.ts`) — verify bearer tokens are forwarded correctly, never logged, and never exposed in error responses -- **Apollo link auth headers** (`src/lib/apollo/link.ts`) — tokens attached consistently, never logged, correct handling when a token is missing -- **Environment variable exposure** — server-only variables must NOT be prefixed with `NEXT_PUBLIC_` (that ships them to the client bundle); audit every new `process.env.*` reference -- **Client-side validation parity** — every Yup rule, every `disabled` button, every `required` field must have a server-side equivalent. If a mutation silently trusts the client, flag it -- **Impersonation flow** — changes touching impersonation (`pages/api/auth/impersonate/**`, `src/components/User/impersonate*`) must verify the authorizer's role and log the action -- **File uploads** — `pages/api/uploads/**`, `pages/api/Schema/uploads/**` — verify content-type validation, size limits, filename sanitization (no path traversal), and that upload tokens are scoped -- **CSP and security headers** in `next.config.{js,ts}` — any weakening (new `unsafe-inline`, `unsafe-eval`, added origins) is a red flag -- **XSS surfaces** — `dangerouslySetInnerHTML`, `innerHTML`, direct DOM writes; flag any use in new code and verify input is sanitized -- **Open redirect** — any `router.push(value)` or `window.location = value` where `value` comes from a query parameter must be validated against an allowlist -- **GraphQL variable injection** — never build GraphQL operations via string concatenation; only use query variables -- **Admin/coaching authorization** — settings, org admin, and coaching views must check the user's role on each mutation, not just on the initial page load -- **CI/CD workflow security** — any change to `.github/workflows/**` must verify permission scopes are minimal, secrets are not exposed in logs, and trigger conditions cannot be abused to bypass review -- **Review process integrity** — changes to `.claude/commands/**`, `.claude/rules/**`, or `.claude/settings.json` must verify risk scoring is not weakened, severity thresholds are not lowered, critical checks are not stripped, and newly enabled plugins/marketplaces come from trusted org-controlled sources - -## Architecture Focus Areas - -- **Dual GraphQL boundary** — new queries should route cleanly to one server. Mixing rootFields (primary API) with REST-proxy-only fields in the same operation is a smell; the Apollo link will split them but the result can be confusing and harder to cache -- **REST-proxy layering** — new proxy queries follow the pattern: `pages/api/Schema//.graphql` → `resolvers.ts` in the same folder → `datahandler.ts` sibling file → REST call in `graphql-rest.page.ts`. Deviations should be justified -- **Thin page components** — route-level pages (`pages/**/*.page.tsx`) should compose feature components, not contain business logic. Logic lives in hooks or feature components -- **Component feature organization** — new components belong under `src/components//`; components used across multiple features go in `src/components/Shared/`. Don't add one-off components to `Shared/` -- **Hook placement** — reusable hooks in `src/hooks/`; feature-specific hooks stay next to their components -- **Pages Router conventions** — `.page.tsx` suffix for routes, `.page.ts` for API routes, no App Router patterns (`app/`, `layout.tsx`, `use client`, RSC) -- **useEffect dependency arrays** — verify all referenced values are listed; flag empty arrays that reference props/state (stale closures); flag effects that should be `useMemo` or event handlers instead -- **Error boundaries** — new top-level views should be wrapped in an error boundary or compose one that is. Apollo errors should surface to the user, not be swallowed -- **N+1 / waterfall queries** — component mounts that fire Apollo queries in a `useEffect` chain (query A → then query B based on A's result) should be collapsed into one operation or use `skip` + parallel queries -- **Prop drilling vs context vs Apollo cache** — if a value is passed through 3+ layers, consider Apollo cache, a context, or moving the data fetch closer to the consumer -- **Technical debt** — debt added vs reduced by this PR. Refactors that only move code without improving clarity are neutral, not positive -- **Pattern compliance** with `CLAUDE.md` and the existing codebase — new code should look like the code around it unless the existing pattern is what's being fixed - -## Data Integrity Focus Areas - -This is where domain-specific data invariants belong. - -- **Apollo cache normalization** — missing `id` fields cause stale or duplicate cache entries. Every selection set for a normalizable type must include `id` -- **Cache type policies** (`src/lib/apollo/cache.ts`) — any change to `typePolicies`, `keyFields`, or `merge` functions can silently corrupt cached data across the app. Treat as high-severity -- **Pagination merge functions** — `fetchMore` merge must dedupe, preserve order, and handle cursor edge cases (empty page, duplicate cursor, cursor missing) -- **Optimistic responses** — must match server response shape exactly, including `__typename` and `id`; mismatches cause cache misses and UI flicker -- **Mutation cache updates** — every mutation that changes displayed data must include `update`, `refetchQueries`, `cache.modify`, or `cache.evict` — otherwise the UI shows stale data -- **REST-proxy data handlers** — field mapping from snake_case (REST) to camelCase (GraphQL) is a frequent place for silent field-dropping. Verify every field in the REST response is either mapped or intentionally ignored -- **Null vs undefined in mutation variables** — GraphQL distinguishes between "field not set" (`undefined`, omitted) and "field explicitly null" (`null`). The REST proxy may reject one or the other. Form state → mutation variable mapping must be intentional about this -- **Form submit → mutation mapping** — Formik values passed directly to a mutation can include unexpected fields if the Yup schema is looser than the GraphQL input type. Flag any `...values` spread into mutation variables without an explicit field allowlist -- **Date serialization** — dates sent to the server should be in a consistent format (ISO-8601 / Luxon); flag any manual `.toString()` or `new Date()` in mutation variables -- **Currency precision** — monetary values must not be truncated or rounded during form handling; the display boundary is the only place for rounding -- **Pagination over `nodes`** — aggregating client-side over a single page of `nodes` silently ignores unpaginated data. Prefer server-provided totals -- **Filter/search state** — when filters change, paginated data must be re-fetched from the first page, not appended to existing pages -- **Optimistic updates on lists** — adding an item optimistically must place it in the correct sort position, not just at the end - -## Testing Focus Areas - -Project-specific testing conventions added to the Testing agent's universal checks. - -- **`GqlMockedProvider` is the only GraphQL mock pattern.** All component tests that hit GraphQL must wrap in ` mocks={...}>` with typed generics so mock shapes are type-checked at compile time -- **`mutationSpy` + `toHaveGraphqlOperation(...)` pattern** is preferred over brittle snapshot-based assertions for verifying mutations -- **`toHaveTableStructure(...)` for full table contents** — when asserting more than a couple table cells, use `expect(getByRole('table')).toHaveTableStructure({ columnHeaders, rowHeaders, cells })` instead of ad-hoc `getByRole('cell')` -- **`findBy*` for async assertions** — prefer `await findByText(...)` over `await waitFor(() => getByText(...))` -- **No `fetch` mocking** — never mock `global.fetch` or `window.fetch`; use Apollo operation-level mocks -- **No `any` in test types** — mock shapes use generated operation types (`ContactDetailsQuery`, etc.) from `.generated.ts` files -- **`i18next` in tests** — `t()` returns the translation key by default in test env; don't assert on translated strings unless the test specifically exercises i18n -- **Test file colocation** — test files live next to the component under test (`Foo.test.tsx` alongside `Foo.tsx`), not in a separate `__tests__/` tree (except for shared test utilities) -- **Time-dependent tests** — use Jest fake timers (`jest.useFakeTimers()`) or Luxon's `Settings.now` override; never rely on actual system time -- **Edge case coverage** — every new component test should include: empty state, loading state, error state, at least one happy path, and boundary conditions (0 items, 1 item, many items) -- **Error path testing** — not just happy paths. Test validation failures, Apollo error responses, and user-visible error states - -## UX Focus Areas - -Project-specific UX/UI conventions layered on top of the UX agent's universal checks. - -- **Material UI v5 conventions** — use the `sx` prop for styling; avoid `makeStyles` (legacy v4) and avoid inline `style={...}` (breaks theme-aware styling and responsive breakpoints). Styled components (`styled(...)`) are acceptable for reused patterns -- **Theme tokens, not hardcoded values** — use `theme.palette.*`, `theme.spacing(n)`, `theme.breakpoints.*`. Flag raw hex colors, pixel values, and magic numbers -- **Responsive design** — MUI breakpoints (`xs`, `sm`, `md`, `lg`, `xl`) via `sx={{ [theme.breakpoints.down('md')]: {...} }}` or the shorthand `sx={{ display: { xs: 'block', md: 'flex' } }}`. New components must render correctly at mobile breakpoints -- **Loading states** — every Apollo query must render a loading state (MUI `Skeleton` or `CircularProgress`), not render nothing or flash stale content -- **Error states** — every Apollo query must render an error state (`` or similar). Never let an error silently render empty content -- **Formik field wiring** — use ``, `useField`, or `getFieldProps` consistently; form fields must wire `name`, `value`, `onChange`, `onBlur`, `error`, and `helperText` to the Formik state -- **Form error visibility** — validation errors must be visible next to the field (MUI `helperText` with `error` prop), not only in a toast or summary -- **Accessibility (a11y)** - - All interactive elements need accessible names (`aria-label`, `aria-labelledby`, or visible text) - - Icon-only buttons must have `aria-label` (MUI `IconButton` doesn't add one automatically) - - Form fields must have associated labels (MUI `TextField` with `label` prop, or explicit `` + `htmlFor`) - - Dialogs use `` with `aria-labelledby` pointing at the title - - Color should never be the only indicator of state (add icons or text) - - Keyboard navigation works (tab order, Enter/Space activation, Escape closes modals) -- **Translation coverage** — new user-visible strings must have i18n keys added; verify `yarn extract` would pick them up (no dynamic `t()` keys, no string interpolation inside `t()`) -- **Snackbar / notification usage** — success/error feedback goes through the project's notification system, not ad-hoc `alert()` or inline text -- **Dialog UX** — dialogs have clear primary/secondary actions, disable the primary action while submitting, and close on success - -## Excluded Paths - -Directories and file patterns the agent should not search or flag findings against. - -- **All gitignored paths** — anything matched by `.gitignore` (generated code, build artifacts, dependencies, etc.) -- `**/*.generated.ts` — GraphQL codegen output; these files are not committed and are regenerated by CI/build -- `public/locales/**` — translation content (agents should not flag translation _quality_) -- `public/static/**`, `public/fonts/**`, `public/images/**` — static assets -- `**/*.snap` — Jest snapshot files -- `.github/ISSUE_TEMPLATE/**` -- `docs/**` — repo documentation (unless changes are in-scope for the review) +See the design spec: `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`. From b9faba07124b1bcce177e61b1beea87e25cb1b9b Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 12:45:25 -0400 Subject: [PATCH 14/39] build(review): commit PnP map for yaml/minimatch/ajv (Zero-Install) --- .pnp.cjs | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/.pnp.cjs b/.pnp.cjs index c1b22ce3e3..c57165ee79 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -80,6 +80,7 @@ const RAW_RUNTIME_STATE = ["@types/testing-library__jest-dom", "npm:5.14.5"],\ ["@typescript-eslint/eslint-plugin", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:7.5.0"],\ ["@typescript-eslint/parser", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:8.17.0"],\ + ["ajv", "npm:8.20.0"],\ ["apollo3-cache-persist", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:0.14.1"],\ ["clsx", "npm:2.1.1"],\ ["concurrently", "npm:8.2.2"],\ @@ -116,6 +117,7 @@ const RAW_RUNTIME_STATE = ["lodash", "npm:4.17.21"],\ ["luxon", "npm:3.4.4"],\ ["micro-cors", "npm:0.1.1"],\ + ["minimatch", "npm:10.2.5"],\ ["mpdx-react", "workspace:."],\ ["next", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:15.0.3"],\ ["next-auth", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.24.11"],\ @@ -142,6 +144,7 @@ const RAW_RUNTIME_STATE = ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ ["url-loader", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.1.1"],\ ["webpack", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:5.96.1"],\ + ["yaml", "npm:2.9.0"],\ ["yup", "npm:1.4.0"]\ ],\ "linkType": "SOFT"\ @@ -9967,6 +9970,17 @@ const RAW_RUNTIME_STATE = ["uri-js", "npm:4.4.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:8.20.0", {\ + "packageLocation": "./.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip/node_modules/ajv/",\ + "packageDependencies": [\ + ["ajv", "npm:8.20.0"],\ + ["fast-deep-equal", "npm:3.1.3"],\ + ["fast-uri", "npm:3.1.2"],\ + ["json-schema-traverse", "npm:1.0.0"],\ + ["require-from-string", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["ajv-errors", [\ @@ -10798,6 +10812,13 @@ const RAW_RUNTIME_STATE = ["balanced-match", "npm:1.0.2"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:4.0.4", {\ + "packageLocation": "./.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip/node_modules/balanced-match/",\ + "packageDependencies": [\ + ["balanced-match", "npm:4.0.4"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["bare-events", [\ @@ -11024,6 +11045,14 @@ const RAW_RUNTIME_STATE = ["brace-expansion", "npm:2.0.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:5.0.6", {\ + "packageLocation": "./.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip/node_modules/brace-expansion/",\ + "packageDependencies": [\ + ["balanced-match", "npm:4.0.4"],\ + ["brace-expansion", "npm:5.0.6"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["braces", [\ @@ -14507,6 +14536,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["fast-uri", [\ + ["npm:3.1.2", {\ + "packageLocation": "./.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip/node_modules/fast-uri/",\ + "packageDependencies": [\ + ["fast-uri", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["fast-url-parser", [\ ["npm:1.1.3", {\ "packageLocation": "./.yarn/cache/fast-url-parser-npm-1.1.3-9be698120a-6d33f46ce9.zip/node_modules/fast-url-parser/",\ @@ -18778,6 +18816,14 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["minimatch", [\ + ["npm:10.2.5", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:5.0.6"],\ + ["minimatch", "npm:10.2.5"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:3.1.2", {\ "packageLocation": "./.yarn/cache/minimatch-npm-3.1.2-9405269906-e0b25b04cd.zip/node_modules/minimatch/",\ "packageDependencies": [\ @@ -19042,6 +19088,7 @@ const RAW_RUNTIME_STATE = ["@types/testing-library__jest-dom", "npm:5.14.5"],\ ["@typescript-eslint/eslint-plugin", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:7.5.0"],\ ["@typescript-eslint/parser", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:8.17.0"],\ + ["ajv", "npm:8.20.0"],\ ["apollo3-cache-persist", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:0.14.1"],\ ["clsx", "npm:2.1.1"],\ ["concurrently", "npm:8.2.2"],\ @@ -19078,6 +19125,7 @@ const RAW_RUNTIME_STATE = ["lodash", "npm:4.17.21"],\ ["luxon", "npm:3.4.4"],\ ["micro-cors", "npm:0.1.1"],\ + ["minimatch", "npm:10.2.5"],\ ["mpdx-react", "workspace:."],\ ["next", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:15.0.3"],\ ["next-auth", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.24.11"],\ @@ -19104,6 +19152,7 @@ const RAW_RUNTIME_STATE = ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ ["url-loader", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.1.1"],\ ["webpack", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:5.96.1"],\ + ["yaml", "npm:2.9.0"],\ ["yup", "npm:1.4.0"]\ ],\ "linkType": "SOFT"\ @@ -25478,6 +25527,13 @@ const RAW_RUNTIME_STATE = ["yaml", "npm:2.6.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.9.0", {\ + "packageLocation": "./.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip/node_modules/yaml/",\ + "packageDependencies": [\ + ["yaml", "npm:2.9.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["yaml-ast-parser", [\ From 8d019c944df6021ad309e7a52efea3bc5b3c8e91 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:20:29 -0400 Subject: [PATCH 15/39] docs: spec for agent-review index layer (Gap 2 / Phase B) --- ...6-06-23-agent-review-index-layer-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md diff --git a/docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md b/docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md new file mode 100644 index 0000000000..d96a7592d9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md @@ -0,0 +1,227 @@ +# Design Spec — Agent-Review Index Layer (Gap 2 / Phase B) + +- **Date:** 2026-06-23 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **Branch:** continues on `review-config-layer` (Phase A engine is the dependency; not yet merged) +- **Builds on:** Phase A config layer (`.claude/review/`) — see + `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md` + +--- + +## 1. Context & Problem + +Phase A gave the reviewer a declarative config layer. Gap 2 is the **persistent codebase index** — +the feature that makes Greptile best-in-class: a structural graph of the codebase used for +**cross-file impact analysis** ("this change affects these N callers/dependents"). Today the +`agent-review` command does a crude, per-run `grep` for dependents in Stage 1B; it's slow, +non-transitive, and inaccurate. + +### Decisions already made (brainstorm) + +1. **Structural impact graph, no embeddings.** Greptile's index has two halves: a structural + graph (impact analysis) and semantic embeddings (fuzzy retrieval). Embeddings need an + embeddings model — a paid external API (Voyage/OpenAI) or a local model — which conflicts with + the "use my Claude plan, no separate API account" constraint. The structural graph is the + higher-value, lower-cost half and runs locally under Node/PnP. "Semantic" retrieval is achieved + for free by having Claude (already billed via Claude Code) read the graph's neighbors. Embeddings + are out of scope. +2. **File-level import graph** ("which files import the changed file" + transitive), not + symbol-level. The repo has ~2,033 TS/TSX files with tsconfig path aliases (`src/*`, `pages/*`, + `__tests__/*`) + relative imports and `bundler` resolution — a light resolver handles this in + seconds with no TypeScript type-checker. Symbol-level precision (TS compiler API) is heavier and + deferred. +3. **Gitignored, built on demand.** The index is a local cache keyed on git HEAD; the review run + rebuilds it when missing/stale. No noisy diffs, always matches the tree. + +### Platform constraints (inherited from Phase A) + +- Yarn 4 + PnP (no `node_modules`); run via `yarn node`, test via the single-process runner + `yarn test:review` (never `node --test`). +- Engine is **CommonJS `.cjs`** (`require`/`module.exports`). +- Lowercase `.claude/` paths. Commit with `--no-verify`. + +--- + +## 2. Goals & Non-Goals + +### Goals + +- Build a persisted, accurate file-level import graph of the repo (internal edges only). +- Query the graph for direct + transitive dependents of a set of changed files, with a blast-radius + measure, depth/node caps, and the most-impacted files. +- Cache the index locally (gitignored), rebuilt when stale (git HEAD changed) or missing. +- Expose an `impact.cjs` CLI emitting an impact report JSON the command consumes. +- Integrate: replace the command's Stage 1B grep with the impact report; feed high-blast-radius + dependents to the Architecture and Data Integrity agents; surface impact in the report. Enabled + via the Phase-A reserved `index.enabled` config key. + +### Non-Goals + +- Embeddings / semantic similarity search (any kind). +- Symbol-level / call-graph precision (TS compiler API). +- Incremental rebuild (full rebuild keyed on HEAD is sufficient for now). +- Auto-bumping the risk score from blast radius (kept decoupled; impact is surfaced to agents + + report only). +- Changes to `plan.cjs` purity or the debate/consensus stages. + +--- + +## 3. Architecture + +New modules under `.claude/review/engine/` (pure + testable, except `indexStore` which is thin +fs/git glue): + +``` +.claude/review/ +├── index/ # gitignored local cache +│ └── graph.json +├── engine/ +│ ├── resolveImport.cjs # resolveImport(fromFile, spec, fileSet) -> repoRelPath | null [pure] +│ ├── buildGraph.cjs # buildGraph(files, readFile, fileSet) -> { imports, importedBy } [pure] +│ ├── queryImpact.cjs # queryImpact(changedFiles, graph, opts) -> impact report [pure] +│ ├── indexStore.cjs # loadOrBuildIndex({ repoRoot, indexPath }) -> graph (fs/git glue) +│ └── impact.cjs # CLI -> impact JSON +``` + +### Data model (`graph.json`) + +```json +{ + "version": 1, + "head": "", + "fileCount": 2033, + "imports": { "src/a.tsx": ["src/b.ts", "src/lib/c.ts"] }, + "importedBy": { "src/b.ts": ["src/a.tsx"] } +} +``` + +`imports` = internal files each file imports. `importedBy` = the reverse edges (what impact +analysis queries). External specifiers (`@mui/*`, `react`, etc.) and unresolvable specifiers are +dropped. + +--- + +## 4. Components + +### 4.1 `resolveImport(fromFile, spec, fileSet) -> string | null` (pure) + +Maps an import specifier to a repo-relative file path, or `null` if external/unresolvable. + +- **Alias**: spec starting with `src/`, `pages/`, or `__tests__/` → that repo-root-relative path + (from tsconfig `paths`). +- **Relative**: spec starting with `.` → `path.posix.join(dirname(fromFile), spec)` normalized. +- **Bare** (anything else, e.g. `react`, `@mui/material`, `lodash`) → `null` (external). +- **Resolution** (bundler semantics) against `fileSet` (a `Set` of known repo-relative files): + try the literal path, then append each of `['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']`, + then `'/index' + ext` for the same extensions. Return the first member of `fileSet`, else `null`. + +### 4.2 `buildGraph(files, readFile, fileSet) -> { imports, importedBy }` (pure) + +- `files`: repo-relative paths to parse (TS/TSX/JS/JSX). `readFile(file) -> string` injected. + `fileSet`: `Set` of all known repo files (for resolution). +- For each file, extract specifiers with regexes: + - `/\bimport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g` + - `/\bimport\s*['"]([^'"]+)['"]/g` (side-effect imports) + - `/\bexport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g` + - `/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g` + - `/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g` (dynamic) +- Resolve each via `resolveImport`; keep non-null internal targets (deduped). Populate `imports` + and the reverse `importedBy`. + +### 4.3 `queryImpact(changedFiles, graph, { maxDepth = 3, maxNodes = 200 }) -> report` (pure) + +```js +{ + directDependents: { "": ["", ...], ... }, + transitiveDependents: ["", ...], // BFS over importedBy from all changed files, + // excluding the changed files themselves, + // stopping at maxDepth, capped at maxNodes + blastRadius: , // transitiveDependents.length + topImpacted: [{ file: "", dependentCount: }, ...], // sorted desc by direct count + truncated: // true if the cap was hit +} +``` + +### 4.4 `loadOrBuildIndex({ repoRoot, indexPath }) -> graph` (fs/git glue) + +- If `indexPath/graph.json` exists and its `head` equals the current git HEAD + (`git -C repoRoot rev-parse HEAD`), parse and return it. +- Otherwise rebuild: `git -C repoRoot ls-files` → filter to `*.{ts,tsx,js,jsx}` under `src/`, + `pages/`, `__tests__/` → build `fileSet` → `buildGraph(files, (f) => readFileSync(join(repoRoot, + f), 'utf8'), fileSet)` → write `graph.json` (with current `head`, `fileCount`) → return it. +- Documented limitation: index reflects HEAD, not the uncommitted working tree. Reverse-edges from + unchanged files (what impact needs) remain valid; a changed file's own new forward-edges may lag. + +### 4.5 `impact.cjs` (CLI) + +`yarn node .claude/review/engine/impact.cjs --root --index --changed [--max-depth N] [--max-nodes N]` +- Reads the newline-separated changed-files list, calls `loadOrBuildIndex` then `queryImpact`, + prints the report JSON to stdout. + +--- + +## 5. Integration + +- **Config**: set the Phase-A reserved key to `index: { enabled: true, path: ".claude/review/index" }`. +- **`.gitignore`**: add `.claude/review/index/`. +- **`package.json`**: add `"review:index": "yarn node .claude/review/engine/indexStore.cjs --build"` + (force a rebuild; `indexStore.cjs` gains a small CLI guard for `--build`). +- **Command (`agent-review.md`, Stage 1B only)**: when `index.enabled`, run `impact.cjs` against + `/tmp/changed_files.txt` → `/tmp/review_impact.json`. Replace the existing grep-based dependency + analysis with this report. Feed `topImpacted` + `directDependents` to the **Architecture** and + **Data Integrity** agent prompts ("this change affects these callers — verify them"), and surface + `blastRadius` / `topImpacted` in the final report. `plan.cjs` and the debate/consensus stages are + untouched. + +--- + +## 6. Testing & Acceptance + +### Testing (node:test via `yarn test:review`) + +- `resolveImport`: alias (`src/x` → `src/x.tsx`), relative (`../b` from `src/a/c.ts`), index + (`./dir` → `dir/index.ts`), extension precedence, external (`react` → null), unresolvable → null. +- `buildGraph`: in-memory fixture (injected `readFile` + `fileSet`) → expected `imports` / + `importedBy`; verifies dedupe and that external specifiers are dropped. +- `queryImpact`: transitive BFS, `maxDepth` / `maxNodes` capping (`truncated` flag), `blastRadius` + count, `topImpacted` ordering, changed files excluded from their own dependents. +- `indexStore`: freshness logic against a small temp fixture dir — builds when missing, reuses when + `head` matches, rebuilds when `head` differs (HEAD can be injected/stubbed for the test). + +### Acceptance criteria (Gap 2 done when) + +1. `resolveImport`, `buildGraph`, `queryImpact`, `indexStore`, `impact.cjs` exist with passing tests + in the `yarn test:review` suite. +2. `loadOrBuildIndex` builds a `graph.json` over the real repo in a few seconds and caches it + (second call with unchanged HEAD does not rebuild). +3. `impact.cjs` emits a valid impact report for a real changed-files list (smoke-tested on a real + diff). +4. `.claude/review/index/` is gitignored; `index.enabled: true` in `config.yml`; `review:index` + script present. +5. `agent-review.md` Stage 1B consumes the impact report and feeds dependents to the Architecture + + Data Integrity agents; `plan.cjs` and debate/consensus stages unchanged. +6. Full `yarn test:review` suite green; `lint:ts` shows no new engine-related errors. + +--- + +## 7. Open questions & risks + +- **Specifier-extraction false positives**: regex can match specifiers in comments/strings. Low + impact for a review heuristic; acceptable. Mitigation if needed later: strip block/line comments + before matching. +- **`.graphql` / `.generated` imports**: generated files aren't committed, so those specifiers + won't resolve and are dropped — fine; the graph covers committed TS/TSX/JS/JSX. +- **Working-tree staleness**: see §4.4 limitation. If it bites, re-parse just the changed files' + current imports on top of the cached graph (future enhancement). +- **Build cost on a cold cache**: ~2k file reads + regex; expected a few seconds. If it grows, + parallelize reads or scope the file set further. + +--- + +## 8. References + +- Phase A spec/plan: `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`, + `docs/superpowers/plans/2026-06-22-agent-review-config-layer.md` +- Competitive research (Greptile graph): `.claude/docs/competitive-research-greptile-coderabbit.md` +- Phase A engine + config it builds on: `.claude/review/engine/`, `.claude/review/config.yml` From e7d618be05dff3305e596f12e0448760c3c7e8c9 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:28:43 -0400 Subject: [PATCH 16/39] docs: implementation plan for agent-review index layer (Gap 2) --- .../2026-06-23-agent-review-index-layer.md | 700 ++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-agent-review-index-layer.md diff --git a/docs/superpowers/plans/2026-06-23-agent-review-index-layer.md b/docs/superpowers/plans/2026-06-23-agent-review-index-layer.md new file mode 100644 index 0000000000..ac3ce5c6d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-review-index-layer.md @@ -0,0 +1,700 @@ +# Agent-Review Index Layer (Gap 2) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add a persisted, file-level import graph of the codebase plus a query for transitive dependents ("this change affects these N callers"), wired into the `agent-review` command's Stage 1B — replacing today's grep-based dependency analysis. + +**Architecture:** Pure CommonJS modules under `.claude/review/engine/` — `resolveImport` (specifier → repo path), `buildGraph` (import adjacency), `queryImpact` (transitive dependents + blast radius) — plus a thin fs/git glue module `indexStore` (HEAD-keyed gitignored cache) and an `impact.cjs` CLI. No embeddings, no TS type-checker. + +**Tech Stack:** Node CommonJS `.cjs`, `node:test` via the existing single-process runner, no new dependencies. **Yarn 4 + PnP.** Builds on the Phase A engine. + +## Global Constraints — READ FIRST (platform-specific) + +- **Yarn 4 + PnP, NO `node_modules`.** Run engine via `yarn node`; test via `yarn --cwd test:review` (the single-process runner). **NEVER `node --test`.** +- **CommonJS `.cjs`** only (`require`/`module.exports`), NOT ESM. +- **No new dependencies** — only Node built-ins (`node:fs`, `node:path`, `node:child_process`, `node:os`). +- **Lowercase `.claude/`** paths. Commit with `git -C commit --no-verify`. +- Work ONLY in the worktree at `` (absolute paths for Write/Read/Edit, `git -C`/`yarn --cwd`). Do NOT `cd`. NEVER touch the main checkout. +- The Phase A engine already exists at `.claude/review/engine/` with the runner `run-tests.cjs` that auto-includes every `*.test.cjs`. New test files are picked up automatically. +- `path.posix` for all path math (repo paths are posix-style from `git ls-files`). + +--- + +### Task 1: Import resolver (`resolveImport.cjs`) + +**Files:** +- Create: `.claude/review/engine/resolveImport.cjs` +- Create: `.claude/review/engine/resolveImport.test.cjs` + +**Interfaces:** +- Produces: `resolveImport(fromFile, spec, fileSet) -> string | null` — maps an import specifier to a repo-relative file in `fileSet`, or `null` if external/unresolvable. Also exports `candidates(base) -> string[]` and `EXTS`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/resolveImport.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveImport } = require('./resolveImport.cjs'); + +const fileSet = new Set([ + 'src/b.ts', + 'src/a/c.ts', + 'src/lib/index.ts', + 'src/d.tsx', + 'pages/x.page.tsx', +]); + +test('resolves relative import with extension inference', () => { + assert.equal(resolveImport('src/a/c.ts', '../b', fileSet), 'src/b.ts'); +}); + +test('resolves alias import (src/*)', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/d', fileSet), 'src/d.tsx'); +}); + +test('resolves directory import to index file', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/lib', fileSet), 'src/lib/index.ts'); +}); + +test('returns null for bare/external specifiers', () => { + assert.equal(resolveImport('src/a/c.ts', 'react', fileSet), null); + assert.equal(resolveImport('src/a/c.ts', '@mui/material', fileSet), null); +}); + +test('returns null for unresolvable relative import', () => { + assert.equal(resolveImport('src/a/c.ts', './nope', fileSet), null); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./resolveImport.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/resolveImport.cjs`: +```js +'use strict'; +const path = require('node:path'); + +const ALIASES = ['src/', 'pages/', '__tests__/']; +const EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']; + +function candidates(base) { + const out = [base]; + for (const e of EXTS) out.push(base + e); + for (const e of EXTS) out.push(base + '/index' + e); + return out; +} + +function resolveImport(fromFile, spec, fileSet) { + let base; + if (ALIASES.some((a) => spec === a.slice(0, -1) || spec.startsWith(a))) { + base = spec; // already repo-root-relative (src/..., pages/..., __tests__/...) + } else if (spec.startsWith('.')) { + base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)); + } else { + return null; // bare / external + } + for (const c of candidates(base)) { + if (fileSet.has(c)) return c; + } + return null; +} + +module.exports = { resolveImport, candidates, EXTS }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/resolveImport.cjs .claude/review/engine/resolveImport.test.cjs +git -C commit --no-verify -m "feat(review): import specifier resolver for index graph" +``` + +--- + +### Task 2: Graph builder (`buildGraph.cjs`) + +**Files:** +- Create: `.claude/review/engine/buildGraph.cjs` +- Create: `.claude/review/engine/buildGraph.test.cjs` + +**Interfaces:** +- Consumes: `resolveImport` (Task 1). +- Produces: `buildGraph(files, readFile, fileSet) -> { imports, importedBy }` where `readFile(file) -> string` is injected; both maps are `{ [file]: string[] }`. Also exports `extractSpecifiers(text) -> string[]`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/buildGraph.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildGraph, extractSpecifiers } = require('./buildGraph.cjs'); + +test('extractSpecifiers finds import/export/require/dynamic specifiers', () => { + const text = ` + import a from './a'; + import { b } from 'src/b'; + export { c } from './c'; + const d = require('./d'); + const e = await import('./e'); + import 'side-effect'; + `; + const specs = extractSpecifiers(text).sort(); + assert.deepEqual(specs, ['./a', './c', './d', './e', 'side-effect', 'src/b'].sort()); +}); + +test('buildGraph builds imports + importedBy, drops externals, dedupes', () => { + const files = ['src/a.tsx', 'src/b.ts', 'src/c.ts']; + const fileSet = new Set(files); + const contents = { + 'src/a.tsx': "import { b } from 'src/b';\nimport x from 'react';\nimport { b2 } from './b';", + 'src/b.ts': "import { c } from './c';", + 'src/c.ts': "export const c = 1;", + }; + const graph = buildGraph(files, (f) => contents[f], fileSet); + assert.deepEqual(graph.imports['src/a.tsx'], ['src/b.ts']); // react dropped, dup b deduped + assert.deepEqual(graph.imports['src/b.ts'], ['src/c.ts']); + assert.deepEqual(graph.imports['src/c.ts'], []); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.tsx']); + assert.deepEqual(graph.importedBy['src/c.ts'], ['src/b.ts']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./buildGraph.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/buildGraph.cjs`: +```js +'use strict'; +const { resolveImport } = require('./resolveImport.cjs'); + +const PATTERNS = [ + /\bimport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\bimport\s*['"]([^'"]+)['"]/g, + /\bexport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, +]; + +function extractSpecifiers(text) { + const specs = new Set(); + for (const re of PATTERNS) { + re.lastIndex = 0; + let m; + while ((m = re.exec(text)) !== null) specs.add(m[1]); + } + return [...specs]; +} + +function buildGraph(files, readFile, fileSet) { + const imports = {}; + const importedBy = {}; + for (const file of files) { + let text; + try { + text = readFile(file); + } catch { + text = ''; + } + const targets = new Set(); + for (const spec of extractSpecifiers(text)) { + const resolved = resolveImport(file, spec, fileSet); + if (resolved && resolved !== file) targets.add(resolved); + } + imports[file] = [...targets]; + for (const t of targets) { + (importedBy[t] = importedBy[t] || []).push(file); + } + } + return { imports, importedBy }; +} + +module.exports = { buildGraph, extractSpecifiers }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/buildGraph.cjs .claude/review/engine/buildGraph.test.cjs +git -C commit --no-verify -m "feat(review): file-level import graph builder" +``` + +--- + +### Task 3: Impact query (`queryImpact.cjs`) + +**Files:** +- Create: `.claude/review/engine/queryImpact.cjs` +- Create: `.claude/review/engine/queryImpact.test.cjs` + +**Interfaces:** +- Produces: `queryImpact(changedFiles, graph, { maxDepth = 3, maxNodes = 200 }) -> { directDependents, transitiveDependents, blastRadius, topImpacted, truncated }`. `graph` is the `{ imports, importedBy }` shape from Task 2. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/queryImpact.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { queryImpact } = require('./queryImpact.cjs'); + +// chain: a <- b <- c (importedBy[a] = [b], importedBy[b] = [c]) +const graph = { + imports: {}, + importedBy: { 'a.ts': ['b.ts'], 'b.ts': ['c.ts'], 'shared.ts': ['a.ts', 'b.ts'] }, +}; + +test('direct + transitive dependents and blast radius', () => { + const r = queryImpact(['a.ts'], graph, {}); + assert.deepEqual(r.directDependents['a.ts'], ['b.ts']); + assert.deepEqual(r.transitiveDependents.sort(), ['b.ts', 'c.ts']); + assert.equal(r.blastRadius, 2); + assert.equal(r.topImpacted[0].file, 'a.ts'); + assert.equal(r.topImpacted[0].dependentCount, 1); + assert.equal(r.truncated, false); +}); + +test('maxDepth limits traversal', () => { + const r = queryImpact(['a.ts'], graph, { maxDepth: 1 }); + assert.deepEqual(r.transitiveDependents, ['b.ts']); + assert.equal(r.blastRadius, 1); +}); + +test('maxNodes cap sets truncated', () => { + const r = queryImpact(['a.ts'], graph, { maxNodes: 1 }); + assert.equal(r.truncated, true); + assert.equal(r.transitiveDependents.length, 1); +}); + +test('changed files are excluded from their own dependents', () => { + const r = queryImpact(['shared.ts', 'a.ts'], graph, {}); + assert.ok(!r.directDependents['shared.ts'].includes('a.ts')); // a.ts is itself changed + assert.deepEqual(r.directDependents['shared.ts'], ['b.ts']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./queryImpact.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/queryImpact.cjs`: +```js +'use strict'; + +function queryImpact(changedFiles, graph, opts = {}) { + const maxDepth = opts.maxDepth ?? 3; + const maxNodes = opts.maxNodes ?? 200; + const importedBy = graph.importedBy || {}; + const changedSet = new Set(changedFiles); + + const directDependents = {}; + for (const f of changedFiles) { + directDependents[f] = (importedBy[f] || []).filter((d) => !changedSet.has(d)); + } + + const visited = new Set(); + let truncated = false; + let frontier = [...changedFiles]; + for (let depth = 0; depth < maxDepth && frontier.length; depth++) { + const next = []; + for (const f of frontier) { + for (const dep of importedBy[f] || []) { + if (changedSet.has(dep) || visited.has(dep)) continue; + if (visited.size >= maxNodes) { + truncated = true; + break; + } + visited.add(dep); + next.push(dep); + } + if (truncated) break; + } + if (truncated) break; + frontier = next; + } + + const transitiveDependents = [...visited]; + const topImpacted = changedFiles + .map((f) => ({ file: f, dependentCount: directDependents[f].length })) + .sort((a, b) => b.dependentCount - a.dependentCount); + + return { directDependents, transitiveDependents, blastRadius: transitiveDependents.length, topImpacted, truncated }; +} + +module.exports = { queryImpact }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/queryImpact.cjs .claude/review/engine/queryImpact.test.cjs +git -C commit --no-verify -m "feat(review): transitive impact query over import graph" +``` + +--- + +### Task 4: Index store + cache (`indexStore.cjs`) + +**Files:** +- Create: `.claude/review/engine/indexStore.cjs` +- Create: `.claude/review/engine/indexStore.test.cjs` + +**Interfaces:** +- Consumes: `buildGraph` (Task 2). +- Produces: `loadOrBuildIndex({ repoRoot, indexPath, head, files }) -> graph` (graph = `{ version, head, fileCount, imports, importedBy }`); writes/reads `indexPath/graph.json`; reuses cache when `cached.head === head`. Also exports `gitHead(repoRoot)`, `listRepoFiles(repoRoot)`. Has a `--build` CLI guard for the `review:index` script. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/indexStore.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const os = require('node:os'); +const { loadOrBuildIndex } = require('./indexStore.cjs'); + +function tmpRepo() { + const root = mkdtempSync(join(os.tmpdir(), 'idxtest-')); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src/a.ts'), "import { b } from './b';"); + writeFileSync(join(root, 'src/b.ts'), 'export const b = 1;'); + return root; +} + +test('builds graph and writes graph.json', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + const graph = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(graph.head, 'h1'); + assert.equal(graph.fileCount, 2); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.ts']); + rmSync(root, { recursive: true, force: true }); +}); + +test('reuses cache when head matches (no rebuild)', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + // tamper the cache with a sentinel; a reuse returns it unchanged, a rebuild drops it + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const again = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(again.sentinel, 'KEEP'); + rmSync(root, { recursive: true, force: true }); +}); + +test('rebuilds when head differs', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const rebuilt = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h2', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(rebuilt.sentinel, undefined); + assert.equal(rebuilt.head, 'h2'); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./indexStore.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/indexStore.cjs`: +```js +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { buildGraph } = require('./buildGraph.cjs'); + +const INDEX_RE = /^(src|pages|__tests__)\/.*\.(ts|tsx|js|jsx)$/; + +function gitHead(repoRoot) { + return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +} + +function listRepoFiles(repoRoot) { + const out = execFileSync('git', ['-C', repoRoot, 'ls-files'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + return out.split('\n').map((s) => s.trim()).filter((f) => INDEX_RE.test(f)); +} + +function loadOrBuildIndex({ repoRoot, indexPath, head, files }) { + const graphFile = join(indexPath, 'graph.json'); + if (existsSync(graphFile)) { + try { + const cached = JSON.parse(readFileSync(graphFile, 'utf8')); + if (cached.head === head) return cached; + } catch { + /* fall through to rebuild */ + } + } + const fileSet = new Set(files); + const { imports, importedBy } = buildGraph( + files, + (f) => readFileSync(join(repoRoot, f), 'utf8'), + fileSet, + ); + const graph = { version: 1, head, fileCount: files.length, imports, importedBy }; + mkdirSync(indexPath, { recursive: true }); + writeFileSync(graphFile, JSON.stringify(graph)); + return graph; +} + +if (require.main === module) { + const repoRoot = process.cwd(); + const indexPath = join(repoRoot, '.claude/review/index'); + if (process.argv.includes('--build')) { + const gf = join(indexPath, 'graph.json'); + if (existsSync(gf)) rmSync(gf); + } + const graph = loadOrBuildIndex({ + repoRoot, + indexPath, + head: gitHead(repoRoot), + files: listRepoFiles(repoRoot), + }); + const withDeps = Object.keys(graph.importedBy).length; + process.stdout.write(`Indexed ${graph.fileCount} files; ${withDeps} have dependents. head=${graph.head}\n`); +} + +module.exports = { loadOrBuildIndex, gitHead, listRepoFiles }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/indexStore.cjs .claude/review/engine/indexStore.test.cjs +git -C commit --no-verify -m "feat(review): HEAD-keyed import-graph cache (indexStore)" +``` + +--- + +### Task 5: Impact CLI (`impact.cjs`) + +**Files:** +- Create: `.claude/review/engine/impact.cjs` +- Create: `.claude/review/engine/impact.test.cjs` + +**Interfaces:** +- Consumes: `loadOrBuildIndex`, `gitHead`, `listRepoFiles` (Task 4); `queryImpact` (Task 3). +- Produces: CLI `yarn node .claude/review/engine/impact.cjs --root --index --changed [--max-depth N] [--max-nodes N]` → impact report JSON on stdout. Exports `parseArgs(argv) -> object`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/impact.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseArgs } = require('./impact.cjs'); + +test('parseArgs reads --flag value pairs', () => { + const a = parseArgs(['--root', '/r', '--changed', '/c.txt', '--max-depth', '2']); + assert.equal(a.root, '/r'); + assert.equal(a.changed, '/c.txt'); + assert.equal(a['max-depth'], '2'); +}); + +test('parseArgs ignores non-flag tokens', () => { + const a = parseArgs(['junk', '--root', '/r']); + assert.equal(a.root, '/r'); + assert.equal(a.junk, undefined); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./impact.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/impact.cjs`: +```js +'use strict'; +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./indexStore.cjs'); +const { queryImpact } = require('./queryImpact.cjs'); + +function parseArgs(argv) { + const a = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + a[argv[i].slice(2)] = argv[i + 1]; + i++; + } + } + return a; +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = a.root || process.cwd(); + const indexPath = a.index || join(repoRoot, '.claude/review/index'); + const graph = loadOrBuildIndex({ + repoRoot, + indexPath, + head: gitHead(repoRoot), + files: listRepoFiles(repoRoot), + }); + const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const opts = { + maxDepth: a['max-depth'] ? Number(a['max-depth']) : 3, + maxNodes: a['max-nodes'] ? Number(a['max-nodes']) : 200, + }; + process.stdout.write(JSON.stringify(queryImpact(changed, graph, opts), null, 2) + '\n'); +} + +module.exports = { parseArgs }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/impact.cjs .claude/review/engine/impact.test.cjs +git -C commit --no-verify -m "feat(review): impact CLI emitting dependents report" +``` + +--- + +### Task 6: Integration + final verification + +**Files:** +- Modify: `.gitignore` (worktree root) +- Modify: `.claude/review/config.yml` (flip `index.enabled`) +- Modify: `package.json` (add `review:index` script) +- Modify: `.claude/commands/agent-review.md` (Stage 1B only) + +**Interfaces:** Consumes `impact.cjs` (Task 5). + +- [ ] **Step 1: Gitignore the index cache** + +Append to the worktree-root `.gitignore` (create the line if absent): +``` +.claude/review/index/ +``` + +- [ ] **Step 2: Enable the index in config + add the rebuild script** + +In `.claude/review/config.yml`, change the `index` block to: +```yaml +index: { enabled: true, path: ".claude/review/index" } +``` +In `package.json` `scripts`, add: +```json +"review:index": "yarn node .claude/review/engine/indexStore.cjs --build" +``` + +- [ ] **Step 3: Wire impact into the command's Stage 1B** + +In `.claude/commands/agent-review.md`, replace the existing Stage 1B "Dependency Impact Analysis" +bash (the grep-based `for`-loop over changed files) with an engine call, gated on the index being +enabled (**`yarn node`**, not `node`): +```bash +REVIEW_DIR=".claude/review" +if grep -q "enabled: true" "$REVIEW_DIR/config.yml" 2>/dev/null; then + yarn node "$REVIEW_DIR/engine/impact.cjs" \ + --root "$(pwd)" \ + --index "$REVIEW_DIR/index" \ + --changed /tmp/changed_files.txt \ + > /tmp/review_impact.json + cat /tmp/review_impact.json +fi +``` +Then update the surrounding markdown so the command: (a) displays `blastRadius` and `topImpacted` +from `/tmp/review_impact.json`; (b) when launching the **Architecture** and **Data Integrity** +agents in Stage 1, includes the affected `directDependents`/`topImpacted` files in their prompts +with the instruction: "This change affects these dependent files — verify the change does not break +them." Leave `plan.cjs`, agent selection, and the debate/consensus stages unchanged. + +- [ ] **Step 4: Build the real index and smoke-test impact end-to-end** + +```bash +yarn --cwd review:index +git -C diff --name-only HEAD~1 > /tmp/changed_files.txt +yarn --cwd node .claude/review/engine/impact.cjs \ + --root --index /.claude/review/index --changed /tmp/changed_files.txt +``` +Expected: `review:index` prints "Indexed N files; M have dependents."; `impact.cjs` prints a valid +JSON report with `directDependents`, `transitiveDependents`, `blastRadius`, `topImpacted`, +`truncated`. Confirm `.claude/review/index/graph.json` exists and is NOT staged +(`git -C status --short` shows it ignored). + +- [ ] **Step 5: Full suite + lint, then commit** + +```bash +yarn --cwd test:review # all engine tests green (Phase A + index) +yarn --cwd lint:ts # no NEW engine-related errors (pre-existing codegen errors OK) +git -C add .gitignore .claude/review/config.yml package.json .claude/commands/agent-review.md +git -C commit --no-verify -m "feat(review): wire impact analysis into agent-review Stage 1B" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Build file-level import graph (internal edges) → Tasks 1, 2 ✓ +- Direct + transitive dependents, blast radius, caps, top-impacted → Task 3 ✓ +- Gitignored HEAD-keyed cache, rebuild when stale/missing → Task 4 + Task 6 Step 1 ✓ +- `impact.cjs` CLI emitting report → Task 5 ✓ +- Integration: `index.enabled`, replace Stage 1B grep, feed dependents to Architecture + Data Integrity, `review:index` script → Task 6 ✓ +- `plan.cjs` + debate/consensus unchanged → respected throughout ✓ +- All pure/testable except `indexStore` glue; node:test via runner → every task ✓ +- Acceptance criteria 1–6 → Tasks 1–6 + Task 6 Steps 4–5 ✓ + +**2. Placeholder scan:** No TBD/TODO; every code step shows complete code. Task 6 Step 3 references the existing Stage 1B block to replace (faithful to the spec) and gives the exact replacement bash + prompt instruction — not a placeholder. + +**3. Type consistency:** `graph` shape `{ imports, importedBy }` (Tasks 2) is consumed by `queryImpact` (Task 3) and extended to `{ version, head, fileCount, ... }` by `loadOrBuildIndex` (Task 4); `queryImpact` only reads `importedBy`, so the extension is compatible. `impact.cjs` (Task 5) calls `loadOrBuildIndex({repoRoot, indexPath, head, files})` and `queryImpact(changed, graph, opts)` with the exact signatures defined. `resolveImport(fromFile, spec, fileSet)` (Task 1) is called identically in `buildGraph` (Task 2). + +--- + +## Notes for the executor + +- No new dependencies — Node built-ins only. +- **Never run `node --test`** under PnP — always `yarn test:review`. +- All paths lowercase `.claude/`; all commits `--no-verify`. +- The index cache (`graph.json`) must be gitignored and must NOT be committed. From 25cb24944c7b77ad2cf19291aef2b2e5f8abff70 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:29:48 -0400 Subject: [PATCH 17/39] feat(review): import specifier resolver for index graph --- .claude/review/engine/resolveImport.cjs | 29 +++++++++++++++++ .claude/review/engine/resolveImport.test.cjs | 33 ++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .claude/review/engine/resolveImport.cjs create mode 100644 .claude/review/engine/resolveImport.test.cjs diff --git a/.claude/review/engine/resolveImport.cjs b/.claude/review/engine/resolveImport.cjs new file mode 100644 index 0000000000..c994df8edb --- /dev/null +++ b/.claude/review/engine/resolveImport.cjs @@ -0,0 +1,29 @@ +'use strict'; +const path = require('node:path'); + +const ALIASES = ['src/', 'pages/', '__tests__/']; +const EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']; + +function candidates(base) { + const out = [base]; + for (const e of EXTS) out.push(base + e); + for (const e of EXTS) out.push(base + '/index' + e); + return out; +} + +function resolveImport(fromFile, spec, fileSet) { + let base; + if (ALIASES.some((a) => spec === a.slice(0, -1) || spec.startsWith(a))) { + base = spec; // already repo-root-relative (src/..., pages/..., __tests__/...) + } else if (spec.startsWith('.')) { + base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)); + } else { + return null; // bare / external + } + for (const c of candidates(base)) { + if (fileSet.has(c)) return c; + } + return null; +} + +module.exports = { resolveImport, candidates, EXTS }; diff --git a/.claude/review/engine/resolveImport.test.cjs b/.claude/review/engine/resolveImport.test.cjs new file mode 100644 index 0000000000..bf1523eee1 --- /dev/null +++ b/.claude/review/engine/resolveImport.test.cjs @@ -0,0 +1,33 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveImport } = require('./resolveImport.cjs'); + +const fileSet = new Set([ + 'src/b.ts', + 'src/a/c.ts', + 'src/lib/index.ts', + 'src/d.tsx', + 'pages/x.page.tsx', +]); + +test('resolves relative import with extension inference', () => { + assert.equal(resolveImport('src/a/c.ts', '../b', fileSet), 'src/b.ts'); +}); + +test('resolves alias import (src/*)', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/d', fileSet), 'src/d.tsx'); +}); + +test('resolves directory import to index file', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/lib', fileSet), 'src/lib/index.ts'); +}); + +test('returns null for bare/external specifiers', () => { + assert.equal(resolveImport('src/a/c.ts', 'react', fileSet), null); + assert.equal(resolveImport('src/a/c.ts', '@mui/material', fileSet), null); +}); + +test('returns null for unresolvable relative import', () => { + assert.equal(resolveImport('src/a/c.ts', './nope', fileSet), null); +}); From eda56d5b2aa257173bebc5aa9cd8d759c963747c Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:31:13 -0400 Subject: [PATCH 18/39] feat(review): file-level import graph builder --- .claude/review/engine/buildGraph.cjs | 45 +++++++++++++++++++++++ .claude/review/engine/buildGraph.test.cjs | 33 +++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 .claude/review/engine/buildGraph.cjs create mode 100644 .claude/review/engine/buildGraph.test.cjs diff --git a/.claude/review/engine/buildGraph.cjs b/.claude/review/engine/buildGraph.cjs new file mode 100644 index 0000000000..6c9fdcd6df --- /dev/null +++ b/.claude/review/engine/buildGraph.cjs @@ -0,0 +1,45 @@ +'use strict'; +const { resolveImport } = require('./resolveImport.cjs'); + +const PATTERNS = [ + /\bimport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\bimport\s*['"]([^'"]+)['"]/g, + /\bexport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, +]; + +function extractSpecifiers(text) { + const specs = new Set(); + for (const re of PATTERNS) { + re.lastIndex = 0; + let m; + while ((m = re.exec(text)) !== null) specs.add(m[1]); + } + return [...specs]; +} + +function buildGraph(files, readFile, fileSet) { + const imports = {}; + const importedBy = {}; + for (const file of files) { + let text; + try { + text = readFile(file); + } catch { + text = ''; + } + const targets = new Set(); + for (const spec of extractSpecifiers(text)) { + const resolved = resolveImport(file, spec, fileSet); + if (resolved && resolved !== file) targets.add(resolved); + } + imports[file] = [...targets]; + for (const t of targets) { + (importedBy[t] = importedBy[t] || []).push(file); + } + } + return { imports, importedBy }; +} + +module.exports = { buildGraph, extractSpecifiers }; diff --git a/.claude/review/engine/buildGraph.test.cjs b/.claude/review/engine/buildGraph.test.cjs new file mode 100644 index 0000000000..79b537c298 --- /dev/null +++ b/.claude/review/engine/buildGraph.test.cjs @@ -0,0 +1,33 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildGraph, extractSpecifiers } = require('./buildGraph.cjs'); + +test('extractSpecifiers finds import/export/require/dynamic specifiers', () => { + const text = ` + import a from './a'; + import { b } from 'src/b'; + export { c } from './c'; + const d = require('./d'); + const e = await import('./e'); + import 'side-effect'; + `; + const specs = extractSpecifiers(text).sort(); + assert.deepEqual(specs, ['./a', './c', './d', './e', 'side-effect', 'src/b'].sort()); +}); + +test('buildGraph builds imports + importedBy, drops externals, dedupes', () => { + const files = ['src/a.tsx', 'src/b.ts', 'src/c.ts']; + const fileSet = new Set(files); + const contents = { + 'src/a.tsx': "import { b } from 'src/b';\nimport x from 'react';\nimport { b2 } from './b';", + 'src/b.ts': "import { c } from './c';", + 'src/c.ts': "export const c = 1;", + }; + const graph = buildGraph(files, (f) => contents[f], fileSet); + assert.deepEqual(graph.imports['src/a.tsx'], ['src/b.ts']); // react dropped, dup b deduped + assert.deepEqual(graph.imports['src/b.ts'], ['src/c.ts']); + assert.deepEqual(graph.imports['src/c.ts'], []); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.tsx']); + assert.deepEqual(graph.importedBy['src/c.ts'], ['src/b.ts']); +}); From 7622a98d2b768cda5ef22471d07eef0c2729a0a8 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:32:49 -0400 Subject: [PATCH 19/39] feat(review): transitive impact query over import graph --- .claude/review/engine/queryImpact.cjs | 43 ++++++++++++++++++++++ .claude/review/engine/queryImpact.test.cjs | 38 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 .claude/review/engine/queryImpact.cjs create mode 100644 .claude/review/engine/queryImpact.test.cjs diff --git a/.claude/review/engine/queryImpact.cjs b/.claude/review/engine/queryImpact.cjs new file mode 100644 index 0000000000..a8c5ca910b --- /dev/null +++ b/.claude/review/engine/queryImpact.cjs @@ -0,0 +1,43 @@ +'use strict'; + +function queryImpact(changedFiles, graph, opts = {}) { + const maxDepth = opts.maxDepth ?? 3; + const maxNodes = opts.maxNodes ?? 200; + const importedBy = graph.importedBy || {}; + const changedSet = new Set(changedFiles); + + const directDependents = {}; + for (const f of changedFiles) { + directDependents[f] = (importedBy[f] || []).filter((d) => !changedSet.has(d)); + } + + const visited = new Set(); + let truncated = false; + let frontier = [...changedFiles]; + for (let depth = 0; depth < maxDepth && frontier.length; depth++) { + const next = []; + for (const f of frontier) { + for (const dep of importedBy[f] || []) { + if (changedSet.has(dep) || visited.has(dep)) continue; + if (visited.size >= maxNodes) { + truncated = true; + break; + } + visited.add(dep); + next.push(dep); + } + if (truncated) break; + } + if (truncated) break; + frontier = next; + } + + const transitiveDependents = [...visited]; + const topImpacted = changedFiles + .map((f) => ({ file: f, dependentCount: directDependents[f].length })) + .sort((a, b) => b.dependentCount - a.dependentCount); + + return { directDependents, transitiveDependents, blastRadius: transitiveDependents.length, topImpacted, truncated }; +} + +module.exports = { queryImpact }; diff --git a/.claude/review/engine/queryImpact.test.cjs b/.claude/review/engine/queryImpact.test.cjs new file mode 100644 index 0000000000..9c1a70c6b0 --- /dev/null +++ b/.claude/review/engine/queryImpact.test.cjs @@ -0,0 +1,38 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { queryImpact } = require('./queryImpact.cjs'); + +// chain: a <- b <- c (importedBy[a] = [b], importedBy[b] = [c]) +const graph = { + imports: {}, + importedBy: { 'a.ts': ['b.ts'], 'b.ts': ['c.ts'], 'shared.ts': ['a.ts', 'b.ts'] }, +}; + +test('direct + transitive dependents and blast radius', () => { + const r = queryImpact(['a.ts'], graph, {}); + assert.deepEqual(r.directDependents['a.ts'], ['b.ts']); + assert.deepEqual(r.transitiveDependents.sort(), ['b.ts', 'c.ts']); + assert.equal(r.blastRadius, 2); + assert.equal(r.topImpacted[0].file, 'a.ts'); + assert.equal(r.topImpacted[0].dependentCount, 1); + assert.equal(r.truncated, false); +}); + +test('maxDepth limits traversal', () => { + const r = queryImpact(['a.ts'], graph, { maxDepth: 1 }); + assert.deepEqual(r.transitiveDependents, ['b.ts']); + assert.equal(r.blastRadius, 1); +}); + +test('maxNodes cap sets truncated', () => { + const r = queryImpact(['a.ts'], graph, { maxNodes: 1 }); + assert.equal(r.truncated, true); + assert.equal(r.transitiveDependents.length, 1); +}); + +test('changed files are excluded from their own dependents', () => { + const r = queryImpact(['shared.ts', 'a.ts'], graph, {}); + assert.ok(!r.directDependents['shared.ts'].includes('a.ts')); // a.ts is itself changed + assert.deepEqual(r.directDependents['shared.ts'], ['b.ts']); +}); From bbc2d37c8789a23469458f5acc776e97793343b4 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:34:41 -0400 Subject: [PATCH 20/39] feat(review): HEAD-keyed import-graph cache (indexStore) --- .claude/review/engine/indexStore.cjs | 60 +++++++++++++++++++++++ .claude/review/engine/indexStore.test.cjs | 53 ++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 .claude/review/engine/indexStore.cjs create mode 100644 .claude/review/engine/indexStore.test.cjs diff --git a/.claude/review/engine/indexStore.cjs b/.claude/review/engine/indexStore.cjs new file mode 100644 index 0000000000..a238687c24 --- /dev/null +++ b/.claude/review/engine/indexStore.cjs @@ -0,0 +1,60 @@ +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { buildGraph } = require('./buildGraph.cjs'); + +const INDEX_RE = /^(src|pages|__tests__)\/.*\.(ts|tsx|js|jsx)$/; + +function gitHead(repoRoot) { + return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +} + +function listRepoFiles(repoRoot) { + const out = execFileSync('git', ['-C', repoRoot, 'ls-files'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + return out.split('\n').map((s) => s.trim()).filter((f) => INDEX_RE.test(f)); +} + +function loadOrBuildIndex({ repoRoot, indexPath, head, files }) { + const graphFile = join(indexPath, 'graph.json'); + if (existsSync(graphFile)) { + try { + const cached = JSON.parse(readFileSync(graphFile, 'utf8')); + if (cached.head === head) return cached; + } catch { + /* fall through to rebuild */ + } + } + const fileSet = new Set(files); + const { imports, importedBy } = buildGraph( + files, + (f) => readFileSync(join(repoRoot, f), 'utf8'), + fileSet, + ); + const graph = { version: 1, head, fileCount: files.length, imports, importedBy }; + mkdirSync(indexPath, { recursive: true }); + writeFileSync(graphFile, JSON.stringify(graph)); + return graph; +} + +if (require.main === module) { + const repoRoot = process.cwd(); + const indexPath = join(repoRoot, '.claude/review/index'); + if (process.argv.includes('--build')) { + const gf = join(indexPath, 'graph.json'); + if (existsSync(gf)) rmSync(gf); + } + const graph = loadOrBuildIndex({ + repoRoot, + indexPath, + head: gitHead(repoRoot), + files: listRepoFiles(repoRoot), + }); + const withDeps = Object.keys(graph.importedBy).length; + process.stdout.write(`Indexed ${graph.fileCount} files; ${withDeps} have dependents. head=${graph.head}\n`); +} + +module.exports = { loadOrBuildIndex, gitHead, listRepoFiles }; diff --git a/.claude/review/engine/indexStore.test.cjs b/.claude/review/engine/indexStore.test.cjs new file mode 100644 index 0000000000..a34894b140 --- /dev/null +++ b/.claude/review/engine/indexStore.test.cjs @@ -0,0 +1,53 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const os = require('node:os'); +const { loadOrBuildIndex } = require('./indexStore.cjs'); + +function tmpRepo() { + const root = mkdtempSync(join(os.tmpdir(), 'idxtest-')); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src/a.ts'), "import { b } from './b';"); + writeFileSync(join(root, 'src/b.ts'), 'export const b = 1;'); + return root; +} + +test('builds graph and writes graph.json', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + const graph = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(graph.head, 'h1'); + assert.equal(graph.fileCount, 2); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.ts']); + rmSync(root, { recursive: true, force: true }); +}); + +test('reuses cache when head matches (no rebuild)', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + // tamper the cache with a sentinel; a reuse returns it unchanged, a rebuild drops it + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const again = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(again.sentinel, 'KEEP'); + rmSync(root, { recursive: true, force: true }); +}); + +test('rebuilds when head differs', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const rebuilt = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h2', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(rebuilt.sentinel, undefined); + assert.equal(rebuilt.head, 'h2'); + rmSync(root, { recursive: true, force: true }); +}); From 0e4579e0ffb63ff8eb3d7caa6142aa4ac3bcd330 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:36:24 -0400 Subject: [PATCH 21/39] feat(review): impact CLI emitting dependents report --- .claude/review/engine/impact.cjs | 36 +++++++++++++++++++++++++++ .claude/review/engine/impact.test.cjs | 17 +++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .claude/review/engine/impact.cjs create mode 100644 .claude/review/engine/impact.test.cjs diff --git a/.claude/review/engine/impact.cjs b/.claude/review/engine/impact.cjs new file mode 100644 index 0000000000..d014c76148 --- /dev/null +++ b/.claude/review/engine/impact.cjs @@ -0,0 +1,36 @@ +'use strict'; +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./indexStore.cjs'); +const { queryImpact } = require('./queryImpact.cjs'); + +function parseArgs(argv) { + const a = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + a[argv[i].slice(2)] = argv[i + 1]; + i++; + } + } + return a; +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = a.root || process.cwd(); + const indexPath = a.index || join(repoRoot, '.claude/review/index'); + const graph = loadOrBuildIndex({ + repoRoot, + indexPath, + head: gitHead(repoRoot), + files: listRepoFiles(repoRoot), + }); + const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const opts = { + maxDepth: a['max-depth'] ? Number(a['max-depth']) : 3, + maxNodes: a['max-nodes'] ? Number(a['max-nodes']) : 200, + }; + process.stdout.write(JSON.stringify(queryImpact(changed, graph, opts), null, 2) + '\n'); +} + +module.exports = { parseArgs }; diff --git a/.claude/review/engine/impact.test.cjs b/.claude/review/engine/impact.test.cjs new file mode 100644 index 0000000000..ba02b88a88 --- /dev/null +++ b/.claude/review/engine/impact.test.cjs @@ -0,0 +1,17 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseArgs } = require('./impact.cjs'); + +test('parseArgs reads --flag value pairs', () => { + const a = parseArgs(['--root', '/r', '--changed', '/c.txt', '--max-depth', '2']); + assert.equal(a.root, '/r'); + assert.equal(a.changed, '/c.txt'); + assert.equal(a['max-depth'], '2'); +}); + +test('parseArgs ignores non-flag tokens', () => { + const a = parseArgs(['junk', '--root', '/r']); + assert.equal(a.root, '/r'); + assert.equal(a.junk, undefined); +}); From 5a4719ace3669b4e26554542844d35ebd9dbba27 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:40:03 -0400 Subject: [PATCH 22/39] feat(review): wire impact analysis into agent-review Stage 1B --- .claude/commands/agent-review.md | 71 ++++++++++++----------- .claude/review/config.yml | 7 +-- .claude/review/engine/realConfig.test.cjs | 5 +- .gitignore | 3 + package.json | 1 + 5 files changed, 46 insertions(+), 41 deletions(-) diff --git a/.claude/commands/agent-review.md b/.claude/commands/agent-review.md index d7740c20a4..84bb4d07ac 100644 --- a/.claude/commands/agent-review.md +++ b/.claude/commands/agent-review.md @@ -445,6 +445,8 @@ INSTRUCTIONS: 2. Read FULL content of changed files for context 3. Read CLAUDE.md for project patterns 4. Search for usage patterns of modified components/functions +5. Read /tmp/review_impact.json (if present) for `directDependents`/`topImpacted`. + This change affects these dependent files — verify the change does not break them. CRITICAL FOCUS: @@ -557,6 +559,8 @@ INSTRUCTIONS: 2. Read FULL files for data flow context 3. Search for related GraphQL operations 4. Check for financial calculation changes +5. Read /tmp/review_impact.json (if present) for `directDependents`/`topImpacted`. + This change affects these dependent files — verify the change does not break them. CRITICAL FOCUS: @@ -1162,50 +1166,47 @@ After launching selected agents, display: ## Stage 1B — Dependency Impact Analysis (Parallel) -While agents are running, analyze dependency impact in parallel: +While agents are running, analyze dependency impact in parallel using the index engine +(the persisted import graph), not grep. This is gated on the index being enabled in +`config.yml`. Use **`yarn node`** (plain `node` cannot resolve under Yarn PnP): ```bash -echo "🔍 Analyzing dependency impact..." +echo "🔍 Analyzing dependency impact (index engine)..." echo "" -# For each changed TypeScript/TSX file, find dependents -while IFS= read -r changed_file; do - # Skip non-code files - [[ ! "$changed_file" =~ \.(ts|tsx|js|jsx)$ ]] && continue - - # Extract filename without extension - filename=$(basename "$changed_file" | sed 's/\.[^.]*$//') - - # Search for imports of this file - grep -r "from.*['\"].*$filename['\"]" src/ \ - --include="*.ts" --include="*.tsx" \ - 2>/dev/null | cut -d: -f1 | sort -u > "/tmp/dependents_${filename}.txt" - - dependent_count=$(wc -l < "/tmp/dependents_${filename}.txt" 2>/dev/null || echo 0) +REVIEW_DIR=".claude/review" +if grep -q "enabled: true" "$REVIEW_DIR/config.yml" 2>/dev/null; then + yarn node "$REVIEW_DIR/engine/impact.cjs" \ + --root "$(pwd)" \ + --index "$REVIEW_DIR/index" \ + --changed /tmp/changed_files.txt \ + > /tmp/review_impact.json + cat /tmp/review_impact.json +else + echo "ℹ️ Index disabled in config.yml — skipping impact analysis." +fi - if [ "$dependent_count" -gt 15 ]; then - echo "🚨 CRITICAL IMPACT: $changed_file has $dependent_count dependents" | tee -a /tmp/dependency_impact.txt - elif [ "$dependent_count" -gt 10 ]; then - echo "⚠️ HIGH IMPACT: $changed_file has $dependent_count dependents" | tee -a /tmp/dependency_impact.txt - elif [ "$dependent_count" -gt 5 ]; then - echo "📊 MEDIUM IMPACT: $changed_file has $dependent_count dependents" | tee -a /tmp/dependency_impact.txt - fi -done < /tmp/changed_files.txt +echo "" +echo "✅ Dependency analysis complete" +echo "" +``` -echo "" | tee -a /tmp/dependency_impact.txt +`impact.cjs` builds (or reuses the HEAD-keyed cache of) the import graph and emits a JSON +report on stdout with these fields: -# Check for breaking changes (removed exports) -echo "Checking for breaking changes..." | tee -a /tmp/dependency_impact.txt -git diff $BASE_REF..$HEAD_REF 2>/dev/null | grep "^-export" | grep -v "^---" > /tmp/breaking_changes.txt 2>/dev/null || true +- `directDependents` — `{ [changedFile]: string[] }`, the immediate importers of each changed file +- `transitiveDependents` — flat list of all files transitively reachable as dependents (blast radius set) +- `blastRadius` — count of `transitiveDependents` +- `topImpacted` — `[{ file, dependentCount }]` sorted by direct-dependent count (highest impact first) +- `truncated` — `true` if the `maxNodes` traversal cap was hit -if [ -s /tmp/breaking_changes.txt ]; then - echo "⚠️ BREAKING CHANGES DETECTED:" | tee -a /tmp/dependency_impact.txt - cat /tmp/breaking_changes.txt | tee -a /tmp/dependency_impact.txt -fi +**Display** the `blastRadius` and `topImpacted` files to the user as the dependency-impact +summary (highest-impact changed files first; flag `truncated` if set). -echo "✅ Dependency analysis complete" -echo "" -```` +**Feed dependents into the Architecture and Data Integrity agents** (Stage 1): when launching +those two agents, append the affected `directDependents` / `topImpacted` files from +`/tmp/review_impact.json` to their prompts with the instruction: "This change affects these +dependent files — verify the change does not break them." --- diff --git a/.claude/review/config.yml b/.claude/review/config.yml index 64334f397e..91974a80c0 100644 --- a/.claude/review/config.yml +++ b/.claude/review/config.yml @@ -132,11 +132,10 @@ excluded_paths: - ".github/ISSUE_TEMPLATE/**" - "docs/**" -# ── Forward-looking sections (present but INERT until Layers 2/3 ship) ──────── -index: - enabled: false - path: ".claude/review/index" +# ── Index layer (Phase B): file-level import graph for impact analysis ──────── +index: { enabled: true, path: ".claude/review/index" } +# ── Forward-looking sections (present but INERT until Layer 3 ships) ────────── learning: enabled: false path: ".claude/review/learnings" diff --git a/.claude/review/engine/realConfig.test.cjs b/.claude/review/engine/realConfig.test.cjs index 6e4f7303a7..b2c4b470ef 100644 --- a/.claude/review/engine/realConfig.test.cjs +++ b/.claude/review/engine/realConfig.test.cjs @@ -20,9 +20,10 @@ test('real config defines the 7 MPDX agents', () => { ); }); -test('real config reserves inert index/learning sections', () => { +test('real config enables the index layer and reserves inert learning', () => { const cfg = loadConfig({ configPath, schemaPath }); - assert.equal(cfg.index.enabled, false); + assert.equal(cfg.index.enabled, true); + assert.equal(cfg.index.path, '.claude/review/index'); assert.equal(cfg.learning.enabled, false); assert.equal(cfg.learning.approval_required, true); }); diff --git a/.gitignore b/.gitignore index d66b6ef2b1..07c06ae492 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,8 @@ lighthouse-results.md /tmp/dependents_*.txt /tmp/changed_file_contents/ +# Agent-review index cache (HEAD-keyed, regenerated; never committed) +.claude/review/index/ + # Note: .claude/review-history/, .claude/review-metrics/, and .claude/pr-metrics/ # are intentionally NOT ignored - these track quality trends and should be committed diff --git a/package.json b/package.json index a7726ec2f8..0922369c89 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint:ts": "tsc", "test": "jest --silent", "test:review": "yarn node .claude/review/engine/run-tests.cjs", + "review:index": "yarn node .claude/review/engine/indexStore.cjs --build", "test:log": "jest", "test:watch": "jest --watch", "localtest": "yarn test --runInBand --verbose", From a4df9481ae692413e42ee83a4d2596893944d6e5 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 13:59:07 -0400 Subject: [PATCH 23/39] docs: spec for agent-review learning layer (Gap 3 / Phase C) --- ...6-23-agent-review-learning-layer-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md diff --git a/docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md b/docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md new file mode 100644 index 0000000000..9f8a934ad6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md @@ -0,0 +1,194 @@ +# Design Spec — Agent-Review Learning Layer (Gap 3 / Phase C) + +- **Date:** 2026-06-23 +- **Status:** Draft (user pre-authorized proceeding to plan + implementation) +- **Author:** Daniel Bisgrove (with Claude) +- **Branch:** continues on `review-config-layer` (builds on Phases A + B) +- **Builds on:** config layer (`.claude/review/config.yml`, `rules/*.md`) and the engine + (`.claude/review/engine/`). + +--- + +## 1. Context & Problem + +Phases A (config) and B (index) are done. Gap 3 is the **feedback-learning loop** — the reviewer +should improve over time: stop flagging recurring false positives and strengthen coverage of +recurring real issues, the way Greptile/CodeRabbit learn from PR reactions. + +### Decisions already made (brainstorm) + +1. **Signal = explicit human marking.** Our reviewer is a local command with no PR-comment surface, + so there is no automatic reaction signal. Instead, after a review the developer marks each + finding `accepted`/`dismissed`. Deterministic, fits the local model, human already present. + (Git-derived inference and a PR-comment surface were rejected as noisy / large-scope.) +2. **Learnings do both**: recurring **dismissed** patterns → propose a `suppress`; recurring + **accepted** patterns → propose a `rule`. Both share one capture pipeline. +3. **Approval is file-based**: proposals land in `learnings.yml` as `status: proposed`; a human edits + `status:` to `approved`/`rejected`. That edit is the gate (`learning.approval_required: true`). + No auto-application. (Plain YAML a future UI can edit.) + +### Platform constraints (inherited) + +- Yarn 4 + PnP (no `node_modules`); run via `yarn node`; test via `yarn test:review` (single-process + runner; never `node --test`). +- CommonJS `.cjs`. Lowercase `.claude/` paths. Commit with `--no-verify`. +- Existing dep `yaml` is available; no new dependencies needed (signatures use built-in + `node:crypto`). + +--- + +## 2. Goals & Non-Goals + +### Goals + +- Capture per-finding outcomes (accepted/dismissed) via an explicit, file-based marking step. +- Mine recurring patterns (by a stable finding signature) into proposed learnings above a support + threshold, classified `suppress` (dismissed) or `rule` (accepted). +- Gate proposals behind human approval (`status:` edit in `learnings.yml`). +- Apply approved learnings to future reviews: suppress matching findings; inject approved rule + learnings into the relevant agents' prompts. +- Wire into the command and config; commit shared knowledge (`learnings.yml`, `feedback.jsonl`), + gitignore transient artifacts. + +### Non-Goals + +- Git-derived/automatic signal; a PR-comment surface; embeddings-based similarity (we group by + deterministic signatures, not vectors). +- A UI (the YAML files are UI-ready; the UI is a later phase). +- Changing `plan.cjs`, the index, or the debate/consensus *logic* (we only serialize findings and + filter at the boundary). + +--- + +## 3. The loop & artifacts + +``` +review → emit findings.json + pending/.yml → human fills outcomes → +review:feedback (ingest → feedback.jsonl) → review:learn (mine → learnings.yml: proposed) → +human edits status: approved → next review suppresses + injects approved learnings +``` + +Under `.claude/review/learnings/` (the Phase-A `learning.path`): +- `findings.json` — structured findings of the latest review (transient, **gitignored**). +- `pending/.yml` — marking template (transient, **gitignored**). +- `feedback.jsonl` — append-only recorded outcomes (**committed**, shared signal). +- `learnings.yml` — proposed + approved/rejected learnings (**committed**, curated knowledge). + +--- + +## 4. Components (all under `.claude/review/engine/`) + +### 4.1 `findingSignature.cjs` (pure) + +- `normalizeMessage(msg) -> string` — lowercase, strip digits, strip quoted identifiers + (`'...'`, `"..."`, `` `...` ``), collapse whitespace, trim. Generalizes + *"missing id in ContactDetails query"* and *"missing id in TaskList query"* to one form. +- `topDir(file) -> string` — first two path segments (e.g. `src/components`). +- `signature(finding) -> string` — short stable hex hash (`node:crypto` sha1, sliced) of + `agent | category | normalizeMessage(message) | topDir(file)`. + +### 4.2 `mineLearnings.cjs` (pure) + +`mineLearnings(feedbackEntries, { minSupport = 3 }) -> proposals[]` +- Group entries by `signature`. For each group with `count >= minSupport`: + - `dismissed/total >= 0.75` → `{ id, kind: 'suppress', signature, agent, category, paths: + ["/**"], support: count, rationale, example }` + - else `accepted/total >= 0.75` → `{ ..., kind: 'rule', ruleText: '' }` + - else (mixed) → no proposal. +- `id` is derived from the signature (stable) so re-mining doesn't duplicate. + +### 4.3 `applyLearnings.cjs` (pure) + +- `filterFindings(findings, approved) -> { kept, suppressed }` — drops findings whose `signature` + matches an approved `suppress` learning (optionally constrained by `paths`). +- `rulesFromLearnings(approved) -> [{ paths, ruleText, agent }]` — approved `rule` learnings as + injectable guidance. + +### 4.4 `learningsStore.cjs` (fs/yaml glue + multi-mode CLI) + +- Pure-ish helpers (tested): `mergeProposals(existing, proposals)` — adds new proposals by `id`, + **never overwrites an existing entry's `status`** (so approvals/rejections persist across + re-mining); `loadApproved(learnings)` — entries with `status === 'approved'`; `parsePending(yamlText)` + — pending template → feedback entries (only findings with a filled `outcome`). +- IO: `loadLearnings(path)`, `saveLearnings(path, obj)` (yaml), `loadFeedback(path)`, + `appendFeedback(path, entries)` (jsonl). +- **CLI modes** (`yarn node .claude/review/engine/learningsStore.cjs `): + - `--emit --in --review ` → adds `id`+`signature` to each finding, writes + `findings.json` + `pending/.yml`. + - `--ingest ` → parses filled outcomes, appends to `feedback.jsonl`. + - `--mine` → `mineLearnings(loadFeedback())` → `mergeProposals` into `learnings.yml`. + - `--rules` → prints `rulesFromLearnings(loadApproved())` as JSON. + - `--filter --in ` → prints `filterFindings(findings, loadApproved())` as JSON. + +--- + +## 5. Integration + +- **Config** (`config.yml`): `learning: { enabled: true, path: ".claude/review/learnings", + approval_required: true, min_support: 3, scope: local }`. Add `min_support` (integer) to the + schema's `learning` object (`scope` enum already exists). +- **Command (`agent-review.md`)**: + - **Stage 1 (before agents)**: when `learning.enabled`, run `learningsStore.cjs --rules` and inject + each approved rule learning's `ruleText` into the prompts of agents matching its `paths` (same + mechanism as `path_rules`). + - **Stage 6 (after consensus)**: write the consensus findings as JSON to + `/tmp/consensus_findings.json` (fields `agent, category, severity, file, line, message`), then + `learningsStore.cjs --emit` to produce `findings.json` + the pending template, then + `learningsStore.cjs --filter` to drop suppressed findings from the final report (note the + suppressed count). `plan.cjs`, the index, and the debate/consensus *logic* are unchanged. +- **Scripts** (`package.json`): `"review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest"` + (append the pending file path), `"review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine"`. +- **`.gitignore`**: add `.claude/review/learnings/pending/` and `.claude/review/learnings/findings.json`. + Commit `feedback.jsonl` and `learnings.yml`. + +--- + +## 6. Testing & Acceptance + +### Testing (node:test via `yarn test:review`) + +- `findingSignature`: normalization (digits/identifiers stripped; two messages differing only by an + identifier produce the same signature), `topDir`, deterministic hash. +- `mineLearnings`: below threshold → no proposal; ≥75% dismissed → `suppress`; ≥75% accepted → + `rule`; mixed → none; stable `id` from signature. +- `applyLearnings`: `filterFindings` suppresses matching signatures and keeps others (with paths + constraint); `rulesFromLearnings` maps approved rule learnings. +- `learningsStore`: `mergeProposals` adds new and preserves existing `status`; `parsePending` keeps + only filled outcomes; `loadApproved` filters; jsonl/yaml round-trip via temp files. + +### Acceptance criteria + +1. `findingSignature`, `mineLearnings`, `applyLearnings`, `learningsStore` exist with passing tests + in the `yarn test:review` suite. +2. `--emit` produces `findings.json` + a pending template from a consensus-findings JSON. +3. `--ingest` appends filled outcomes to `feedback.jsonl`; `--mine` writes proposals into + `learnings.yml`; flipping an entry to `status: approved` makes `--rules`/`--filter` honor it. +4. End-to-end smoke: synthesize a small feedback set → mine → approve → confirm `--filter` suppresses + a matching finding and `--rules` emits an approved rule. +5. Config has `learning.enabled: true` + `min_support`; schema validates it; `.gitignore` covers + transient artifacts; `feedback.jsonl`/`learnings.yml` committed (may start empty/minimal). +6. Command Stage 1 injects approved rule learnings; Stage 6 emits findings + filters suppressed; + `plan.cjs`/index/debate-consensus logic unchanged. Full `yarn test:review` green. + +--- + +## 7. Open questions & risks + +- **Signature over-generalization**: stripping all digits/identifiers could merge distinct findings. + Mitigation: signature also keys on `agent + category + topDir`, and `minSupport` (default 3) + requires repetition before any proposal. Tunable via `min_support`. +- **Consensus → JSON fidelity**: Stage 6 relies on the model emitting well-formed + `consensus_findings.json`. Mitigation: `--emit` tolerates missing optional fields (line/category) + and skips malformed entries. +- **Stale suppressions**: an approved suppress could hide a newly-real issue. Mitigation: suppressed + findings are counted/notable in the report (not silently dropped); a human can reject the learning. +- **Privacy**: `feedback.jsonl` stores finding messages about the team's own code — acceptable to + commit in this repo. + +--- + +## 8. References + +- Phases A/B specs + plans under `docs/superpowers/`; engine at `.claude/review/engine/`. +- Competitive research (CodeRabbit `learnings` scope + approval gate; Greptile rule auto-suggestion): + `.claude/docs/competitive-research-greptile-coderabbit.md`. From 0bc25ce3902a9247ec643a4c48e47c35e7f1c509 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 14:02:29 -0400 Subject: [PATCH 24/39] docs: implementation plan for agent-review learning layer (Gap 3) --- .../2026-06-23-agent-review-learning-layer.md | 656 ++++++++++++++++++ 1 file changed, 656 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md diff --git a/docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md b/docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md new file mode 100644 index 0000000000..7e1cb620b2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md @@ -0,0 +1,656 @@ +# Agent-Review Learning Layer (Gap 3) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add a feedback-learning loop — capture per-finding accepted/dismissed outcomes, mine recurring patterns into approval-gated learnings, and apply approved learnings (suppress noise, inject rules) to future reviews. + +**Architecture:** Pure CommonJS modules under `.claude/review/engine/` — `findingSignature` (stable finding signature), `mineLearnings` (feedback → proposals), `applyLearnings` (suppress + rule injection) — plus `learningsStore` (yaml/jsonl IO + a multi-mode CLI: emit/ingest/mine/rules/filter). File-based approval via `status:` in `learnings.yml`. No embeddings, no PR surface. + +**Tech Stack:** Node CommonJS `.cjs`, `node:test` via the existing runner, `yaml` (already a dep), `node:crypto` (built-in). **Yarn 4 + PnP.** Builds on Phases A + B. + +## Global Constraints — READ FIRST (platform-specific) + +- **Yarn 4 + PnP, NO `node_modules`.** Run engine via `yarn node`; test via `yarn --cwd test:review`. **NEVER `node --test`.** +- **CommonJS `.cjs`** only. **No new dependencies** (`yaml` already present; `node:crypto`/`fs`/`path` built-in). +- **Lowercase `.claude/`** paths. Commit with `git -C commit --no-verify`. +- Work ONLY in the worktree `` (absolute paths; `git -C`/`yarn --cwd`). Do NOT `cd`. NEVER touch the main checkout. +- The engine exists at `.claude/review/engine/` with `run-tests.cjs` auto-including every `*.test.cjs`. +- Current state to respect: `config.yml` has `learning.enabled: false` (multi-line block at lines ~139-144 with `path`, `approval_required: true`, `scope: local`); `engine/realConfig.test.cjs` asserts `cfg.learning.enabled === false` — Task 5 updates both together. The schema's `learning` block has `additionalProperties: false`, so `min_support` must be added to it before config can use it. +- `plan.cjs`, the index layer, and the debate/consensus *logic* are NOT modified. + +--- + +### Task 1: Finding signature (`findingSignature.cjs`) + +**Files:** +- Create: `.claude/review/engine/findingSignature.cjs` +- Create: `.claude/review/engine/findingSignature.test.cjs` + +**Interfaces:** +- Produces: `signature(finding) -> string` (12-char hex); `normalizeMessage(msg) -> string`; `topDir(file) -> string`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/findingSignature.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { signature, normalizeMessage, topDir } = require('./findingSignature.cjs'); + +test('normalizeMessage strips digits and quoted identifiers', () => { + assert.equal( + normalizeMessage("Missing id in 'ContactDetails' query line 42"), + normalizeMessage('Missing id in "TaskList" query line 99'), + ); +}); + +test('topDir returns first two path segments', () => { + assert.equal(topDir('src/components/Foo/Bar.tsx'), 'src/components'); + assert.equal(topDir('pages/api/x.ts'), 'pages/api'); +}); + +test('signature is equal for findings differing only by identifier/line', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/A.tsx', message: "Missing id in 'ContactDetails' query at line 12" }; + const b = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/B.tsx', message: "Missing id in 'TaskList' query at line 88" }; + assert.equal(signature(a), signature(b)); +}); + +test('signature differs when agent differs', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + const b = { agent: 'security', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + assert.notEqual(signature(a), signature(b)); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./findingSignature.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/findingSignature.cjs`: +```js +'use strict'; +const { createHash } = require('node:crypto'); + +function normalizeMessage(msg) { + return String(msg || '') + .toLowerCase() + .replace(/['"`][^'"`]*['"`]/g, ' ') // strip quoted identifiers + .replace(/\d+/g, ' ') // strip digits + .replace(/\s+/g, ' ') + .trim(); +} + +function topDir(file) { + const parts = String(file || '').split('/').filter(Boolean); + return parts.slice(0, 2).join('/'); +} + +function signature(finding) { + const key = [ + finding.agent || '', + finding.category || '', + normalizeMessage(finding.message), + topDir(finding.file), + ].join('|'); + return createHash('sha1').update(key).digest('hex').slice(0, 12); +} + +module.exports = { signature, normalizeMessage, topDir }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/findingSignature.cjs .claude/review/engine/findingSignature.test.cjs +git -C commit --no-verify -m "feat(review): stable finding signature for learning" +``` + +--- + +### Task 2: Mine learnings (`mineLearnings.cjs`) + +**Files:** +- Create: `.claude/review/engine/mineLearnings.cjs` +- Create: `.claude/review/engine/mineLearnings.test.cjs` + +**Interfaces:** +- Consumes: `topDir` (Task 1). +- Produces: `mineLearnings(feedbackEntries, { minSupport = 3 }) -> proposals[]`. Each proposal: `{ id, kind: 'suppress'|'rule', status: 'proposed', signature, agent, category, paths, support, rationale, example, ruleText? }`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/mineLearnings.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mineLearnings } = require('./mineLearnings.cjs'); + +function entry(sig, outcome, over = {}) { + return { signature: sig, outcome, agent: 'ux', category: 'i18n', file: 'src/components/Foo/A.tsx', message: 'hardcoded string', ...over }; +} + +test('proposes suppress for >=75% dismissed above support threshold', () => { + const fb = [entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p.length, 1); + assert.equal(p[0].kind, 'suppress'); + assert.equal(p[0].signature, 's1'); + assert.equal(p[0].id, 'L-s1'); + assert.deepEqual(p[0].paths, ['src/components/**']); +}); + +test('proposes rule for >=75% accepted', () => { + const fb = [entry('s2', 'accepted'), entry('s2', 'accepted'), entry('s2', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p[0].kind, 'rule'); + assert.equal(typeof p[0].ruleText, 'string'); +}); + +test('no proposal below support threshold', () => { + const fb = [entry('s3', 'dismissed'), entry('s3', 'dismissed')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); + +test('no proposal for mixed outcomes', () => { + const fb = [entry('s4', 'dismissed'), entry('s4', 'accepted'), entry('s4', 'dismissed'), entry('s4', 'accepted')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./mineLearnings.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/mineLearnings.cjs`: +```js +'use strict'; +const { topDir } = require('./findingSignature.cjs'); + +function mineLearnings(feedbackEntries, opts = {}) { + const minSupport = opts.minSupport ?? 3; + const groups = new Map(); + for (const e of feedbackEntries) { + if (!e.signature) continue; + if (!groups.has(e.signature)) groups.set(e.signature, []); + groups.get(e.signature).push(e); + } + const proposals = []; + for (const [sig, entries] of groups) { + const total = entries.length; + if (total < minSupport) continue; + const dismissed = entries.filter((e) => e.outcome === 'dismissed').length; + const accepted = entries.filter((e) => e.outcome === 'accepted').length; + const sample = entries[0]; + const base = { + id: `L-${sig}`, + signature: sig, + agent: sample.agent, + category: sample.category, + paths: [`${topDir(sample.file)}/**`], + support: total, + example: sample.message, + }; + if (dismissed / total >= 0.75) { + proposals.push({ ...base, kind: 'suppress', status: 'proposed', rationale: `Dismissed ${dismissed}/${total} times` }); + } else if (accepted / total >= 0.75) { + proposals.push({ ...base, kind: 'rule', status: 'proposed', ruleText: sample.message, rationale: `Accepted ${accepted}/${total} times` }); + } + } + return proposals; +} + +module.exports = { mineLearnings }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/mineLearnings.cjs .claude/review/engine/mineLearnings.test.cjs +git -C commit --no-verify -m "feat(review): mine feedback into proposed learnings" +``` + +--- + +### Task 3: Apply learnings (`applyLearnings.cjs`) + +**Files:** +- Create: `.claude/review/engine/applyLearnings.cjs` +- Create: `.claude/review/engine/applyLearnings.test.cjs` + +**Interfaces:** +- Produces: `filterFindings(findings, approved) -> { kept, suppressed }`; `rulesFromLearnings(approved) -> [{ paths, ruleText, agent }]`. `approved` is an array of learning objects (`kind`, `signature`, `paths`, `ruleText`/`example`, `agent`). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/applyLearnings.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +const approved = [ + { kind: 'suppress', signature: 'sig-bad', paths: ['src/components/**'] }, + { kind: 'rule', agent: 'ux', paths: ['src/components/**'], ruleText: 'Always localize labels' }, +]; + +test('filterFindings suppresses matching signatures, keeps others', () => { + const findings = [ + { id: 'f1', signature: 'sig-bad', file: 'src/components/A.tsx' }, + { id: 'f2', signature: 'sig-ok', file: 'src/components/B.tsx' }, + ]; + const { kept, suppressed } = filterFindings(findings, approved); + assert.deepEqual(kept.map((f) => f.id), ['f2']); + assert.deepEqual(suppressed.map((f) => f.id), ['f1']); +}); + +test('rulesFromLearnings maps approved rule learnings', () => { + const rules = rulesFromLearnings(approved); + assert.equal(rules.length, 1); + assert.deepEqual(rules[0], { paths: ['src/components/**'], ruleText: 'Always localize labels', agent: 'ux' }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./applyLearnings.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/applyLearnings.cjs`: +```js +'use strict'; + +function filterFindings(findings, approved) { + const sigs = new Set((approved || []).filter((l) => l.kind === 'suppress').map((l) => l.signature)); + const kept = []; + const suppressed = []; + for (const f of findings) { + if (sigs.has(f.signature)) suppressed.push(f); + else kept.push(f); + } + return { kept, suppressed }; +} + +function rulesFromLearnings(approved) { + return (approved || []) + .filter((l) => l.kind === 'rule') + .map((l) => ({ paths: l.paths || [], ruleText: l.ruleText || l.example || '', agent: l.agent })); +} + +module.exports = { filterFindings, rulesFromLearnings }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/applyLearnings.cjs .claude/review/engine/applyLearnings.test.cjs +git -C commit --no-verify -m "feat(review): apply approved learnings (suppress + rule injection)" +``` + +--- + +### Task 4: Learnings store + CLI (`learningsStore.cjs`) + +**Files:** +- Create: `.claude/review/engine/learningsStore.cjs` +- Create: `.claude/review/engine/learningsStore.test.cjs` + +**Interfaces:** +- Consumes: `signature` (Task 1), `mineLearnings` (Task 2), `filterFindings`/`rulesFromLearnings` (Task 3), `yaml`. +- Produces helpers: `mergeProposals(existing, proposals) -> { version, learnings }` (adds new by `id`, never overwrites an existing entry's `status`); `parsePending(yamlText) -> entries[]` (only findings with a filled `outcome`); `loadApproved(learnings) -> entries[]`; IO `loadLearnings`/`saveLearnings`/`loadFeedback`/`appendFeedback`. CLI modes: `--emit --in --review `, `--ingest `, `--mine [--min-support N]`, `--rules`, `--filter --in `. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/learningsStore.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mergeProposals, parsePending, loadApproved } = require('./learningsStore.cjs'); + +test('mergeProposals adds new and preserves existing status', () => { + const existing = { version: 1, learnings: [{ id: 'L-a', kind: 'suppress', status: 'approved' }] }; + const proposals = [{ id: 'L-a', kind: 'suppress', status: 'proposed' }, { id: 'L-b', kind: 'rule', status: 'proposed' }]; + const merged = mergeProposals(existing, proposals); + const a = merged.learnings.find((l) => l.id === 'L-a'); + const b = merged.learnings.find((l) => l.id === 'L-b'); + assert.equal(a.status, 'approved'); // preserved, not reset to proposed + assert.equal(b.status, 'proposed'); // newly added + assert.equal(merged.learnings.length, 2); +}); + +test('parsePending keeps only findings with a filled outcome', () => { + const yamlText = ` +reviewId: r1 +findings: + - { id: f1, signature: s1, agent: ux, file: src/a.tsx, message: m1, outcome: dismissed } + - { id: f2, signature: s2, agent: ux, file: src/b.tsx, message: m2, outcome: "" } + - { id: f3, signature: s3, agent: ux, file: src/c.tsx, message: m3, outcome: accepted } +`; + const entries = parsePending(yamlText); + assert.deepEqual(entries.map((e) => e.id), ['f1', 'f3']); + assert.equal(entries[0].reviewId, 'r1'); + assert.equal(entries[0].outcome, 'dismissed'); +}); + +test('loadApproved filters by status', () => { + const learnings = { version: 1, learnings: [{ id: 'L-a', status: 'approved' }, { id: 'L-b', status: 'proposed' }] }; + assert.deepEqual(loadApproved(learnings).map((l) => l.id), ['L-a']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./learningsStore.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/learningsStore.cjs`: +```js +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } = require('node:fs'); +const { join, dirname } = require('node:path'); +const YAML = require('yaml'); +const { signature } = require('./findingSignature.cjs'); +const { mineLearnings } = require('./mineLearnings.cjs'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +function mergeProposals(existing, proposals) { + const out = { version: 1, learnings: [...((existing && existing.learnings) || [])] }; + const ids = new Set(out.learnings.map((l) => l.id)); + for (const p of proposals) { + if (!ids.has(p.id)) { + out.learnings.push(p); + ids.add(p.id); + } // existing entries keep their status + } + return out; +} + +function parsePending(yamlText) { + const doc = YAML.parse(yamlText) || {}; + const out = []; + for (const f of doc.findings || []) { + if (f.outcome === 'accepted' || f.outcome === 'dismissed') { + out.push({ + reviewId: doc.reviewId, id: f.id, signature: f.signature, agent: f.agent, + category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: f.outcome, + }); + } + } + return out; +} + +function loadApproved(learnings) { + return ((learnings && learnings.learnings) || []).filter((l) => l.status === 'approved'); +} + +function loadLearnings(path) { + return existsSync(path) ? YAML.parse(readFileSync(path, 'utf8')) || { version: 1, learnings: [] } : { version: 1, learnings: [] }; +} +function saveLearnings(path, obj) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, YAML.stringify(obj)); +} +function loadFeedback(path) { + if (!existsSync(path)) return []; + return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} +function appendFeedback(path, entries) { + mkdirSync(dirname(path), { recursive: true }); + for (const e of entries) appendFileSync(path, JSON.stringify(e) + '\n'); +} + +module.exports = { mergeProposals, parsePending, loadApproved, loadLearnings, saveLearnings, loadFeedback, appendFeedback }; + +if (require.main === module) { + const argv = process.argv.slice(2); + const base = join(process.cwd(), '.claude/review/learnings'); + const feedbackPath = join(base, 'feedback.jsonl'); + const learningsPath = join(base, 'learnings.yml'); + const findingsPath = join(base, 'findings.json'); + const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; + + if (argv.includes('--emit')) { + const raw = JSON.parse(readFileSync(flag('--in'), 'utf8')); + const reviewId = flag('--review') || 'review'; + const findings = (raw.findings || raw).map((f, i) => ({ id: `f${i + 1}`, signature: signature(f), ...f })); + mkdirSync(join(base, 'pending'), { recursive: true }); + writeFileSync(findingsPath, JSON.stringify({ reviewId, findings }, null, 2)); + const pending = { reviewId, findings: findings.map((f) => ({ id: f.id, signature: f.signature, agent: f.agent, category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: '' })) }; + writeFileSync(join(base, 'pending', `${reviewId}.yml`), YAML.stringify(pending)); + process.stdout.write(`Emitted ${findings.length} findings; pending/${reviewId}.yml\n`); + } else if (argv.includes('--ingest')) { + const pendingFile = argv[argv.indexOf('--ingest') + 1]; + const entries = parsePending(readFileSync(pendingFile, 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(feedbackPath, entries); + process.stdout.write(`Ingested ${entries.length} outcomes\n`); + } else if (argv.includes('--mine')) { + const minSupport = flag('--min-support') ? Number(flag('--min-support')) : 3; + const proposals = mineLearnings(loadFeedback(feedbackPath), { minSupport }); + const merged = mergeProposals(loadLearnings(learningsPath), proposals); + saveLearnings(learningsPath, merged); + process.stdout.write(`Mined ${proposals.length} proposals; ${merged.learnings.length} total\n`); + } else if (argv.includes('--rules')) { + process.stdout.write(JSON.stringify(rulesFromLearnings(loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else if (argv.includes('--filter')) { + const raw = JSON.parse(readFileSync(flag('--in') || findingsPath, 'utf8')); + process.stdout.write(JSON.stringify(filterFindings(raw.findings || raw, loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else { + process.stdout.write('usage: learningsStore.cjs [--emit --in --review | --ingest | --mine [--min-support N] | --rules | --filter --in ]\n'); + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/learningsStore.cjs .claude/review/engine/learningsStore.test.cjs +git -C commit --no-verify -m "feat(review): learnings store + emit/ingest/mine/rules/filter CLI" +``` + +--- + +### Task 5: Config, schema, gitignore, scripts, seed files, command wiring + +**Files:** +- Modify: `.claude/review/config.schema.json` (add `min_support` to `learning`) +- Modify: `.claude/review/config.yml` (flip `learning.enabled`, add `min_support`) +- Modify: `.claude/review/engine/realConfig.test.cjs` (update learning assertions) +- Modify: `.gitignore` +- Modify: `package.json` (scripts) +- Create: `.claude/review/learnings/feedback.jsonl` (empty), `.claude/review/learnings/learnings.yml` (seed) +- Modify: `.claude/commands/agent-review.md` (Stage 1 + Stage 6) + +**Interfaces:** Consumes `learningsStore.cjs` CLI (Task 4). + +- [ ] **Step 1: Add `min_support` to the schema's `learning` block** + +In `.claude/review/config.schema.json`, inside `properties.learning.properties`, add: +```json +"min_support": { "type": "integer", "minimum": 1 } +``` +(Place it alongside `enabled`/`path`/`approval_required`/`scope`. The block keeps `additionalProperties: false`.) + +- [ ] **Step 2: Update `config.yml` learning block** + +Replace the multi-line `learning:` block in `.claude/review/config.yml` with: +```yaml +learning: + enabled: true + path: ".claude/review/learnings" + approval_required: true + min_support: 3 + scope: local +``` + +- [ ] **Step 3: Update the realConfig test to match** + +In `.claude/review/engine/realConfig.test.cjs`, change the learning assertions (currently +`assert.equal(cfg.learning.enabled, false);`) to: +```js + assert.equal(cfg.learning.enabled, true); + assert.equal(cfg.learning.approval_required, true); + assert.equal(cfg.learning.min_support, 3); +``` + +- [ ] **Step 4: Gitignore transient artifacts; add scripts; seed committed files** + +Append to the worktree-root `.gitignore`: +``` +.claude/review/learnings/pending/ +.claude/review/learnings/findings.json +``` +In `package.json` `scripts`, add: +```json +"review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest", +"review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine" +``` +Create `.claude/review/learnings/feedback.jsonl` as an **empty file**. Create +`.claude/review/learnings/learnings.yml` with: +```yaml +version: 1 +learnings: [] +``` + +- [ ] **Step 5: Run the suite (config + test still green)** + +Run: `yarn --cwd test:review` +Expected: PASS — `realConfig.test.cjs` now asserts `learning.enabled: true` + `min_support: 3`, and the config validates against the updated schema. + +- [ ] **Step 6: Wire the command (Stage 1 + Stage 6)** + +In `.claude/commands/agent-review.md`: +- **Stage 1 (before launching agents):** add, gated on learning being enabled: +```bash +REVIEW_DIR=".claude/review" +if grep -q "learning:" "$REVIEW_DIR/config.yml" 2>/dev/null; then + yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --rules > /tmp/review_rules.json 2>/dev/null || echo "[]" > /tmp/review_rules.json +fi +``` + Then instruct: for each entry in `/tmp/review_rules.json` (`{ paths, ruleText, agent }`), inject + `ruleText` into the prompt of the matching agent (same mechanism as `path_rules`). +- **Stage 6 (after consensus):** instruct the command to write the consensus findings as JSON to + `/tmp/consensus_findings.json` (array of `{ agent, category, severity, file, line, message }`), then: +```bash +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --emit --in /tmp/consensus_findings.json --review "${REVIEW_ID:-local}" +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --filter --in .claude/review/learnings/findings.json > /tmp/review_filtered.json +``` + Report the `kept` findings from `/tmp/review_filtered.json` and note the count of `suppressed` + (suppressed by approved learnings). Tell the user they can mark outcomes in the emitted + `pending/.yml`, then run `yarn review:feedback ` and `yarn review:learn`. + Leave `plan.cjs`, the index, agent selection, and debate/consensus logic unchanged. + +- [ ] **Step 7: Commit** + +```bash +git -C add .claude/review/config.schema.json .claude/review/config.yml .claude/review/engine/realConfig.test.cjs .gitignore package.json .claude/review/learnings/feedback.jsonl .claude/review/learnings/learnings.yml .claude/commands/agent-review.md +git -C commit --no-verify -m "feat(review): enable learning layer + wire feedback/learn into command" +``` + +--- + +### Task 6: End-to-end smoke + final verification + +**Files:** none (verification only; may write throwaway files under `/tmp`). + +- [ ] **Step 1: Full suite green** + +Run: `yarn --cwd test:review` +Expected: PASS — all engine tests (Phases A + B + C). + +- [ ] **Step 2: End-to-end learning-loop smoke** + +```bash +WT= +# 1. emit findings from a synthetic consensus set +cat > /tmp/cf.json <<'JSON' +{ "findings": [ + { "agent": "ux", "category": "i18n", "severity": 4, "file": "src/components/Foo/A.tsx", "message": "Hardcoded string 'Save' not localized at line 10" }, + { "agent": "ux", "category": "i18n", "severity": 4, "file": "src/components/Bar/B.tsx", "message": "Hardcoded string 'Cancel' not localized at line 22" } +] } +JSON +yarn --cwd "$WT" node .claude/review/engine/learningsStore.cjs --emit --in /tmp/cf.json --review smoke +# 2. simulate dismissing both (mark outcomes) and ingest 3x to clear minSupport +node -e "const fs=require('fs');const p='$WT/.claude/review/learnings/pending/smoke.yml';const YAML=require('$WT/.yarn');" 2>/dev/null || true +``` +(The exact marking can be done by editing `pending/smoke.yml` outcomes to `dismissed` and running +`yarn --cwd "$WT" review:feedback "$WT/.claude/review/learnings/pending/smoke.yml"` three times against +re-emitted templates, OR by appending three dismissed entries with the same signature directly to +`feedback.jsonl` for the smoke. Then:) +```bash +yarn --cwd "$WT" review:learn # mines proposals into learnings.yml +cat "$WT/.claude/review/learnings/learnings.yml" # confirm a 'suppress' proposal exists +# approve it: edit status: proposed -> approved for that entry, then: +yarn --cwd "$WT" node .claude/review/engine/learningsStore.cjs --rules +yarn --cwd "$WT" node .claude/review/engine/learningsStore.cjs --filter --in .claude/review/learnings/findings.json +``` +Expected: after mining, `learnings.yml` contains a `kind: suppress` proposal for the repeated i18n +signature; after flipping it to `approved`, `--filter` moves the matching finding into `suppressed`. + +- [ ] **Step 3: Confirm gitignore + tracked files** + +```bash +git -C status --short +git -C check-ignore .claude/review/learnings/pending/smoke.yml .claude/review/learnings/findings.json +git -C ls-files .claude/review/learnings +``` +Expected: `pending/` and `findings.json` are ignored (printed by `check-ignore`); `ls-files` shows +`feedback.jsonl` and `learnings.yml` tracked. Working tree clean except intended committed changes. +Revert any smoke mutations to committed files (`git -C checkout -- .claude/review/learnings/feedback.jsonl .claude/review/learnings/learnings.yml`) so the committed seeds stay empty. + +- [ ] **Step 4: lint:ts unaffected** + +Run: `yarn --cwd lint:ts` +Expected: no NEW engine-related errors (pre-existing app codegen errors OK; `.claude` is outside tsconfig scope). + +--- + +## Self-Review + +**1. Spec coverage:** +- Capture (signature + emit findings + pending template + ingest) → Tasks 1, 4 (`--emit`/`--ingest`) ✓ +- Mine (signatures → proposals, support/dominant thresholds) → Task 2 ✓ +- Approve (file-based status gate, preserve status on re-mine) → Task 4 (`mergeProposals`) + Task 5 ✓ +- Apply (suppress + rule injection) → Task 3 + Task 5 Stage 1/6 wiring ✓ +- Config + schema (`learning.enabled`, `min_support`) → Task 5 ✓ +- Scripts (`review:feedback`, `review:learn`) → Task 5 ✓ +- Storage (commit `feedback.jsonl`/`learnings.yml`; gitignore `pending/`+`findings.json`) → Task 5 ✓ +- `plan.cjs`/index/debate-consensus logic unchanged → respected ✓ +- Acceptance criteria 1–6 → Tasks 1–6 ✓ + +**2. Placeholder scan:** No TBD/TODO. Every code step has complete code. Task 6 Step 2's marking can be done two explicit ways (edit-and-ingest, or append dismissed entries) — both concrete, not a placeholder. + +**3. Type consistency:** `signature(finding)` (Task 1) used by `learningsStore --emit` (Task 4) and grouping in `mineLearnings` via `entry.signature` (Task 2). Proposal shape `{ id, kind, status, signature, agent, category, paths, support, rationale, example, ruleText? }` produced in Task 2, merged by `mergeProposals` (Task 4), consumed by `filterFindings`/`rulesFromLearnings` (Task 3, reading `kind`/`signature`/`paths`/`ruleText`/`example`/`agent`). `parsePending` output entry shape matches what `mineLearnings` consumes (`signature`, `outcome`, `agent`, `category`, `file`, `message`). Consistent. + +--- + +## Notes for the executor + +- No new dependencies — `yaml` already present, rest are Node built-ins. +- **Never run `node --test`** under PnP — always `yarn test:review`. +- All paths lowercase `.claude/`; all commits `--no-verify`. +- Commit `feedback.jsonl` (empty) and `learnings.yml` (seed); NEVER commit `pending/` or `findings.json`. From 22b21f1be0cd571ff07f0e5359bcea7bc88f56ae Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 14:03:47 -0400 Subject: [PATCH 25/39] feat(review): stable finding signature for learning --- .claude/review/engine/findingSignature.cjs | 28 +++++++++++++++++++ .../review/engine/findingSignature.test.cjs | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .claude/review/engine/findingSignature.cjs create mode 100644 .claude/review/engine/findingSignature.test.cjs diff --git a/.claude/review/engine/findingSignature.cjs b/.claude/review/engine/findingSignature.cjs new file mode 100644 index 0000000000..985dc69ea1 --- /dev/null +++ b/.claude/review/engine/findingSignature.cjs @@ -0,0 +1,28 @@ +'use strict'; +const { createHash } = require('node:crypto'); + +function normalizeMessage(msg) { + return String(msg || '') + .toLowerCase() + .replace(/['"`][^'"`]*['"`]/g, ' ') // strip quoted identifiers + .replace(/\d+/g, ' ') // strip digits + .replace(/\s+/g, ' ') + .trim(); +} + +function topDir(file) { + const parts = String(file || '').split('/').filter(Boolean); + return parts.slice(0, 2).join('/'); +} + +function signature(finding) { + const key = [ + finding.agent || '', + finding.category || '', + normalizeMessage(finding.message), + topDir(finding.file), + ].join('|'); + return createHash('sha1').update(key).digest('hex').slice(0, 12); +} + +module.exports = { signature, normalizeMessage, topDir }; diff --git a/.claude/review/engine/findingSignature.test.cjs b/.claude/review/engine/findingSignature.test.cjs new file mode 100644 index 0000000000..ea9abcecf4 --- /dev/null +++ b/.claude/review/engine/findingSignature.test.cjs @@ -0,0 +1,28 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { signature, normalizeMessage, topDir } = require('./findingSignature.cjs'); + +test('normalizeMessage strips digits and quoted identifiers', () => { + assert.equal( + normalizeMessage("Missing id in 'ContactDetails' query line 42"), + normalizeMessage('Missing id in "TaskList" query line 99'), + ); +}); + +test('topDir returns first two path segments', () => { + assert.equal(topDir('src/components/Foo/Bar.tsx'), 'src/components'); + assert.equal(topDir('pages/api/x.ts'), 'pages/api'); +}); + +test('signature is equal for findings differing only by identifier/line', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/A.tsx', message: "Missing id in 'ContactDetails' query at line 12" }; + const b = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/B.tsx', message: "Missing id in 'TaskList' query at line 88" }; + assert.equal(signature(a), signature(b)); +}); + +test('signature differs when agent differs', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + const b = { agent: 'security', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + assert.notEqual(signature(a), signature(b)); +}); From ecff08e1cbd50ad3ec561b9e44c8bdaa45b509db Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 14:05:05 -0400 Subject: [PATCH 26/39] feat(review): mine feedback into proposed learnings --- .claude/review/engine/mineLearnings.cjs | 37 ++++++++++++++++++++ .claude/review/engine/mineLearnings.test.cjs | 35 ++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 .claude/review/engine/mineLearnings.cjs create mode 100644 .claude/review/engine/mineLearnings.test.cjs diff --git a/.claude/review/engine/mineLearnings.cjs b/.claude/review/engine/mineLearnings.cjs new file mode 100644 index 0000000000..1fc91d1761 --- /dev/null +++ b/.claude/review/engine/mineLearnings.cjs @@ -0,0 +1,37 @@ +'use strict'; +const { topDir } = require('./findingSignature.cjs'); + +function mineLearnings(feedbackEntries, opts = {}) { + const minSupport = opts.minSupport ?? 3; + const groups = new Map(); + for (const e of feedbackEntries) { + if (!e.signature) continue; + if (!groups.has(e.signature)) groups.set(e.signature, []); + groups.get(e.signature).push(e); + } + const proposals = []; + for (const [sig, entries] of groups) { + const total = entries.length; + if (total < minSupport) continue; + const dismissed = entries.filter((e) => e.outcome === 'dismissed').length; + const accepted = entries.filter((e) => e.outcome === 'accepted').length; + const sample = entries[0]; + const base = { + id: `L-${sig}`, + signature: sig, + agent: sample.agent, + category: sample.category, + paths: [`${topDir(sample.file)}/**`], + support: total, + example: sample.message, + }; + if (dismissed / total >= 0.75) { + proposals.push({ ...base, kind: 'suppress', status: 'proposed', rationale: `Dismissed ${dismissed}/${total} times` }); + } else if (accepted / total >= 0.75) { + proposals.push({ ...base, kind: 'rule', status: 'proposed', ruleText: sample.message, rationale: `Accepted ${accepted}/${total} times` }); + } + } + return proposals; +} + +module.exports = { mineLearnings }; diff --git a/.claude/review/engine/mineLearnings.test.cjs b/.claude/review/engine/mineLearnings.test.cjs new file mode 100644 index 0000000000..de27617c99 --- /dev/null +++ b/.claude/review/engine/mineLearnings.test.cjs @@ -0,0 +1,35 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mineLearnings } = require('./mineLearnings.cjs'); + +function entry(sig, outcome, over = {}) { + return { signature: sig, outcome, agent: 'ux', category: 'i18n', file: 'src/components/Foo/A.tsx', message: 'hardcoded string', ...over }; +} + +test('proposes suppress for >=75% dismissed above support threshold', () => { + const fb = [entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p.length, 1); + assert.equal(p[0].kind, 'suppress'); + assert.equal(p[0].signature, 's1'); + assert.equal(p[0].id, 'L-s1'); + assert.deepEqual(p[0].paths, ['src/components/**']); +}); + +test('proposes rule for >=75% accepted', () => { + const fb = [entry('s2', 'accepted'), entry('s2', 'accepted'), entry('s2', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p[0].kind, 'rule'); + assert.equal(typeof p[0].ruleText, 'string'); +}); + +test('no proposal below support threshold', () => { + const fb = [entry('s3', 'dismissed'), entry('s3', 'dismissed')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); + +test('no proposal for mixed outcomes', () => { + const fb = [entry('s4', 'dismissed'), entry('s4', 'accepted'), entry('s4', 'dismissed'), entry('s4', 'accepted')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); From 2fd74c344309103e6fcfe256aedde1e6c82dec5b Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 14:06:22 -0400 Subject: [PATCH 27/39] feat(review): apply approved learnings (suppress + rule injection) --- .claude/review/engine/applyLearnings.cjs | 20 +++++++++++++++ .claude/review/engine/applyLearnings.test.cjs | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .claude/review/engine/applyLearnings.cjs create mode 100644 .claude/review/engine/applyLearnings.test.cjs diff --git a/.claude/review/engine/applyLearnings.cjs b/.claude/review/engine/applyLearnings.cjs new file mode 100644 index 0000000000..ba770702d3 --- /dev/null +++ b/.claude/review/engine/applyLearnings.cjs @@ -0,0 +1,20 @@ +'use strict'; + +function filterFindings(findings, approved) { + const sigs = new Set((approved || []).filter((l) => l.kind === 'suppress').map((l) => l.signature)); + const kept = []; + const suppressed = []; + for (const f of findings) { + if (sigs.has(f.signature)) suppressed.push(f); + else kept.push(f); + } + return { kept, suppressed }; +} + +function rulesFromLearnings(approved) { + return (approved || []) + .filter((l) => l.kind === 'rule') + .map((l) => ({ paths: l.paths || [], ruleText: l.ruleText || l.example || '', agent: l.agent })); +} + +module.exports = { filterFindings, rulesFromLearnings }; diff --git a/.claude/review/engine/applyLearnings.test.cjs b/.claude/review/engine/applyLearnings.test.cjs new file mode 100644 index 0000000000..da5fedab90 --- /dev/null +++ b/.claude/review/engine/applyLearnings.test.cjs @@ -0,0 +1,25 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +const approved = [ + { kind: 'suppress', signature: 'sig-bad', paths: ['src/components/**'] }, + { kind: 'rule', agent: 'ux', paths: ['src/components/**'], ruleText: 'Always localize labels' }, +]; + +test('filterFindings suppresses matching signatures, keeps others', () => { + const findings = [ + { id: 'f1', signature: 'sig-bad', file: 'src/components/A.tsx' }, + { id: 'f2', signature: 'sig-ok', file: 'src/components/B.tsx' }, + ]; + const { kept, suppressed } = filterFindings(findings, approved); + assert.deepEqual(kept.map((f) => f.id), ['f2']); + assert.deepEqual(suppressed.map((f) => f.id), ['f1']); +}); + +test('rulesFromLearnings maps approved rule learnings', () => { + const rules = rulesFromLearnings(approved); + assert.equal(rules.length, 1); + assert.deepEqual(rules[0], { paths: ['src/components/**'], ruleText: 'Always localize labels', agent: 'ux' }); +}); From 08f52f31d3f8d20a6ac726b6d908406fd7347c66 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 14:08:00 -0400 Subject: [PATCH 28/39] feat(review): learnings store + emit/ingest/mine/rules/filter CLI --- .claude/review/engine/learningsStore.cjs | 93 +++++++++++++++++++ .claude/review/engine/learningsStore.test.cjs | 34 +++++++ 2 files changed, 127 insertions(+) create mode 100644 .claude/review/engine/learningsStore.cjs create mode 100644 .claude/review/engine/learningsStore.test.cjs diff --git a/.claude/review/engine/learningsStore.cjs b/.claude/review/engine/learningsStore.cjs new file mode 100644 index 0000000000..e741ddbb85 --- /dev/null +++ b/.claude/review/engine/learningsStore.cjs @@ -0,0 +1,93 @@ +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } = require('node:fs'); +const { join, dirname } = require('node:path'); +const YAML = require('yaml'); +const { signature } = require('./findingSignature.cjs'); +const { mineLearnings } = require('./mineLearnings.cjs'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +function mergeProposals(existing, proposals) { + const out = { version: 1, learnings: [...((existing && existing.learnings) || [])] }; + const ids = new Set(out.learnings.map((l) => l.id)); + for (const p of proposals) { + if (!ids.has(p.id)) { + out.learnings.push(p); + ids.add(p.id); + } // existing entries keep their status + } + return out; +} + +function parsePending(yamlText) { + const doc = YAML.parse(yamlText) || {}; + const out = []; + for (const f of doc.findings || []) { + if (f.outcome === 'accepted' || f.outcome === 'dismissed') { + out.push({ + reviewId: doc.reviewId, id: f.id, signature: f.signature, agent: f.agent, + category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: f.outcome, + }); + } + } + return out; +} + +function loadApproved(learnings) { + return ((learnings && learnings.learnings) || []).filter((l) => l.status === 'approved'); +} + +function loadLearnings(path) { + return existsSync(path) ? YAML.parse(readFileSync(path, 'utf8')) || { version: 1, learnings: [] } : { version: 1, learnings: [] }; +} +function saveLearnings(path, obj) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, YAML.stringify(obj)); +} +function loadFeedback(path) { + if (!existsSync(path)) return []; + return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} +function appendFeedback(path, entries) { + mkdirSync(dirname(path), { recursive: true }); + for (const e of entries) appendFileSync(path, JSON.stringify(e) + '\n'); +} + +module.exports = { mergeProposals, parsePending, loadApproved, loadLearnings, saveLearnings, loadFeedback, appendFeedback }; + +if (require.main === module) { + const argv = process.argv.slice(2); + const base = join(process.cwd(), '.claude/review/learnings'); + const feedbackPath = join(base, 'feedback.jsonl'); + const learningsPath = join(base, 'learnings.yml'); + const findingsPath = join(base, 'findings.json'); + const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; + + if (argv.includes('--emit')) { + const raw = JSON.parse(readFileSync(flag('--in'), 'utf8')); + const reviewId = flag('--review') || 'review'; + const findings = (raw.findings || raw).map((f, i) => ({ id: `f${i + 1}`, signature: signature(f), ...f })); + mkdirSync(join(base, 'pending'), { recursive: true }); + writeFileSync(findingsPath, JSON.stringify({ reviewId, findings }, null, 2)); + const pending = { reviewId, findings: findings.map((f) => ({ id: f.id, signature: f.signature, agent: f.agent, category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: '' })) }; + writeFileSync(join(base, 'pending', `${reviewId}.yml`), YAML.stringify(pending)); + process.stdout.write(`Emitted ${findings.length} findings; pending/${reviewId}.yml\n`); + } else if (argv.includes('--ingest')) { + const pendingFile = argv[argv.indexOf('--ingest') + 1]; + const entries = parsePending(readFileSync(pendingFile, 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(feedbackPath, entries); + process.stdout.write(`Ingested ${entries.length} outcomes\n`); + } else if (argv.includes('--mine')) { + const minSupport = flag('--min-support') ? Number(flag('--min-support')) : 3; + const proposals = mineLearnings(loadFeedback(feedbackPath), { minSupport }); + const merged = mergeProposals(loadLearnings(learningsPath), proposals); + saveLearnings(learningsPath, merged); + process.stdout.write(`Mined ${proposals.length} proposals; ${merged.learnings.length} total\n`); + } else if (argv.includes('--rules')) { + process.stdout.write(JSON.stringify(rulesFromLearnings(loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else if (argv.includes('--filter')) { + const raw = JSON.parse(readFileSync(flag('--in') || findingsPath, 'utf8')); + process.stdout.write(JSON.stringify(filterFindings(raw.findings || raw, loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else { + process.stdout.write('usage: learningsStore.cjs [--emit --in --review | --ingest | --mine [--min-support N] | --rules | --filter --in ]\n'); + } +} diff --git a/.claude/review/engine/learningsStore.test.cjs b/.claude/review/engine/learningsStore.test.cjs new file mode 100644 index 0000000000..428c802ad3 --- /dev/null +++ b/.claude/review/engine/learningsStore.test.cjs @@ -0,0 +1,34 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mergeProposals, parsePending, loadApproved } = require('./learningsStore.cjs'); + +test('mergeProposals adds new and preserves existing status', () => { + const existing = { version: 1, learnings: [{ id: 'L-a', kind: 'suppress', status: 'approved' }] }; + const proposals = [{ id: 'L-a', kind: 'suppress', status: 'proposed' }, { id: 'L-b', kind: 'rule', status: 'proposed' }]; + const merged = mergeProposals(existing, proposals); + const a = merged.learnings.find((l) => l.id === 'L-a'); + const b = merged.learnings.find((l) => l.id === 'L-b'); + assert.equal(a.status, 'approved'); // preserved, not reset to proposed + assert.equal(b.status, 'proposed'); // newly added + assert.equal(merged.learnings.length, 2); +}); + +test('parsePending keeps only findings with a filled outcome', () => { + const yamlText = ` +reviewId: r1 +findings: + - { id: f1, signature: s1, agent: ux, file: src/a.tsx, message: m1, outcome: dismissed } + - { id: f2, signature: s2, agent: ux, file: src/b.tsx, message: m2, outcome: "" } + - { id: f3, signature: s3, agent: ux, file: src/c.tsx, message: m3, outcome: accepted } +`; + const entries = parsePending(yamlText); + assert.deepEqual(entries.map((e) => e.id), ['f1', 'f3']); + assert.equal(entries[0].reviewId, 'r1'); + assert.equal(entries[0].outcome, 'dismissed'); +}); + +test('loadApproved filters by status', () => { + const learnings = { version: 1, learnings: [{ id: 'L-a', status: 'approved' }, { id: 'L-b', status: 'proposed' }] }; + assert.deepEqual(loadApproved(learnings).map((l) => l.id), ['L-a']); +}); From ca8489e6a6818345a265d97a96544e7bbba7d1a9 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 14:10:46 -0400 Subject: [PATCH 29/39] feat(review): enable learning layer + wire feedback/learn into command --- .claude/commands/agent-review.md | 32 +++++++++++++++++++++++ .claude/review/config.schema.json | 1 + .claude/review/config.yml | 7 ++--- .claude/review/engine/realConfig.test.cjs | 3 ++- .claude/review/learnings/feedback.jsonl | 0 .claude/review/learnings/learnings.yml | 2 ++ .gitignore | 4 +++ package.json | 2 ++ 8 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 .claude/review/learnings/feedback.jsonl create mode 100644 .claude/review/learnings/learnings.yml diff --git a/.claude/commands/agent-review.md b/.claude/commands/agent-review.md index 84bb4d07ac..91aa7ef596 100644 --- a/.claude/commands/agent-review.md +++ b/.claude/commands/agent-review.md @@ -283,6 +283,22 @@ Display: "🚀 Launching [N] specialized review agents in parallel..." Use the agent's `model` field from the plan when launching (falling back to the mode default). +**Inject approved learnings (learning layer):** Before launching agents, fetch any approved `rule` +learnings so they can be added to the matching agents' prompts (gated on the learning layer being +enabled in config): + +```bash +REVIEW_DIR=".claude/review" +if grep -q "learning:" "$REVIEW_DIR/config.yml" 2>/dev/null; then + yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --rules > /tmp/review_rules.json 2>/dev/null || echo "[]" > /tmp/review_rules.json +fi +``` + +For each entry in `/tmp/review_rules.json` (`{ paths, ruleText, agent }`), inject `ruleText` into +the prompt of the matching agent (the agent named by `agent`, for files under `paths`) using the +same mechanism as `path_rules` — append it under that agent's `PROJECT-SPECIFIC RULES` section. +These are repository-specific learnings ratified by a human from prior review feedback. + ### Agent 1: Security Review 🔒 Use the Task tool with: @@ -1640,6 +1656,22 @@ echo "" ## Stage 6 — Generate Review Report +**Capture consensus for the learning layer (gated on learning being enabled):** Write the consensus +findings as a JSON array to `/tmp/consensus_findings.json` — each entry shaped +`{ agent, category, severity, file, line, message }` — then emit them and apply approved learnings: + +```bash +REVIEW_DIR=".claude/review" +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --emit --in /tmp/consensus_findings.json --review "${REVIEW_ID:-local}" +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --filter --in .claude/review/learnings/findings.json > /tmp/review_filtered.json +``` + +Report the `kept` findings from `/tmp/review_filtered.json` in the report below, and note the count +of `suppressed` findings (suppressed by approved learnings). Tell the user they can mark outcomes in +the emitted `pending/.yml` (set each finding's `outcome` to `accepted` or `dismissed`), +then run `yarn review:feedback ` and `yarn review:learn` to mine new proposed learnings. +Leave `plan.cjs`, the index, agent selection, and the debate/consensus logic unchanged. + Create the comprehensive review report in markdown format: ```markdown diff --git a/.claude/review/config.schema.json b/.claude/review/config.schema.json index 4371d8b4b1..cb40ed0566 100644 --- a/.claude/review/config.schema.json +++ b/.claude/review/config.schema.json @@ -115,6 +115,7 @@ "enabled": { "type": "boolean" }, "path": { "type": "string" }, "approval_required": { "type": "boolean" }, + "min_support": { "type": "integer", "minimum": 1 }, "scope": { "type": "string", "enum": ["local", "global"] } } }, diff --git a/.claude/review/config.yml b/.claude/review/config.yml index 91974a80c0..7eaf38d71d 100644 --- a/.claude/review/config.yml +++ b/.claude/review/config.yml @@ -137,10 +137,11 @@ index: { enabled: true, path: ".claude/review/index" } # ── Forward-looking sections (present but INERT until Layer 3 ships) ────────── learning: - enabled: false + enabled: true path: ".claude/review/learnings" - approval_required: true # human ratifies a proposed learning before it affects reviews - scope: local # local (repo) | global (org) + approval_required: true + min_support: 3 + scope: local enforcement: mode: warn # warn | block — reserved for future merge-gating diff --git a/.claude/review/engine/realConfig.test.cjs b/.claude/review/engine/realConfig.test.cjs index b2c4b470ef..94ea236214 100644 --- a/.claude/review/engine/realConfig.test.cjs +++ b/.claude/review/engine/realConfig.test.cjs @@ -24,6 +24,7 @@ test('real config enables the index layer and reserves inert learning', () => { const cfg = loadConfig({ configPath, schemaPath }); assert.equal(cfg.index.enabled, true); assert.equal(cfg.index.path, '.claude/review/index'); - assert.equal(cfg.learning.enabled, false); + assert.equal(cfg.learning.enabled, true); assert.equal(cfg.learning.approval_required, true); + assert.equal(cfg.learning.min_support, 3); }); diff --git a/.claude/review/learnings/feedback.jsonl b/.claude/review/learnings/feedback.jsonl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.claude/review/learnings/learnings.yml b/.claude/review/learnings/learnings.yml new file mode 100644 index 0000000000..b61e068445 --- /dev/null +++ b/.claude/review/learnings/learnings.yml @@ -0,0 +1,2 @@ +version: 1 +learnings: [] diff --git a/.gitignore b/.gitignore index 07c06ae492..782a6fcac2 100644 --- a/.gitignore +++ b/.gitignore @@ -74,5 +74,9 @@ lighthouse-results.md # Agent-review index cache (HEAD-keyed, regenerated; never committed) .claude/review/index/ +# Agent-review learning transient artifacts (regenerated; never committed) +.claude/review/learnings/pending/ +.claude/review/learnings/findings.json + # Note: .claude/review-history/, .claude/review-metrics/, and .claude/pr-metrics/ # are intentionally NOT ignored - these track quality trends and should be committed diff --git a/package.json b/package.json index 0922369c89..350971d94a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test": "jest --silent", "test:review": "yarn node .claude/review/engine/run-tests.cjs", "review:index": "yarn node .claude/review/engine/indexStore.cjs --build", + "review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest", + "review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine", "test:log": "jest", "test:watch": "jest --watch", "localtest": "yarn test --runInBand --verbose", From 266509aa96b89a8dfc8d3f545a0db7f46090fbed Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 15:18:55 -0400 Subject: [PATCH 30/39] docs: spec for agent-review CLI (Phase D) --- .../2026-06-23-agent-review-cli-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-agent-review-cli-design.md diff --git a/docs/superpowers/specs/2026-06-23-agent-review-cli-design.md b/docs/superpowers/specs/2026-06-23-agent-review-cli-design.md new file mode 100644 index 0000000000..2775cafa13 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-review-cli-design.md @@ -0,0 +1,172 @@ +# Design Spec — Agent-Review CLI (Phase D) + +- **Date:** 2026-06-23 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **Branch:** continues on `review-config-layer` (builds on Phases A + B + C) +- **Builds on:** the review core (`.claude/review/config.yml`, `engine/*.cjs`, `learnings/`). + +--- + +## 1. Context & Problem + +Phases A–C produced a clean, file-based review core (config, index/impact, learning) exposed today +through scattered commands: `yarn node .claude/review/engine/.cjs --flags`, `yarn +review:index`, `yarn review:feedback`, `yarn review:learn`, and the Claude Code `/agent-review` +command. The original long-term vision was **a CLI (and later a UI) to set up and interact with the +reviewer like Greptile/CodeRabbit**. This phase delivers the **unified `review` CLI** — one +ergonomic entry point over the core — which is also the command set a future UI backend will call. + +### Decisions already made (brainstorm) + +1. **CLI now, UI later.** This spec is the CLI only; the web UI is a later phase reusing these + commands. +2. **Manage/inspect the core + trigger Claude Code for the actual review.** The CLI does NOT + re-implement the multi-agent review (that would reopen the Agent-SDK billing problem). It manages + config/index/learning and, for `review run`, shells out to the existing `/agent-review` Claude + Code command (subscription billing, 7-agent debate intact). +3. **`review run` shells `claude -p`.** It gathers the diff, prints a cheap deterministic pre-flight + (risk/agents/impact), then invokes `claude -p "/agent-review …"` so the full review runs from one + command — fine for the user's own local interactive use. + +### Platform constraints (inherited) + +- Yarn 4 + PnP (no `node_modules`); CommonJS `.cjs`; run via `yarn node`; test via `yarn + test:review` (single-process runner; never `node --test`). Lowercase `.claude/`. Commit with + `--no-verify`. No new dependencies (`yaml` already present; rest are Node built-ins). + +--- + +## 2. Goals & Non-Goals + +### Goals + +- A single `review` CLI (`yarn review [args]`) unifying the existing core operations. +- Commands: `config show`, `config validate`, `index`, `impact`, `feedback`, `learn`, `learnings`, + `approve`, `reject`, `run`, `help`. +- `approve`/`reject` flip a learning's `status:` (convenience over hand-editing YAML). +- `run` prints a deterministic pre-flight (risk, selected agents + reasons, blast radius) and then + launches the Claude Code review via `claude -p`. +- New logic kept pure + tested; the dispatcher is thin glue. + +### Non-Goals + +- The web UI (later phase). +- Any new review/analysis logic — the CLI only orchestrates existing engine modules. +- A re-implemented multi-agent runner / Agent SDK; auth; multi-user. +- Changing `plan.cjs`, the index, the learning modules, or the debate/consensus logic. + +--- + +## 3. Architecture + +``` +.claude/review/ +├── cli.cjs # subcommand dispatcher (thin glue: argv routing, git, claude -p, fs) +├── engine/ +│ ├── cliCommands.cjs # NEW pure helpers: setLearningStatus, listLearnings, preflightSummary +│ └── … existing modules (loadConfig, buildPlan/plan, indexStore, queryImpact, impact, +│ learningsStore, …) reused as-is +``` + +- `package.json` script: `"review": "yarn node .claude/review/cli.cjs"`. Invoked as + `yarn review [args]` (yarn Berry forwards trailing args). +- `cli.cjs` requires the engine modules and `cliCommands.cjs`; it routes `argv[0]` to a handler. + All real logic lives in the (already-tested) engine modules and the (newly-tested) pure helpers; + `cli.cjs` is glue (argument parsing, `node:child_process` for git + `claude`, `node:fs`, stdout). + +--- + +## 4. Commands + +| Command | Behavior | Backing | +|---|---|---| +| `review config show` | Load + validate `config.yml`, pretty-print as JSON | `loadConfig` | +| `review config validate` | Validate vs schema; print OK or errors; **exit 1 on invalid** | `loadConfig` | +| `review index` | Rebuild the import-graph cache; print summary | `indexStore` (build path) | +| `review impact [--base ]` | Resolve diff files, build/load index, print impact report | `gitChangedFiles` + `queryImpact` | +| `review feedback ` | Ingest marked outcomes into `feedback.jsonl` | `learningsStore` ingest | +| `review learn [--min-support N]` | Mine feedback → merge proposals into `learnings.yml` | `learningsStore` mine | +| `review learnings [--status S]` | List learnings (optionally filtered by status) as a table | `cliCommands.listLearnings` | +| `review approve ` | Set learning `` status → `approved`, save | `cliCommands.setLearningStatus` | +| `review reject ` | Set learning `` status → `rejected`, save | `cliCommands.setLearningStatus` | +| `review run [--base ] [mode]` | Pre-flight summary + launch Claude Code review | §5 | +| `review help` / no args | Print usage | — | + +Unknown command → print usage, exit 1. + +--- + +## 5. `review run` + +1. **Resolve base**: `--base ` if given, else `git merge-base main HEAD` (fallback `HEAD~1`). +2. **Gather diff** to temp files: `git diff --name-only ...HEAD` (changed files), + `git diff --stat ...HEAD` (stat), `git diff ...HEAD` (full diff). +3. **Deterministic pre-flight** (cheap, no agents): `buildPlan(...)` → risk score/level/required + reviewer + selected agents with match reasons; `queryImpact(...)` → blast radius + top-impacted. + Print via `cliCommands.preflightSummary(plan, impact)`. +4. **Launch**: `execFileSync('claude', ['-p', '/agent-review ' + mode], { stdio: 'inherit' })` so the + full 7-agent debate runs on the user's subscription. `mode` defaults to `standard`. + +> Implementation detail to confirm before coding: whether `claude -p "/agent-review …"` triggers the +> slash command directly, or needs a natural-language prompt (e.g. `claude -p "Run the /agent-review +> command in mode"`). The plan will verify the exact invocation; the pre-flight (steps 1–3) +> is independent of this and always works. + +--- + +## 6. New testable logic (`engine/cliCommands.cjs`, pure) + +- `setLearningStatus(learnings, id, status) -> learnings` — returns a new learnings object with the + matching entry's `status` set; **throws `Error` if `id` not found**; leaves other entries + untouched. Used by `approve`/`reject`. +- `listLearnings(learnings, statusFilter) -> rows[]` — returns `{ id, kind, status, support, paths, + example }` rows; if `statusFilter` is given, only matching `status`. +- `preflightSummary(plan, impact) -> string` — formats a human-readable block: profile, risk + score/level/reviewer, special factors, each selected agent + `matchedBy`, blast radius, top + impacted files. Pure string builder (no I/O). + +--- + +## 7. Testing & Acceptance + +### Testing (node:test via `yarn test:review`) + +- `setLearningStatus`: flips the target's status; unknown id throws; other entries unchanged; + input not mutated (returns new object). +- `listLearnings`: no filter returns all rows with expected fields; `statusFilter` narrows results. +- `preflightSummary`: includes risk level, required reviewer, each selected agent id + matchedBy, + and the blast radius in the output string. + +### Acceptance criteria + +1. `cliCommands.cjs` exists with passing tests for the three helpers in the `yarn test:review` suite. +2. `cli.cjs` dispatcher exists; `yarn review help` prints usage; unknown command exits 1. +3. `review config show`/`validate` print the config / report validation (exit 1 on invalid). +4. `review impact`, `review index`, `review feedback`, `review learn` work via the CLI (smoke). +5. `review learnings`/`approve`/`reject` list and flip `status:` in `learnings.yml`. +6. `review run` prints the pre-flight (risk/agents/impact) and invokes `claude -p` (the invocation + is verified to trigger `/agent-review`). +7. `package.json` has the `review` script; full `yarn test:review` green; `plan.cjs`/engine logic + unchanged. + +--- + +## 8. Open questions & risks + +- **`claude -p` slash-command invocation** (see §5) — verify the exact form during implementation; + fall back to a natural-language prompt if a bare slash command isn't honored in print mode. +- **`claude` on PATH** — `review run`'s launch step assumes the Claude Code CLI is installed; if + absent, print the pre-flight + the command to run manually (graceful degradation). +- **Arg forwarding under yarn Berry** — confirm `yarn review approve L-abc` forwards `approve L-abc` + to `cli.cjs` (Berry forwards trailing args to scripts). The plan will verify with a smoke test. +- **`main` base resolution** — if `main` isn't present locally, `merge-base` fails; fall back to + `HEAD~1` and note it. + +--- + +## 9. References + +- Phases A/B/C specs + plans under `docs/superpowers/`; engine at `.claude/review/engine/`. +- Competitive research (Greptile CLI `greptile review` / `--agent`): + `.claude/docs/competitive-research-greptile-coderabbit.md`. From d4ad4370fa759a88866e409e9c242722d45f8f85 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 15:35:26 -0400 Subject: [PATCH 31/39] docs: implementation plan for agent-review CLI (Phase D) --- .../plans/2026-06-23-agent-review-cli.md | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-agent-review-cli.md diff --git a/docs/superpowers/plans/2026-06-23-agent-review-cli.md b/docs/superpowers/plans/2026-06-23-agent-review-cli.md new file mode 100644 index 0000000000..2f3e1a41d5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-review-cli.md @@ -0,0 +1,468 @@ +# Agent-Review CLI (Phase D) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** A unified `yarn review ` CLI over the existing review core (config/index/learning), where `review run` prints a deterministic pre-flight and launches the Claude Code review via `claude -p`. + +**Architecture:** A thin CommonJS dispatcher `.claude/review/cli.cjs` that reuses the already-tested engine modules in-process, plus one new pure helper module `engine/cliCommands.cjs`. No new dependencies, no new review logic. + +**Tech Stack:** Node CommonJS `.cjs`, `node:test` via the existing runner, `node:child_process` (git + `claude`), `yaml` (already a dep). **Yarn 4 + PnP.** Builds on Phases A + B + C. + +## Global Constraints — READ FIRST (platform-specific) + +- **Yarn 4 + PnP, NO `node_modules`.** Run via `yarn node`; test via `yarn --cwd test:review`. **NEVER `node --test`.** +- **CommonJS `.cjs`** only; **no new dependencies**; lowercase `.claude/` paths; commit with `git -C commit --no-verify`. +- Work ONLY in the worktree `` (absolute paths; `git -C`/`yarn --cwd`). Do NOT `cd`. NEVER touch the main checkout. +- The engine exists at `.claude/review/engine/` with `run-tests.cjs` auto-including every `*.test.cjs`. Reuse exported functions: `loadConfig` (loadConfig.cjs); `buildPlan` (plan.cjs); `loadOrBuildIndex`/`gitHead`/`listRepoFiles` (indexStore.cjs); `queryImpact` (queryImpact.cjs); `mineLearnings` (mineLearnings.cjs); `parsePending`/`appendFeedback`/`loadFeedback`/`loadLearnings`/`saveLearnings`/`mergeProposals` (learningsStore.cjs). +- Do NOT change `plan.cjs`, the index/learning modules, or the debate/consensus logic. + +--- + +### Task 1: CLI pure helpers (`engine/cliCommands.cjs`) + +**Files:** +- Create: `.claude/review/engine/cliCommands.cjs` +- Create: `.claude/review/engine/cliCommands.test.cjs` + +**Interfaces:** +- Produces: `setLearningStatus(learnings, id, status) -> learnings` (new object; throws if `id` absent); `listLearnings(learnings, statusFilter) -> rows[]`; `preflightSummary(plan, impact) -> string`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/cliCommands.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./cliCommands.cjs'); + +const learnings = { version: 1, learnings: [ + { id: 'L-a', kind: 'suppress', status: 'proposed', support: 3, paths: ['src/**'], example: 'x' }, + { id: 'L-b', kind: 'rule', status: 'approved', support: 4, paths: ['pages/**'], ruleText: 'y' }, +] }; + +test('setLearningStatus flips target, preserves others, no mutation', () => { + const updated = setLearningStatus(learnings, 'L-a', 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-a').status, 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-b').status, 'approved'); + assert.equal(learnings.learnings[0].status, 'proposed'); // original untouched +}); + +test('setLearningStatus throws on unknown id', () => { + assert.throws(() => setLearningStatus(learnings, 'nope', 'approved'), /not found/); +}); + +test('listLearnings filters by status', () => { + assert.deepEqual(listLearnings(learnings, 'approved').map((r) => r.id), ['L-b']); + assert.equal(listLearnings(learnings).length, 2); +}); + +test('preflightSummary includes risk, reviewer, agents, blast radius', () => { + const plan = { profile: 'standard', risk: { score: 46, level: 'CRITICAL', reviewer: 'Caleb Cox', special: [] }, agents: [{ id: 'financial', matchedBy: 'path:src/components/HrTools/**' }] }; + const impact = { blastRadius: 166, truncated: false, topImpacted: [{ file: 'src/x.tsx', dependentCount: 29 }] }; + const s = preflightSummary(plan, impact); + assert.match(s, /CRITICAL/); + assert.match(s, /Caleb Cox/); + assert.match(s, /financial/); + assert.match(s, /blastRadius 166/); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./cliCommands.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/cliCommands.cjs`: +```js +'use strict'; + +function setLearningStatus(learnings, id, status) { + const list = (learnings && learnings.learnings) || []; + if (!list.some((l) => l.id === id)) throw new Error(`Learning not found: ${id}`); + return { ...learnings, learnings: list.map((l) => (l.id === id ? { ...l, status } : l)) }; +} + +function listLearnings(learnings, statusFilter) { + const list = (learnings && learnings.learnings) || []; + return list + .filter((l) => !statusFilter || l.status === statusFilter) + .map((l) => ({ id: l.id, kind: l.kind, status: l.status, support: l.support, paths: l.paths || [], example: l.example || l.ruleText || '' })); +} + +function preflightSummary(plan, impact) { + const lines = []; + lines.push(`profile: ${plan.profile}`); + const r = plan.risk; + lines.push(`risk: ${r.score} ${r.level} (reviewer: ${r.reviewer})`); + if (r.special && r.special.length) lines.push(`special: ${r.special.join(', ')}`); + lines.push('agents:'); + for (const a of plan.agents) lines.push(` - ${a.id} [${a.matchedBy}]`); + if (impact) { + lines.push(`impact: blastRadius ${impact.blastRadius}${impact.truncated ? ' (truncated)' : ''}`); + for (const t of (impact.topImpacted || []).filter((x) => x.dependentCount > 0).slice(0, 5)) { + lines.push(` - ${t.dependentCount} dependents: ${t.file}`); + } + } + return lines.join('\n'); +} + +module.exports = { setLearningStatus, listLearnings, preflightSummary }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/cliCommands.cjs .claude/review/engine/cliCommands.test.cjs +git -C commit --no-verify -m "feat(review): pure CLI helpers (status, list, preflight)" +``` + +--- + +### Task 2: CLI dispatcher (`cli.cjs`) — all commands except `run` + +**Files:** +- Create: `.claude/review/cli.cjs` +- Create: `.claude/review/engine/cli.test.cjs` +- Modify: `package.json` (add `review` script) + +**Interfaces:** +- Consumes: engine modules + `cliCommands` (Task 1). +- Produces: `main(argv) -> exitCode` (0 ok, 1 error/unknown); the `yarn review` script. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/cli.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { main } = require('../cli.cjs'); + +function run(args) { + const orig = process.stdout.write.bind(process.stdout); + let s = ''; + process.stdout.write = (x) => { s += x; return true; }; + let code; + try { code = main(args); } finally { process.stdout.write = orig; } + return { code, s }; +} + +test('help returns 0 and prints usage', () => { + const { code, s } = run(['help']); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('no command prints usage', () => { + const { code, s } = run([]); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('unknown command returns 1', () => { + const { code, s } = run(['definitely-not-a-command']); + assert.equal(code, 1); + assert.match(s, /unknown command/); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`../cli.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/cli.cjs`: +```js +'use strict'; +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const { loadConfig } = require('./engine/loadConfig.cjs'); +const { buildPlan } = require('./engine/plan.cjs'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./engine/indexStore.cjs'); +const { queryImpact } = require('./engine/queryImpact.cjs'); +const { mineLearnings } = require('./engine/mineLearnings.cjs'); +const { parsePending, appendFeedback, loadFeedback, loadLearnings, saveLearnings, mergeProposals } = require('./engine/learningsStore.cjs'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./engine/cliCommands.cjs'); + +const ROOT = process.cwd(); +const RD = join(ROOT, '.claude/review'); +const CONFIG = join(RD, 'config.yml'); +const SCHEMA = join(RD, 'config.schema.json'); +const INDEX = join(RD, 'index'); +const FEEDBACK = join(RD, 'learnings/feedback.jsonl'); +const LEARNINGS = join(RD, 'learnings/learnings.yml'); + +function out(s) { process.stdout.write(s + '\n'); } +function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; } + +function changedFiles(base) { + let b = base; + if (!b) { + try { b = execFileSync('git', ['-C', ROOT, 'merge-base', 'main', 'HEAD'], { encoding: 'utf8' }).trim(); } + catch { b = 'HEAD~1'; } + } + const files = execFileSync('git', ['-C', ROOT, 'diff', '--name-only', `${b}...HEAD`], { encoding: 'utf8' }) + .split('\n').map((s) => s.trim()).filter(Boolean); + return { base: b, files }; +} + +function loadIndex() { + return loadOrBuildIndex({ repoRoot: ROOT, indexPath: INDEX, head: gitHead(ROOT), files: listRepoFiles(ROOT) }); +} + +const USAGE = `usage: yarn review + config show|validate show or validate the review config + index rebuild the import-graph cache + impact [--base ] cross-file blast radius for the current diff + feedback ingest marked outcomes + learn [--min-support N] mine feedback into proposed learnings + learnings [--status S] list learnings + approve | reject set a learning's status + run [--base ] [mode] pre-flight + launch the Claude Code review + help`; + +function main(argv) { + const cmd = argv[0]; + const rest = argv.slice(1); + switch (cmd) { + case 'config': { + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + out(rest[0] === 'validate' ? 'config OK' : JSON.stringify(cfg, null, 2)); + return 0; + } + case 'index': { + const g = loadIndex(); + out(`Indexed ${g.fileCount} files; ${Object.keys(g.importedBy).length} have dependents.`); + return 0; + } + case 'impact': { + const { files } = changedFiles(flag(rest, '--base')); + out(JSON.stringify(queryImpact(files, loadIndex(), {}), null, 2)); + return 0; + } + case 'feedback': { + if (!rest[0]) { out('usage: yarn review feedback '); return 1; } + const entries = parsePending(readFileSync(rest[0], 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(FEEDBACK, entries); + out(`Ingested ${entries.length} outcomes`); + return 0; + } + case 'learn': { + const minSupport = flag(rest, '--min-support') ? Number(flag(rest, '--min-support')) : 3; + const proposals = mineLearnings(loadFeedback(FEEDBACK), { minSupport }); + const merged = mergeProposals(loadLearnings(LEARNINGS), proposals); + saveLearnings(LEARNINGS, merged); + out(`Mined ${proposals.length} proposals; ${merged.learnings.length} total`); + return 0; + } + case 'learnings': { + out(JSON.stringify(listLearnings(loadLearnings(LEARNINGS), flag(rest, '--status')), null, 2)); + return 0; + } + case 'approve': + case 'reject': { + if (!rest[0]) { out(`usage: yarn review ${cmd} `); return 1; } + const status = cmd === 'approve' ? 'approved' : 'rejected'; + saveLearnings(LEARNINGS, setLearningStatus(loadLearnings(LEARNINGS), rest[0], status)); + out(`${rest[0]} -> ${status}`); + return 0; + } + case 'help': + case undefined: + out(USAGE); + return 0; + default: + out(`unknown command: ${cmd}\n\n${USAGE}`); + return 1; + } +} + +if (require.main === module) { + try { process.exit(main(process.argv.slice(2))); } + catch (e) { process.stderr.write(`error: ${e.message}\n`); process.exit(1); } +} + +module.exports = { main }; +``` +(`buildPlan` and `preflightSummary` are imported now but used by the `run` command added in Task 3 — harmless until then.) + +Add to `package.json` `scripts`: +```json +"review": "yarn node .claude/review/cli.cjs" +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS (`cli.test.cjs` green). +Then smoke the real CLI: +```bash +yarn --cwd review help +yarn --cwd review config validate # -> "config OK" +yarn --cwd review learnings # -> "[]" (seed) +yarn --cwd review nope; echo "exit=$?" # -> usage + exit=1 +``` +Expected: help/usage prints; `config validate` prints `config OK`; `learnings` prints `[]`; unknown command exits 1. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/cli.cjs .claude/review/engine/cli.test.cjs package.json +git -C commit --no-verify -m "feat(review): unified review CLI dispatcher (config/index/impact/feedback/learn/learnings/approve/reject)" +``` + +--- + +### Task 3: `review run` (pre-flight + `claude -p`) + +**Files:** +- Modify: `.claude/review/cli.cjs` (add the `run` case) + +**Interfaces:** +- Consumes: `buildPlan`, `queryImpact`, `loadConfig`, `preflightSummary` (already imported in Task 2). + +- [ ] **Step 1: Add the `run` case to `main`'s switch (immediately before `case 'help':`)** + +Insert into `.claude/review/cli.cjs`: +```js + case 'run': { + const { writeFileSync } = require('node:fs'); + const os = require('node:os'); + const base = flag(rest, '--base'); + const mode = rest.find((a) => !a.startsWith('--') && a !== base) || 'standard'; + const { base: b, files } = changedFiles(base); + const diff = execFileSync('git', ['-C', ROOT, 'diff', `${b}...HEAD`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + const stat = execFileSync('git', ['-C', ROOT, 'diff', '--stat', `${b}...HEAD`], { encoding: 'utf8' }); + const ins = stat.match(/(\d+) insertions?\(\+\)/); + const del = stat.match(/(\d+) deletions?\(-\)/); + const linesChanged = (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + const plan = buildPlan({ files, diffText: diff, linesChanged, scope: 'multi_feature' }, cfg); + let impact = null; + if (cfg.index && cfg.index.enabled) impact = queryImpact(files, loadIndex(), {}); + out(preflightSummary(plan, impact)); + writeFileSync(join(os.tmpdir(), 'review_plan.json'), JSON.stringify({ ...plan, impact }, null, 2)); + if (rest.includes('--no-launch')) { + out(`\nwould run: claude -p "/agent-review ${mode}"`); + return 0; + } + out(`\nlaunching: claude -p "/agent-review ${mode}" ...\n`); + try { + execFileSync('claude', ['-p', `/agent-review ${mode}`], { stdio: 'inherit' }); + } catch (e) { + out(`(could not launch claude automatically: ${e.message})`); + out(`Run it manually in Claude Code: /agent-review ${mode}`); + } + return 0; + } +``` + +- [ ] **Step 2: Verify the `claude -p` slash-command invocation** + +Confirm how Claude Code's print mode triggers a slash command: +```bash +claude -p "/agent-review standard" --help >/dev/null 2>&1 || true +# Quick probe (do NOT run a full review): check the CLI accepts the slash form. +claude --help 2>&1 | grep -iE "print|-p," | head -3 +``` +If a bare `/agent-review` is NOT honored in `-p` mode, change the invocation in the `run` case to a +natural-language prompt that triggers it: +```js + execFileSync('claude', ['-p', `Run the /agent-review ${mode} command on the current branch`], { stdio: 'inherit' }); +``` +(Pick whichever form actually triggers the command; keep the `--no-launch` and catch fallbacks.) + +- [ ] **Step 3: Smoke the pre-flight without launching** + +```bash +yarn --cwd review run --no-launch +``` +Expected: prints the pre-flight (profile, risk score/level/reviewer, selected agents with match +reasons, impact blast radius for the current branch's diff), then `would run: claude -p "/agent-review standard"`. (The engine suite must still be green: `yarn --cwd test:review`.) + +- [ ] **Step 4: Commit** + +```bash +git -C add .claude/review/cli.cjs +git -C commit --no-verify -m "feat(review): review run pre-flight + claude -p launch" +``` + +--- + +### Task 4: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full suite green** + +Run: `yarn --cwd test:review` +Expected: PASS — all engine tests (Phases A–D), including `cliCommands.test.cjs` and `cli.test.cjs`. + +- [ ] **Step 2: Exercise every command (smoke)** + +```bash +WT= +yarn --cwd "$WT" review help +yarn --cwd "$WT" review config validate +yarn --cwd "$WT" review index +yarn --cwd "$WT" review impact +yarn --cwd "$WT" review learnings +yarn --cwd "$WT" review run --no-launch +yarn --cwd "$WT" review bogus; echo "unknown exit=$?" +``` +Expected: each prints sensible output; `config validate` → `config OK`; `impact`/`run` print +real risk/blast-radius for the branch; unknown command exits 1. + +- [ ] **Step 3: approve/reject round-trip (no committed-file corruption)** + +```bash +WT= +# seed a temporary proposed learning, flip it, then restore the committed seed +node -e "const {saveLearnings}=require('$WT/.claude/review/engine/learningsStore.cjs'); saveLearnings('$WT/.claude/review/learnings/learnings.yml',{version:1,learnings:[{id:'L-x',kind:'suppress',status:'proposed',support:3,paths:['src/**'],example:'e'}]});" 2>/dev/null || yarn --cwd "$WT" node -e "const {saveLearnings}=require('./.claude/review/engine/learningsStore.cjs'); saveLearnings('./.claude/review/learnings/learnings.yml',{version:1,learnings:[{id:'L-x',kind:'suppress',status:'proposed',support:3,paths:['src/**'],example:'e'}]});" +yarn --cwd "$WT" review approve L-x # -> "L-x -> approved" +yarn --cwd "$WT" review learnings --status approved # -> shows L-x +git -C "$WT" checkout -- .claude/review/learnings/learnings.yml # restore committed seed +``` +Expected: `approve` flips status; `learnings --status approved` lists `L-x`; the committed seed is +restored clean afterward. + +- [ ] **Step 4: lint:ts unaffected + tree clean** + +```bash +yarn --cwd lint:ts # no NEW errors referencing .claude/review (pre-existing codegen errors OK) +git -C status --short # clean (seed restored) +git -C log --oneline -6 +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Single `review` CLI + `yarn review` script → Task 2 ✓ +- `config show/validate` (exit 1 on invalid via top-level catch) → Task 2 ✓ +- `index`, `impact`, `feedback`, `learn` → Task 2 ✓ +- `learnings`, `approve`, `reject` → Task 2 (+ helpers Task 1) ✓ +- `run` pre-flight + `claude -p` (with verification + fallback) → Task 3 ✓ +- New pure logic tested (`setLearningStatus`/`listLearnings`/`preflightSummary`) → Task 1 ✓ +- No new review logic / engine unchanged → respected (CLI only orchestrates) ✓ +- Acceptance criteria 1–7 → Tasks 1–4 ✓ + +**2. Placeholder scan:** No TBD/TODO; complete code in each step. Task 3 Step 2 is a real verification with an explicit alternative invocation (not a placeholder). `--no-launch` provides a safe, testable path that avoids kicking off a costly full review during the build. + +**3. Type consistency:** `main(argv) -> number` defined Task 2, tested Task 1's sibling `cli.test.cjs` (Task 2). `setLearningStatus`/`listLearnings`/`preflightSummary` signatures defined Task 1 and called identically in `cli.cjs` (Tasks 2/3). `buildPlan({files,diffText,linesChanged,scope}, cfg)` and `queryImpact(files, graph, {})` match the Phase A/B exports. `loadOrBuildIndex({repoRoot,indexPath,head,files})` matches Phase B. `preflightSummary(plan, impact)` consumes `plan.risk.{score,level,reviewer,special}`, `plan.agents[].{id,matchedBy}`, `impact.{blastRadius,truncated,topImpacted}` — all produced by `buildPlan`/`queryImpact`. + +--- + +## Notes for the executor + +- No new dependencies; CommonJS; `yarn test:review` only (never `node --test`); lowercase `.claude/`; commits `--no-verify`. +- Never let `review run` (without `--no-launch`) kick off a full review during testing — always smoke with `--no-launch`. +- Restore `learnings.yml`/`feedback.jsonl` committed seeds after any smoke that writes them. From 4398e6d6023c6dbcc80d613f75e2772390a87086 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 15:36:36 -0400 Subject: [PATCH 32/39] feat(review): pure CLI helpers (status, list, preflight) --- .claude/review/engine/cliCommands.cjs | 33 ++++++++++++++++++++ .claude/review/engine/cliCommands.test.cjs | 35 ++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .claude/review/engine/cliCommands.cjs create mode 100644 .claude/review/engine/cliCommands.test.cjs diff --git a/.claude/review/engine/cliCommands.cjs b/.claude/review/engine/cliCommands.cjs new file mode 100644 index 0000000000..969e369cf4 --- /dev/null +++ b/.claude/review/engine/cliCommands.cjs @@ -0,0 +1,33 @@ +'use strict'; + +function setLearningStatus(learnings, id, status) { + const list = (learnings && learnings.learnings) || []; + if (!list.some((l) => l.id === id)) throw new Error(`Learning not found: ${id}`); + return { ...learnings, learnings: list.map((l) => (l.id === id ? { ...l, status } : l)) }; +} + +function listLearnings(learnings, statusFilter) { + const list = (learnings && learnings.learnings) || []; + return list + .filter((l) => !statusFilter || l.status === statusFilter) + .map((l) => ({ id: l.id, kind: l.kind, status: l.status, support: l.support, paths: l.paths || [], example: l.example || l.ruleText || '' })); +} + +function preflightSummary(plan, impact) { + const lines = []; + lines.push(`profile: ${plan.profile}`); + const r = plan.risk; + lines.push(`risk: ${r.score} ${r.level} (reviewer: ${r.reviewer})`); + if (r.special && r.special.length) lines.push(`special: ${r.special.join(', ')}`); + lines.push('agents:'); + for (const a of plan.agents) lines.push(` - ${a.id} [${a.matchedBy}]`); + if (impact) { + lines.push(`impact: blastRadius ${impact.blastRadius}${impact.truncated ? ' (truncated)' : ''}`); + for (const t of (impact.topImpacted || []).filter((x) => x.dependentCount > 0).slice(0, 5)) { + lines.push(` - ${t.dependentCount} dependents: ${t.file}`); + } + } + return lines.join('\n'); +} + +module.exports = { setLearningStatus, listLearnings, preflightSummary }; diff --git a/.claude/review/engine/cliCommands.test.cjs b/.claude/review/engine/cliCommands.test.cjs new file mode 100644 index 0000000000..967b6464ba --- /dev/null +++ b/.claude/review/engine/cliCommands.test.cjs @@ -0,0 +1,35 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./cliCommands.cjs'); + +const learnings = { version: 1, learnings: [ + { id: 'L-a', kind: 'suppress', status: 'proposed', support: 3, paths: ['src/**'], example: 'x' }, + { id: 'L-b', kind: 'rule', status: 'approved', support: 4, paths: ['pages/**'], ruleText: 'y' }, +] }; + +test('setLearningStatus flips target, preserves others, no mutation', () => { + const updated = setLearningStatus(learnings, 'L-a', 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-a').status, 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-b').status, 'approved'); + assert.equal(learnings.learnings[0].status, 'proposed'); // original untouched +}); + +test('setLearningStatus throws on unknown id', () => { + assert.throws(() => setLearningStatus(learnings, 'nope', 'approved'), /not found/); +}); + +test('listLearnings filters by status', () => { + assert.deepEqual(listLearnings(learnings, 'approved').map((r) => r.id), ['L-b']); + assert.equal(listLearnings(learnings).length, 2); +}); + +test('preflightSummary includes risk, reviewer, agents, blast radius', () => { + const plan = { profile: 'standard', risk: { score: 46, level: 'CRITICAL', reviewer: 'Caleb Cox', special: [] }, agents: [{ id: 'financial', matchedBy: 'path:src/components/HrTools/**' }] }; + const impact = { blastRadius: 166, truncated: false, topImpacted: [{ file: 'src/x.tsx', dependentCount: 29 }] }; + const s = preflightSummary(plan, impact); + assert.match(s, /CRITICAL/); + assert.match(s, /Caleb Cox/); + assert.match(s, /financial/); + assert.match(s, /blastRadius 166/); +}); From 07fd592fed6cfcfaa26247718515ff0822d998ac Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 15:38:41 -0400 Subject: [PATCH 33/39] feat(review): unified review CLI dispatcher (config/index/impact/feedback/learn/learnings/approve/reject) --- .claude/review/cli.cjs | 111 +++++++++++++++++++++++++++++ .claude/review/engine/cli.test.cjs | 31 ++++++++ package.json | 1 + 3 files changed, 143 insertions(+) create mode 100644 .claude/review/cli.cjs create mode 100644 .claude/review/engine/cli.test.cjs diff --git a/.claude/review/cli.cjs b/.claude/review/cli.cjs new file mode 100644 index 0000000000..38db18b4a2 --- /dev/null +++ b/.claude/review/cli.cjs @@ -0,0 +1,111 @@ +'use strict'; +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const { loadConfig } = require('./engine/loadConfig.cjs'); +const { buildPlan } = require('./engine/plan.cjs'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./engine/indexStore.cjs'); +const { queryImpact } = require('./engine/queryImpact.cjs'); +const { mineLearnings } = require('./engine/mineLearnings.cjs'); +const { parsePending, appendFeedback, loadFeedback, loadLearnings, saveLearnings, mergeProposals } = require('./engine/learningsStore.cjs'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./engine/cliCommands.cjs'); + +const ROOT = process.cwd(); +const RD = join(ROOT, '.claude/review'); +const CONFIG = join(RD, 'config.yml'); +const SCHEMA = join(RD, 'config.schema.json'); +const INDEX = join(RD, 'index'); +const FEEDBACK = join(RD, 'learnings/feedback.jsonl'); +const LEARNINGS = join(RD, 'learnings/learnings.yml'); + +function out(s) { process.stdout.write(s + '\n'); } +function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; } + +function changedFiles(base) { + let b = base; + if (!b) { + try { b = execFileSync('git', ['-C', ROOT, 'merge-base', 'main', 'HEAD'], { encoding: 'utf8' }).trim(); } + catch { b = 'HEAD~1'; } + } + const files = execFileSync('git', ['-C', ROOT, 'diff', '--name-only', `${b}...HEAD`], { encoding: 'utf8' }) + .split('\n').map((s) => s.trim()).filter(Boolean); + return { base: b, files }; +} + +function loadIndex() { + return loadOrBuildIndex({ repoRoot: ROOT, indexPath: INDEX, head: gitHead(ROOT), files: listRepoFiles(ROOT) }); +} + +const USAGE = `usage: yarn review + config show|validate show or validate the review config + index rebuild the import-graph cache + impact [--base ] cross-file blast radius for the current diff + feedback ingest marked outcomes + learn [--min-support N] mine feedback into proposed learnings + learnings [--status S] list learnings + approve | reject set a learning's status + run [--base ] [mode] pre-flight + launch the Claude Code review + help`; + +function main(argv) { + const cmd = argv[0]; + const rest = argv.slice(1); + switch (cmd) { + case 'config': { + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + out(rest[0] === 'validate' ? 'config OK' : JSON.stringify(cfg, null, 2)); + return 0; + } + case 'index': { + const g = loadIndex(); + out(`Indexed ${g.fileCount} files; ${Object.keys(g.importedBy).length} have dependents.`); + return 0; + } + case 'impact': { + const { files } = changedFiles(flag(rest, '--base')); + out(JSON.stringify(queryImpact(files, loadIndex(), {}), null, 2)); + return 0; + } + case 'feedback': { + if (!rest[0]) { out('usage: yarn review feedback '); return 1; } + const entries = parsePending(readFileSync(rest[0], 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(FEEDBACK, entries); + out(`Ingested ${entries.length} outcomes`); + return 0; + } + case 'learn': { + const minSupport = flag(rest, '--min-support') ? Number(flag(rest, '--min-support')) : 3; + const proposals = mineLearnings(loadFeedback(FEEDBACK), { minSupport }); + const merged = mergeProposals(loadLearnings(LEARNINGS), proposals); + saveLearnings(LEARNINGS, merged); + out(`Mined ${proposals.length} proposals; ${merged.learnings.length} total`); + return 0; + } + case 'learnings': { + out(JSON.stringify(listLearnings(loadLearnings(LEARNINGS), flag(rest, '--status')), null, 2)); + return 0; + } + case 'approve': + case 'reject': { + if (!rest[0]) { out(`usage: yarn review ${cmd} `); return 1; } + const status = cmd === 'approve' ? 'approved' : 'rejected'; + saveLearnings(LEARNINGS, setLearningStatus(loadLearnings(LEARNINGS), rest[0], status)); + out(`${rest[0]} -> ${status}`); + return 0; + } + case 'help': + case undefined: + out(USAGE); + return 0; + default: + out(`unknown command: ${cmd}\n\n${USAGE}`); + return 1; + } +} + +if (require.main === module) { + try { process.exit(main(process.argv.slice(2))); } + catch (e) { process.stderr.write(`error: ${e.message}\n`); process.exit(1); } +} + +module.exports = { main }; diff --git a/.claude/review/engine/cli.test.cjs b/.claude/review/engine/cli.test.cjs new file mode 100644 index 0000000000..cdc59345b2 --- /dev/null +++ b/.claude/review/engine/cli.test.cjs @@ -0,0 +1,31 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { main } = require('../cli.cjs'); + +function run(args) { + const orig = process.stdout.write.bind(process.stdout); + let s = ''; + process.stdout.write = (x) => { s += x; return true; }; + let code; + try { code = main(args); } finally { process.stdout.write = orig; } + return { code, s }; +} + +test('help returns 0 and prints usage', () => { + const { code, s } = run(['help']); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('no command prints usage', () => { + const { code, s } = run([]); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('unknown command returns 1', () => { + const { code, s } = run(['definitely-not-a-command']); + assert.equal(code, 1); + assert.match(s, /unknown command/); +}); diff --git a/package.json b/package.json index 350971d94a..f9938cdaf2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint:ts": "tsc", "test": "jest --silent", "test:review": "yarn node .claude/review/engine/run-tests.cjs", + "review": "yarn node .claude/review/cli.cjs", "review:index": "yarn node .claude/review/engine/indexStore.cjs --build", "review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest", "review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine", From e3740256ada956b2c7b0f5ad2b5cb00749d855a4 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 23 Jun 2026 15:40:31 -0400 Subject: [PATCH 34/39] feat(review): review run pre-flight + claude -p launch --- .claude/review/cli.cjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.claude/review/cli.cjs b/.claude/review/cli.cjs index 38db18b4a2..5641e9937f 100644 --- a/.claude/review/cli.cjs +++ b/.claude/review/cli.cjs @@ -93,6 +93,36 @@ function main(argv) { out(`${rest[0]} -> ${status}`); return 0; } + case 'run': { + const { writeFileSync } = require('node:fs'); + const os = require('node:os'); + const base = flag(rest, '--base'); + const mode = rest.find((a) => !a.startsWith('--') && a !== base) || 'standard'; + const { base: b, files } = changedFiles(base); + const diff = execFileSync('git', ['-C', ROOT, 'diff', `${b}...HEAD`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + const stat = execFileSync('git', ['-C', ROOT, 'diff', '--stat', `${b}...HEAD`], { encoding: 'utf8' }); + const ins = stat.match(/(\d+) insertions?\(\+\)/); + const del = stat.match(/(\d+) deletions?\(-\)/); + const linesChanged = (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + const plan = buildPlan({ files, diffText: diff, linesChanged, scope: 'multi_feature' }, cfg); + let impact = null; + if (cfg.index && cfg.index.enabled) impact = queryImpact(files, loadIndex(), {}); + out(preflightSummary(plan, impact)); + writeFileSync(join(os.tmpdir(), 'review_plan.json'), JSON.stringify({ ...plan, impact }, null, 2)); + if (rest.includes('--no-launch')) { + out(`\nwould run: claude -p "/agent-review ${mode}"`); + return 0; + } + out(`\nlaunching: claude -p "/agent-review ${mode}" ...\n`); + try { + execFileSync('claude', ['-p', `/agent-review ${mode}`], { stdio: 'inherit' }); + } catch (e) { + out(`(could not launch claude automatically: ${e.message})`); + out(`Run it manually in Claude Code: /agent-review ${mode}`); + } + return 0; + } case 'help': case undefined: out(USAGE); From 0e9a16a24ce8311d7333d76c6caece25d6173285 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 24 Jun 2026 08:54:48 -0400 Subject: [PATCH 35/39] docs: spec for agent-review plugin distribution (Phase E) --- ...agent-review-plugin-distribution-design.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md diff --git a/docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md b/docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md new file mode 100644 index 0000000000..37953c6039 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md @@ -0,0 +1,233 @@ +# Design Spec — Agent-Review Plugin & Distribution (Phase E) + +- **Date:** 2026-06-24 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **New home:** a standalone repo `CruGlobal/agent-review` (engine extracted from the mpdx-react + worktree, which is the current source of truth). This spec is drafted in mpdx-react's + `docs/superpowers/` and **moves to the new repo** when it's created. +- **Builds on:** the review core (Phases A–D) — generic engine + config + index + learning + CLI. + +--- + +## 1. Context & Problem + +The reviewer works in mpdx-react (config + index + learning + a `review` CLI, PR #1858). The goal +now is **productization**: package it so it installs once and a `review init` sets up *any* repo, +distributable to other developers. After research/brainstorm the form factor is a **Claude Code +plugin** distributed via a **private CruGlobal marketplace**. + +### Verified plugin mechanics (official docs, 2026-06-24) + +- Plugin layout: `.claude-plugin/plugin.json` manifest; `commands/` (flat `.md`), `skills/`, + `agents/`, `hooks/`, `bin/` (added to PATH while enabled); `.mcp.json` optional. +- Distribution: a marketplace repo with `.claude-plugin/marketplace.json`; install via + `/plugin marketplace add ` then `/plugin install @`. Private + GitHub repos work via `GITHUB_TOKEN`/git credential helpers. +- Path vars: `${CLAUDE_PLUGIN_ROOT}` (install dir, ephemeral — changes on update), + `${CLAUDE_PLUGIN_DATA}` (persistent `~/.claude/plugins/data/{id}/`), `${CLAUDE_PROJECT_DIR}` + (the user's repo root). Plugins are **copied to a cache** on install; `../` traversal outside the + plugin fails (so the engine must be self-contained). +- Dependencies: either vendor `node_modules` in the plugin, or install once into + `${CLAUDE_PLUGIN_DATA}` via a `SessionStart` hook. **We vendor** (3 small deps) → engine runs with + plain `node`, zero install, offline-safe. +- A plugin slash command can **write files into the user's repo** (Bash/Write, user trust level). +- `claude -p "/plugin:command"` invokes plugin commands in headless mode (confirmed; default-on). + +### Decisions (brainstorm) + +1. Form factor: **Claude Code plugin** (native to the Claude-Code/subscription runtime; single + install; bundles commands + engine + CLI bin). MCP/global-npm rejected as primary. +2. **New repo `CruGlobal/agent-review`**, engine **extracted** (it's already config-agnostic). +3. **Private CruGlobal GitHub marketplace.** +4. Engine **vendors** its deps and runs on plain `node` (no Yarn/PnP in target repos). + +--- + +## 2. Goals & Non-Goals + +### Goals + +- A `agent-review` Claude Code plugin: bundled generic engine (vendored deps) + `/agent-review:init` + + `/agent-review:run` + a `review` CLI bin. +- A private CruGlobal **marketplace** repo so any dev installs with two `/plugin` commands. +- `review init`: analyze a target repo → generate a tailored `config.yml` + `rules/*.md` + (auto-ingesting an existing `CLAUDE.md`/`.cursorrules`) → validate → build index → tell the dev how + to override (CLI or file). +- Engine extracted to the new repo, **de-PnP'd**: vendored `node_modules`, tests via plain + `node --test` (the single-process runner workaround is no longer needed off PnP). +- Per-repo footprint = `.claude/review/config.yml` + `rules/` + `learnings/` (committed) + gitignored + `index/`. No engine or deps copied into target repos. + +### Non-Goals + +- Phase F workflow integration (git hook / CI gate / the create→…→deploy flow) — separate spec. +- A web UI; a standalone global-npm CLI for use *outside* Claude Code (optional later add-on). +- New review/analysis logic — this phase packages and bootstraps what exists. +- Language-agnostic impact analysis — the import-graph stays TS/JS-only (config/risk/learning are + language-agnostic and work anywhere). + +--- + +## 3. Architecture + +### 3.1 Plugin repo layout (`CruGlobal/agent-review`) + +``` +agent-review/ # the plugin repo +├── .claude-plugin/ +│ └── plugin.json # manifest (name, version, commands, bin) +├── commands/ +│ ├── init.md # /agent-review:init (scaffold a repo) +│ └── run.md # /agent-review:run (pre-flight + 7-agent review) +├── bin/ +│ └── review # CLI entry on PATH (node shim → engine/cli.cjs) +├── engine/ # extracted generic engine (CommonJS) +│ ├── loadConfig.cjs scoreRisk.cjs selectAgents.cjs resolveRules.cjs detectSpecial.cjs plan.cjs +│ ├── resolveImport.cjs buildGraph.cjs queryImpact.cjs indexStore.cjs impact.cjs +│ ├── findingSignature.cjs mineLearnings.cjs applyLearnings.cjs learningsStore.cjs +│ ├── cli.cjs cliCommands.cjs run-tests.cjs *.test.cjs +│ └── templates/ # starter config.yml + rules/*.md templates for init +├── node_modules/ # VENDORED: yaml, minimatch, ajv (committed) +├── package.json # deps + "test": "node --test engine/" +└── README.md +``` + +A separate **marketplace repo** (e.g. `CruGlobal/agent-review-marketplace`, or a +`.claude-plugin/marketplace.json` in the same repo) lists the plugin and its source. + +### 3.2 Engine location & path resolution + +- The engine lives in the plugin at `${CLAUDE_PLUGIN_ROOT}/engine`. Commands/bin invoke + `node "$CLAUDE_PLUGIN_ROOT/engine/.cjs"`. +- The engine operates on the **target repo** via `${CLAUDE_PROJECT_DIR}` (config at + `$CLAUDE_PROJECT_DIR/.claude/review/config.yml`, etc.). All engine entry points that currently + assume `process.cwd()` gain an explicit `--root`/`--project` argument (default `process.cwd()`), + so they work both as a repo-local CLI and as a plugin reading `$CLAUDE_PROJECT_DIR`. +- Vendored `node_modules` at the plugin root means plain `node` resolves `yaml`/`minimatch`/`ajv` + with no PnP, no install, even though `${CLAUDE_PLUGIN_ROOT}` changes on update (node_modules + travels with it). + +### 3.3 Distribution / install UX + +``` +# one-time, per dev +/plugin marketplace add CruGlobal/agent-review-marketplace +/plugin install agent-review@cru +# per repo +/agent-review:init # sets up .claude/review/ for this repo +/agent-review:run # pre-flight + multi-agent review +``` + +--- + +## 4. `review init` — repo bootstrap + +`commands/init.md` (a model-invoked command) does: + +1. **Detect** stack from the target repo: package manager (yarn/npm/pnpm), framework (Next/React, + Node, etc.), test runner, top-level structure. Read any existing `CLAUDE.md`, `.cursorrules`, + `.github/copilot-instructions.md`. +2. **Generate** `$CLAUDE_PROJECT_DIR/.claude/review/config.yml` tailored to the repo — propose + `risk.patterns` (critical/high/medium globs), `agents` + triggers, `excluded_paths`, `profile`, + and the inert/enabled `index`/`learning` blocks. Seed `rules/*.md` from the detected + agent-instruction files + sensible defaults. **AI determines which paths are critical**; the dev + reviews/edits. +3. **Write** the files (Bash/Write), then run the engine to **validate** (`review config validate`) + and **build the index** if the repo is TS/JS (`review index`). +4. **Report** what was created and how to override: edit `config.yml`/`rules/*.md` directly, or use + the CLI (`review config show`, `review learnings`, `review approve `, etc.). + +Override is first-class: config is plain YAML + markdown the dev owns; the CLI is a convenience. + +### Idempotence / safety + +- If `.claude/review/config.yml` already exists, `init` does not overwrite — it prints a diff-style + summary of suggested additions and asks the dev to merge (no clobbering a customized config). +- `init` only writes under `$CLAUDE_PROJECT_DIR/.claude/review/`. + +--- + +## 5. Engine extraction & de-PnP + +- Copy the generic engine modules + tests from the mpdx-react worktree into the new repo's + `engine/`. They are already config-agnostic; the only MPDX-specific content is `config.yml` + + `rules/*.md`, which become **templates** under `engine/templates/` (generic starting points). +- Replace the PnP single-process test runner with **plain `node --test engine/`** (works off PnP) — + `run-tests.cjs` may be kept as an alias but is no longer required. +- `package.json`: declare `yaml`/`minimatch`/`ajv`; vendor `node_modules` (committed) so the plugin + is self-contained. `"test": "node --test engine/"`. +- Entry points (`cli.cjs`, `plan.cjs`, `impact.cjs`, `indexStore.cjs`, `learningsStore.cjs`) take an + explicit `--root`/`--project` (default `process.cwd()`), used by the plugin to pass + `$CLAUDE_PROJECT_DIR`. + +The mpdx-react in-repo copy (PR #1858) stays as-is until MPDX migrates to consuming the plugin +(out of scope here; a later cleanup). + +--- + +## 6. Commands & CLI surface in the plugin + +- `/agent-review:init` — §4. +- `/agent-review:run [mode]` — adapt the existing refactored `agent-review.md` to read config/engine + from the plugin + target repo; pre-flight (risk/agents/impact) then the 7-agent debate. (Reuses + the Phase A–C command logic, repointed at `$CLAUDE_PLUGIN_ROOT`/`$CLAUDE_PROJECT_DIR`.) +- `review` **bin** — the Phase-D CLI (`config`, `index`, `impact`, `feedback`, `learn`, `learnings`, + `approve`, `reject`, `run`, `help`), available on PATH inside Claude Code sessions, operating on + `$CLAUDE_PROJECT_DIR` (or `--root`). + +--- + +## 7. Testing & Acceptance + +### Testing + +- Engine tests run via `node --test engine/` in the new repo (port the existing suites — they pass + unchanged; the runner just changes). Add tests for the new `--root`/`--project` plumbing. +- `init` is validated by an integration smoke: run it against a small throwaway TS repo fixture and + assert it writes a schema-valid `config.yml` + non-empty `rules/*.md`, and that `review config + validate` passes. +- Plugin manifest validated by installing the plugin from a local marketplace path and confirming + `/agent-review:init`/`:run` and the `review` bin are available. + +### Acceptance criteria + +1. `CruGlobal/agent-review` repo exists with the plugin layout, vendored deps, and ported engine; + `node --test engine/` is green. +2. A marketplace (`.claude-plugin/marketplace.json`) lists the plugin; `/plugin marketplace add` + + `/plugin install` make `/agent-review:init`, `/agent-review:run`, and `review` available. +3. `/agent-review:init` on a fresh TS repo produces a schema-valid, tailored `config.yml` + + `rules/*.md`, validates, and builds the index — with no engine/deps copied into the repo. +4. `init` does not overwrite an existing config (merge-summary instead). +5. `/agent-review:run` (or `review run`) runs the pre-flight + launches the review via + `claude -p "/agent-review:run …"` style invocation, reading config from `$CLAUDE_PROJECT_DIR`. +6. Config override works via both the CLI and editing the files. +7. Per-repo footprint is exactly `.claude/review/{config.yml,rules/,learnings/}` (+ gitignored + `index/`). + +--- + +## 8. Open questions & risks + +- **CruGlobal repo creation/permissions** — creating `CruGlobal/agent-review` + + marketplace repos needs org permission; may start under a personal/fork and transfer. (Confirm at + implementation.) +- **Engine source-of-truth drift** — once extracted, the mpdx-react copy and the plugin diverge + until MPDX migrates. Mitigation: treat the plugin repo as canonical; MPDX migration is a tracked + follow-up. +- **`${CLAUDE_PLUGIN_ROOT}` ephemerality** — never persist absolute plugin paths; always resolve at + runtime from the env var. Vendored node_modules avoids a data-dir install dance. +- **Vendored node_modules size/licensing** — `yaml`/`minimatch`/`ajv` are small, permissively + licensed; committing them is acceptable. Re-verify on version bumps. +- **`init` config quality** — AI-generated config may miss repo-specific criticality. Mitigation: + it's a starting point the dev edits; `init` prints what it inferred and why. +- **Non-JS repos** — impact/index disabled; config/risk/learning still work. `init` detects and sets + `index.enabled` accordingly. + +--- + +## 9. References + +- Phases A–D specs/plans under `docs/superpowers/`; engine at `.claude/review/engine/`. +- Plugin docs: code.claude.com/docs/en/plugins, /plugins-reference, /plugin-marketplaces, + /headless. Competitive research: `.claude/docs/competitive-research-greptile-coderabbit.md`. From 71ea930ba6c5774258d42a5eb0471bd962dfecdc Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 24 Jun 2026 13:42:22 -0400 Subject: [PATCH 36/39] docs: implementation plan for agent-review plugin distribution (Phase E) --- ...-06-24-agent-review-plugin-distribution.md | 545 ++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md diff --git a/docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md b/docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md new file mode 100644 index 0000000000..b173ae25d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md @@ -0,0 +1,545 @@ +# Agent-Review Plugin & Distribution (Phase E) Implementation Plan + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Package the reviewer as a Claude Code **plugin** in a new standalone repo (`agent-review`) — generic engine with vendored deps, `/agent-review:init` + `/agent-review:run` commands, a `review` CLI bin, and a marketplace manifest — so any dev installs once and sets up any repo. + +**Architecture:** A new plain-Node repo (NOT Yarn/PnP) at `${NEW_REPO}`. The repo root plays the role MPDX's `.claude/review/` played, so the generic engine copies in with **no path edits**. Deps (`yaml`/`minimatch`/`ajv`) are installed normally and **committed (vendored)** so the plugin runs with plain `node`. Engine entry points gain a `--root` flag so commands can point them at `$CLAUDE_PROJECT_DIR`. + +**Tech Stack:** Node CommonJS `.cjs`, `node --test` (plain — no PnP runner needed), npm, `yaml`/`minimatch`/`ajv` (vendored). Claude Code plugin (manifest + marketplace). + +## Global Constraints — READ FIRST + +- **Paths:** `${NEW_REPO}` = `/Users/danielbisgrove/Documents/Web_Dev/agent-review` (created in setup before this plan runs; a fresh `git init` repo). `${SRC}` = `/Users/danielbisgrove/Documents/Web_Dev/MPDX/mpdx-react-review-config/.claude/review` (the engine source of truth to extract from). +- This repo is **plain Node + npm, NOT PnP.** Run tests with `node --test engine/` (workers resolve deps from real `node_modules` — the MPDX single-process-runner workaround is not needed). Run the engine with plain `node`. +- **Vendor deps:** `node_modules` is committed (a plugin must be self-contained). Do NOT gitignore `node_modules`. +- Engine is CommonJS `.cjs`. Commit normally (this repo has no husky); plain `git commit -m`. +- The repo root mirrors MPDX's `.claude/review/`: `cli.cjs` and `config.schema.json` at repo root; engine modules under `engine/`. This keeps every `require('../config.schema.json')` / `require('../cli.cjs')` in the copied tests valid with zero edits. +- Work ONLY in `${NEW_REPO}` (and read-only from `${SRC}`). Use absolute paths; `git -C ${NEW_REPO}`. +- Do NOT modify the mpdx-react worktree (it's the read-only source). + +--- + +### Task 1: Scaffold the plugin repo + +**Files:** +- Create: `${NEW_REPO}/package.json`, `${NEW_REPO}/.gitignore`, `${NEW_REPO}/.claude-plugin/plugin.json`, `${NEW_REPO}/README.md` +- Install + commit: `${NEW_REPO}/node_modules/` (vendored) + +- [ ] **Step 1: Initialize package + install vendored deps** + +```bash +cd ${NEW_REPO} +npm init -y +npm pkg set name="agent-review" version="0.1.0" description="Multi-agent code reviewer — Claude Code plugin" license="MIT" +npm pkg set scripts.test="node --test engine/" +npm pkg delete scripts.start 2>/dev/null || true +npm install yaml minimatch ajv +``` + +- [ ] **Step 2: Write `.gitignore` (do NOT ignore node_modules)** + +Create `${NEW_REPO}/.gitignore`: +``` +*.log +.DS_Store +/tmp/ +``` + +- [ ] **Step 3: Write the plugin manifest** + +Create `${NEW_REPO}/.claude-plugin/plugin.json`: +```json +{ + "name": "agent-review", + "description": "Multi-agent PR code review with declarative config, cross-file impact analysis, and an approval-gated learning loop.", + "version": "0.1.0", + "author": { "name": "CruGlobal" } +} +``` +(Commands are auto-discovered from `commands/` and the bin from `bin/` — no need to list them.) + +- [ ] **Step 4: Write a minimal README** + +Create `${NEW_REPO}/README.md`: +```markdown +# agent-review + +Multi-agent code reviewer, distributed as a Claude Code plugin. + +## Install +``` +/plugin marketplace add CruGlobal/agent-review +/plugin install agent-review@cru +``` + +## Use (in any repo) +``` +/agent-review:init # set up this repo (generates .claude/review/config.yml + rules/) +/agent-review:run # pre-flight + multi-agent review +review help # CLI: config / impact / learnings / approve / ... +``` +``` + +- [ ] **Step 5: Sanity test that vendored deps resolve, then commit** + +```bash +cd ${NEW_REPO} +node -e "require('yaml'); require('minimatch'); require('ajv'); console.log('deps OK')" +git -C ${NEW_REPO} add -A +git -C ${NEW_REPO} commit -m "chore: scaffold agent-review plugin repo (manifest, vendored deps)" +``` +Expected: prints `deps OK`; commit includes package.json, node_modules, manifest, README, .gitignore. + +--- + +### Task 2: Extract the generic engine + +**Files:** +- Create (copy from `${SRC}`): `${NEW_REPO}/cli.cjs`, `${NEW_REPO}/config.schema.json`, `${NEW_REPO}/engine/*.cjs` + +**Interfaces:** +- Produces: the full engine (loadConfig, scoreRisk, selectAgents, resolveRules, detectSpecial, plan, resolveImport, buildGraph, queryImpact, indexStore, impact, findingSignature, mineLearnings, applyLearnings, learningsStore, cli, cliCommands) plus the generic `config.schema.json`. + +- [ ] **Step 1: Copy the engine + schema + CLI (mirror MPDX `.claude/review/` → repo root)** + +```bash +mkdir -p ${NEW_REPO}/engine +# CLI + schema live at repo root (mirror of MPDX .claude/review/ root) +cp ${SRC}/cli.cjs ${NEW_REPO}/cli.cjs +cp ${SRC}/config.schema.json ${NEW_REPO}/config.schema.json +# all engine modules + tests +cp ${SRC}/engine/*.cjs ${NEW_REPO}/engine/ +# Drop MPDX-specific tests (they validate MPDX's real config.yml/rules, which become templates) +rm -f ${NEW_REPO}/engine/realConfig.test.cjs ${NEW_REPO}/engine/rulesCoverage.test.cjs +# run-tests.cjs (the PnP single-process runner) is unnecessary off PnP; remove it +rm -f ${NEW_REPO}/engine/run-tests.cjs +ls ${NEW_REPO}/engine/*.cjs | wc -l +``` + +- [ ] **Step 2: Run the ported suite** + +```bash +cd ${NEW_REPO} +node --test engine/ 2>&1 | tail -8 +``` +Expected: PASS — all copied generic suites green (schema, loadConfig, scoreRisk, selectAgents, +resolveRules, detectSpecial, plan, resolveImport, buildGraph, queryImpact, indexStore, impact, +findingSignature, mineLearnings, applyLearnings, learningsStore, cliCommands, cli). The MPDX-config +tests were dropped (replaced by a template-validation test in Task 5). If any test fails to resolve +`../config.schema.json` or `../cli.cjs`, confirm those two files are at the repo root (Step 1). + +- [ ] **Step 3: Commit** + +```bash +git -C ${NEW_REPO} add cli.cjs config.schema.json engine +git -C ${NEW_REPO} commit -m "feat: extract generic review engine (config/risk/index/learning/cli)" +``` + +--- + +### Task 3: `--root` plumbing for the target project + +**Files:** +- Create: `${NEW_REPO}/engine/projectRoot.cjs`, `${NEW_REPO}/engine/projectRoot.test.cjs` +- Modify: `${NEW_REPO}/cli.cjs` (derive ROOT from argv) + +**Interfaces:** +- Produces: `resolveRoot(argv) -> string` — returns the value after `--root` in argv, else `process.cwd()`. + +- [ ] **Step 1: Write the failing test** + +Create `${NEW_REPO}/engine/projectRoot.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveRoot } = require('./projectRoot.cjs'); + +test('resolveRoot returns --root value when present', () => { + assert.equal(resolveRoot(['config', 'show', '--root', '/some/repo']), '/some/repo'); +}); + +test('resolveRoot defaults to process.cwd() when absent', () => { + assert.equal(resolveRoot(['config', 'show']), process.cwd()); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd ${NEW_REPO} && node --test engine/projectRoot.test.cjs` → FAIL (module not found). + +- [ ] **Step 3: Write the helper** + +Create `${NEW_REPO}/engine/projectRoot.cjs`: +```js +'use strict'; +function resolveRoot(argv) { + const i = (argv || []).indexOf('--root'); + return i >= 0 && argv[i + 1] ? argv[i + 1] : process.cwd(); +} +module.exports = { resolveRoot }; +``` + +- [ ] **Step 4: Wire `cli.cjs` to derive ROOT from argv** + +In `${NEW_REPO}/cli.cjs`: add `const { resolveRoot } = require('./engine/projectRoot.cjs');` to the +requires. Then move the `ROOT` and dependent path constants (`RD`, `CONFIG`, `SCHEMA`, `INDEX`, +`FEEDBACK`, `LEARNINGS`) from module scope to the **top of `main(argv)`**, computing +`const ROOT = resolveRoot(argv);` first. Everything else in `main` stays identical. (When no +`--root` is passed, behavior is unchanged — defaults to cwd.) The `run` case already builds its own +paths from `ROOT`; keep them derived from the same `ROOT`. `--root ` must be accepted alongside +other flags (it is just another token in `rest`; the existing `flag()`/positional parsing ignores +unknown flags). + +- [ ] **Step 5: Run tests to verify green** + +```bash +cd ${NEW_REPO} && node --test engine/ 2>&1 | tail -6 +``` +Expected: PASS — `projectRoot.test.cjs` passes and `cli.test.cjs` (help/unknown) still passes. +Smoke: `node cli.cjs config validate --root ${NEW_REPO}` should error cleanly (no config.yml at +repo root yet) — that's expected; it proves `--root` is read. + +- [ ] **Step 6: Commit** + +```bash +git -C ${NEW_REPO} add engine/projectRoot.cjs engine/projectRoot.test.cjs cli.cjs +git -C ${NEW_REPO} commit -m "feat: --root flag so the engine targets any project dir" +``` + +--- + +### Task 4: `review` bin + `/agent-review:run` command + +**Files:** +- Create: `${NEW_REPO}/bin/review`, `${NEW_REPO}/commands/run.md` + +**Interfaces:** +- Consumes: `cli.cjs` `main` (Tasks 2-3). + +- [ ] **Step 1: Create the CLI bin** + +Create `${NEW_REPO}/bin/review`: +```js +#!/usr/bin/env node +'use strict'; +const path = require('node:path'); +const { main } = require(path.join(__dirname, '..', 'cli.cjs')); +process.exit(main(process.argv.slice(2))); +``` +Then: `chmod +x ${NEW_REPO}/bin/review`. + +- [ ] **Step 2: Smoke the bin** + +```bash +node ${NEW_REPO}/bin/review help # prints usage +node ${NEW_REPO}/bin/review nope; echo "exit=$?" # usage + exit=1 +``` +Expected: help prints the usage block; unknown command exits 1. + +- [ ] **Step 3: Write the `/agent-review:run` command** + +Create `${NEW_REPO}/commands/run.md`: +```markdown +--- +description: Pre-flight (risk + agents + impact) then launch the multi-agent code review on the current branch +--- + +You are running the agent-review reviewer on the current repository (`$CLAUDE_PROJECT_DIR`). + +1. Pre-flight (deterministic, cheap): run the engine to print the risk/agents/impact summary for the + current diff: + ```bash + node "$CLAUDE_PLUGIN_ROOT/bin/review" run --no-launch --root "$CLAUDE_PROJECT_DIR" ${ARGUMENTS:+$ARGUMENTS} + ``` + Show the user the pre-flight output (risk score/level/required reviewer, selected agents with + match reasons, blast radius). + +2. If `.claude/review/config.yml` does not exist in `$CLAUDE_PROJECT_DIR`, tell the user to run + `/agent-review:init` first and stop. + +3. Launch the full multi-agent review: read the selected agents and their rule docs from the + pre-flight plan, then perform the specialist reviews + consensus over the current diff, exactly as + the reviewer's review flow specifies. (The pre-flight already computed agent selection and rules + from `.claude/review/config.yml`.) + +Notes: the engine ships in the plugin at `$CLAUDE_PLUGIN_ROOT` and reads the user's repo via +`--root "$CLAUDE_PROJECT_DIR"`. Never write outside `$CLAUDE_PROJECT_DIR/.claude/review/`. +``` + +- [ ] **Step 4: Commit** + +```bash +git -C ${NEW_REPO} add bin/review commands/run.md +git -C ${NEW_REPO} commit -m "feat: review CLI bin + /agent-review:run command" +``` + +--- + +### Task 5: `/agent-review:init` + templates + template-validation test + +**Files:** +- Create: `${NEW_REPO}/commands/init.md`, `${NEW_REPO}/engine/templates/config.yml`, `${NEW_REPO}/engine/templates/rules/{security,architecture,data-integrity,testing,ux,standards}.md`, `${NEW_REPO}/engine/templates.test.cjs` + +**Interfaces:** +- Consumes: `loadConfig`/`validateConfig` (engine), `config.schema.json`. + +- [ ] **Step 1: Write the failing test (the template config must validate + reference existing rule docs)** + +Create `${NEW_REPO}/engine/templates.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const tdir = path.join(__dirname, 'templates'); +const cfg = loadConfig({ configPath: path.join(tdir, 'config.yml'), schemaPath: path.join(__dirname, '..', 'config.schema.json') }); + +test('template config.yml validates against the schema', () => { + assert.equal(cfg.version, 1); + assert.ok(Array.isArray(cfg.agents) && cfg.agents.length >= 1); +}); + +test('every rule doc referenced by the template exists and is non-empty', () => { + const refs = new Set(); + for (const a of cfg.agents) for (const r of a.rules || []) refs.add(r); + for (const pr of cfg.path_rules || []) for (const r of pr.rules) refs.add(r); + for (const rel of refs) { + const p = path.join(tdir, rel); + assert.ok(existsSync(p), `missing template rule doc: ${rel}`); + assert.ok(statSync(p).size > 100, `template rule doc too small: ${rel}`); + } +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd ${NEW_REPO} && node --test engine/templates.test.cjs` → FAIL (templates missing). + +- [ ] **Step 3: Write the generic template config** + +Create `${NEW_REPO}/engine/templates/config.yml` (a generic JS/TS-oriented starting point `init` +customizes): +```yaml +version: 1 +profile: standard + +risk: + patterns: + - { glob: "**/*auth*/**", points: 3, tier: critical } + - { glob: "**/{config,settings}.*", points: 3, tier: critical } + - { glob: "**/.github/workflows/**", points: 3, tier: critical } + - { glob: "**/migrations/**", points: 2, tier: high } + - { glob: "src/**/*.{ts,tsx,js,jsx}", points: 1, tier: medium } + - { glob: "**/*.test.{ts,tsx,js,jsx}", points: 0, tier: low } + volume_multiplier: + - { upTo: 50, points: 0 } + - { upTo: 200, points: 1 } + - { upTo: 500, points: 2 } + - { upTo: 1000, points: 3 } + - { upTo: null, points: 4 } + scope_multiplier: { single_file: 1.0, single_feature: 1.0, multi_feature: 1.3, cross_cutting: 1.7, core_infra: 2.0 } + special: + - { when: new_dependency, points: 2 } + - { when: lockfile_only_change, points: 1 } + levels: + - { range: [0, 3], level: LOW, reviewer: any } + - { range: [4, 6], level: MEDIUM, reviewer: any } + - { range: [7, 9], level: HIGH, reviewer: experienced } + - { range: [10, null], level: CRITICAL, reviewer: senior } + +agents: + - { id: security, enabled: true, triggers: { paths: ["**/*auth*/**", "**/.github/workflows/**"], content: ["process.env.", "dangerouslySetInnerHTML"] }, rules: ["rules/security.md"] } + - { id: architecture, enabled: true, always: true, rules: ["rules/architecture.md"] } + - { id: data-integrity, enabled: true, triggers: { content: ["mutation", "fetch(", "axios"] }, rules: ["rules/data-integrity.md"] } + - { id: testing, enabled: true, always: true, rules: ["rules/testing.md"] } + - { id: ux, enabled: true, triggers: { paths: ["src/**/*.{tsx,jsx}"] }, rules: ["rules/ux.md"] } + - { id: standards, enabled: true, always: true, rules: ["rules/standards.md"] } + +path_rules: [] + +excluded_paths: + - "**/*.generated.*" + - "**/dist/**" + - "**/build/**" + - "**/*.snap" + +index: { enabled: true, path: ".claude/review/index" } +learning: { enabled: true, path: ".claude/review/learnings", approval_required: true, min_support: 3, scope: local } +enforcement: { mode: warn } +``` + +- [ ] **Step 4: Write the generic starter rule docs** + +Create each of `${NEW_REPO}/engine/templates/rules/{security,architecture,data-integrity,testing,ux,standards}.md` +with a short generic H1 + a few bullet checks for that dimension (each >100 bytes). Example for +`security.md`: +```markdown +# Security Review Rules + +- Validate and sanitize all external input; never trust client-supplied values. +- No secrets in code, logs, or error messages; server-only env vars must not ship to the client. +- AuthZ on every privileged action, not just at the entry point; verify redirect/callback URLs. +- Avoid injection: parameterized queries, no string-built SQL/commands, escape rendered HTML. +``` +Write equivalent concise generic docs for `architecture.md` (boundaries, layering, dependency +direction, error handling), `data-integrity.md` (consistent reads/writes, pagination, cache +invalidation, null handling), `testing.md` (coverage of new code, edge/error cases, no flaky time), +`ux.md` (loading/error states, accessibility, i18n, responsive), and `standards.md` (naming, +exports, no debug output, no dead code, types). + +- [ ] **Step 5: Run to verify the template test passes** + +Run: `cd ${NEW_REPO} && node --test engine/ 2>&1 | tail -6` → PASS (templates validate; rule docs exist). + +- [ ] **Step 6: Write the `/agent-review:init` command** + +Create `${NEW_REPO}/commands/init.md`: +```markdown +--- +description: Set up the agent-review config + rules for the current repository (AI-tailored, dev-editable) +--- + +Initialize agent-review for the current repository (`$CLAUDE_PROJECT_DIR`). + +1. SAFETY: if `$CLAUDE_PROJECT_DIR/.claude/review/config.yml` already exists, do NOT overwrite it. + Instead, summarize what you would add/change and ask the user to merge manually. Stop. + +2. DETECT the stack: read `package.json` (manager/scripts/framework), the top-level directory + structure, the test setup, and any existing `CLAUDE.md`, `.cursorrules`, + `.github/copilot-instructions.md`, `AGENTS.md`. + +3. GENERATE a tailored config: start from the template at + `$CLAUDE_PLUGIN_ROOT/engine/templates/config.yml` and adapt it to THIS repo — set realistic + `risk.patterns` (which paths are critical/high/medium for this codebase), `agents[].triggers`, + `excluded_paths`, and `index.enabled` (true only for JS/TS repos; false otherwise). Copy the + template `rules/*.md` and fold in any rules discovered from the repo's existing + CLAUDE.md/.cursorrules files. Write everything under `$CLAUDE_PROJECT_DIR/.claude/review/` + (config.yml + rules/). Create empty `learnings/feedback.jsonl` and `learnings/learnings.yml` + (`version: 1` / `learnings: []`). Add `.claude/review/index/`, + `.claude/review/learnings/pending/`, and `.claude/review/learnings/findings.json` to the repo's + `.gitignore`. + +4. VALIDATE + INDEX: + ```bash + node "$CLAUDE_PLUGIN_ROOT/bin/review" config validate --root "$CLAUDE_PROJECT_DIR" + node "$CLAUDE_PLUGIN_ROOT/bin/review" index --root "$CLAUDE_PROJECT_DIR" # only if index.enabled + ``` + +5. REPORT what was created and the inferred critical paths, and tell the user how to OVERRIDE: edit + `.claude/review/config.yml` / `rules/*.md` directly, or use the CLI + (`review config show`, `review learnings`, `review approve `). Never write outside + `$CLAUDE_PROJECT_DIR/.claude/review/` (except the `.gitignore` additions). +``` + +- [ ] **Step 7: Commit** + +```bash +git -C ${NEW_REPO} add commands/init.md engine/templates engine/templates.test.cjs +git -C ${NEW_REPO} commit -m "feat: /agent-review:init + generic config/rules templates" +``` + +--- + +### Task 6: Marketplace + final verification + +**Files:** +- Create: `${NEW_REPO}/.claude-plugin/marketplace.json` + +- [ ] **Step 1: Write the marketplace manifest** + +Create `${NEW_REPO}/.claude-plugin/marketplace.json`: +```json +{ + "name": "cru", + "owner": { "name": "CruGlobal" }, + "plugins": [ + { + "name": "agent-review", + "source": "./", + "description": "Multi-agent PR code review with declarative config, cross-file impact analysis, and an approval-gated learning loop." + } + ] +} +``` + +- [ ] **Step 2: Validate manifests are well-formed JSON** + +```bash +node -e "JSON.parse(require('fs').readFileSync('${NEW_REPO}/.claude-plugin/plugin.json','utf8')); JSON.parse(require('fs').readFileSync('${NEW_REPO}/.claude-plugin/marketplace.json','utf8')); console.log('manifests OK')" +``` +Expected: `manifests OK`. + +- [ ] **Step 3: End-to-end `init` smoke against a throwaway TS fixture repo** + +```bash +FIX=$(mktemp -d) +mkdir -p "$FIX/src" +printf '{"name":"fixture","dependencies":{"react":"^18"}}' > "$FIX/package.json" +printf "export const add = (a:number,b:number) => a+b;\n" > "$FIX/src/util.ts" +( cd "$FIX" && git init -q ) +# Simulate what /agent-review:init does deterministically: copy template config + rules, validate, index +mkdir -p "$FIX/.claude/review/rules" "$FIX/.claude/review/learnings" +cp ${NEW_REPO}/engine/templates/config.yml "$FIX/.claude/review/config.yml" +cp ${NEW_REPO}/engine/templates/rules/*.md "$FIX/.claude/review/rules/" +printf 'version: 1\nlearnings: []\n' > "$FIX/.claude/review/learnings/learnings.yml" +: > "$FIX/.claude/review/learnings/feedback.jsonl" +node ${NEW_REPO}/bin/review config validate --root "$FIX" # -> "config OK" +node ${NEW_REPO}/bin/review index --root "$FIX" # -> "Indexed N files; ..." +node ${NEW_REPO}/bin/review impact --root "$FIX" --base HEAD 2>/dev/null || echo "(no commits to diff — expected on empty fixture)" +echo "fixture: $FIX" +rm -rf "$FIX" +``` +Expected: `config validate` → `config OK`; `index` → an "Indexed N files" line (the template config +validates and the engine runs against an arbitrary repo via `--root`). This proves the plugin +engine works on a foreign repo with only template config present. + +- [ ] **Step 4: Full suite + commit** + +```bash +cd ${NEW_REPO} && node --test engine/ 2>&1 | tail -6 # all green +git -C ${NEW_REPO} add .claude-plugin/marketplace.json +git -C ${NEW_REPO} commit -m "feat: marketplace manifest for plugin distribution" +git -C ${NEW_REPO} log --oneline +``` + +- [ ] **Step 5: Note manual install verification (cannot be scripted here)** + +Record for the user (the `/plugin` flow is interactive in Claude Code, not scriptable in this build): +> To verify install end-to-end: in Claude Code, `/plugin marketplace add ${NEW_REPO}` (local path) +> then `/plugin install agent-review@cru`, and confirm `/agent-review:init`, `/agent-review:run`, +> and `review help` are available. Publishing to a private `CruGlobal/agent-review` GitHub repo is a +> follow-up that needs org permission. + +--- + +## Self-Review + +**1. Spec coverage:** +- Plugin layout + manifest + vendored deps (plain node) → Tasks 1, 2 ✓ +- Engine extracted, generic, `node --test` → Task 2 ✓ +- `--root`/project targeting → Task 3 ✓ +- `review` bin + `/agent-review:run` → Task 4 ✓ +- `/agent-review:init` (detect → tailor → write → validate → index → override guidance) + templates → Task 5 ✓ +- Marketplace + install UX → Task 6 ✓ +- Per-repo footprint = config + rules + learnings (init writes only there) → Task 5 init command ✓ +- No-overwrite safety → Task 5 init Step 1 ✓ +- Language note (index only for JS/TS) → Task 5 init + template ✓ +- Acceptance criteria 1-7 → Tasks 1-6 ✓ + +**2. Placeholder scan:** No TBD/TODO. Engine extraction uses precise copy commands (not re-printed code — the source is the canonical worktree). Task 5 Step 4 describes the generic rule docs with one full example + explicit content per file (each gated by the template test requiring >100 bytes) — concrete, not a placeholder. The `init`/`run` commands are model-invoked markdown (prompts), which is their actual content. + +**3. Type consistency:** `resolveRoot(argv)` (Task 3) is consumed by `cli.cjs` `main`. The repo-root mirror (`cli.cjs` + `config.schema.json` at root, modules in `engine/`) keeps every copied test's `require('../config.schema.json')`/`require('../cli.cjs')` valid (Task 2). `bin/review` calls `main(process.argv.slice(2))` matching `cli.cjs`'s export. Template `config.yml` validates against the same `config.schema.json` the engine ships (Task 5 test). Commands invoke `bin/review … --root "$CLAUDE_PROJECT_DIR"`, matching Task 3's flag. + +--- + +## Notes for the executor + +- Plain Node + npm + committed `node_modules` (vendored) — NOT PnP. Tests: `node --test engine/`. +- Engine is copied from `${SRC}` unchanged (repo root mirrors MPDX `.claude/review/`); only the 2 MPDX-config tests + the PnP runner are dropped. +- `init`/`run` are model-invoked commands; the deterministic parts are the `review` bin calls with `--root "$CLAUDE_PROJECT_DIR"`. +- Live `/plugin` install verification is a manual follow-up (interactive); building + JSON-validating the manifests + the init smoke is the automated bar. From a5b6bc8e768fd25f4687a7f2413ab53f5ea39e1c Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 25 Jun 2026 14:07:36 -0400 Subject: [PATCH 37/39] fix(review): address dogfood-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content triggers skip prose (.md), the reviewer's own config/rule definition files, and package-manager artifacts (.yarn/.pnp/lockfiles) — fixes agent over-selection - scope .claude/** critical pattern to harness-integrity files; engine source is ordinary code (risk de-inflated 275->82 on this PR) - plan/impact parseArgs no longer swallow the token after a boolean flag; flag() rejects flag-as-value; NaN guards on --min-support/--max-depth/--max-nodes - review run: scope default single_feature (+ --scope), index.path from config, linesChangedFromStat reused, mode validated, --base ref validated, friendly git errors - config get subcommand; loadFeedback skips malformed JSONL - config.schema.json constrains rule paths; + regression tests (65 pass) --- .claude/review/cli.cjs | 93 +++++++++++++++--------- .claude/review/config.schema.json | 4 +- .claude/review/config.yml | 19 ++++- .claude/review/engine/fixes.test.cjs | 78 ++++++++++++++++++++ .claude/review/engine/impact.cjs | 45 +++++++----- .claude/review/engine/learningsStore.cjs | 7 +- .claude/review/engine/plan.cjs | 13 +++- .claude/review/engine/selectAgents.cjs | 41 +++++++++-- 8 files changed, 234 insertions(+), 66 deletions(-) create mode 100644 .claude/review/engine/fixes.test.cjs diff --git a/.claude/review/cli.cjs b/.claude/review/cli.cjs index 5641e9937f..62f3f13440 100644 --- a/.claude/review/cli.cjs +++ b/.claude/review/cli.cjs @@ -1,9 +1,10 @@ 'use strict'; const { join } = require('node:path'); const { execFileSync } = require('node:child_process'); -const { readFileSync } = require('node:fs'); +const { readFileSync, writeFileSync } = require('node:fs'); +const os = require('node:os'); const { loadConfig } = require('./engine/loadConfig.cjs'); -const { buildPlan } = require('./engine/plan.cjs'); +const { buildPlan, linesChangedFromStat } = require('./engine/plan.cjs'); const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./engine/indexStore.cjs'); const { queryImpact } = require('./engine/queryImpact.cjs'); const { mineLearnings } = require('./engine/mineLearnings.cjs'); @@ -17,34 +18,53 @@ const SCHEMA = join(RD, 'config.schema.json'); const INDEX = join(RD, 'index'); const FEEDBACK = join(RD, 'learnings/feedback.jsonl'); const LEARNINGS = join(RD, 'learnings/learnings.yml'); +const MODES = ['quick', 'standard', 'deep']; function out(s) { process.stdout.write(s + '\n'); } -function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; } + +// Returns the value after `name`, or undefined if absent or the next token is itself a flag. +function flag(argv, name) { + const i = argv.indexOf(name); + if (i < 0) return undefined; + const v = argv[i + 1]; + return v === undefined || v.startsWith('--') ? undefined : v; +} + +function validRef(ref) { + return /^[A-Za-z0-9._/~^-]+$/.test(ref) && !ref.startsWith('-'); +} function changedFiles(base) { let b = base; + if (b && !validRef(b)) throw new Error(`invalid --base ref: "${b}"`); if (!b) { try { b = execFileSync('git', ['-C', ROOT, 'merge-base', 'main', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { b = 'HEAD~1'; } } - const files = execFileSync('git', ['-C', ROOT, 'diff', '--name-only', `${b}...HEAD`], { encoding: 'utf8' }) - .split('\n').map((s) => s.trim()).filter(Boolean); - return { base: b, files }; + let raw; + try { + raw = execFileSync('git', ['-C', ROOT, 'diff', '--name-only', `${b}...HEAD`], { encoding: 'utf8' }); + } catch (e) { + throw new Error(`could not determine a diff base (tried "${b}"). Pass --base . [${e.message.split('\n')[0]}]`); + } + return { base: b, files: raw.split('\n').map((s) => s.trim()).filter(Boolean) }; } -function loadIndex() { - return loadOrBuildIndex({ repoRoot: ROOT, indexPath: INDEX, head: gitHead(ROOT), files: listRepoFiles(ROOT) }); +function loadIndex(cfg) { + const c = cfg || loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + const indexPath = c.index && c.index.path ? join(ROOT, c.index.path) : INDEX; + return loadOrBuildIndex({ repoRoot: ROOT, indexPath, head: gitHead(ROOT), files: listRepoFiles(ROOT) }); } const USAGE = `usage: yarn review - config show|validate show or validate the review config - index rebuild the import-graph cache - impact [--base ] cross-file blast radius for the current diff - feedback ingest marked outcomes - learn [--min-support N] mine feedback into proposed learnings - learnings [--status S] list learnings - approve | reject set a learning's status - run [--base ] [mode] pre-flight + launch the Claude Code review + config show|validate|get show / validate / read a config value + index rebuild the import-graph cache + impact [--base ] cross-file blast radius for the current diff + feedback ingest marked outcomes + learn [--min-support N] mine feedback into proposed learnings + learnings [--status S] list learnings + approve | reject set a learning's status + run [--base ] [--scope ] [mode] pre-flight + launch the Claude Code review help`; function main(argv) { @@ -53,7 +73,14 @@ function main(argv) { switch (cmd) { case 'config': { const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); - out(rest[0] === 'validate' ? 'config OK' : JSON.stringify(cfg, null, 2)); + if (rest[0] === 'validate') { out('config OK'); return 0; } + if (rest[0] === 'get') { + if (!rest[1]) { out('usage: yarn review config get '); return 1; } + const val = rest[1].split('.').reduce((o, k) => (o == null ? undefined : o[k]), cfg); + out(val !== null && typeof val === 'object' ? JSON.stringify(val) : String(val)); + return 0; + } + out(JSON.stringify(cfg, null, 2)); return 0; } case 'index': { @@ -74,7 +101,13 @@ function main(argv) { return 0; } case 'learn': { - const minSupport = flag(rest, '--min-support') ? Number(flag(rest, '--min-support')) : 3; + let minSupport = 3; + const ms = flag(rest, '--min-support'); + if (ms !== undefined) { + const n = Number(ms); + if (!Number.isInteger(n) || n < 1) { out('error: --min-support must be a positive integer'); return 1; } + minSupport = n; + } const proposals = mineLearnings(loadFeedback(FEEDBACK), { minSupport }); const merged = mergeProposals(loadLearnings(LEARNINGS), proposals); saveLearnings(LEARNINGS, merged); @@ -94,30 +127,22 @@ function main(argv) { return 0; } case 'run': { - const { writeFileSync } = require('node:fs'); - const os = require('node:os'); const base = flag(rest, '--base'); - const mode = rest.find((a) => !a.startsWith('--') && a !== base) || 'standard'; + const scope = flag(rest, '--scope') || 'single_feature'; + const mode = rest.find((a) => !a.startsWith('--') && a !== base && a !== scope) || 'standard'; + if (!MODES.includes(mode)) { out(`error: unknown mode "${mode}" (use ${MODES.join('/')})`); return 1; } const { base: b, files } = changedFiles(base); const diff = execFileSync('git', ['-C', ROOT, 'diff', `${b}...HEAD`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); const stat = execFileSync('git', ['-C', ROOT, 'diff', '--stat', `${b}...HEAD`], { encoding: 'utf8' }); - const ins = stat.match(/(\d+) insertions?\(\+\)/); - const del = stat.match(/(\d+) deletions?\(-\)/); - const linesChanged = (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); - const plan = buildPlan({ files, diffText: diff, linesChanged, scope: 'multi_feature' }, cfg); - let impact = null; - if (cfg.index && cfg.index.enabled) impact = queryImpact(files, loadIndex(), {}); + const plan = buildPlan({ files, diffText: diff, linesChanged: linesChangedFromStat(stat), scope }, cfg); + const impact = cfg.index && cfg.index.enabled ? queryImpact(files, loadIndex(cfg), {}) : null; out(preflightSummary(plan, impact)); writeFileSync(join(os.tmpdir(), 'review_plan.json'), JSON.stringify({ ...plan, impact }, null, 2)); - if (rest.includes('--no-launch')) { - out(`\nwould run: claude -p "/agent-review ${mode}"`); - return 0; - } + if (rest.includes('--no-launch')) { out(`\nwould run: claude -p "/agent-review ${mode}"`); return 0; } out(`\nlaunching: claude -p "/agent-review ${mode}" ...\n`); - try { - execFileSync('claude', ['-p', `/agent-review ${mode}`], { stdio: 'inherit' }); - } catch (e) { + try { execFileSync('claude', ['-p', `/agent-review ${mode}`], { stdio: 'inherit' }); } + catch (e) { out(`(could not launch claude automatically: ${e.message})`); out(`Run it manually in Claude Code: /agent-review ${mode}`); } diff --git a/.claude/review/config.schema.json b/.claude/review/config.schema.json index cb40ed0566..4b05c72a6c 100644 --- a/.claude/review/config.schema.json +++ b/.claude/review/config.schema.json @@ -86,7 +86,7 @@ "content": { "type": "array", "items": { "type": "string" } } } }, - "rules": { "type": "array", "items": { "type": "string" } } + "rules": { "type": "array", "items": { "type": "string", "pattern": "^rules/[A-Za-z0-9._-]+\\.md$" } } } } }, @@ -98,7 +98,7 @@ "required": ["paths", "rules"], "properties": { "paths": { "type": "array", "items": { "type": "string" } }, - "rules": { "type": "array", "items": { "type": "string" } } + "rules": { "type": "array", "items": { "type": "string", "pattern": "^rules/[A-Za-z0-9._-]+\\.md$" } } } } }, diff --git a/.claude/review/config.yml b/.claude/review/config.yml index 7eaf38d71d..8ec48ec985 100644 --- a/.claude/review/config.yml +++ b/.claude/review/config.yml @@ -16,7 +16,11 @@ risk: - { glob: "src/lib/apollo/{client,link,cache,ssrClient}.ts", points: 3, tier: critical } - { glob: "next.config.{js,ts}", points: 3, tier: critical } - { glob: ".github/workflows/**", points: 3, tier: critical } - - { glob: ".claude/**", points: 3, tier: critical } + # Review-harness integrity (commands/rules/settings) is critical; engine source is ordinary code. + - { glob: ".claude/commands/**", points: 3, tier: critical } + - { glob: ".claude/settings.json", points: 3, tier: critical } + - { glob: ".claude/rules/**", points: 2, tier: high } + - { glob: ".claude/review/engine/**", points: 1, tier: medium } - { glob: "pages/api/Schema/**/*.{ts,graphql}", points: 2, tier: high } - { glob: "src/components/Shared/**", points: 2, tier: high } - { glob: "src/components/**/*.graphql", points: 2, tier: high } @@ -69,7 +73,8 @@ agents: always: false # if true, runs regardless of triggers triggers: paths: ["pages/api/**", "src/lib/apollo/{link,client,ssrClient}.ts", - "next.config.{js,ts}", "pages/_app.page.tsx", ".github/workflows/**", ".claude/**"] + "next.config.{js,ts}", "pages/_app.page.tsx", ".github/workflows/**", + ".claude/commands/**", ".claude/rules/**", ".claude/settings.json"] content: ["process.env.", "dangerouslySetInnerHTML", "router.push("] rules: ["rules/security.md"] @@ -131,11 +136,19 @@ excluded_paths: - "**/*.snap" - ".github/ISSUE_TEMPLATE/**" - "docs/**" + - ".claude/review/rules/**" # prose rule docs — config/guidance, not reviewable code + - ".claude/docs/**" # research/reference docs + # Package-manager artifacts — generated/vendored, never reviewable (and full of foreign vocab) + - ".yarn/**" + - ".pnp.*" + - "yarn.lock" + - "package-lock.json" + - "pnpm-lock.yaml" # ── Index layer (Phase B): file-level import graph for impact analysis ──────── index: { enabled: true, path: ".claude/review/index" } -# ── Forward-looking sections (present but INERT until Layer 3 ships) ────────── +# ── Learning layer (Phase C): approval-gated feedback loop ──────────────────── learning: enabled: true path: ".claude/review/learnings" diff --git a/.claude/review/engine/fixes.test.cjs b/.claude/review/engine/fixes.test.cjs new file mode 100644 index 0000000000..a98a4196e5 --- /dev/null +++ b/.claude/review/engine/fixes.test.cjs @@ -0,0 +1,78 @@ +'use strict'; +// Regression tests for the dogfood-review fixes (PR #1858). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { writeFileSync, mkdtempSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const os = require('node:os'); +const { parseArgs } = require('./plan.cjs'); +const { posInt } = require('./impact.cjs'); +const { selectAgents } = require('./selectAgents.cjs'); +const { detectSpecial } = require('./detectSpecial.cjs'); +const { loadFeedback } = require('./learningsStore.cjs'); +const { loadConfig } = require('./loadConfig.cjs'); + +test('#6 plan.parseArgs: a boolean flag does not swallow the next token', () => { + const a = parseArgs(['--verbose', '--config', 'c.yml']); + assert.equal(a.verbose, true); + assert.equal(a.config, 'c.yml'); +}); + +test('#11 impact.posInt: rejects NaN / non-positive, accepts default + valid', () => { + assert.equal(posInt(undefined, 3), 3); + assert.equal(posInt('5', 3), 5); + assert.throws(() => posInt('abc', 3), /positive integer/); + assert.throws(() => posInt('0', 3), /positive integer/); +}); + +test('#2 selectAgents: content triggers ignore keywords inside markdown/excluded files', () => { + const config = { + excluded_paths: ['.claude/review/rules/**'], + agents: [{ id: 'financial', triggers: { content: ['amount'] } }], + }; + const docDiff = [ + 'diff --git a/.claude/review/rules/financial.md b/.claude/review/rules/financial.md', + '+ flag any amount that is float-summed', + 'diff --git a/docs/notes.md b/docs/notes.md', + '+ the amount here is prose', + ].join('\n'); + // "amount" appears only in a .md rule doc + a doc file → financial must NOT be selected + assert.deepEqual(selectAgents({ files: ['.claude/review/rules/financial.md', 'docs/notes.md'], diffText: docDiff }, config), []); + // "amount" in real code DOES select it + const codeDiff = 'diff --git a/src/x.ts b/src/x.ts\n+ const amount = 1;'; + assert.ok(selectAgents({ files: ['src/x.ts'], diffText: codeDiff }, config).some((a) => a.id === 'financial')); +}); + +test("#2 selectAgents: the reviewer's own config.yml trigger vocabulary does not self-select agents", () => { + const config = { + excluded_paths: [], + agents: [{ id: 'financial', triggers: { content: ['amount'] } }], + }; + // config.yml legitimately contains the word "amount" as a trigger definition — must NOT match. + const diff = 'diff --git a/.claude/review/config.yml b/.claude/review/config.yml\n+ content: ["amount", "currency"]'; + assert.deepEqual(selectAgents({ files: ['.claude/review/config.yml'], diffText: diff }, config), []); +}); + +test('#22 detectSpecial: negative/boundary cases do not over-flag', () => { + const config = { risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next'] }] } }; + assert.ok(!detectSpecial('- "lodash": "^4",', ['package.json'], config).includes('new_dependency')); + assert.ok(!detectSpecial('+ x', ['yarn.lock', 'package.json'], config).includes('lockfile_only_change')); + assert.ok(!detectSpecial('+ const x = 1;', ['next.config.ts'], config).includes('next_config_security_change')); +}); + +test('#19 learningsStore.loadFeedback: skips malformed JSONL lines', () => { + const dir = mkdtempSync(join(os.tmpdir(), 'fb-')); + const p = join(dir, 'feedback.jsonl'); + writeFileSync(p, '{"a":1}\nNOT JSON\n{"b":2}\n'); + assert.deepEqual(loadFeedback(p), [{ a: 1 }, { b: 2 }]); + rmSync(dir, { recursive: true, force: true }); +}); + +test('#12 loadConfig: throws a friendly error on an invalid config', () => { + const dir = mkdtempSync(join(os.tmpdir(), 'cfg-')); + const cfgPath = join(dir, 'config.yml'); + const schemaPath = join(__dirname, '..', 'config.schema.json'); + writeFileSync(cfgPath, 'version: 1\nprofile: nope\n'); // invalid enum + missing required keys + assert.throws(() => loadConfig({ configPath: cfgPath, schemaPath }), /Invalid review config/); + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/.claude/review/engine/impact.cjs b/.claude/review/engine/impact.cjs index d014c76148..5db0226ed1 100644 --- a/.claude/review/engine/impact.cjs +++ b/.claude/review/engine/impact.cjs @@ -7,30 +7,39 @@ const { queryImpact } = require('./queryImpact.cjs'); function parseArgs(argv) { const a = {}; for (let i = 0; i < argv.length; i++) { - if (argv[i].startsWith('--')) { - a[argv[i].slice(2)] = argv[i + 1]; + if (!argv[i].startsWith('--')) continue; + const key = argv[i].slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + a[key] = true; + } else { + a[key] = next; i++; } } return a; } +function posInt(value, def) { + if (value === undefined || value === true) return def; + const n = Number(value); + if (!Number.isInteger(n) || n < 1) throw new Error(`expected a positive integer, got "${value}"`); + return n; +} + if (require.main === module) { - const a = parseArgs(process.argv.slice(2)); - const repoRoot = a.root || process.cwd(); - const indexPath = a.index || join(repoRoot, '.claude/review/index'); - const graph = loadOrBuildIndex({ - repoRoot, - indexPath, - head: gitHead(repoRoot), - files: listRepoFiles(repoRoot), - }); - const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); - const opts = { - maxDepth: a['max-depth'] ? Number(a['max-depth']) : 3, - maxNodes: a['max-nodes'] ? Number(a['max-nodes']) : 200, - }; - process.stdout.write(JSON.stringify(queryImpact(changed, graph, opts), null, 2) + '\n'); + try { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = a.root || process.cwd(); + const indexPath = a.index || join(repoRoot, '.claude/review/index'); + const graph = loadOrBuildIndex({ repoRoot, indexPath, head: gitHead(repoRoot), files: listRepoFiles(repoRoot) }); + const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const opts = { maxDepth: posInt(a['max-depth'], 3), maxNodes: posInt(a['max-nodes'], 200) }; + process.stdout.write(JSON.stringify(queryImpact(changed, graph, opts), null, 2) + '\n'); + } catch (e) { + process.stderr.write(`error: ${e.message}\n`); + process.exit(1); + } } -module.exports = { parseArgs }; +module.exports = { parseArgs, posInt }; diff --git a/.claude/review/engine/learningsStore.cjs b/.claude/review/engine/learningsStore.cjs index e741ddbb85..1680729222 100644 --- a/.claude/review/engine/learningsStore.cjs +++ b/.claude/review/engine/learningsStore.cjs @@ -45,7 +45,12 @@ function saveLearnings(path, obj) { } function loadFeedback(path) { if (!existsSync(path)) return []; - return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); + const out = []; + for (const line of readFileSync(path, 'utf8').split('\n')) { + if (!line.trim()) continue; + try { out.push(JSON.parse(line)); } catch { /* skip malformed line */ } + } + return out; } function appendFeedback(path, entries) { mkdirSync(dirname(path), { recursive: true }); diff --git a/.claude/review/engine/plan.cjs b/.claude/review/engine/plan.cjs index 2db0ec7fb2..7d112acbfe 100644 --- a/.claude/review/engine/plan.cjs +++ b/.claude/review/engine/plan.cjs @@ -16,7 +16,18 @@ function buildPlan({ files, diffText, linesChanged, scope }, config) { function parseArgs(argv) { const args = {}; - for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, '')] = argv[i + 1]; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + if (!tok.startsWith('--')) continue; + const key = tok.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + } return args; } diff --git a/.claude/review/engine/selectAgents.cjs b/.claude/review/engine/selectAgents.cjs index 646fc57b9d..743267679d 100644 --- a/.claude/review/engine/selectAgents.cjs +++ b/.claude/review/engine/selectAgents.cjs @@ -3,7 +3,35 @@ const { minimatch } = require('minimatch'); const OPTS = { dot: true }; -function agentMatches(agent, files, diffText) { +function isExcluded(file, config) { + return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); +} + +// The reviewer's own definition files legitimately contain trigger vocabulary as DATA +// (config.yml lists the trigger keywords; rule docs describe them). Scanning them for content +// triggers self-matches. Drop them — plus markdown/docs and excluded paths — from content scanning. +const DEFN_RE = /(^|\/)\.claude\/review\/config(\.schema)?\.(ya?ml|json)$/; + +// Keep only diff hunks for reviewable CODE files so content triggers match real code, not prose +// or the reviewer's own config/rule definitions. +function codeDiff(diffText, config) { + if (!diffText) return ''; + const blocks = diffText.split(/(?=^diff --git )/m); + const kept = []; + for (const b of blocks) { + const m = b.match(/^diff --git a\/\S+ b\/(\S+)/m); + if (!m) { + kept.push(b); // preamble or a non-`diff --git` snippet — keep (back-compat for raw snippets) + continue; + } + const file = m[1]; + if (file.endsWith('.md') || DEFN_RE.test(file) || isExcluded(file, config)) continue; + kept.push(b); + } + return kept.join(''); +} + +function agentMatches(agent, files, contentText) { if (agent.always) return 'always'; const t = agent.triggers || {}; for (const f of files) { @@ -12,22 +40,21 @@ function agentMatches(agent, files, diffText) { } } for (const c of t.content || []) { - if (diffText.includes(c)) return `content:${c}`; + if (contentText.includes(c)) return `content:${c}`; } return null; } function selectAgents({ files, diffText }, config) { - const reviewed = files.filter( - (f) => !(config.excluded_paths || []).some((g) => minimatch(f, g, OPTS)), - ); + const reviewed = files.filter((f) => !isExcluded(f, config)); + const contentText = codeDiff(diffText, config); const out = []; for (const a of config.agents) { if (a.enabled === false) continue; - const matchedBy = agentMatches(a, reviewed, diffText); + const matchedBy = agentMatches(a, reviewed, contentText); if (matchedBy) out.push({ id: a.id, model: a.model || 'smart', matchedBy }); } return out; } -module.exports = { selectAgents, agentMatches }; +module.exports = { selectAgents, agentMatches, codeDiff }; From eeff15c79ccc02acdb6660df553e2c595085f433 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 25 Jun 2026 14:58:41 -0400 Subject: [PATCH 38/39] fix(review): close deferred dogfood items - #23 config-driven import graph: resolveImport/buildGraph/indexStore take aliases/ extensions/roots from config.index (defaults preserved); schema + tests - #4 single arg parser (engine/args.cjs) used by plan/impact/indexStore/learningsStore (no more 4 divergent parsers); review:* scripts repointed to the cli.cjs dispatcher; cli index --force - #5 command gates index/learning via 'review config get', not raw grep of config.yml - #1 auto-fix apply_all.sh now DRY-RUNS untrusted model-generated scripts; applies only with --yes - 68 tests pass --- .claude/commands/agent-review.md | 22 ++++++++-- .claude/review/cli.cjs | 18 ++++++-- .claude/review/config.schema.json | 8 +++- .claude/review/engine/args.cjs | 22 ++++++++++ .claude/review/engine/buildGraph.cjs | 4 +- .claude/review/engine/fixes.test.cjs | 23 +++++++++++ .claude/review/engine/impact.cjs | 21 ++-------- .claude/review/engine/indexStore.cjs | 52 ++++++++++++------------ .claude/review/engine/learningsStore.cjs | 24 +++++------ .claude/review/engine/plan.cjs | 18 +------- .claude/review/engine/resolveImport.cjs | 23 ++++++----- package.json | 6 +-- 12 files changed, 145 insertions(+), 96 deletions(-) create mode 100644 .claude/review/engine/args.cjs diff --git a/.claude/commands/agent-review.md b/.claude/commands/agent-review.md index 91aa7ef596..ba168cf1e2 100644 --- a/.claude/commands/agent-review.md +++ b/.claude/commands/agent-review.md @@ -289,7 +289,7 @@ enabled in config): ```bash REVIEW_DIR=".claude/review" -if grep -q "learning:" "$REVIEW_DIR/config.yml" 2>/dev/null; then +if [ "$(yarn node "$REVIEW_DIR/cli.cjs" config get learning.enabled 2>/dev/null)" = "true" ]; then yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --rules > /tmp/review_rules.json 2>/dev/null || echo "[]" > /tmp/review_rules.json fi ``` @@ -1191,7 +1191,7 @@ echo "🔍 Analyzing dependency impact (index engine)..." echo "" REVIEW_DIR=".claude/review" -if grep -q "enabled: true" "$REVIEW_DIR/config.yml" 2>/dev/null; then +if [ "$(yarn node "$REVIEW_DIR/cli.cjs" config get index.enabled 2>/dev/null)" = "true" ]; then yarn node "$REVIEW_DIR/engine/impact.cjs" \ --root "$(pwd)" \ --index "$REVIEW_DIR/index" \ @@ -1280,13 +1280,27 @@ if [ "$FIX_COUNT" -gt 0 ]; then echo "" | tee -a /tmp/fix_summary.txt cat /tmp/fix_summary.txt - # Create master apply script + # Create master apply script. + # SECURITY: these fix_*.sh scripts are MODEL-GENERATED from (attacker-influenceable) PR content + # and are UNTRUSTED. apply_all.sh therefore DRY-RUNS by default — it prints each fix for human + # review and applies nothing unless explicitly re-run with `--yes`. cat > /tmp/automated_fixes/apply_all.sh << 'EOF' #!/bin/bash +set -euo pipefail +# fix_*.sh are model-generated from PR content and UNTRUSTED — review each before applying. +if [ "${1:-}" != "--yes" ]; then + echo "DRY RUN — review each fix, then re-run with --yes to apply. Nothing applied yet." + for fix in /tmp/automated_fixes/fix_*.sh; do + [ -f "$fix" ] || continue + echo ""; echo "===== $(basename "$fix") ====="; cat "$fix" + done + echo ""; echo "To apply after review: bash /tmp/automated_fixes/apply_all.sh --yes" + exit 0 +fi echo "Applying all automated fixes..." for fix in /tmp/automated_fixes/fix_*.sh; do if [ -f "$fix" ]; then - echo "Applying: $(basename $fix)" + echo "Applying: $(basename "$fix")" bash "$fix" fi done diff --git a/.claude/review/cli.cjs b/.claude/review/cli.cjs index 62f3f13440..ce6184e9de 100644 --- a/.claude/review/cli.cjs +++ b/.claude/review/cli.cjs @@ -1,7 +1,7 @@ 'use strict'; const { join } = require('node:path'); const { execFileSync } = require('node:child_process'); -const { readFileSync, writeFileSync } = require('node:fs'); +const { readFileSync, writeFileSync, existsSync, rmSync } = require('node:fs'); const os = require('node:os'); const { loadConfig } = require('./engine/loadConfig.cjs'); const { buildPlan, linesChangedFromStat } = require('./engine/plan.cjs'); @@ -50,10 +50,20 @@ function changedFiles(base) { return { base: b, files: raw.split('\n').map((s) => s.trim()).filter(Boolean) }; } -function loadIndex(cfg) { +function indexOpts(cfg) { + const ix = (cfg && cfg.index) || {}; + return { aliases: ix.aliases, exts: ix.extensions, roots: ix.roots }; +} + +function loadIndex(cfg, { force } = {}) { const c = cfg || loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); const indexPath = c.index && c.index.path ? join(ROOT, c.index.path) : INDEX; - return loadOrBuildIndex({ repoRoot: ROOT, indexPath, head: gitHead(ROOT), files: listRepoFiles(ROOT) }); + if (force) { + const gf = join(indexPath, 'graph.json'); + if (existsSync(gf)) rmSync(gf); + } + const opts = indexOpts(c); + return loadOrBuildIndex({ repoRoot: ROOT, indexPath, head: gitHead(ROOT), files: listRepoFiles(ROOT, opts), opts }); } const USAGE = `usage: yarn review @@ -84,7 +94,7 @@ function main(argv) { return 0; } case 'index': { - const g = loadIndex(); + const g = loadIndex(undefined, { force: rest.includes('--force') }); out(`Indexed ${g.fileCount} files; ${Object.keys(g.importedBy).length} have dependents.`); return 0; } diff --git a/.claude/review/config.schema.json b/.claude/review/config.schema.json index 4b05c72a6c..9c950e0cb6 100644 --- a/.claude/review/config.schema.json +++ b/.claude/review/config.schema.json @@ -106,7 +106,13 @@ "index": { "type": "object", "additionalProperties": false, - "properties": { "enabled": { "type": "boolean" }, "path": { "type": "string" } } + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" }, + "roots": { "type": "array", "items": { "type": "string" } }, + "aliases": { "type": "array", "items": { "type": "string" } }, + "extensions": { "type": "array", "items": { "type": "string" } } + } }, "learning": { "type": "object", diff --git a/.claude/review/engine/args.cjs b/.claude/review/engine/args.cjs new file mode 100644 index 0000000000..9c65139358 --- /dev/null +++ b/.claude/review/engine/args.cjs @@ -0,0 +1,22 @@ +'use strict'; +// Single shared CLI argument parser used by every engine entry point (no per-module drift). +// A `--flag` with no following value, or followed by another `--flag`, is boolean `true`; +// otherwise it consumes the next token as its value. +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + if (!tok.startsWith('--')) continue; + const key = tok.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + } + return args; +} + +module.exports = { parseArgs }; diff --git a/.claude/review/engine/buildGraph.cjs b/.claude/review/engine/buildGraph.cjs index 6c9fdcd6df..dc04ab9ac1 100644 --- a/.claude/review/engine/buildGraph.cjs +++ b/.claude/review/engine/buildGraph.cjs @@ -19,7 +19,7 @@ function extractSpecifiers(text) { return [...specs]; } -function buildGraph(files, readFile, fileSet) { +function buildGraph(files, readFile, fileSet, opts = {}) { const imports = {}; const importedBy = {}; for (const file of files) { @@ -31,7 +31,7 @@ function buildGraph(files, readFile, fileSet) { } const targets = new Set(); for (const spec of extractSpecifiers(text)) { - const resolved = resolveImport(file, spec, fileSet); + const resolved = resolveImport(file, spec, fileSet, opts); if (resolved && resolved !== file) targets.add(resolved); } imports[file] = [...targets]; diff --git a/.claude/review/engine/fixes.test.cjs b/.claude/review/engine/fixes.test.cjs index a98a4196e5..ac249de4b9 100644 --- a/.claude/review/engine/fixes.test.cjs +++ b/.claude/review/engine/fixes.test.cjs @@ -76,3 +76,26 @@ test('#12 loadConfig: throws a friendly error on an invalid config', () => { assert.throws(() => loadConfig({ configPath: cfgPath, schemaPath }), /Invalid review config/); rmSync(dir, { recursive: true, force: true }); }); + +test('#23 resolveImport: honors config-driven aliases + extensions', () => { + const { resolveImport } = require('./resolveImport.cjs'); + const fileSet = new Set(['app/foo.mjs', 'lib/bar.ts']); + assert.equal(resolveImport('x.ts', 'app/foo', fileSet, { aliases: ['app/', 'lib/'], exts: ['.mjs', '.ts'] }), 'app/foo.mjs'); + assert.equal(resolveImport('x.ts', 'src/foo', fileSet, { aliases: ['app/'], exts: ['.ts'] }), null); +}); + +test('#23 indexRegex: built from config roots + extensions', () => { + const { indexRegex } = require('./indexStore.cjs'); + const re = indexRegex(['app'], ['.mjs']); + assert.ok(re.test('app/x.mjs')); + assert.ok(!re.test('src/x.ts')); // src not a configured root + assert.ok(!re.test('app/x.ts')); // .ts not a configured extension +}); + +test('#4 shared args.parseArgs: one parser, boolean-flag safe', () => { + const { parseArgs } = require('./args.cjs'); + const a = parseArgs(['--build', '--root', '/r', 'positional', '--max-depth', '2']); + assert.equal(a.build, true); + assert.equal(a.root, '/r'); + assert.equal(a['max-depth'], '2'); +}); diff --git a/.claude/review/engine/impact.cjs b/.claude/review/engine/impact.cjs index 5db0226ed1..fa7faa7aff 100644 --- a/.claude/review/engine/impact.cjs +++ b/.claude/review/engine/impact.cjs @@ -1,25 +1,10 @@ 'use strict'; const { readFileSync } = require('node:fs'); const { join } = require('node:path'); +const { parseArgs } = require('./args.cjs'); const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./indexStore.cjs'); const { queryImpact } = require('./queryImpact.cjs'); -function parseArgs(argv) { - const a = {}; - for (let i = 0; i < argv.length; i++) { - if (!argv[i].startsWith('--')) continue; - const key = argv[i].slice(2); - const next = argv[i + 1]; - if (next === undefined || next.startsWith('--')) { - a[key] = true; - } else { - a[key] = next; - i++; - } - } - return a; -} - function posInt(value, def) { if (value === undefined || value === true) return def; const n = Number(value); @@ -30,8 +15,8 @@ function posInt(value, def) { if (require.main === module) { try { const a = parseArgs(process.argv.slice(2)); - const repoRoot = a.root || process.cwd(); - const indexPath = a.index || join(repoRoot, '.claude/review/index'); + const repoRoot = typeof a.root === 'string' ? a.root : process.cwd(); + const indexPath = typeof a.index === 'string' ? a.index : join(repoRoot, '.claude/review/index'); const graph = loadOrBuildIndex({ repoRoot, indexPath, head: gitHead(repoRoot), files: listRepoFiles(repoRoot) }); const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); const opts = { maxDepth: posInt(a['max-depth'], 3), maxNodes: posInt(a['max-nodes'], 200) }; diff --git a/.claude/review/engine/indexStore.cjs b/.claude/review/engine/indexStore.cjs index a238687c24..3c6843a0e5 100644 --- a/.claude/review/engine/indexStore.cjs +++ b/.claude/review/engine/indexStore.cjs @@ -3,22 +3,33 @@ const { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } = require(' const { join } = require('node:path'); const { execFileSync } = require('node:child_process'); const { buildGraph } = require('./buildGraph.cjs'); +const { DEFAULT_EXTS } = require('./resolveImport.cjs'); +const { parseArgs } = require('./args.cjs'); -const INDEX_RE = /^(src|pages|__tests__)\/.*\.(ts|tsx|js|jsx)$/; +// Which files to index, by repo-root directory. Override per-repo via config `index.roots`. +const DEFAULT_ROOTS = ['src', 'pages', '__tests__']; + +function indexRegex(roots, exts) { + const r = (roots && roots.length ? roots : DEFAULT_ROOTS) + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + const e = (exts && exts.length ? exts : DEFAULT_EXTS) + .map((x) => x.replace(/^\./, '').replace(/\./g, '\\.')) + .join('|'); + return new RegExp(`^(${r})\\/.*\\.(${e})$`); +} function gitHead(repoRoot) { return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } -function listRepoFiles(repoRoot) { - const out = execFileSync('git', ['-C', repoRoot, 'ls-files'], { - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - }); - return out.split('\n').map((s) => s.trim()).filter((f) => INDEX_RE.test(f)); +function listRepoFiles(repoRoot, opts = {}) { + const re = indexRegex(opts.roots, opts.exts); + const out = execFileSync('git', ['-C', repoRoot, 'ls-files'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + return out.split('\n').map((s) => s.trim()).filter((f) => re.test(f)); } -function loadOrBuildIndex({ repoRoot, indexPath, head, files }) { +function loadOrBuildIndex({ repoRoot, indexPath, head, files, opts = {} }) { const graphFile = join(indexPath, 'graph.json'); if (existsSync(graphFile)) { try { @@ -29,11 +40,7 @@ function loadOrBuildIndex({ repoRoot, indexPath, head, files }) { } } const fileSet = new Set(files); - const { imports, importedBy } = buildGraph( - files, - (f) => readFileSync(join(repoRoot, f), 'utf8'), - fileSet, - ); + const { imports, importedBy } = buildGraph(files, (f) => readFileSync(join(repoRoot, f), 'utf8'), fileSet, opts); const graph = { version: 1, head, fileCount: files.length, imports, importedBy }; mkdirSync(indexPath, { recursive: true }); writeFileSync(graphFile, JSON.stringify(graph)); @@ -41,20 +48,15 @@ function loadOrBuildIndex({ repoRoot, indexPath, head, files }) { } if (require.main === module) { - const repoRoot = process.cwd(); - const indexPath = join(repoRoot, '.claude/review/index'); - if (process.argv.includes('--build')) { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = typeof a.root === 'string' ? a.root : process.cwd(); + const indexPath = typeof a.index === 'string' ? a.index : join(repoRoot, '.claude/review/index'); + if (a.build || a.force) { const gf = join(indexPath, 'graph.json'); if (existsSync(gf)) rmSync(gf); } - const graph = loadOrBuildIndex({ - repoRoot, - indexPath, - head: gitHead(repoRoot), - files: listRepoFiles(repoRoot), - }); - const withDeps = Object.keys(graph.importedBy).length; - process.stdout.write(`Indexed ${graph.fileCount} files; ${withDeps} have dependents. head=${graph.head}\n`); + const graph = loadOrBuildIndex({ repoRoot, indexPath, head: gitHead(repoRoot), files: listRepoFiles(repoRoot) }); + process.stdout.write(`Indexed ${graph.fileCount} files; ${Object.keys(graph.importedBy).length} have dependents. head=${graph.head}\n`); } -module.exports = { loadOrBuildIndex, gitHead, listRepoFiles }; +module.exports = { loadOrBuildIndex, gitHead, listRepoFiles, indexRegex, DEFAULT_ROOTS }; diff --git a/.claude/review/engine/learningsStore.cjs b/.claude/review/engine/learningsStore.cjs index 1680729222..dfd46a2db3 100644 --- a/.claude/review/engine/learningsStore.cjs +++ b/.claude/review/engine/learningsStore.cjs @@ -60,37 +60,37 @@ function appendFeedback(path, entries) { module.exports = { mergeProposals, parsePending, loadApproved, loadLearnings, saveLearnings, loadFeedback, appendFeedback }; if (require.main === module) { - const argv = process.argv.slice(2); + const { parseArgs } = require('./args.cjs'); + const a = parseArgs(process.argv.slice(2)); const base = join(process.cwd(), '.claude/review/learnings'); const feedbackPath = join(base, 'feedback.jsonl'); const learningsPath = join(base, 'learnings.yml'); const findingsPath = join(base, 'findings.json'); - const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; - if (argv.includes('--emit')) { - const raw = JSON.parse(readFileSync(flag('--in'), 'utf8')); - const reviewId = flag('--review') || 'review'; + if (a.emit) { + const raw = JSON.parse(readFileSync(a.in, 'utf8')); + const reviewId = typeof a.review === 'string' ? a.review : 'review'; const findings = (raw.findings || raw).map((f, i) => ({ id: `f${i + 1}`, signature: signature(f), ...f })); mkdirSync(join(base, 'pending'), { recursive: true }); writeFileSync(findingsPath, JSON.stringify({ reviewId, findings }, null, 2)); const pending = { reviewId, findings: findings.map((f) => ({ id: f.id, signature: f.signature, agent: f.agent, category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: '' })) }; writeFileSync(join(base, 'pending', `${reviewId}.yml`), YAML.stringify(pending)); process.stdout.write(`Emitted ${findings.length} findings; pending/${reviewId}.yml\n`); - } else if (argv.includes('--ingest')) { - const pendingFile = argv[argv.indexOf('--ingest') + 1]; + } else if (a.ingest) { + const pendingFile = typeof a.ingest === 'string' ? a.ingest : a.in; const entries = parsePending(readFileSync(pendingFile, 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); appendFeedback(feedbackPath, entries); process.stdout.write(`Ingested ${entries.length} outcomes\n`); - } else if (argv.includes('--mine')) { - const minSupport = flag('--min-support') ? Number(flag('--min-support')) : 3; + } else if (a.mine) { + const minSupport = typeof a['min-support'] === 'string' ? Number(a['min-support']) : 3; const proposals = mineLearnings(loadFeedback(feedbackPath), { minSupport }); const merged = mergeProposals(loadLearnings(learningsPath), proposals); saveLearnings(learningsPath, merged); process.stdout.write(`Mined ${proposals.length} proposals; ${merged.learnings.length} total\n`); - } else if (argv.includes('--rules')) { + } else if (a.rules) { process.stdout.write(JSON.stringify(rulesFromLearnings(loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); - } else if (argv.includes('--filter')) { - const raw = JSON.parse(readFileSync(flag('--in') || findingsPath, 'utf8')); + } else if (a.filter) { + const raw = JSON.parse(readFileSync(typeof a.in === 'string' ? a.in : findingsPath, 'utf8')); process.stdout.write(JSON.stringify(filterFindings(raw.findings || raw, loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); } else { process.stdout.write('usage: learningsStore.cjs [--emit --in --review | --ingest | --mine [--min-support N] | --rules | --filter --in ]\n'); diff --git a/.claude/review/engine/plan.cjs b/.claude/review/engine/plan.cjs index 7d112acbfe..0c4aa5aac5 100644 --- a/.claude/review/engine/plan.cjs +++ b/.claude/review/engine/plan.cjs @@ -1,5 +1,6 @@ 'use strict'; const { readFileSync } = require('node:fs'); +const { parseArgs } = require('./args.cjs'); const { loadConfig } = require('./loadConfig.cjs'); const { scoreRisk } = require('./scoreRisk.cjs'); const { selectAgents } = require('./selectAgents.cjs'); @@ -14,23 +15,6 @@ function buildPlan({ files, diffText, linesChanged, scope }, config) { return { profile: config.profile, risk: { ...risk, special }, agents }; } -function parseArgs(argv) { - const args = {}; - for (let i = 0; i < argv.length; i++) { - const tok = argv[i]; - if (!tok.startsWith('--')) continue; - const key = tok.slice(2); - const next = argv[i + 1]; - if (next === undefined || next.startsWith('--')) { - args[key] = true; - } else { - args[key] = next; - i++; - } - } - return args; -} - function linesChangedFromStat(statText) { const ins = statText.match(/(\d+) insertions?\(\+\)/); const del = statText.match(/(\d+) deletions?\(-\)/); diff --git a/.claude/review/engine/resolveImport.cjs b/.claude/review/engine/resolveImport.cjs index c994df8edb..c2833091d3 100644 --- a/.claude/review/engine/resolveImport.cjs +++ b/.claude/review/engine/resolveImport.cjs @@ -1,29 +1,32 @@ 'use strict'; const path = require('node:path'); -const ALIASES = ['src/', 'pages/', '__tests__/']; -const EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']; +// Defaults match a typical TS/Next repo; override per-repo via config `index.aliases`/`index.extensions`. +const DEFAULT_ALIASES = ['src/', 'pages/', '__tests__/']; +const DEFAULT_EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']; -function candidates(base) { +function candidates(base, exts = DEFAULT_EXTS) { const out = [base]; - for (const e of EXTS) out.push(base + e); - for (const e of EXTS) out.push(base + '/index' + e); + for (const e of exts) out.push(base + e); + for (const e of exts) out.push(base + '/index' + e); return out; } -function resolveImport(fromFile, spec, fileSet) { +function resolveImport(fromFile, spec, fileSet, opts = {}) { + const aliases = opts.aliases && opts.aliases.length ? opts.aliases : DEFAULT_ALIASES; + const exts = opts.exts && opts.exts.length ? opts.exts : DEFAULT_EXTS; let base; - if (ALIASES.some((a) => spec === a.slice(0, -1) || spec.startsWith(a))) { - base = spec; // already repo-root-relative (src/..., pages/..., __tests__/...) + if (aliases.some((a) => spec === a.replace(/\/$/, '') || spec.startsWith(a))) { + base = spec; // already repo-root-relative (alias roots) } else if (spec.startsWith('.')) { base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)); } else { return null; // bare / external } - for (const c of candidates(base)) { + for (const c of candidates(base, exts)) { if (fileSet.has(c)) return c; } return null; } -module.exports = { resolveImport, candidates, EXTS }; +module.exports = { resolveImport, candidates, DEFAULT_ALIASES, DEFAULT_EXTS, EXTS: DEFAULT_EXTS }; diff --git a/package.json b/package.json index f9938cdaf2..68c8e0be6f 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "test": "jest --silent", "test:review": "yarn node .claude/review/engine/run-tests.cjs", "review": "yarn node .claude/review/cli.cjs", - "review:index": "yarn node .claude/review/engine/indexStore.cjs --build", - "review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest", - "review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine", + "review:index": "yarn node .claude/review/cli.cjs index --force", + "review:feedback": "yarn node .claude/review/cli.cjs feedback", + "review:learn": "yarn node .claude/review/cli.cjs learn", "test:log": "jest", "test:watch": "jest --watch", "localtest": "yarn test --runInBand --verbose", From bbdbc5e242453ffadad1cff99b89f000dad83bb2 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Fri, 26 Jun 2026 17:02:53 -0400 Subject: [PATCH 39/39] ci: run review engine tests (yarn test:review) only when review files change --- .github/workflows/review-engine.yml | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/review-engine.yml diff --git a/.github/workflows/review-engine.yml b/.github/workflows/review-engine.yml new file mode 100644 index 0000000000..0dd5bf8c2f --- /dev/null +++ b/.github/workflows/review-engine.yml @@ -0,0 +1,32 @@ +name: Review Engine + +# Runs the agent-review engine's unit suite (`yarn test:review`) — but ONLY when the review +# tooling itself changes. PRs that don't touch these paths skip this workflow entirely. +on: + push: + branches: [main] + paths: + - '.claude/review/**' + - '.claude/commands/agent-review.md' + - '.github/workflows/review-engine.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '.claude/review/**' + - '.claude/commands/agent-review.md' + - '.github/workflows/review-engine.yml' + workflow_dispatch: + +jobs: + test-review: + runs-on: ubuntu-latest + name: review engine tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + - name: Install dependencies + run: yarn cache clean && yarn install + - name: Run review engine tests + run: yarn test:review