packages feed

claude-gate-1.0.0: src/Claude/Gate/RuleReview.hs

-- | Phase A: rule review.
--
-- Claims the edit stack and reviews every diff in a single reviewer call against
-- the rules corpus (global and project CLAUDE.md plus the skills matching the
-- touched file types), with the full file contents for context. Violations block
-- the Stop with the findings; the model's fixes are themselves edits, recorded on
-- a fresh stack and re-reviewed next Stop, so review loops until clean. A reviewer
-- that fails returns the diffs to the stack and is surfaced loudly.
module Claude.Gate.RuleReview
  ( runRuleReview
  ) where

import Control.Monad (when)
import Data.Maybe (isJust)
import Data.Text (Text)
import Claude.Gate.Corpus (buildCorpus)
import Claude.Gate.DiffRender (renderDiffs)
import Claude.Gate.EditStack (readEdits, returnClaimedToStack, stackFilePaths)
import Claude.Gate.FileContext (renderFullFiles)
import Claude.Gate.GateConfig (envInt, envStr, phaseDisabled)
import Claude.Gate.HookProtocol (BlockReason (BlockReason), blockAndExit)
import Claude.Gate.NestedClaude (NestedResult (NestedBroken, NestedOutput), Reviewer (Reviewer), runNested, surfaceNestedFailure)
import Claude.Gate.ReviewPrompt (buildReviewPrompt, hasViolations, reviewBlockReason)
import Claude.Gate.TurnState
  ( TurnPaths (claimedStack, reviewApproved, reviewBroke, reviewStack)
  , claimReviewStack
  , fileNonEmpty
  , removeIfExists
  , writeFlag
  )
import System.Directory (findExecutable)

runRuleReview :: Text -> TurnPaths -> IO ()
runRuleReview session paths = do
  disabled <- phaseDisabled "CLAUDE_SKIP_RULE_CHECK"
  claudeAvailable <- isJust <$> findExecutable "claude"
  stackReady <- fileNonEmpty (reviewStack paths)
  when (not disabled && claudeAvailable && stackReady) $ do
    -- Claim the stack atomically so any edit recorded after this point lands on a
    -- fresh stack and is reviewed on the next Stop rather than lost.
    claimed <- claimReviewStack paths
    claimedReady <- if claimed then fileNonEmpty (claimedStack paths) else pure False
    when claimedReady (reviewClaimed session paths)

reviewClaimed :: Text -> TurnPaths -> IO ()
reviewClaimed session paths = do
  files <- stackFilePaths (claimedStack paths)
  edits <- readEdits (claimedStack paths)
  corpus <- buildCorpus files
  fullFiles <- renderFullFiles files
  timeoutSecs <- envInt "CLAUDE_RULE_REVIEW_TIMEOUT" 300
  -- Phase A reviewer model. Decision: Sonnet rather than Haiku. The review only
  -- fires on turns that edited files and batches that turn's diffs into one call,
  -- so cost is per-edit-turn, not per-Stop, and the extra reasoning catches
  -- semantic violations (silent failures, tests asserting static content) a
  -- smaller model misses. The main-loop model is still the final judge. Read via
  -- the env, defaulting to the current Sonnet, so bumping the model to the next
  -- generation needs no rebuild, matching CLAUDE_CRITIQUE_MODEL / CLAUDE_DUMBIFY_MODEL.
  model <- envStr "CLAUDE_REVIEWER_MODEL" "claude-sonnet-5"
  let reviewer = Reviewer model True timeoutSecs Nothing
      prompt = buildReviewPrompt corpus (renderDiffs edits) fullFiles
  result <- runNested reviewer prompt
  case result of
    NestedBroken exitCode emptyOut stderrText -> do
      -- Do not lose the diffs: return them to the stack so the next Stop
      -- re-reviews once the reviewer works again, then surface the failure.
      returnClaimedToStack paths
      surfaceNestedFailure session "rule review" model exitCode emptyOut stderrText (reviewBroke paths)
    NestedOutput output -> do
      removeIfExists (claimedStack paths)
      if hasViolations output
        then blockAndExit (BlockReason (reviewBlockReason model output))
        else writeFlag (reviewApproved paths)