angela

package
v1.2.3 Latest Latest
Warning

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

Go to latest
Published: May 3, 2026 License: AGPL-3.0 Imports: 31 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 (
	LogResultWritten           = "written"
	LogResultDryRun            = "dryrun"
	LogResultAbortedArbitrate  = "aborted_arbitrate"
	LogResultAbortedCorruptSrc = "aborted_corrupt_src"
	LogResultAIError           = "ai_error"
)

Result sentinel values for LogEntry.Result.

View Source
const (
	LogModeFull        = "full"
	LogModeIncremental = "incremental"
	LogModeDryRun      = "dry-run"
	LogModeInteractive = "interactive"
)

Mode sentinel values for LogEntry.Mode.

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 QuarantineTimestampLayout = "20060102T150405.000"

QuarantineTimestampLayout is the Go reference-time layout used for the `.corrupt-<stamp>` suffix on quarantined state files. Exported so the single format is shared by the producer (this file) and the consumer (internal/angela/gc/corrupt_quarantine_pruner.go). Story 8-23 P0 fix: previously the format lived as a literal in both sites and could silently drift if one side was changed — the pruner would then fail to parse stamps and never delete anything.

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 (
	// ErrArbitrateAbort is returned when the user selected [a] in TTY
	// or when --arbitrate-rule=abort was supplied.
	ErrArbitrateAbort = errors.New("polish: arbitration aborted")

	// ErrArbitrateRefused is returned when duplicates are present but
	// the caller is non-interactive AND no --arbitrate-rule was set.
	// The pipeline surfaces this as a neutral stderr message pointing
	// at the flag (invariant I27).
	ErrArbitrateRefused = errors.New("polish: duplicate sections need TTY or --arbitrate-rule")
)

Typed errors returned by arbitrateDuplicates. Callers use errors.Is to distinguish.

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 AppendLogEntry added in v1.2.3

func AppendLogEntry(stateDir string, entry LogEntry) error

AppendLogEntry serializes the entry to a JSON line and appends it atomically to <stateDir>/polish.log. An advisory flock is held for the duration of the append to serialize concurrent polish invocations running in different processes.

I/O errors are wrapped and returned; callers should log but not block polish success on a failed log append. The pipeline treats log write failure as non-fatal.

If the entry.Timestamp is the zero value, it is set to time.Now().UTC() before marshaling — callers that want a specific timestamp (e.g. a test injecting a fixed clock) should populate the field themselves.

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 ApplySynthesizerProposal added in v1.2.0

func ApplySynthesizerProposal(p SynthesizerProposal) (string, domain.DocMeta, error)

ApplySynthesizerProposal returns the modified doc body and frontmatter after applying p. The caller is responsible for persisting both via storage.Marshal + the write pipeline (atomic write, backup).

The function does NOT mutate p.Doc — it works on copies so callers can preview the result safely (used by the dry-run path).

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 BuildPersonaReviewPrompt added in v1.2.0

func BuildPersonaReviewPrompt(personas []PersonaProfile) string

BuildPersonaReviewPrompt builds the persona-lens block for the REVIEW command. Each persona's directive MUST be review-specific: corpus-wide coherence concerns, not per-document polish fixes. When a persona has no ReviewDirective populated, we fall back to PromptDirective with an explicit WARNING line so the mis-targeted instructions do not silently degrade review quality.

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. This wrapper always passes nil personas (baseline behavior).

func BuildReviewPromptWithVHS added in v1.1.0

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

BuildReviewPromptWithVHS constructs the AI prompt including VHS cross-reference signals. When personas is non-empty, persona directives are injected into the user content and the AI is instructed to attribute each finding to the persona(s) that flagged it.

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 ExpectedOutputTokens added in v1.2.0

func ExpectedOutputTokens(inputTokens, maxOutput int) int

ExpectedOutputTokens returns the heuristic "expected" output token count for a given input size and output ceiling. Kept as a single source of truth so cost estimators outside this package don't have to re-invent the formula (which would drift over time).

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 FormatResolution added in v1.2.3

func FormatResolution(source string, choice ArbitrateChoice) string

FormatResolution renders the per-group resolution string for the LogDuplicate.Resolution field. Source is "user" or "rule"; choice is the ArbitrateChoice from the resolution layer.

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 ModelContextLimit added in v1.2.0

func ModelContextLimit(model string) (int, bool)

ModelContextLimit returns the context window size (in tokens) for a known model, or (0, false) when the model is not registered. Exported so cmd-side formatters can share this canonical table instead of duplicating it.

func ModelOutputSpeed added in v1.2.0

func ModelOutputSpeed(model string) (float64, bool)

ModelOutputSpeed returns the typical output speed (tokens/second) for a known model, or (0, false) when the model is not registered.

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 PolishLogPath added in v1.2.3

func PolishLogPath(stateDir string) string

PolishLogPath returns the canonical log file path for the supplied state directory. The caller owns the state-dir resolution — this function only composes the filename.

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 RenderBlockMarkdown added in v1.2.0

func RenderBlockMarkdown(doc *synthesizer.Doc, block synthesizer.Block) string

RenderBlockMarkdown produces the markdown snippet to insert into the doc. The heading level matches the doc's parent section level + 1 so the generated subsection nests cleanly under the section identified by block.InsertAfterHeading.

Block format:

#### <Block.Title>

```<Block.Language>
<Block.Content>
```

- <Block.Notes[0]>
- <Block.Notes[1]>

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 ValidArbitrationRule added in v1.2.3

func ValidArbitrationRule(s string) bool

ValidArbitrationRule reports whether a user-supplied string is a recognized rule value. Used by the CLI flag validator.

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 ArbitrateChoice added in v1.2.3

type ArbitrateChoice int

ArbitrateChoice is the per-group resolution a user or rule settled on.

const (
	// ChoiceFirst keeps only the earliest occurrence of the heading.
	ChoiceFirst ArbitrateChoice = iota
	// ChoiceSecond keeps only the second occurrence; falls back to
	// ChoiceFirst if the group has a single occurrence.
	ChoiceSecond
	// ChoiceBoth keeps all occurrences in source order. No merging,
	// no combining — each block is preserved verbatim.
	ChoiceBoth
	// ChoiceAbort is a sentinel returned by promptPerGroup when the
	// user selects [a]. arbitrateDuplicates converts it to
	// ErrArbitrateAbort before returning.
	ChoiceAbort
)

type ArbitrateOptions added in v1.2.3

type ArbitrateOptions struct {
	// Verbose: when true, preview 8 lines per occurrence instead of 3.
	Verbose bool
}

ArbitrateOptions carries optional UI settings for the interactive prompt. Zero value is safe.

type ArbitrationRule added in v1.2.3

type ArbitrationRule string

ArbitrationRule encodes the non-interactive policy for resolving duplicate sections in AI body output. Set from the --arbitrate-rule CLI flag.

const (
	// RuleNone means the flag was not set. In TTY the pipeline will
	// prompt per group; in non-TTY the pipeline refuses.
	RuleNone ArbitrationRule = ""

	RuleFirst  ArbitrationRule = "first"
	RuleSecond ArbitrationRule = "second"
	RuleBoth   ArbitrationRule = "both"
	RuleAbort  ArbitrationRule = "abort"
)

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 DupGroup added in v1.2.3

type DupGroup struct {
	Heading     string
	Occurrences []SectionLocation
}

DupGroup collects all occurrences of a single heading that appears more than once in an AI body. Occurrences are ordered by source appearance (first hit first).

Used to drive invariant I27 (duplicate sections trigger arbitration, never silent de-dup) in the polish pipeline.

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 LogAIInfo added in v1.2.3

type LogAIInfo struct {
	Provider         string `json:"provider"`
	Model            string `json:"model,omitempty"`
	PromptTokens     int    `json:"prompt_tokens,omitempty"`
	CompletionTokens int    `json:"completion_tokens,omitempty"`
}

LogAIInfo captures the provider detail for the AI call that produced (or was expected to produce) this polish. May be nil when no AI call was issued — e.g. aborted_corrupt_src where the pipeline refused before contacting the provider (I28).

type LogDuplicate added in v1.2.3

type LogDuplicate struct {
	Heading    string `json:"heading"`
	Count      int    `json:"count"`
	Resolution string `json:"resolution"`
}

LogDuplicate reports one duplicate-section group and how it was resolved. The Resolution field is formatted as "<source>:<decision>" where source is "user" (interactive prompt) or "rule" (--arbitrate-rule flag), and decision is first / second / both. "user:abort" and "rule:abort" may also appear — in that case the owning entry's Result is aborted_arbitrate.

type LogEntry added in v1.2.3

type LogEntry struct {
	Timestamp time.Time   `json:"ts"`
	File      string      `json:"file"`
	Op        string      `json:"op"`   // always "polish" for v1
	Mode      string      `json:"mode"` // see LogMode* constants
	AI        *LogAIInfo  `json:"ai,omitempty"`
	Findings  LogFindings `json:"findings"`
	Result    string      `json:"result"` // see LogResult* constants
	Exit      int         `json:"exit"`
}

LogEntry is the schema v1 of a single polish.log record. One entry per terminal polish state; every terminal state writes exactly one line — this is invariant I30.

The schema is intentionally flat and append-oriented: new optional fields may be added in v2 without breaking v1 readers (standard JSON compatibility). No field rename, no field removal.

func ReadLogEntries added in v1.2.3

func ReadLogEntries(stateDir string) ([]LogEntry, error)

ReadLogEntries parses every line of polish.log into a LogEntry slice, ordered oldest-first (file order). Malformed lines are skipped silently — this mirrors the tolerant read path used elsewhere in the codebase for audit artifacts (e.g. review state corruption quarantine at draft_state.go). Intended for tests and for a future `polish --show-log` subcommand.

type LogFindings added in v1.2.3

type LogFindings struct {
	LeakedFM   *LogLeakedFM   `json:"leaked_fm,omitempty"`
	Duplicates []LogDuplicate `json:"duplicates,omitempty"`
}

LogFindings aggregates the structural events detected during this polish invocation: leaked frontmatter stripped, duplicate sections arbitrated.

type LogLeakedFM added in v1.2.3

type LogLeakedFM struct {
	Stripped bool `json:"stripped"`
	Bytes    int  `json:"bytes"`
}

LogLeakedFM reports whether the AI re-emitted a `---\n...\n---\n` block on top of its body and how many bytes were removed by the sanitizer (I26).

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 is the AI instruction block used for DRAFT/POLISH.
	// It is written for the "one document, make it better" task.
	PromptDirective string
	// ReviewDirective is the AI instruction block used for REVIEW.
	// Review is corpus-wide coherence analysis — each persona must tell the
	// AI what THEIR lens looks for across the set of docs (contradictions,
	// gaps, stylistic drift, etc.), NOT how to fix a single doc. When empty,
	// BuildPersonaReviewPrompt falls back to PromptDirective with a warning
	// footer so the regression is surfaced in the prompt itself.
	ReviewDirective 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 PersonaByName added in v1.2.0

func PersonaByName(name string) (PersonaProfile, bool)

PersonaByName is the exported variant of personaByName for callers outside the angela package (e.g. cmd formatters that need to resolve persona names carried on ReviewFinding.Personas to a full PersonaProfile for display).

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 Resolution added in v1.2.3

type Resolution struct {
	Heading string
	Choice  ArbitrateChoice
}

Resolution pairs a heading with the chosen action. The slice returned by arbitrateDuplicates has one entry per input DupGroup, in the same order.

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"

	// Personas lists the persona names that flagged this finding. Populated
	// only when the review was run with persona injection.
	// When multiple personas flag the same issue, the AI emits a single
	// finding with all names here. Empty in baseline (no-persona) reviews.
	Personas []string `json:"personas,omitempty"`

	// AgreementCount is len(Personas) when personas are active. Kept as a
	// distinct field so JSON consumers (CI scripts, dashboards) can filter
	// by agreement strength without parsing the array. Zero in baseline reviews.
	AgreementCount int `json:"agreement_count,omitempty"`
}

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.

func RunSynthesizerReview added in v1.2.0

func RunSynthesizerReview(
	ctx context.Context,
	reader domain.CorpusReader,
	docs []domain.DocMeta,
	registry *synthesizer.Registry,
	cfg config.SynthesizersConfig,
) ([]ReviewFinding, error)

RunSynthesizerReview runs every enabled Example Synthesizer against each doc in docs and converts the resulting opportunities into ReviewFindings. Findings are evidence-grounded by construction because each synthesizer Detect/Synthesize call returns a literal Evidence list pointing at the source spans that justify the proposed enrichment.

Severity defaults to "info" per the 2026-04-15 design decision (Q9). Operators escalate selectively via cfg.Synthesizers.PerSynthesizer "<name>.severity" or globally via cfg.Synthesizers.PerSynthesizer "review.severity".

The function never returns the AI provider's findings - the cmd layer merges these synthesizer findings with Review()'s output.

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

	// Personas, when non-empty, activates persona-aware review.
	// The prompt injects BuildPersonaPrompt(Personas) and instructs the AI
	// to attribute each finding to the persona(s) that flagged it. Activation
	// is strictly opt-in: the cmd layer populates this only when the user
	// explicitly opted in (--persona flag, --use-configured-personas, or
	// interactive confirmation). nil/empty = baseline review.
	Personas []PersonaProfile
}

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 SanitizeReport added in v1.2.3

type SanitizeReport struct {
	// LeakedFM records whether an AI-emitted `---\n...\n---\n` block
	// was stripped from the head of the body. Zero value means no
	// strip happened.
	LeakedFM StripInfo

	// DupGroups holds the duplicate-section groups that were detected
	// (before any arbitration). Empty when no duplicates.
	DupGroups []DupGroup

	// Resolutions holds the per-group resolutions that were applied.
	// Same length and order as DupGroups on success. Populated when
	// arbitration succeeded; empty on arbitration abort/refuse.
	Resolutions []Resolution

	// Source records where the arbitration decision came from:
	// "user" (TTY prompt), "rule" (--arbitrate-rule flag), or "" when
	// no duplicates were present.
	Source string
}

SanitizeReport describes the structural events encountered while processing an AI polish response: leaked frontmatter stripped, and duplicate sections detected + arbitrated. Callers wire this into a LogEntry for audit (invariant I30) and into stderr messages when --verbose is set.

func DetectStructuralIssues added in v1.2.3

func DetectStructuralIssues(rawAIOutput []byte) ([]byte, SanitizeReport)

DetectStructuralIssues returns the findings (leaked FM, duplicate sections) in an AI polish response WITHOUT running arbitration. Used by the dry-run path to report findings on stderr while preserving its zero-side-effect contract (AC-14): no prompt, no write, no polish.log entry.

The returned body is the input with any leaked `---\n...\n---\n` block stripped from the head — useful for dry-run stdout so that piped tools see a clean body rather than a mixed full-doc payload.

func SanitizeAIOutput added in v1.2.3

func SanitizeAIOutput(
	rawAIOutput []byte,
	rule ArbitrationRule,
	isTTY bool,
	streams domain.IOStreams,
	opts ArbitrateOptions,
) (cleanedBody []byte, report SanitizeReport, err error)

SanitizeAIOutput runs the sanitize + arbitrate pipeline on the raw AI response. This is the single entry point the polish command calls after a provider response lands and before the diff/write stage.

Responsibilities, in order:

  1. If the AI cheated and emitted a full document (leading `---\n` block), extract the body via storage.ExtractFrontmatter — the leaked front matter bytes are discarded. Invariant I26.
  2. Defensively run stripLeakedFrontmatter one more time in case the AI wrote an unparseable `---` prefix that ExtractFrontmatter rejected but that stripLeakedFrontmatter's simpler scan can still match.
  3. Detect duplicate sections via detectDuplicateSections. I27.
  4. Arbitrate per the given rule/TTY/streams. User abort or non-TTY refusal surface as typed errors (ErrArbitrateAbort / ErrArbitrateRefused).
  5. Apply resolutions to produce a cleaned body.

The returned SanitizeReport is always populated with the findings that were observed, including Source (pre-populated before arbitration runs) so callers can log the outcome regardless of whether arbitration succeeded, aborted, or was refused.

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 SectionLocation added in v1.2.3

type SectionLocation struct {
	Heading   string // trimmed heading text including the "## " prefix
	Line      int    // 1-based line number of the heading line
	ByteStart int    // inclusive offset in body where the heading line begins
	ByteEnd   int    // exclusive offset where the section ends
	Words     int    // word count of the section body (after the heading line) — for UI preview
}

SectionLocation describes where a `## Heading` section sits in a body.

ByteStart / ByteEnd form a half-open range [ByteStart, ByteEnd) over the original body bytes: the range begins at the heading line and ends at the start of the next heading (or end of body for the last section). applyDuplicateResolutions uses these offsets for in-place removal without reparsing.

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 StripInfo added in v1.2.3

type StripInfo struct {
	Stripped bool
	Bytes    int // number of bytes removed (the leaked block length)
	Line     int // 1-based starting line of the stripped block (always 1 for prefix strips)
}

StripInfo reports whether a leaked frontmatter block was stripped from an AI-produced body, and if so how many bytes were removed. When Stripped is false, the remaining fields carry no meaning.

Used by the polish pipeline to honor invariant I26 (leaked `---` blocks stripped from AI body before write) — silent by default, visible under --verbose (see story 8-21 AC-4).

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.

func SynthesizerDraftSuggestions added in v1.2.0

func SynthesizerDraftSuggestions(
	docPath string,
	docContent []byte,
	registry *synthesizer.Registry,
	cfg config.SynthesizersConfig,
) []Suggestion

SynthesizerDraftSuggestions surfaces enrichment opportunities during `lore angela draft` WITHOUT modifying anything. Draft is offline forever (invariant I1) - this code path imports no AI provider, performs no network I/O, and writes nothing.

What it does: parse the doc, ask each enabled synthesizer if it has candidates whose signature is stale or missing, and emit one Suggestion per stale synthesizer with category "synthesizer" and code "pending_enrichment". The user sees these alongside structural and persona suggestions and can decide whether to run polish.

Dual-mode (I2): the function uses synthesizer.ParseDoc which uses the permissive frontmatter parser, so docs in standalone mkdocs/hugo corpora are accepted even when their frontmatter lacks lore-required fields.

docContent is the raw doc bytes (frontmatter + body); the function does its own parse rather than reusing AnalyzeDraft's stripFrontMatter path because the synthesizer needs structured access to sections and lines.

type SynthesizerProposal added in v1.2.0

type SynthesizerProposal struct {
	Doc             *synthesizer.Doc
	SynthesizerName string
	Block           synthesizer.Block
	Evidence        []synthesizer.Evidence
	Warnings        []synthesizer.Warning
	Signature       synthesizer.Signature
	CandidateKey    string

	// RenderedMarkdown is the markdown snippet to insert into the doc body
	// (Title heading + fenced code block + notes bullets). Already sized
	// for the parent section's depth.
	RenderedMarkdown string
}

SynthesizerProposal is one insertion the polish pipeline can offer the user. It carries the rendered Block plus the signature that should be written to the doc's frontmatter once the user accepts.

Polish never modifies docs without proposing first: proposals are routed through the interactive diff in interactive mode, or the dry-run reporter in --synthesizer-dry-run mode (I7 — no silent merge).

func SynthesizerProposalsForDoc added in v1.2.0

func SynthesizerProposalsForDoc(
	doc *synthesizer.Doc,
	registry *synthesizer.Registry,
	cfg config.SynthesizersConfig,
) ([]SynthesizerProposal, error)

SynthesizerProposalsForDoc gathers polish proposals for a single doc by iterating every applicable synthesizer and short-circuiting any whose existing signature is still fresh (I6).

version is looked up from the per-synthesizer config; absence falls back to "" which forces the freshness comparison to use raw hash equality.

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.

Directories

Path Synopsis
Package gc holds the Pruner registry used by `lore doctor --prune`.
Package gc holds the Pruner registry used by `lore doctor --prune`.
Package synthesizer is Angela's doc-enrichment framework.
Package synthesizer is Angela's doc-enrichment framework.
impls/apipostman
Package apipostman implements the first concrete ExampleSynthesizer an api-postman synthesizer that composes ready-to-import HTTP+JSON request examples from information already present in a feature doc's Endpoints / Filters / Security sections.
Package apipostman implements the first concrete ExampleSynthesizer an api-postman synthesizer that composes ready-to-import HTTP+JSON request examples from information already present in a feature doc's Endpoints / Filters / Security sections.

Jump to

Keyboard shortcuts

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