angela

package
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Apr 13, 2026 License: AGPL-3.0 Imports: 28 Imported by: 0

Documentation

Overview

Package angela — draft_state.go

Differential draft — hash-based incremental analysis.

Running `lore angela draft --all` on a 60+ doc corpus produces the same ~130 findings week after week. The user drowns in repeat noise and misses what actually changed. This file adds a JSON state file that records each document's content hash and last-computed suggestions so that a second run can:

  1. Skip analysis entirely for docs whose hash matches the stored value
  2. Classify every finding as NEW, PERSISTING, or RESOLVED relative to the previous run
  3. Let the user hide the persisting noise with `--diff-only`

Draft stays strictly offline (invariant I1 from the Angela MVP v1 spec): hashing is SHA-256 of the raw file bytes, diffing is pure Go, the state file is local JSON written under the state directory.

Package angela — evidence_validator.go

Reject hallucinated findings via evidence validation.

The AI sometimes invents plausible-sounding contradictions that do not actually exist in the corpus. This validator runs after the AI response is parsed: for every ReviewFinding it walks the Evidence array and checks, file-by-file, that each quoted snippet literally appears in the corresponding document (after whitespace normalization). Findings that fail any check are pulled out of the kept pile and reported separately so the user can see WHY they were dropped.

The validator is pure Go and deterministic — no AI round-trip. It reads the corpus via the existing domain.CorpusReader so it works transparently in both lore-native and standalone modes.

Package angela — hallucination_check.go

Local, deterministic post-polish verification that detects factual claims the AI may have invented during a rewrite.

The check works in three phases:

  1. Sentence diff: split both original and polished into sentences, find sentences in the polished text that don't appear in the original (new text added by the AI).
  2. Claim extraction: scan each new sentence for metric patterns, version strings, proper nouns from a tech whitelist, and large numbers preceded by action verbs.
  3. Support check: for each extracted claim, verify that its core token (the number, version, or noun) appears somewhere in the original document or in the corpus summary. Claims that cannot be sourced are flagged as unsupported.

Pure Go, zero API calls. Default strictness is "warn".

Package angela — polish_backup.go

Backup writer and restore helpers (polish safety nets).

Before `lore angela polish` overwrites a document, WriteBackup copies the current on-disk version into a timestamped file under the state directory. This gives users a recoverable "undo" even when the document has not been committed to git yet — which is the exact scenario polish is designed for (polishing drafts before the first commit).

The timestamp format is ISO 8601 basic (YYYYMMDDTHHmmss) local time: it is lexicographically sortable, contains no filesystem-hostile characters, and is unambiguous when combined with the `.bak` suffix. The relative path from workDir is preserved inside the backup directory so two files with the same basename (e.g. `docs/guides/intro.md` and `docs/admin/intro.md`) never collide.

Retention pruning runs AFTER the new backup is written so a crash during pruning cannot leave the user without any backup.

Package angela — polish_incremental.go

Orchestrator that re-polishes only changed sections of a document, falling back to full polish when conditions aren't met.

The incremental path works as follows:

  1. Parse the document into sections via SplitSections.
  2. Hash each section and compare with the stored hashes from the polish state file.
  3. If no sections changed → skip AI entirely, return original doc.
  4. Build a prompt containing the FULL outline (all headings) plus the body of changed sections ONLY. This gives the AI enough context to maintain coherence while limiting the token cost.
  5. Parse the AI response, match polished sections back to their originals by heading, and reassemble with unchanged sections.
  6. Update the polish state with new section hashes.

Fallback triggers:

  • Fewer than 2 sections → fall back (section splitting unreliable)
  • First-run (no stored entry) → full polish then store hashes
  • AI returns unparseable structure → fall back to full polish
  • Any error in the incremental path → fall back + stderr warning

Package angela — polish_state.go

State file tracking per-document section hashes so re-polish only touches sections that changed.

The state schema is intentionally minimal: one entry per polished document, each containing a flat map of `## heading` → SHA-256 of the section body. Section identity is the heading text (normalized whitespace, original casing) so a renamed heading is treated as a new section — which is correct: if the heading changed, the AI should evaluate the content under its new name.

Opt-in by default: the state file is only written when `cfg.Angela.Polish.Incremental.Enabled` is true or the user passes `--incremental` on the CLI.

Package angela — review_state.go

JSON state file tracking the lifecycle of every review finding across runs (differential review).

Where the draft state file is a content-hash cache that short-circuits expensive analysis, the review state file is a finding-lifecycle tracker. Review calls cost an AI round-trip every time, so we don't try to skip the call — instead we annotate each returned finding as NEW / PERSISTING / REGRESSED / RESOLVED relative to the previous run, and let the user mark findings as resolved or ignored to keep the noise floor low.

REGRESSED is the highest-signal status: a finding that the user previously marked `resolved` (it was supposedly fixed) or `ignored` (it was a known false positive) has resurfaced. The validator surfaces these prominently so the user notices that something has come back.

Package angela — unified_diff.go

Unified diff helper for `--dry-run`.

The polish command's hunk-based TUI (diff.go) is designed for interactive review. The dry-run mode instead wants a pipeable, standard-looking unified diff ready to be consumed by `diff`, `bat`, or a human in a CI log. This file provides that separate rendering path using pmezard/go-difflib — the library is already present in go.sum as an indirect dependency, so story task 6.1's dependency check is satisfied without a new direct dep.

Index

Constants

View Source
const (
	DiffStatusNew        = "new"
	DiffStatusPersisting = "persisting"
	DiffStatusRegressed  = "regressed" // review-only
	DiffStatusResolved   = "resolved"
)

DiffStatus* constants are the string values written into Suggestion.DiffStatus (draft) and ReviewFinding.DiffStatus (review) by the differential runners. Exported so callers in cmd/ can reference them without magic strings.

A single unified namespace shared by both draft-side and review-side code. The only extra status is DiffStatusRegressed, which only review uses (a draft finding cannot regress because it has no lifecycle marks).

View Source
const (
	EvidenceModeStrict  = "strict"  // reject failing findings (default)
	EvidenceModeLenient = "lenient" // keep findings but record rejection reason
	EvidenceModeOff     = "off"     // skip validation entirely
)

Validation strictness values for ReviewOpts.Evidence.Mode. Match the public config names exactly (cfg.Angela.Review.Evidence.Validation).

View Source
const (
	StatusActive   = "active"
	StatusResolved = "resolved"
	StatusIgnored  = "ignored"
)

Status* constants are the lifecycle values stored in StatefulFinding.Status. They are NOT the same set as DiffStatus*: a finding's persistent status is one of {active, resolved, ignored}, while its per-run DiffStatus is one of {new, persisting, regressed, resolved}. The transition table is documented in UpdateReviewState.

View Source
const (
	ReviewDiffNew        = DiffStatusNew
	ReviewDiffPersisting = DiffStatusPersisting
	ReviewDiffRegressed  = DiffStatusRegressed
	ReviewDiffResolved   = DiffStatusResolved
)

ReviewDiff* aliases of the unified DiffStatus* constants. Kept so existing callers compile unchanged; new code should use DiffStatus* directly.

The per-run diff labels alias the unified DiffStatus* family in draft_state.go so the two packages cannot drift.

View Source
const (
	SeverityInfo    = "info"
	SeverityWarning = "warning"
	SeverityError   = "error"
)

Severity level constants used by the draft pipeline. Keep these aligned with the Suggestion.Severity values produced by the analyzers (draft.go, coherence.go, persona.go, style.go).

View Source
const AnalyzerSchemaVersion = 1

AnalyzerSchemaVersion is a monotonic integer bumped whenever the output of AnalyzeDraft / CheckCoherence / ScoreDocument / the persona registry changes in a way that makes cached suggestions from the old version invalid. Cached entries whose AnalyzerSchemaVersion differs from this value are treated as a cache miss on read. Bump this on any behavior-affecting change in internal/angela/draft.go, coherence.go, score.go, or personas.

View Source
const BackupSuffix = ".bak"

BackupSuffix is appended to the stamped filename.

View Source
const BackupTimeFormat = "20060102T150405Z"

BackupTimeFormat is the filename-safe, lexicographically-sortable timestamp format used to stamp backup files. UTC is used so pruning stays deterministic across DST transitions and container TZ changes.

View Source
const DraftStateVersion = 2

DraftStateVersion is the schema version of the persisted state file. Bump it whenever an incompatible change is made to DraftState or DraftEntry — LoadDraftState will notice the mismatch and return a fresh empty state instead of garbage.

Version 2: added AnalyzerSchemaVersion to DraftEntry so stale cached suggestions are invalidated when the analyzer's internal schema evolves (e.g. a persona registry edit or a new coherence rule). Version 1 entries cannot be re-used safely because we cannot know which analyzer version produced them.

View Source
const PolishStateVersion = 1

PolishStateVersion is the schema version of the polish state file. Bump on any incompatible schema change.

View Source
const ReviewStateVersion = 1

ReviewStateVersion is the schema version of the review state file. Bump it on any incompatible change to ReviewState or StatefulFinding — LoadReviewState detects the mismatch and returns a fresh empty state alongside a non-nil error so the caller can log a notice.

Variables

View Source
var (
	TUIStyleTitle   = lipgloss.NewStyle().Bold(true)
	TUIStyleDim     = lipgloss.NewStyle().Faint(true)
	TUIStyleHelpKey = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
	TUIStyleCursor  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
	TUIStyleSpinner = lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
)

Shared lipgloss styles for both interactive TUIs.

View Source
var (
	TUIStyleError   = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))  // red
	TUIStyleWarning = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow
	TUIStyleInfo    = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))  // gray
)

Severity colors shared by review and draft TUIs.

View Source
var ErrStateCorrupt = errors.New("state file corrupt or incompatible")

ErrStateCorrupt is returned by LoadDraftState / LoadReviewState when the on-disk file exists but cannot be parsed or has an unexpected schema version. Callers can use `errors.Is(err, ErrStateCorrupt)` to decide whether to quarantine the file via QuarantineCorruptState before overwriting it with a fresh snapshot. The previous contract silently overwrote corrupt files on the next run, losing every resolve/ignore mark a user had accumulated.

Functions

func AnnotateAndDiff added in v1.1.0

func AnnotateAndDiff(prev *DraftState, currentFiles map[string][]Suggestion) (DraftDiff, []ResolvedSuggestion)

AnnotateAndDiff walks the per-file suggestions, tags each one with a DiffStatus relative to the previous state, and produces the aggregate DraftDiff counts. It also returns a slice of "resolved" suggestions (previous findings that disappeared) so the reporter can show them even though they don't belong to any current file row.

prevHashes is consumed destructively; callers must not reuse prev after calling this function.

The resolved slice is built by iterating the previous state's entries: any stored suggestion whose findingHash does not appear in the current run is a RESOLVED finding. This includes both:

  1. Files still present in the corpus where a specific finding went away (the user fixed it)
  2. Files deleted entirely from the corpus (their old findings all become resolved in one go)

`currentFiles` is indexed by the same filename keys as `prev.Entries` so we only diff pairs that were loaded from the same place.

func ApplyDiff

func ApplyDiff(original string, hunks []DiffHunk, choices []DiffChoice) string

ApplyDiff applies chosen hunks to the original text. Hunks are applied in reverse order to preserve line offsets. DiffAccept replaces original with modified, DiffBoth keeps both.

func AssertContainedRelPath added in v1.1.0

func AssertContainedRelPath(relPath string) error

AssertContainedRelPath rejects a relative path that would escape the containment root when joined. It implements the canonical Go guard (filepath.Rel check + explicit ".." / absolute rejection) and is used by every filesystem operation that joins a user-supplied subdirectory with a state or backup root.

.lorerc fields such as `angela.polish.backup.path` and `angela.draft.differential.state_file` used to be joined onto the state directory without validation, giving a malicious config-file author a path to arbitrary file creation/deletion under the user's uid.

func AutofixDryRun added in v1.1.0

func AutofixDryRun(original, fixed, filename string) string

AutofixDryRun computes what would change without writing. Returns a unified diff string.

func AutofixWriteWithBackup added in v1.1.0

func AutofixWriteWithBackup(docPath, fixed string, backupEnabled bool, workDir, stateDir string) (string, error)

AutofixWriteWithBackup writes the fixed content with optional backup.

func AverageScore

func AverageScore(scored []ScoredPersona) float64

AverageScore returns the average resolution score of the given scored personas.

func BuildCorpusSummary

func BuildCorpusSummary(corpus []domain.DocMeta) string

BuildCorpusSummary creates a compact summary of corpus metadata for the prompt. Limited to 20 entries to respect token limits.

func BuildDeepDivePrompt added in v1.1.0

func BuildDeepDivePrompt(finding ReviewFinding, reader domain.CorpusReader) (string, string)

BuildDeepDivePrompt constructs the system and user prompt for a targeted deep-dive analysis of a single finding. If reader is non-nil, the full content of referenced documents is included so the AI can verify.

func BuildPersonaPrompt

func BuildPersonaPrompt(personas []PersonaProfile) string

BuildPersonaPrompt constructs the persona section for the AI polish prompt.

func BuildPolishPrompt

func BuildPolishPrompt(doc string, meta domain.DocMeta, styleGuide string, corpusSummary string, personas []PersonaProfile, audience ...string) (string, string)

BuildPolishPrompt constructs the AI prompt for polishing a document. Returns (systemPrompt, userContent) where system is stable/cacheable and user varies per call. When personas is non-nil, persona directives are injected into the user content.

func BuildReviewPrompt

func BuildReviewPrompt(docs []DocSummary, styleGuide string, signals *CorpusSignals, audience ...string) (string, string)

BuildReviewPrompt is retained for test compatibility; production uses BuildReviewPromptWithVHS.

func BuildReviewPromptWithVHS added in v1.1.0

func BuildReviewPromptWithVHS(docs []DocSummary, styleGuide string, signals *CorpusSignals, vhs *VHSSignals, audience ...string) (string, string)

BuildReviewPromptWithVHS constructs the AI prompt including VHS cross-reference signals.

func ContentHash added in v1.1.0

func ContentHash(content []byte) string

ContentHash returns "sha256:<hex>" for the given bytes. Exported so the runner and tests can compute the same value. The sha256 prefix is intentional: it future-proofs the schema against a later switch to BLAKE3 or similar — consumers can check the prefix before comparing.

func DescribePersonas added in v1.1.0

func DescribePersonas(scored []ScoredPersona) string

DescribePersonas returns a human-readable string of active personas with scores.

func DetectChangedSections added in v1.1.0

func DetectChangedSections(sections []Section, stored map[string]string, minChangeLines int) ([]int, bool)

DetectChangedSections compares current section hashes with stored ones and returns the indices of sections (in the `sections` slice) that are new or changed. The `minChangeLines` threshold skips sections with fewer than N non-blank lines in total (a size threshold, not a diff-size threshold — we only have stored hashes, not the previous body text, so an exact diff line count is not available).

Returns (changedIndices, allUnchanged). If allUnchanged is true the orchestrator can skip the AI call entirely.

func DetectLanguage added in v1.1.0

func DetectLanguage(line string) string

DetectLanguage guesses the programming language from the first line(s) of a code block. Returns the language tag (e.g., "java", "sql") or "" if unknown.

func DetectLanguageMultiLine added in v1.1.0

func DetectLanguageMultiLine(lines []string) string

DetectLanguageMultiLine guesses the language from multiple lines for better accuracy. Checks first 5 non-empty lines and uses majority vote. Ties are broken deterministically: the language whose first vote occurred earliest in the line sequence wins. This prevents map-iteration randomness (which differed between Linux/darwin and Windows) from producing flaky results.

func DraftFindingHash added in v1.1.0

func DraftFindingHash(filename string, s Suggestion) string

DraftFindingHash computes a stable identity for a draft finding.

func EstimateCost added in v1.1.0

func EstimateCost(model string, inputTokens, outputTokens int) float64

EstimateCost returns the estimated cost in USD for an API call. Returns -1 if unknown.

func ExitCodeFor added in v1.1.0

func ExitCodeFor(suggestions []Suggestion, failOn string) int

ExitCodeFor returns the process exit code for a set of suggestions given a fail_on threshold.

fail_on sets the MINIMUM severity that triggers a non-zero exit. Findings below the threshold are ignored for exit-code purposes. Example: fail_on=warning ignores info-level findings.

Semantics:

fail_on = "never"   → always 0 (unless a hard error happens elsewhere)
fail_on = "info"    → exit 1 if info-or-warning findings, exit 2 if
                       any error-level finding (error trumps)
fail_on = "warning" → exit 1 if only warnings exist, exit 2 if any
                       error-level findings exist (error trumps warning
                       in exit code)
fail_on = "error"   → 2 if any error, else 0  (default)

The distinction between 1 and 2 is important for CI pipelines that want to mark warnings as soft failures (exit 1) but errors as hard failures (exit 2).

func ExtractAdaptiveSummary

func ExtractAdaptiveSummary(body string, maxRunes int) string

ExtractAdaptiveSummary extracts top 3 sections from a document body, scored by semantic relevance rather than length. Each section is truncated to maxRunes/N runes (N = number of selected sections). Budget is not redistributed from short sections to longer ones.

func FormatAutofixReport added in v1.1.0

func FormatAutofixReport(report AutofixReport) string

FormatAutofixReport produces the human-readable summary.

func FormatDiff

func FormatDiff(hunks []DiffHunk, streams domain.IOStreams)

FormatDiff displays hunks to the writer with colored output.

func FormatScore added in v1.1.0

func FormatScore(s QualityScore) string

FormatScore returns a compact one-line summary: "72/100 (B)"

func FormatScoreDetail added in v1.1.0

func FormatScoreDetail(s QualityScore) string

FormatScoreDetail is retained for test compatibility and future CLI use.

FormatScoreDetail returns a multi-line breakdown whose per-category max values reflect the scoring profile that produced the score. A "strict" profile shows the lore-native categories (Why, References, ...); a "free-form" profile shows the narrative layout (Paragraphs, higher Structure and Density caps, no Why row).

func FormatStyleGuideRules

func FormatStyleGuideRules(guide *StyleGuide) string

FormatStyleGuideRules returns a prompt-ready string describing the active rules. Returns empty string if no rules are active.

func HashReviewFindingWithAudience added in v1.1.0

func HashReviewFindingWithAudience(f ReviewFinding, audience string) string

HashReviewFindingWithAudience computes the stable identity for a finding under a specific audience. Empty audience is the plain "default" lifecycle.

func HashSections added in v1.1.0

func HashSections(sections []Section) map[string]string

HashSections computes a `"sha256:<hex>"` hash for each section in a document parsed via SplitSections. The preamble (index 0, empty heading) is stored under the key "" so it participates in change detection — if front matter changes the whole doc should re-polish. HashSections computes hashes for each section. Note: duplicate headings are not supported — if two sections share the same heading text, the last one wins (last-wins behavior). This is acceptable for MVP because well-formed lore documents should not have duplicate ## headings.

func IsInteractiveAvailable added in v1.1.0

func IsInteractiveAvailable() bool

IsInteractiveAvailable checks if stdout is a TTY. Delegates to the shared IsTTYAvailable in tui_common.go.

func IsTTYAvailable added in v1.1.0

func IsTTYAvailable() bool

IsTTYAvailable checks if stdout is a character device (TTY). Used by both --interactive flags for non-TTY fallback.

func MarkIgnored added in v1.1.0

func MarkIgnored(state *ReviewState, hash, reason string, now time.Time) error

MarkIgnored flips a stored finding's status to "ignored" and records the user's reason. Used by `lore angela review ignore`. Returns an error if the hash is not in state OR the reason is empty (ignore must be deliberate).

func MarkResolved added in v1.1.0

func MarkResolved(state *ReviewState, hash, by string, now time.Time) error

MarkResolved flips a stored finding's status to "resolved" and stamps ResolvedAt + ResolvedBy. Used by the `lore angela review resolve` subcommand. Returns an error if the hash is not in state.

func MergeSections added in v1.1.0

func MergeSections(sections []Section) string

MergeSections reassembles sections into a full document.

func Polish

func Polish(ctx context.Context, provider domain.AIProvider, doc string, meta domain.DocMeta, styleGuide string, corpusSummary string, personas []PersonaProfile, opts ...PolishOpts) (string, error)

Polish sends a document to the AI provider for enhancement. Returns the improved document content. Exactly 1 API call.

func PolishMultiPass added in v1.1.0

func PolishMultiPass(ctx context.Context, provider domain.AIProvider, doc string, meta domain.DocMeta,
	styleGuide string, personas []PersonaProfile, progress MultiPassProgress, audience string) (string, error)

PolishMultiPass polishes a document section by section. Each section sees the context of other sections and the style of previously polished ones. Returns the full polished document. Progress callback is called after each section.

func PostProcess added in v1.1.0

func PostProcess(original, polished string) string

PostProcess applies local text transformations to improve AI output quality. No API calls — pure string processing. Applied after AI response, before diff. Each transformation is idempotent and independent.

func PruneMissingEntries added in v1.1.0

func PruneMissingEntries(state *DraftState, currentFiles map[string]bool) int

PruneMissingEntries removes state entries for files that no longer exist in the current corpus. Called by the runner after the per-file loop so the state file stays small and accurate. Returns the number of entries removed so the caller can log a verbose notice if desired.

Snapshot the keys before deleting so the semantics are obvious and future-proof against accidental concurrent writes.

func PruneOldBackups added in v1.1.0

func PruneOldBackups(backupDir string, retentionDays int) error

PruneOldBackups deletes backup files under backupDir whose timestamp is older than the cutoff (now - retentionDays). A retentionDays of 0 (or less) disables pruning and is a no-op.

The walk is non-recursive-tolerant: subdirectories are traversed so backups of nested documents are also pruned. Entries with unparseable filenames are left alone so unrelated files in the directory stay untouched.

Errors encountered while removing a single file do not abort the walk — they are collected and returned as a joined error so the caller can log them without losing the other successful deletions.

func QuarantineCorruptState added in v1.1.0

func QuarantineCorruptState(path string) (string, error)

QuarantineCorruptState renames a broken state file aside with a timestamped `.corrupt-<ts>` suffix so the user can recover it by hand if needed. Returns the quarantine path on success, or an empty string and an error if the rename fails (e.g. perms). A best-effort recovery: callers should treat a QuarantineCorruptState failure as a reason to NOT save over the original.

func ReanalyzeAfterFix added in v1.1.0

func ReanalyzeAfterFix(fixed string, meta domain.DocMeta, guide *StyleGuide, corpus []domain.DocMeta) int

ReanalyzeAfterFix runs AnalyzeDraft on the fixed content and returns the count of remaining findings.

func ResolveByPrefix added in v1.1.0

func ResolveByPrefix(state *ReviewState, prefix string) (string, error)

ResolveByPrefix returns the full hash matching a user-supplied prefix. Required so the resolve / ignore subcommands can accept abbreviated hashes (first 6 chars if unambiguous). Note: the ambiguous-prefix error message leaks full hashes. This is acceptable for a CLI tool where the user already sees hashes in the review output.

Three outcomes:

  • exactly one match → returns the full hash, nil error
  • zero matches → returns "", error("hash %s not found")
  • more than one match → returns "", error listing the candidates

func ResolveMaxTokens

func ResolveMaxTokens(mode string, docWordCount int, configMaxTokens ...int) int

ResolveMaxTokens returns the max_tokens cap for a given Angela mode. For polish mode, the cap is dynamic based on document word count. If configMaxTokens > 0, it overrides the computed/default value. Unknown modes default to 2000.

func RestoreBackup added in v1.1.0

func RestoreBackup(workDir, relPath, backupPath string) error

RestoreBackup copies the backup at backupPath back to the source document at <workDir>/<relPath>, atomically (tempfile + rename). Returns an error if the backup is missing or the rename fails. The caller is responsible for picking the right backup via ListBackups first.

func ReviewFindingHash added in v1.1.0

func ReviewFindingHash(f ReviewFinding) string

ReviewFindingHash returns a stable identity for a review finding scoped to a specific audience. Inputs are severity, sorted document filenames, normalized title, and a normalized audience key. The description is intentionally NOT part of the hash so an AI that rephrases the same finding next week still maps to the same entry.

The hash is the first 16 hex chars of SHA-256 over a canonical NUL-separated form — 64 bits of identity. NUL is chosen as separator because it cannot legally appear inside any of the inputs (severity is a fixed vocabulary, filenames reject NUL on every supported OS, titles are text). This avoids a `|`-delimiter collision where a doc named `a,b.md` in Documents would merge with two docs `[a.md, b.md]`.

Previously the hash was pipe-delimited and audience-agnostic, so running `review` followed by `review --for CTO` surfaced every finding as NEW. Audience is now part of the canonical input so the two runs maintain independent lifecycles. Callers that need audience-scoped hashes must call HashReviewFindingWithAudience directly. ReviewFindingHash is retained for test compatibility; production uses HashReviewFindingWithAudience. This wrapper is for audience-agnostic contexts only.

func SaveDraftState added in v1.1.0

func SaveDraftState(path string, state *DraftState) error

SaveDraftState writes state to path atomically (tempfile + rename in the same directory). Creates the parent directory if missing so callers don't need to pre-mkdir the state root. The file is indented JSON: state files are small (one entry per doc, ~200 bytes each) and human diff-ability is worth more than the handful of bytes saved.

The tempfile+sync+rename+fsync dance lives in fileutil.AtomicWriteJSON, shared with SaveReviewState and the polish backup writers.

func SavePolishState added in v1.1.0

func SavePolishState(path string, state *PolishState) error

SavePolishState writes state to path atomically.

func SaveReviewCache

func SaveReviewCache(loreDir string, report *ReviewReport, totalDocs int) error

SaveReviewCache writes the review results to .lore/cache/review.json.

func SaveReviewState added in v1.1.0

func SaveReviewState(path string, state *ReviewState) error

SaveReviewState writes state to path atomically (tempfile in the same dir + os.Rename). Creates the parent directory if missing so callers don't have to mkdir first. Stamps LastRun at save time.

Delegates the tempfile+sync+rename+fsync dance to fileutil.AtomicWriteJSON so durability tweaks touch one place.

func SerializeTOON

func SerializeTOON(summaries []DocSummary, signals *CorpusSignals) string

SerializeTOON produces a pipe-separated corpus + signals block for review prompts. Headers are declared once per section. Each data row is pipe-separated.

func SerializeTOONWithVHS added in v1.1.0

func SerializeTOONWithVHS(summaries []DocSummary, signals *CorpusSignals, vhs *VHSSignals) string

SerializeTOONWithVHS extends SerializeTOON to include VHS cross-reference signals. These signals help the AI reviewer detect documentation ↔ demo inconsistencies.

func SetBackupClock added in v1.1.0

func SetBackupClock(now func() time.Time) func()

SetBackupClock swaps the package-level clock. The returned function restores the previous value — callers typically defer it.

restore := angela.SetBackupClock(func() time.Time { return fixed })
defer restore()

func ShouldMultiPass added in v1.1.0

func ShouldMultiPass(docWordCount int) bool

ShouldMultiPass returns true if the document is large enough to benefit from multi-pass polishing.

func SortDraftFindings added in v1.1.0

func SortDraftFindings(findings []DraftFinding)

SortDraftFindings sorts findings by category priority, then severity, then filename alphabetically.

func UnifiedDiffString added in v1.1.0

func UnifiedDiffString(original, modified string, opts UnifiedDiffOptions) (string, error)

UnifiedDiffString computes a unified diff of original vs modified and returns it as a single string. Line endings are preserved (the output ends with a trailing newline as produced by difflib). An empty diff string means the two inputs are identical.

func UpdateReviewState added in v1.1.0

func UpdateReviewState(state *ReviewState, diff ReviewDiff, now time.Time)

UpdateReviewState merges the diff back into the persisted state so the next run has an accurate snapshot. Rules:

  • NEW finding → insert with status=active, FirstSeen=LastSeen=now
  • PERSISTING finding → bump LastSeen, keep status (active)
  • REGRESSED finding → flip status back to active, bump LastSeen, clear ResolvedAt/ResolvedBy/IgnoreReason so the user knows it's been re-opened, and remember the regression in LastSeen
  • RESOLVED finding (natural) → leave the prev entry alone — it simply stops bumping LastSeen. The user can prune it later via the `log` subcommand if they care.

User-marked statuses (set via the resolve/ignore subcommands) are preserved across runs. If the user resolved a finding and the AI stops returning it, the entry stays in state with its resolved timestamp intact. Document rename → shadow duplicates: When corpus documents are renamed, findings citing the old name naturally RESOLVE and re-appear as NEW under the new name. Users who had ignored the old finding will not see the connection. A future dedup pass could match by title+severity across audience-scoped hashes to detect renames.

func WriteBackup added in v1.1.0

func WriteBackup(workDir, stateDir, backupSubdir, relPath string) (string, error)

WriteBackup copies the file at <workDir>/<relPath> into the backup area under stateDir. The returned path is absolute.

Parameters:

  • workDir: the user's working directory (typically ".").
  • stateDir: absolute state directory (as returned by config.ResolveStateDir).
  • backupSubdir: directory under stateDir that holds backups. Empty string falls back to "polish-backups" so callers that haven't loaded the full config still behave sensibly.
  • relPath: the document path RELATIVE to workDir (e.g. ".lore/docs/foo.md" or simply "foo.md"). Preserved inside the backup area so nested docs with identical basenames don't collide.

The copy is atomic with respect to the backup filename: the content is streamed into a tempfile in the same directory, flushed, and then renamed into place. A crash before the rename leaves either no backup or a correctly-finished one — never a half-written file.

func WriteUnifiedDiff added in v1.1.0

func WriteUnifiedDiff(w io.Writer, original, modified string, opts UnifiedDiffOptions) error

WriteUnifiedDiff streams the diff into w. Equivalent to calling UnifiedDiffString and writing the result, but avoids an intermediate allocation for large documents.

Types

type AutoResult added in v1.1.0

type AutoResult struct {
	Accepted int
	Rejected int
	Asked    int
	Details  []string // human-readable lines for summary
}

AutoResult holds the summary of auto-mode decisions.

type AutofixFileResult added in v1.1.0

type AutofixFileResult struct {
	Filename     string
	Fixed        []string // descriptions of fixes applied
	Skipped      []string // findings that need manual fix
	Error        error
	StillPresent int // findings remaining after re-analysis
}

AutofixFileResult is the outcome for a single file.

func RunAutofix added in v1.1.0

func RunAutofix(content string, meta domain.DocMeta, mode AutofixMode, corpus []domain.DocMeta) (string, AutofixFileResult)

RunAutofix applies mechanical fixes to a single file. Returns the fixed content and what was changed. Does NOT write to disk (caller handles that).

type AutofixMode added in v1.1.0

type AutofixMode int

AutofixMode controls which fixers are applied.

const (
	AutofixSafe       AutofixMode = iota // additive only, no content deletion
	AutofixAggressive                    // stubs + tags + related
)

func ParseAutofixMode added in v1.1.0

func ParseAutofixMode(s string) (AutofixMode, error)

ParseAutofixMode converts a string flag value to AutofixMode.

type AutofixReport added in v1.1.0

type AutofixReport struct {
	FilesModified int
	FindingsFixed int
	FilesSkipped  int
	Errors        int
	Files         []AutofixFileResult
}

AutofixReport summarizes the full autofix run.

type BackupEntry added in v1.1.0

type BackupEntry struct {
	Path      string    // absolute path to the backup file on disk
	Timestamp time.Time // parsed from the filename (local time)
	Stamp     string    // raw stamp string as it appears in the filename
}

BackupEntry describes a single backup file for a given source document. Instances are returned by ListBackups sorted newest-first.

func FindBackupByStamp added in v1.1.0

func FindBackupByStamp(entries []BackupEntry, stamp string) (BackupEntry, bool)

FindBackupByStamp scans ListBackups output for the first entry whose Stamp matches the exact string. Returns ("", false) when no match exists. Convenience helper for the `--timestamp` flag of the restore command.

func ListBackups added in v1.1.0

func ListBackups(stateDir, backupSubdir, relPath string) ([]BackupEntry, error)

ListBackups returns every backup for the document at relPath, newest first. The relPath is interpreted the same way as in WriteBackup: relative to workDir, with the directory tree preserved inside the backup area.

Returns an empty slice (not an error) when the backup directory does not exist or contains no matching files.

type CorpusSignals

type CorpusSignals struct {
	// PotentialPairs are docs of the same type with shared tags but distant dates.
	// These are the most likely contradiction candidates.
	PotentialPairs []DocPair

	// TagClusters groups docs by tag for thematic analysis.
	TagClusters map[string][]string // tag → filenames

	// ScopeClusters groups docs by scope for consolidation analysis.
	// Value is a slice of filenames; use ScopeTypes for type lookup.
	ScopeClusters map[string][]string // scope → filenames

	// ScopeTypes maps filename → doc type for scope-cluster members.
	ScopeTypes map[string]string

	// IsolatedDocs are docs with no shared tags with any other doc.
	IsolatedDocs []string

	// TypeDistribution counts docs per type.
	TypeDistribution map[string]int

	// UnconsolidatedScopes are scopes with 2+ docs but no summary doc.
	UnconsolidatedScopes []ScopeGroup
}

CorpusSignals holds locally computed analysis of the entire corpus. Zero API calls — all analysis is string matching and metadata comparison. Extends the CheckCoherence pattern (single-doc) to corpus-wide scope.

func AnalyzeCorpusSignals

func AnalyzeCorpusSignals(docs []DocSummary) *CorpusSignals

AnalyzeCorpusSignals performs local corpus-wide analysis. Uses only metadata (no doc content reads, no API calls).

type DiffChoice added in v1.1.0

type DiffChoice int

DiffChoice represents the user's decision for a hunk.

const (
	DiffReject DiffChoice = iota // n — discard the change
	DiffAccept                   // y — apply the change (replace original with modified)
	DiffBoth                     // b — keep both original and modified lines
)

func InteractiveDiff

func InteractiveDiff(hunks []DiffHunk, streams domain.IOStreams, opts DiffOptions) ([]DiffChoice, error)

InteractiveDiff prompts the user for each hunk. Returns a slice of DiffChoice indicating the decision per hunk.

type DiffHunk

type DiffHunk struct {
	OrigStart     int        // 0-based line index in original
	OrigCount     int        // number of original lines in this hunk
	ModStart      int        // 0-based line index in modified
	ModCount      int        // number of modified lines in this hunk
	ContextBefore []string   // up to 3 lines before the change
	Original      []string   // lines removed/changed from original (includes = lines for merged hunks)
	Modified      []string   // lines added/changed in modified (includes = lines for merged hunks)
	ContextAfter  []string   // up to 3 lines after the change
	Lines         []DiffLine // ordered edit operations for display (nil for non-merged hunks)
}

DiffHunk represents a contiguous group of changes with surrounding context.

func ComputeDiff

func ComputeDiff(original, modified string) []DiffHunk

ComputeDiff produces a list of diff hunks between original and modified text. Uses a simple LCS-based line diff. Returns nil if texts are identical. Falls back to a single whole-document hunk if either text exceeds maxDiffLines.

type DiffLine added in v1.1.0

type DiffLine struct {
	Kind byte   // '=' unchanged, '-' removed, '+' added
	Text string // line content
}

DiffLine represents a single line in a diff hunk with its edit operation.

type DiffOptions

type DiffOptions struct {
	DryRun bool
	YesAll bool
	Auto   bool // auto-accept additions, auto-reject deletions, ask modifications
}

DiffOptions controls the behavior of InteractiveDiff.

type DocPair

type DocPair struct {
	DocA     string // filename
	DocB     string // filename
	Type     string // shared type
	Tags     string // shared tags
	DaysDiff int    // approximate days between dates
}

DocPair represents two documents that may contradict each other.

type DocSummary

type DocSummary struct {
	Filename string
	Type     string
	Date     string
	Tags     []string
	Branch   string // branch at capture time; "" for legacy docs
	Scope    string // scope from conventional commit; "" if none
	Summary  string // adaptive: top sections by content length (max 450 runes total)
}

DocSummary holds extracted metadata and content snippets for a single document.

func PrepareDocSummaries

func PrepareDocSummaries(reader domain.CorpusReader, filters ...ReviewFilter) ([]DocSummary, int, error)

type DraftCheck

type DraftCheck struct {
	Label string
	Check func(body string, sections map[string]string) *Suggestion
}

DraftCheck is a persona-specific structural check run during draft analysis. Check returns a raw Suggestion (without persona prefix); the caller decorates.

type DraftDiff added in v1.1.0

type DraftDiff struct {
	New        int `json:"new"`
	Persisting int `json:"persisting"`
	Resolved   int `json:"resolved"`
}

DraftDiff summarises the per-run difference between the previous DraftState and the current analysis results. Counts are aggregated across the whole corpus; per-finding labels live on Suggestion.DiffStatus.

type DraftEntry added in v1.1.0

type DraftEntry struct {
	ContentHash           string       `json:"content_hash"` // "sha256:<hex>"
	LastAnalyzed          time.Time    `json:"last_analyzed"`
	Suggestions           []Suggestion `json:"suggestions"`
	Score                 int          `json:"score"`
	Grade                 string       `json:"grade"`
	Profile               string       `json:"profile"`
	AnalyzerSchemaVersion int          `json:"analyzer_schema_version"`
}

DraftEntry is one document's cached analysis result. Suggestions are stored BEFORE any severity-override / strict-mode processing so that reading old state under new config gives the same answer as a fresh run would. LastAnalyzed is stamped with time.Now() by the runner; it is not mockable at the DraftEntry level. Tests that need deterministic timestamps should override at the runner/caller layer.

type DraftFinding added in v1.1.0

type DraftFinding struct {
	Filename   string
	Suggestion Suggestion
	Hash       string // stable identity for ignore tracking
}

DraftFinding wraps a Suggestion with file context for the interactive TUI.

type DraftInteractiveModel added in v1.1.0

type DraftInteractiveModel struct {

	// Quit summary text
	QuitSummary string
	// contains filtered or unexported fields
}

DraftInteractiveModel is the Bubbletea model for the interactive draft TUI.

func NewDraftInteractiveModel added in v1.1.0

func NewDraftInteractiveModel(
	findings []DraftFinding,
	docsDir string,
	meta map[string]domain.DocMeta,
	guide *StyleGuide,
	corpus []domain.DocMeta,
	personas []PersonaProfile,
	standalone bool,
) DraftInteractiveModel

NewDraftInteractiveModel creates the draft TUI model.

func (DraftInteractiveModel) Init added in v1.1.0

func (m DraftInteractiveModel) Init() tea.Cmd

Init implements tea.Model.

func (DraftInteractiveModel) Update added in v1.1.0

func (m DraftInteractiveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (DraftInteractiveModel) View added in v1.1.0

func (m DraftInteractiveModel) View() string

type DraftState added in v1.1.0

type DraftState struct {
	Version int                   `json:"version"`
	LastRun time.Time             `json:"last_run"`
	Entries map[string]DraftEntry `json:"entries"`
}

DraftState is the on-disk schema for the draft state file. One instance per corpus. Entries is keyed by filename (relative to docsDir) so lookup is O(1) per document in the run loop.

func LoadDraftState added in v1.1.0

func LoadDraftState(path string) (*DraftState, error)

LoadDraftState reads a state file from path. Missing file → empty state with the current version (NOT an error). Corrupt file or wrong version → empty state plus a non-nil error that the caller can log as a notice before proceeding. Returning a usable state in every case keeps the runner loop simple: there is no "state is missing" branch to handle.

type Evidence added in v1.1.0

type Evidence struct {
	File  string `json:"file"`
	Quote string `json:"quote"`
	Line  int    `json:"line,omitempty"`
}

Evidence is a verbatim citation from a specific corpus document used to justify a ReviewFinding. The AI populates these; the validator checks that each File exists and each Quote literally appears in its File (after whitespace normalization) before the finding is kept.

type EvidenceValidation added in v1.1.0

type EvidenceValidation struct {
	// Required enables validation. When false, the validator is a no-op
	// and every finding passes through unchanged.
	Required bool

	// MinConfidence filters findings whose AI-reported confidence is
	// below this threshold (0.0 - 1.0). Only applied when Required.
	MinConfidence float64

	// Mode is one of EvidenceModeStrict / EvidenceModeLenient / EvidenceModeOff.
	// Empty defaults to strict for safety.
	Mode string
}

EvidenceValidation carries the subset of cfg.Angela.Review.Evidence that the angela package needs, so the package does not have to import internal/config (preserving the layering boundary).

type FactualClaim added in v1.1.0

type FactualClaim struct {
	Text    string `json:"text"`    // the sentence containing the claim
	Section string `json:"section"` // the ## section it lives in (may be empty)
	Type    string `json:"type"`    // "metric", "version", "proper-noun", "number"
	Core    string `json:"core"`    // the extracted token (e.g. "200ms", "v2.0", "PostgreSQL")
}

FactualClaim represents a specific claim found in new text.

type GIFRef added in v1.1.0

type GIFRef struct {
	DocFilename string
	GIFPath     string
}

GIFRef represents a GIF referenced in a documentation file.

type HallucinationCheck added in v1.1.0

type HallucinationCheck struct {
	NewFactualClaims []FactualClaim `json:"new_factual_claims"`
	Unsupported      []FactualClaim `json:"unsupported"`
}

HallucinationCheck holds the result of a post-polish verification.

func CheckHallucinations added in v1.1.0

func CheckHallucinations(original, polished, corpusSummary string) HallucinationCheck

CheckHallucinations compares original and polished text, extracts factual claims from newly added sentences, and classifies each as supported or unsupported.

Main entry point. corpusSummary may be empty. Wired into cmd/angela_polish.go (~line 346) for both full and incremental polish paths — the hallucination check runs on the unified result.Polished output regardless of which path produced it.

type HunkClass added in v1.1.0

type HunkClass int

HunkClass categorizes a hunk for auto-mode decisions.

const (
	HunkModification  HunkClass = iota // balanced change — needs review
	HunkPureAddition                   // only additions, no deletions
	HunkPureDeletion                   // only deletions, no additions
	HunkCosmetic                       // whitespace-only change
	HunkMajorDeletion                  // net loss > 15 lines
)

func ClassifyHunk added in v1.1.0

func ClassifyHunk(h DiffHunk) HunkClass

ClassifyHunk determines the category of a hunk for auto-mode.

type IncrementalOpts added in v1.1.0

type IncrementalOpts struct {
	// Provider is the AI backend (must not be nil).
	Provider domain.AIProvider

	// Meta is the document metadata.
	Meta domain.DocMeta

	// StyleGuide is the loaded style guide content (may be empty).
	StyleGuide string

	// CorpusSummary is the corpus-wide context summary (may be empty).
	CorpusSummary string

	// Personas is the resolved persona profiles for polish.
	Personas []PersonaProfile

	// Audience is the --for flag value (may be empty).
	Audience string

	// ConfigMaxToks is angela.max_tokens from .lorerc (0 = auto).
	ConfigMaxToks int

	// MinChangeLines is cfg.Angela.Polish.Incremental.MinChangeLines.
	// Sections with fewer non-blank lines of change are skipped.
	MinChangeLines int
}

IncrementalOpts configures the incremental polish pass.

type IncrementalResult added in v1.1.0

type IncrementalResult struct {
	// Polished is the resulting document content.
	Polished string

	// WasIncremental is true when incremental mode was used (not all
	// sections re-polished). False when a fallback to full polish
	// happened.
	WasIncremental bool

	// ChangedCount is the number of sections that were sent to the AI.
	ChangedCount int

	// Skipped is true when 0 sections changed → no AI call at all.
	Skipped bool

	// NewHashes is the section hash map to persist into the state
	// file after a successful polish.
	NewHashes map[string]string
}

IncrementalResult describes what happened during an incremental polish attempt.

func PolishIncremental added in v1.1.0

func PolishIncremental(ctx context.Context, doc string, storedHashes map[string]string, o IncrementalOpts) (*IncrementalResult, error)

PolishIncremental attempts a section-level incremental polish.

`storedHashes` is the PolishStateEntry.SectionHashes from the previous run (nil or empty on first run → falls back to full).

Returns an IncrementalResult; any error means a hard failure that should abort the polish entirely (e.g. provider error). The caller is responsible for fallback-on-warning via the WasIncremental flag.

type LangRule added in v1.1.0

type LangRule struct {
	Lang            string   // language tag for code fences (e.g., "java", "sql")
	Prefixes        []string // line prefixes that indicate this language (case-sensitive unless CaseInsensitive)
	Contains        []string // substrings that indicate this language (checked if no prefix matches)
	CaseInsensitive bool     // if true, prefixes are matched case-insensitively
}

LangRule defines a detection rule for a programming language.

type MultiPassProgress added in v1.1.0

type MultiPassProgress func(sectionIndex, totalSections int, heading string, changed bool)

MultiPassProgress is called after each section is polished.

type PersonaProfile

type PersonaProfile struct {
	Name            string
	DisplayName     string
	Icon            string
	Expertise       string
	Principles      []string
	DraftChecks     []DraftCheck
	PromptDirective string
	DocTypes        []string // explicit type activation
	ContentSignals  []string // keyword content activation (EN + FR)
}

PersonaProfile represents an expert lens that Angela can activate for document review. Personas are Go values — no external files.

func GetRegistry

func GetRegistry() []PersonaProfile

GetRegistry returns a deep copy of the persona registry. Slices (Principles, DraftChecks, DocTypes, ContentSignals) are independently copied.

func Profiles

func Profiles(scored []ScoredPersona) []PersonaProfile

Profiles extracts PersonaProfile slice from scored results.

func SelectPersonasForDoc added in v1.1.0

func SelectPersonasForDoc(docType string, cfg config.PersonasConfig) []PersonaProfile

SelectPersonasForDoc returns the persona profiles to activate for a given document, honoring the PersonasConfig selection mode and free-form mode switch.

Takes docType string (not full DocMeta) by design — only the type field is needed for persona selection in the current scope. Expanding to full DocMeta is deferred to post-MVP.

type PolishInteractiveModel added in v1.1.0

type PolishInteractiveModel struct {

	// Result
	Written     bool
	QuitSummary string
	FinalDoc    string // assembled document if Written
	// contains filtered or unexported fields
}

PolishInteractiveModel is the Bubbletea model for interactive polish.

func NewPolishInteractiveModel added in v1.1.0

func NewPolishInteractiveModel(original, polished, filename string) PolishInteractiveModel

NewPolishInteractiveModel creates the TUI model from original and polished docs.

func (PolishInteractiveModel) Init added in v1.1.0

func (m PolishInteractiveModel) Init() tea.Cmd

Init implements tea.Model.

func (PolishInteractiveModel) Update added in v1.1.0

func (m PolishInteractiveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (PolishInteractiveModel) View added in v1.1.0

func (m PolishInteractiveModel) View() string

type PolishOpts added in v1.1.0

type PolishOpts struct {
	Audience      string // target audience for rewrite mode (empty = standard polish)
	ConfigMaxToks int    // angela.max_tokens from .lorerc (0 = auto-compute)
}

PolishOpts holds optional parameters for Polish.

type PolishState added in v1.1.0

type PolishState struct {
	Version int                         `json:"version"`
	Entries map[string]PolishStateEntry `json:"entries"`
}

PolishState is the on-disk schema for `polish-state.json`. Entries is keyed by document filename relative to docsDir.

func LoadPolishState added in v1.1.0

func LoadPolishState(path string) (*PolishState, error)

LoadPolishState reads the state file from path. Missing file → empty state (no error). Corrupt or version mismatch → empty state plus a non-nil error. Same contract as LoadDraftState.

type PolishStateEntry added in v1.1.0

type PolishStateEntry struct {
	LastPolished  time.Time         `json:"last_polished"`
	SectionHashes map[string]string `json:"sections"`
}

PolishStateEntry records the section-level hashes of a document at the time it was last polished. SectionHashes keys are the raw `##` heading text ("## Why", "## How It Works"); values are "sha256:<hex>" produced by ContentHash.

type PostCallAnalysis added in v1.1.0

type PostCallAnalysis struct {
	Lines []string // feedback lines to display
}

PostCallAnalysis provides feedback after an API call completes.

func AnalyzeUsage added in v1.1.0

func AnalyzeUsage(usage *domain.AIUsage, elapsed time.Duration, maxOutputTokens int) *PostCallAnalysis

AnalyzeUsage produces human-friendly feedback about token consumption.

type PreflightResult added in v1.1.0

type PreflightResult struct {
	EstimatedInputTokens int
	MaxOutputTokens      int
	Timeout              time.Duration
	EstimatedCost        float64 // -1 if unknown
	Warnings             []string
	ShouldAbort          bool   // true if the call will certainly fail
	AbortReason          string // human-readable reason for abort
}

PreflightResult contains warnings and estimates before an API call.

func Preflight added in v1.1.0

func Preflight(doc string, systemPrompt string, model string, maxOutputTokens int, timeout time.Duration) *PreflightResult

Preflight checks if the planned API call is likely to succeed. Returns warnings, cost estimate, and whether to abort.

type QualityScore added in v1.1.0

type QualityScore struct {
	Total     int            // 0-100
	Breakdown map[string]int // category → points earned
	Missing   []string       // actionable items to improve score
	Grade     string         // A, B, C, D, F
	Profile   string         // "strict" | "free-form" — which scoring path produced this
}

QualityScore holds the result of a document quality assessment.

func ScoreDocument added in v1.1.0

func ScoreDocument(content string, meta domain.DocMeta) QualityScore

ScoreDocument evaluates a markdown document's quality on a 0-100 scale. Works on raw content (with or without front matter). Entirely local — no API calls.

Two scoring profiles:

  • Strict (decision/feature/bugfix/refactor): the original lore scoring with heavy weight on ## Why, related refs, front-matter status — the hallmarks of a commit-capture document.
  • Free-form (notes, tutorials, guides, blog posts, concept pages, any unknown type): the same signals minus the lore-specific ones, with points redistributed so a well-written tutorial can legitimately reach an A. Before this split, a perfect tutorial plateaued at F.

type RejectedFinding added in v1.1.0

type RejectedFinding struct {
	Finding ReviewFinding `json:"finding"`
	Reason  string        `json:"reason"`
}

RejectedFinding wraps a ReviewFinding that failed validation along with a human-readable reason. The CLI surfaces these in verbose mode.

type ResolvedSuggestion added in v1.1.0

type ResolvedSuggestion struct {
	File       string     `json:"file"`
	Suggestion Suggestion `json:"suggestion"`
}

ResolvedSuggestion pairs a RESOLVED finding with the file it used to live in. Needed in the reporter because a resolved finding doesn't belong to any current file row (the file may have been deleted).

type ReviewCache

type ReviewCache struct {
	Version    int             `json:"version"`
	LastReview time.Time       `json:"last_review"`
	DocCount   int             `json:"doc_count"`
	TotalDocs  int             `json:"total_docs"`
	Findings   []ReviewFinding `json:"findings"`
}

ReviewCache holds persisted review results for incremental tracking. Version field enables forward-compatible schema evolution (ADR-013).

func LoadReviewCache

func LoadReviewCache(loreDir string) (*ReviewCache, error)

LoadReviewCache reads the cached review results. Returns nil if no cache exists.

type ReviewDiff added in v1.1.0

type ReviewDiff struct {
	New        []ReviewFinding `json:"new,omitempty"`
	Persisting []ReviewFinding `json:"persisting,omitempty"`
	Regressed  []ReviewFinding `json:"regressed,omitempty"`
	Resolved   []ReviewFinding `json:"resolved,omitempty"`
}

ReviewDiff aggregates the per-run lifecycle counts and the slices the reporter walks. NEW + REGRESSED are the high-signal lists; the PERSISTING and RESOLVED lists exist so --diff-only mode can still report counts even when it hides the rows.

func ComputeReviewDiff deprecated added in v1.1.0

func ComputeReviewDiff(prev *ReviewState, current []ReviewFinding) ReviewDiff

ComputeReviewDiff classifies each current finding relative to the previous state and produces a ReviewDiff with NEW / PERSISTING / REGRESSED / RESOLVED slices. Each input finding's Hash is populated in place so callers don't need to recompute it.

REGRESSED is the high-signal class: a finding the user previously marked resolved or ignored has come back, meaning either the fix regressed or the false-positive assumption was wrong.

RESOLVED here means "stored as active in prev, missing from current" — i.e. the corpus changed and the finding naturally went away. This is distinct from `StatusResolved` which is the user's explicit mark.

ComputeReviewDiff is retained for test compatibility; production uses ComputeReviewDiffWithRejected.

Deprecated: kept as a thin wrapper for callers that don't care about audience scoping. New code should call ComputeReviewDiffWithAudience.

func ComputeReviewDiffWithAudience added in v1.1.0

func ComputeReviewDiffWithAudience(prev *ReviewState, current []ReviewFinding, audience string) ReviewDiff

ComputeReviewDiffWithAudience is the audience-scoped variant. All finding hashes are computed under `audience`, so running review without --for and then with --for keeps two independent lifecycles keeps two independent lifecycles.

Rejected findings (e.g. validator drops) should be passed via ComputeReviewDiffWithRejected so they do NOT appear as natural RESOLVED — the AI kept returning them, they were just suppressed client-side. This version treats rejected as absent which is only correct when there are none.

func ComputeReviewDiffWithRejected added in v1.1.0

func ComputeReviewDiffWithRejected(prev *ReviewState, current []ReviewFinding, rejected []RejectedFinding, audience string) ReviewDiff

ComputeReviewDiffWithRejected is the full-context variant used by the review runner when the evidence validator is active. `rejected` is the set of findings the AI produced this run but that the validator dropped for bad/missing evidence; they are considered "still present" for lifecycle purposes so previously stored entries matching them are not mistakenly classified as RESOLVED. The hashes for rejected findings are computed but they are NOT added to the NEW/PERSISTING/REGRESSED slices — the user already saw them via the report.Rejected surface.

func (ReviewDiff) Counts added in v1.1.0

func (d ReviewDiff) Counts() (new_, persisting, regressed, resolved int)

Counts returns the four counts in a single struct for the summary line in the human reporter.

type ReviewFilter added in v1.1.0

type ReviewFilter struct {
	Pattern *regexp.Regexp // if non-nil, only include files matching this pattern
	All     bool           // if true, include all docs (no 25+25 sampling)
}

PrepareDocSummaries reads documents from the corpus and builds summaries. Returns error if fewer than 5 documents exist. Limits to 50 docs: 25 most recent + 25 oldest when corpus exceeds 50. ReviewFilter controls which documents are included in a review.

type ReviewFinding

type ReviewFinding struct {
	Severity    string     `json:"severity"` // "contradiction", "gap", "style", "obsolete"
	Title       string     `json:"title"`
	Description string     `json:"description"`
	Documents   []string   `json:"documents"`             // filenames concerned
	Relevance   string     `json:"relevance,omitempty"`   // "high", "medium", "low" — set when --for is used
	Evidence    []Evidence `json:"evidence,omitempty"`    // verifiable quotes
	Confidence  float64    `json:"confidence,omitempty"`  // AI self-assessment 0.0-1.0
	Hash        string     `json:"hash,omitempty"`        // stable identity for differential tracking
	DiffStatus  string     `json:"diff_status,omitempty"` // "new" | "persisting" | "regressed" | "resolved"
}

ReviewFinding represents a single issue found during corpus review.

Evidence and Confidence: the AI must back every finding with a verbatim quote from a specific document, and the evidence validator rejects findings whose quotes cannot be found in the actual corpus. This is the project's primary defense against AI hallucinations in the review output.

Hash and DiffStatus: when differential review is enabled, the runner computes a stable hash from severity + sorted documents + normalized title and tracks the finding's lifecycle across runs in a JSON state file. DiffStatus tags each finding with its position in that lifecycle for the current run.

type ReviewInteractiveModel added in v1.1.0

type ReviewInteractiveModel struct {

	// Quit summary text (rendered after program exits)
	QuitSummary string
	// contains filtered or unexported fields
}

ReviewInteractiveModel is the Bubbletea model for the interactive review TUI.

func NewReviewInteractiveModel added in v1.1.0

func NewReviewInteractiveModel(
	findings []ReviewFinding,
	state *ReviewState,
	statePath string,
	audience string,
	provider domain.AIProvider,
	reader domain.CorpusReader,
) ReviewInteractiveModel

NewReviewInteractiveModel creates the TUI model with findings and optional state integration. Pass nil state/statePath to disable resolve/ignore.

func (ReviewInteractiveModel) Init added in v1.1.0

func (m ReviewInteractiveModel) Init() tea.Cmd

Init implements tea.Model.

func (ReviewInteractiveModel) Update added in v1.1.0

func (m ReviewInteractiveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (ReviewInteractiveModel) View added in v1.1.0

func (m ReviewInteractiveModel) View() string

type ReviewOpts added in v1.1.0

type ReviewOpts struct {
	Audience   string      // target audience — findings will be framed for this audience
	VHSSignals *VHSSignals // VHS cross-reference signals (nil if no tape dir found)

	// Evidence validation. When Evidence.Required is true and Reader
	// is non-nil, Review() runs ValidateFindings on the parsed
	// findings before sorting and returning. Reader is needed because
	// the validator reads full document content (DocSummary only
	// carries a truncated snippet) to check that each quoted passage
	// literally exists. Leaving Reader nil while Required is true is
	// treated as "no corpus available" and every evidence file-check
	// fails, which is the correct default-deny stance.
	Evidence EvidenceValidation
	Reader   domain.CorpusReader

	// ConfigMaxTokens lets Review() honor the user's `angela.max_tokens`
	// config instead of hard-coding the default. When zero, the package
	// default is used.
	ConfigMaxTokens int
}

BuildReviewPrompt constructs the AI prompt for corpus-wide review. Returns (systemPrompt, userContent) where system is stable/cacheable and user varies per call. signals may be nil (no pre-analysis). Corpus is serialized in TOON format. ReviewOpts holds optional parameters for Review.

type ReviewReport

type ReviewReport struct {
	Findings []ReviewFinding   `json:"findings"`
	DocCount int               `json:"doc_count"`
	Rejected []RejectedFinding `json:"rejected,omitempty"` // evidence-validator drops
	Diff     *ReviewDiff       `json:"diff,omitempty"`     // differential lifecycle
}

ReviewReport holds the complete result of a corpus review.

Diff (omitempty): when differential review is enabled, the cmd layer attaches the per-run lifecycle classification (NEW / PERSISTING / REGRESSED / RESOLVED) here so the reporter can surface the delta without re-doing the diff.

func Review

func Review(ctx context.Context, provider domain.AIProvider, docs []DocSummary, styleGuide string, opts ...ReviewOpts) (*ReviewReport, error)

Review performs a corpus-wide analysis using the AI provider. Exactly 1 API call. Returns the review report sorted by severity.

When opts[0].Evidence.Required is true, the parsed findings are passed through ValidateFindings BEFORE sorting. In strict mode failing findings are moved to ReviewReport.Rejected. In lenient mode they stay in Findings but also show up in Rejected with their reason, so the CLI can surface both. In off mode (or Required=false) the validator is bypassed entirely.

type ReviewState added in v1.1.0

type ReviewState struct {
	Version  int                        `json:"version"`
	LastRun  time.Time                  `json:"last_run"`
	Findings map[string]StatefulFinding `json:"findings"`
}

ReviewState is the on-disk schema for the review state file. One entry per stable finding hash (NOT per run). Findings is keyed by the 16-char hex hash returned by ReviewFindingHash.

func LoadReviewState added in v1.1.0

func LoadReviewState(path string) (*ReviewState, error)

LoadReviewState reads a state file from path. Missing file → fresh empty state, no error. Corrupt file or version mismatch → fresh empty state plus a non-nil error so the runner can log a notice. Same fallback-first contract as LoadDraftState.

type ScopeGroup added in v1.1.0

type ScopeGroup struct {
	Scope    string
	DocCount int
}

ScopeGroup describes a scope that may need consolidation.

type ScoredPersona

type ScoredPersona struct {
	Profile PersonaProfile
	Score   int
}

ScoredPersona pairs a persona with its resolution score.

func ResolvePersonas

func ResolvePersonas(docType, body string) []ScoredPersona

ResolvePersonas selects up to 3 personas based on document type and content signals. Type match = +10 points, each content signal found = +2 points. Returns fallback [tech-writer] if no persona scores > 0.

func ResolvePersonasForAudience added in v1.1.0

func ResolvePersonasForAudience(docType, body, audience string) []ScoredPersona

ResolvePersonasForAudience selects personas optimized for a target audience. It boosts personas whose expertise matches the audience, then falls back to standard resolution for remaining slots.

type Section added in v1.1.0

type Section struct {
	Heading string // "## 4. Logique Métier" (empty for preamble before first ##)
	Body    string // content until next ## or EOF
	Index   int
}

Section represents a document section split by ## headings.

func SplitSections added in v1.1.0

func SplitSections(doc string) []Section

SplitSections divides a document into sections by ## headings. Section 0 is the preamble (front matter + content before first ##).

type SectionDecision added in v1.1.0

type SectionDecision int

SectionDecision tracks the user's choice for each section.

const (
	DecisionPending  SectionDecision = iota
	DecisionAccepted                 // use polished version
	DecisionRejected                 // keep original
	DecisionEdited                   // use user-edited version
	DecisionSkipped                  // decide later
)

type SectionDiff added in v1.1.0

type SectionDiff struct {
	Heading       string // section heading (e.g. "## Why")
	Original      string // original body
	Polished      string // polished body (empty if section removed)
	IsNew         bool   // section exists only in polished
	IsRemoved     bool   // section exists only in original
	Changed       bool   // body differs between original and polished
	Decision      SectionDecision
	EditedContent string // content after user edit (DecisionEdited)
}

SectionDiff pairs an original section with the corresponding polished section.

func ComputeSectionDiffs added in v1.1.0

func ComputeSectionDiffs(original, polished string) []SectionDiff

ComputeSectionDiffs splits original and polished by ## headings and pairs them by heading name. Sections present only in polished are marked IsNew. Sections present only in original are IsRemoved.

type StatefulFinding added in v1.1.0

type StatefulFinding struct {
	Finding      ReviewFinding `json:"finding"`
	Status       string        `json:"status"` // active|resolved|ignored
	FirstSeen    time.Time     `json:"first_seen"`
	LastSeen     time.Time     `json:"last_seen"`
	ResolvedAt   *time.Time    `json:"resolved_at,omitempty"`
	ResolvedBy   string        `json:"resolved_by,omitempty"`
	IgnoreReason string        `json:"ignore_reason,omitempty"`
}

StatefulFinding wraps a ReviewFinding with the lifecycle metadata the differential runner needs: when did we first see this issue, when did we last see it, and what is its current status. Stored once per stable hash; the same finding across many runs collapses into a single entry whose LastSeen drifts forward.

func LogEntries added in v1.1.0

func LogEntries(state *ReviewState) []StatefulFinding

LogEntries returns the state's findings sorted by LastSeen descending — the order the `log` subcommand prints them in.

type StyleGuide

type StyleGuide struct {
	RequireWhy          bool
	RequireAlternatives bool
	MaxBodyLength       int // in runes, 0 = no limit
	MinTags             int

	// Warnings collects any parse-time issues (e.g. unknown rules).
	Warnings []Suggestion
}

StyleGuide holds parsed style rules for Angela draft analysis.

func ParseStyleGuide

func ParseStyleGuide(rules map[string]interface{}) *StyleGuide

ParseStyleGuide parses style guide rules from config. Returns default rules if input is nil. Unknown keys produce warning suggestions (catches typos in .lorerc).

type Suggestion

type Suggestion struct {
	Category   string `json:"category"` // "structure", "completeness", "style", "coherence", "persona"
	Severity   string `json:"severity"` // "info", "warning", "error"
	Message    string `json:"message"`
	DiffStatus string `json:"diff_status,omitempty"` // "new" | "persisting" | "resolved"
}

Suggestion represents a single review finding from Angela draft analysis. JSON tags are snake-case to match the documented draft --format=json schema The field order (category, severity, message) is the same as the human-readable output so JSON consumers and readers see the same layout.

DiffStatus is populated by the differential runner to tag each suggestion as "new", "persisting", or "resolved" relative to the previous draft state. Empty in single-run mode or when differential is disabled, hence the omitempty JSON tag.

func AnalyzeDraft

func AnalyzeDraft(doc string, meta domain.DocMeta, guide *StyleGuide, corpus []domain.DocMeta, personas []PersonaProfile) []Suggestion

AnalyzeDraft performs local structural analysis of a document. Zero API calls — fully deterministic. Returns nil if no suggestions. When personas is non-nil, persona-specific draft checks are included.

func ApplySeverityOverride added in v1.1.0

func ApplySeverityOverride(suggestions []Suggestion, override map[string]string) []Suggestion

ApplySeverityOverride mutates a slice of Suggestions according to a per-category override map. Semantics:

  • Missing key → suggestion unchanged
  • Value "off" → suggestion dropped entirely from the result
  • Value "info" / "warning" / "error" → severity replaced in place

The override map is typically populated from cfg.Angela.Draft.SeverityOverride and merged with any --severity flag values.

The returned slice is a new allocation when any drop occurred; when no drops happen it may be the same backing array (callers should not depend on either behavior).

func CheckCoherence

func CheckCoherence(doc string, meta domain.DocMeta, corpus []domain.DocMeta) []Suggestion

CheckCoherence validates a document against the existing corpus. Works on metadata only (no file reads) for performance.

func PromoteWarningsToErrors added in v1.1.0

func PromoteWarningsToErrors(suggestions []Suggestion) []Suggestion

PromoteWarningsToErrors walks a suggestion slice and upgrades every warning-level entry to error. Used by `draft --strict` mode where the user wants a zero-tolerance CI gate. Info-level findings are left untouched — strict is about promoting known problems, not inventing new ones.

func RunPersonaDraftChecks

func RunPersonaDraftChecks(body string, personas []PersonaProfile) []Suggestion

RunPersonaDraftChecks runs all draft checks from the given personas against the body. Suggestion messages are decorated with the persona's icon and display name.

type TapeMismatch added in v1.1.0

type TapeMismatch struct {
	TapeFile string
	Command  string
	Reason   string // "undocumented_command", "unknown_subcommand"
}

TapeMismatch represents a command in a tape file that may be outdated or undocumented.

type UnifiedDiffOptions added in v1.1.0

type UnifiedDiffOptions struct {
	// FromFile and ToFile are the labels printed in the `---` and `+++`
	// headers. They are purely cosmetic — the diff content is driven by
	// the A/B strings.
	FromFile string
	ToFile   string

	// Context is the number of context lines around each change. Default
	// (0 or negative) maps to 3, matching `diff -u`'s default.
	Context int

	// Colored switches ANSI colors on: additions in green, deletions in
	// red. Callers should only set this to true when the destination is
	// a TTY (see ui.ColorEnabled). When false the output is pure ASCII
	// and safe to redirect into a file or pipe.
	Colored bool
}

UnifiedDiffOptions configures UnifiedDiffString and WriteUnifiedDiff.

type VHSSignals added in v1.1.0

type VHSSignals struct {
	// TapeCommands maps tape filename → list of CLI commands found in Type/Exec lines.
	TapeCommands map[string][]string

	// TapeOutputs maps tape filename → output GIF/PNG path from Output directive.
	TapeOutputs map[string]string

	// OrphanTapes are tape files whose output GIF is not referenced in any doc.
	OrphanTapes []string

	// OrphanGIFs are GIF references in docs that have no corresponding .tape source.
	OrphanGIFs []GIFRef

	// CommandMismatches are CLI commands in tapes that don't appear in any doc.
	CommandMismatches []TapeMismatch
}

VHSSignals holds analysis results from cross-referencing VHS tape files with documentation and CLI commands.

func AnalyzeVHSSignals added in v1.1.0

func AnalyzeVHSSignals(tapeDir, docsDir string, knownCommands []string) *VHSSignals

AnalyzeVHSSignals cross-references VHS tape files with documentation. tapeDir: directory containing .tape files (e.g., assets/vhs/) docsDir: directory containing .md documentation files knownCommands: set of known CLI commands (e.g., from cobra)

type ValidationResult added in v1.1.0

type ValidationResult struct {
	Valid    []ReviewFinding   `json:"valid"`
	Rejected []RejectedFinding `json:"rejected"`
}

ValidationResult is the outcome of ValidateFindings: the kept findings and the pulled-aside rejects. Both slices are non-nil (possibly empty) so callers can freely JSON-marshal or iterate without nil checks.

func ValidateFindings added in v1.1.0

func ValidateFindings(findings []ReviewFinding, reader domain.CorpusReader, v EvidenceValidation) ValidationResult

ValidateFindings walks every finding and checks its Evidence against the corpus via `reader`. A finding is kept when all of the following hold:

  1. Evidence is non-empty
  2. Every Evidence.File exists in the corpus (reader.ReadDoc succeeds)
  3. Every Evidence.Quote appears in its File content after whitespace normalization
  4. Confidence >= v.MinConfidence (only when v.Required is true)

When v.Required is false OR v.Mode is "off", the validator is a total no-op and every finding is returned as Valid with an empty Rejected slice — this is the backward-compat escape hatch.

When v.Mode is "lenient", a failing finding is KEPT in Valid but also recorded in Rejected with its reason, so the CLI can surface the drop-rationale without dropping the finding. This is the debug mode.

Document content is cached per filename across the loop so a finding that cites the same document three times doesn't trigger three disk reads.

Jump to

Keyboard shortcuts

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