warden

package
v0.20.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 4, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package warden implements the code review agent that reviews Smith's changes.

The Warden spawns a separate Claude session with a review-focused prompt, providing the git diff of changes made by the Smith. It returns a structured verdict: approve, reject, or request-changes with feedback.

Index

Constants

View Source
const (
	ArchiveReasonDuplicate = "duplicate"
	ArchiveReasonStale     = "stale"
)

Archive reason constants. ArchivedRule.ArchiveReason must be one of these.

View Source
const ArchiveFileName = ".forge/warden-rules.archive.yaml"

ArchiveFileName is the per-anvil file storing archived (superseded or stale) review rules.

View Source
const RulesFileName = ".forge/warden-rules.yaml"

RulesFileName is the per-anvil file storing learned review rules.

Variables

This section is empty.

Functions

func ArchivePath added in v0.16.0

func ArchivePath(anvilPath string) string

ArchivePath returns the full path to the archive file for an anvil.

func ArchiveStale added in v0.16.0

func ArchiveStale(rules []Rule, archiveAfterDays int, now time.Time) (active []Rule, archived []ArchivedRule)

ArchiveStale partitions rules into the active set (rules to keep) and a slice of ArchivedRule entries describing the stale rules that should be moved to the archive store with reason="stale". The ArchivedRule entries embed the original Rule unchanged and carry LastSeen and ArchivedAt set to now.

Active rules are returned in their original order. ArchiveStale does not touch storage; the caller is responsible for persisting the archived entries to the archive store and replacing the active rules slice.

When archiveAfterDays <= 0 (or no rules are stale), the input slice is returned as the active set and the archived slice is nil.

func Consolidate added in v0.16.0

func Consolidate(ctx context.Context, repoDir string, rf *RulesFile, threshold float64, runner ConsolidationRunner) (replaced []Rule, summary []MergeResult, errs []error)

Consolidate runs the full consolidation pass over rf.Rules: it groups rules by category, clusters within each category at the given threshold, asks runner to merge each cluster, and replaces the cluster members in rf with their merged rule. Replaced rules are returned so the caller can archive them. The summary slice lists one MergeResult per consolidated cluster, suitable for inclusion in a commit message.

Behaviour notes:

  • Categories with fewer than 2 rules are skipped (no clustering possible).
  • When threshold <= 0, the pass is a no-op (returns nil, nil, nil).
  • When runner returns an error for a specific cluster the cluster is skipped (logged via the returned error slice) so a single AI failure does not block other consolidations from completing.

runner may be nil; the default aiRunner is used in that case. Callers wanting a specific warden-stage provider chain should pass a runner built for that chain (e.g. via DefaultConsolidationRunner).

func DistillMergedRule added in v0.16.0

func DistillMergedRule(ctx context.Context, repoDir string, cluster []Rule, runner ConsolidationRunner) (pattern, check string, suggestedID string, err error)

DistillMergedRule asks the warden-stage AI provider to consolidate a cluster of near-duplicate rules into one canonical rule. The cluster is passed verbatim; the response must be a single JSON object describing the merged rule's pattern and check. The caller is responsible for stitching in metadata (sources, added timestamp, ID).

runner is the AI invocation function. When nil, aiRunner is used so this helper works with the standard provider fallback chain.

func FetchRecentPRNumbers

func FetchRecentPRNumbers(ctx context.Context, repoDir string, limit int) ([]int, error)

FetchRecentPRNumbers returns the most recent merged PR numbers for a repo.

func FormatConsolidationSummary added in v0.16.0

func FormatConsolidationSummary(summary []MergeResult) string

FormatConsolidationSummary renders a human-readable bullet list of the consolidations performed in a smelter run. Used in the commit/PR body. Returns "" when summary is empty so callers can omit the section entirely.

func GroupComments

func GroupComments(comments []PRComment) [][]PRComment

GroupComments groups semantically similar comments using keyword overlap. First, comments with identical normalized text are merged. Then, groups whose keyword sets exceed a Jaccard similarity threshold are merged together so that comments about the same pattern (e.g. "missing error check on Open()" vs "error from ReadFile not handled") land in one group.

func GroupRulesByCategory added in v0.16.0

func GroupRulesByCategory(rules []Rule) ([]string, map[string][]Rule)

GroupRulesByCategory partitions rules by Category, preserving first-seen order within each category and across categories. Rules with an empty Category are grouped under "".

func InsertRulesAsPending added in v0.10.0

func InsertRulesAsPending(rules []Rule, anvilName string, insert PendingInserter) error

InsertRulesAsPending serializes each rule to YAML and inserts it into the pending_warden_rules table via the provided inserter function.

func IsStale added in v0.16.0

func IsStale(rule Rule, archiveAfterDays int, now time.Time) bool

IsStale reports whether a rule should be archived due to inactivity.

A rule is considered stale when BOTH of the following hold:

  1. rule.Added is older than archiveAfterDays.
  2. No source entry has been recorded in the last archiveAfterDays/2 days.

Rule source entries currently carry no per-entry timestamp, so the rule's Added date is the only timestamp tracking when this rule was last touched. Until per-source timestamps are introduced, Added is treated as the most recent source activity for purposes of the half-window check — the second condition therefore reduces to "Added is older than archiveAfterDays/2".

Rules with no parseable Added date are conservatively treated as not stale: we cannot prove inactivity without a timestamp. archiveAfterDays <= 0 also disables staleness (callers may use it to mean "never archive").

func Jaccard added in v0.16.0

func Jaccard(a, b TokenSet) float64

Jaccard returns |A ∩ B| / |A ∪ B|. Returns 0 when the union is empty so callers can treat "no shared signal" as "not similar" rather than divide by zero.

func LearnFromCIFix added in v0.4.0

func LearnFromCIFix(ctx context.Context, anvilPath, repoDir string, failingLogs map[string]string, fixDiff string, prNumber int, cfg ...*LearnConfig) error

LearnFromCIFix extracts lint rule patterns from CI failure logs and the subsequent fix diff, then stores them as warden rules so future Smith sessions avoid the anti-patterns before they reach CI.

When cfg is non-nil and cfg.SmelterEnabled is true, new rules are inserted into the pending_warden_rules table instead of being written directly to the rules file. When cfg is nil or SmelterEnabled is false, the existing direct-save behavior is preserved.

It is intentionally non-fatal: callers should log any returned error but not let it block the successful CI fix result from being recorded.

func RulesPath

func RulesPath(anvilPath string) string

RulesPath returns the full path to the warden rules file for an anvil.

func SaveRules

func SaveRules(anvilPath string, rf *RulesFile) error

SaveRules writes the rules file to the anvil path, creating the .forge directory if it does not exist.

func SetActiveFilterConfig added in v0.16.0

func SetActiveFilterConfig(cfg ReviewFilterConfig)

SetActiveFilterConfig replaces the active review filter configuration. Safe to call from any goroutine, including the hot-reload callback.

Types

type Archive added in v0.16.0

type Archive struct {
	Rules []ArchivedRule `yaml:"rules"`
}

Archive is the top-level structure of warden-rules.archive.yaml.

func LoadArchive added in v0.16.0

func LoadArchive(path string) (*Archive, error)

LoadArchive reads the archive file at the given path. Returns an empty Archive (not an error) if the file does not exist.

func (*Archive) Add added in v0.16.0

func (a *Archive) Add(rule Rule, reason, supersededBy string)

Add appends a rule to the archive, recording the reason it was archived and (optionally) the ID of the rule that supersedes it. If an entry with the same ID already exists, it is replaced in place so the archive stays deduplicated by ID. Both ArchivedAt and LastSeen are set to time.Now() at archive time (Rule has no last-seen field yet; a future pass will propagate one when per-rule activity tracking is introduced).

func (*Archive) AddArchived added in v0.16.0

func (a *Archive) AddArchived(ar ArchivedRule)

AddArchived appends a pre-built ArchivedRule, deduplicating by ID. If an entry with the same ID already exists it is replaced in place so the archive stays deduplicated. Unlike Add, this preserves the supplied LastSeen/ArchivedAt/ArchiveReason values rather than rewriting them — used when the caller (e.g. Pass 2 staleness archiving) has already built the entry with the correct bookkeeping.

func (*Archive) Find added in v0.16.0

func (a *Archive) Find(id string) (*ArchivedRule, bool)

Find returns the archived rule with the given ID and true when present.

func (*Archive) Remove added in v0.16.0

func (a *Archive) Remove(id string) (*ArchivedRule, bool)

Remove deletes the archived rule with the given ID and returns it. The second return value reports whether a rule was found and removed.

func (*Archive) Save added in v0.16.0

func (a *Archive) Save(path string) error

Save writes the archive to the given file path, creating the parent directory if it does not exist.

type ArchivedRule added in v0.16.0

type ArchivedRule struct {
	Rule          `yaml:",inline"`
	SupersededBy  string    `yaml:"superseded_by,omitempty" json:"superseded_by,omitempty"`
	LastSeen      time.Time `yaml:"last_seen,omitempty"     json:"last_seen,omitempty"`
	ArchivedAt    time.Time `yaml:"archived_at"             json:"archived_at"`
	ArchiveReason string    `yaml:"archive_reason"          json:"archive_reason"`
}

ArchivedRule represents a Rule that has been retired from the active rules file. It embeds the original Rule and adds bookkeeping fields describing why and when the rule was archived.

func (ArchivedRule) MarshalYAML added in v0.16.0

func (ar ArchivedRule) MarshalYAML() (any, error)

MarshalYAML produces a single mapping node combining Rule's fields (via its custom marshaller) with the archive-specific fields. The default inline embedding does not work here because Rule.MarshalYAML returns a mapping node that would otherwise replace the entire ArchivedRule mapping, dropping the archive bookkeeping fields.

func (*ArchivedRule) UnmarshalYAML added in v0.16.0

func (ar *ArchivedRule) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML decodes a mapping node into both the embedded Rule and the archive-specific fields. Decoding the same node twice — once into Rule and once into the extras struct — sidesteps the inline+custom-marshaler interaction that would otherwise silently drop fields.

type Cluster added in v0.16.0

type Cluster struct {
	Rules         []Rule
	MaxSimilarity float64
}

Cluster represents a group of rules that the similarity pass considers near-duplicates. MaxSimilarity is the highest Jaccard score observed between any pair in the cluster (useful for surfacing diagnostics).

func ClusterByJaccard added in v0.16.0

func ClusterByJaccard(rules []Rule, threshold float64) []Cluster

ClusterByJaccard groups rules whose pairwise Jaccard similarity on RuleWordBag meets or exceeds threshold. Clustering uses union-find: every pair scoring >= threshold is unioned, so transitive matches end up in the same cluster even when not all pairs cross the threshold directly.

Only clusters containing more than one rule are returned; singletons (rules with no similar neighbours) are dropped. The order of returned clusters mirrors the order of the first rule in each cluster as it appeared in the input — stable for deterministic downstream processing.

type ConsolidationRunner added in v0.16.0

type ConsolidationRunner func(ctx context.Context, dir, prompt string) ([]byte, error)

ConsolidationRunner is the AI invocation hook for merging a cluster of rules into a canonical single rule. It receives the working directory and the prompt and returns the raw response bytes.

In production this is satisfied by warden.DefaultConsolidationRunner. Tests inject a stub that returns a deterministic JSON response.

func DefaultConsolidationRunner added in v0.16.0

func DefaultConsolidationRunner() ConsolidationRunner

DefaultConsolidationRunner returns a ConsolidationRunner that invokes the standard warden-stage AI provider chain (Claude → Gemini fallback). Use this when wiring the Smelter from the daemon.

type LearnConfig added in v0.10.0

type LearnConfig struct {
	SmelterEnabled bool
	AnvilName      string          // anvil name for pending rule insertion
	InsertPending  PendingInserter // typically state.DB.InsertPendingRule
}

LearnConfig provides optional smelter-aware routing for learned rules. When SmelterEnabled is true and InsertPending is non-nil, new rules are inserted into the pending_warden_rules table instead of being written directly to the rules file.

type MergeResult added in v0.16.0

type MergeResult struct {
	// Merged is the new canonical rule replacing the cluster.
	Merged Rule
	// ReplacedIDs lists the IDs of the rules that were superseded by Merged.
	ReplacedIDs []string
	// Category is the shared category across the cluster.
	Category string
	// MaxSimilarity is the highest pairwise Jaccard score observed in the
	// cluster (carried through from ClusterByJaccard).
	MaxSimilarity float64
}

MergeResult describes one cluster consolidation outcome.

type PRComment

type PRComment struct {
	Body     string `json:"body"`
	User     string `json:"user"`
	Path     string `json:"path"`
	PRNumber int    `json:"pr_number"`
}

PRComment represents a review comment fetched from GitHub.

func FetchCopilotComments

func FetchCopilotComments(ctx context.Context, repoDir string, prNumber int) ([]PRComment, error)

FetchCopilotComments retrieves review comments on a PR that were authored by copilot[bot], github-actions[bot], or copilot via the gh CLI.

type PendingInserter added in v0.10.0

type PendingInserter func(anvil, ruleYAML, sourcePR string) error

PendingInserter is a function that inserts a rule into the pending_warden_rules table. It matches the signature of state.DB.InsertPendingRule.

type ReviewFilterConfig added in v0.16.0

type ReviewFilterConfig struct {
	// MaxRules caps the number of rules emitted in the checklist. When zero or
	// negative, no cap is applied.
	MaxRules int
	// UseAllRules bypasses all three filters and applies only the MaxRules cap.
	UseAllRules bool
	// FilterPathGlob enables filtering by Rule.Paths against the changed files.
	FilterPathGlob bool
	// FilterCategory enables filtering by Rule.Category against the in-code
	// extension → category map (see categoriesForFile).
	FilterCategory bool
	// FilterPatternGrep enables filtering by ≥4-char words extracted from
	// Rule.Pattern that must appear as substrings in the diff text.
	FilterPatternGrep bool
}

ReviewFilterConfig controls how Rule lists are filtered for the review-time prompt. The Forge daemon reads these from settings.warden in forge.yaml and assigns ActiveFilterConfig at startup.

func DefaultReviewFilterConfig added in v0.16.0

func DefaultReviewFilterConfig() ReviewFilterConfig

DefaultReviewFilterConfig returns the default review-time filter configuration: all three filters enabled, MaxRules=30, UseAllRules=false.

func GetActiveFilterConfig added in v0.16.0

func GetActiveFilterConfig() ReviewFilterConfig

GetActiveFilterConfig returns the current active review filter configuration. Safe to call from any goroutine.

type ReviewIssue

type ReviewIssue struct {
	// File is the affected file path.
	File string `json:"file"`
	// Line is the approximate line number (0 if unknown).
	Line int `json:"line"`
	// Severity is "error", "warning", or "suggestion".
	Severity string `json:"severity"`
	// Message describes the issue.
	Message string `json:"message"`
}

ReviewIssue represents a specific issue found during review.

type ReviewResult

type ReviewResult struct {
	// Verdict is the review decision.
	Verdict Verdict
	// UsedProvider records which provider actually completed the review.
	UsedProvider *provider.Provider
	// Summary is a brief summary of the review.
	Summary string
	// Issues is a list of specific issues found.
	Issues []ReviewIssue
	// RawOutput is the full Claude output.
	RawOutput string
	// Duration is how long the review took.
	Duration time.Duration
	// CostUSD is the cost of the review session.
	CostUSD float64
	// NoDiff is true when the rejection was because Smith produced no diff.
	NoDiff bool
}

ReviewResult captures the Warden's review outcome.

func Review

func Review(ctx context.Context, worktreePath, beadID, beadTitle, beadDescription, anvilPath string, db *state.DB, priorFeedback string, providers ...provider.Provider) (*ReviewResult, error)

Review runs a Warden review of the changes in the given worktree. It gets the git diff, spawns a Claude review session, and parses the verdict.

beadTitle and beadDescription are used to check whether the diff actually implements what the bead requested (scope drift detection). db is used to log lifecycle events; db may be nil to skip logging. providers is the ordered list of AI providers to try. When empty, provider.Defaults() is used. Provider fallback applies on rate limit.

type Rule

type Rule struct {
	ID       string     `yaml:"id"       json:"id"`
	Category string     `yaml:"category" json:"category"`
	Pattern  string     `yaml:"pattern"  json:"pattern"`
	Check    string     `yaml:"check"    json:"check"`
	Source   SourceList `yaml:"source"   json:"source"`
	Added    string     `yaml:"added"    json:"added"`
	// Paths is an optional list of doublestar glob patterns. When set, the
	// path-glob filter (see FilterRules) keeps the rule only when at least one
	// changed file matches one of these patterns. When empty, the rule passes
	// through to the next filter — backward compatible with rules written
	// before this field existed.
	Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
}

Rule represents a single learned review pattern.

func DistillRule

func DistillRule(ctx context.Context, comments []PRComment, repoDir string) (*Rule, error)

DistillRule uses a Claude session to convert a set of similar review comments into a single warden rule. Returns the rule or an error.

func FilterRules added in v0.16.0

func FilterRules(rules []Rule, diff string, changedFiles []string, cfg ReviewFilterConfig) []Rule

FilterRules returns the subset of rules to include in a review-time checklist, applying the path-glob, category, and pattern-grep filters (in that order, each gated by cfg) and then capping the result to cfg.MaxRules. When cfg.UseAllRules is true the three filters are skipped and only the cap applies.

func MergeRule added in v0.16.0

func MergeRule(cluster []Rule, category, pattern, check, suggestedID string, existingIDs map[string]struct{}) Rule

MergeRule builds the canonical merged Rule from a cluster, the AI-derived pattern/check, and a freshly minted ID. existingIDs is consulted so the generated ID does not collide with active rules in the file; when a collision is found, a numeric suffix is appended.

func (Rule) MarshalYAML added in v0.8.0

func (r Rule) MarshalYAML() (any, error)

MarshalYAML implements yaml.Marshaler for Rule, applying appropriate YAML styles to string fields:

  • Scalars longer than 80 characters use a folded block scalar (>-) for readability and easier editing.
  • Shorter scalars that contain YAML-special sequences (": ", '"') or a comment-introducing '#' are double-quoted.

It uses a type alias to get automatic field marshaling (so future fields on Rule are included without updating this method), then post-processes the resulting yaml.Node tree.

type RulesFile

type RulesFile struct {
	Rules []Rule `yaml:"rules"`
}

RulesFile is the top-level structure of warden-rules.yaml.

func LoadRules

func LoadRules(anvilPath string) (*RulesFile, error)

LoadRules reads the warden rules file from the anvil path. Returns an empty RulesFile (not an error) if the file does not exist.

func (*RulesFile) AddRule

func (rf *RulesFile) AddRule(r Rule) bool

AddRule appends a rule to the file, skipping duplicates by ID. Returns true if the rule was added (not a duplicate).

func (*RulesFile) FormatChecklist

func (rf *RulesFile) FormatChecklist() string

FormatChecklist returns the rules formatted as a numbered checklist suitable for inclusion in a review prompt.

This formatter applies no filtering and is kept for the Smith self-review checklist (combined-mode prompt). The Warden review prompt uses FormatChecklistForDiff so it can filter rules against the actual diff.

func (*RulesFile) FormatChecklistForDiff added in v0.16.0

func (rf *RulesFile) FormatChecklistForDiff(diff string, changedFiles []string, cfg ReviewFilterConfig) string

FormatChecklistForDiff returns the learned rules as a numbered checklist after filtering them against the supplied diff and changedFiles per cfg. When the filtered set is empty the result is "" (so the caller can omit the "Learned Review Rules" section entirely).

func (*RulesFile) RemoveRule

func (rf *RulesFile) RemoveRule(id string) bool

RemoveRule removes a rule by ID. Returns true if a rule was removed.

type SourceList added in v0.10.0

type SourceList []string

SourceList is a slice of source references. It YAML-marshals as a plain scalar when there is exactly one element and as a flow sequence when there are multiple, making multi-PR attribution programmatically accessible. It unmarshals from either format for backward compatibility.

func MergeMetadata added in v0.16.0

func MergeMetadata(cluster []Rule) (sources SourceList, oldestAdded string)

MergeMetadata combines the deduplicated source list and oldest Added timestamp from a cluster of rules. Returned Added is in YYYY-MM-DD form; if no cluster member carries a parseable Added date, the returned string is empty (callers can default to "today").

func (SourceList) MarshalYAML added in v0.10.0

func (sl SourceList) MarshalYAML() (any, error)

MarshalYAML emits a plain scalar for a single source and a YAML flow sequence for multiple sources.

func (SourceList) String added in v0.10.0

func (sl SourceList) String() string

String returns the sources joined by ", " for display purposes.

func (*SourceList) UnmarshalYAML added in v0.10.0

func (sl *SourceList) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML reads either a scalar string or a YAML sequence into a SourceList.

type TokenSet added in v0.16.0

type TokenSet map[string]struct{}

TokenSet is the bag-of-words representation used by Jaccard similarity. It is a set keyed by lowercase token; values are unused (struct{}).

func RuleWordBag added in v0.16.0

func RuleWordBag(r Rule) TokenSet

RuleWordBag returns the union of tokens from a rule's Pattern and Check fields. This is the canonical bag-of-words used to score rule similarity.

func Tokenize added in v0.16.0

func Tokenize(s string) TokenSet

Tokenize splits s into a set of significant lowercase word tokens:

  • Splits on any non-alphanumeric rune.
  • Lowercases every token.
  • Drops tokens shorter than minTokenLen.
  • Drops stopwords (defined in learn.go).

Returns an empty (non-nil) set for empty or all-stopword input so callers can safely range over the result without nil checks.

type Verdict

type Verdict string

Verdict represents the Warden's review decision.

const (
	VerdictApprove        Verdict = "approve"
	VerdictReject         Verdict = "reject"
	VerdictRequestChanges Verdict = "request_changes"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL