/** * Hygiene Scan — sentient background loop (T1636, upgraded T1679). * * Each dream/sleep cycle this module runs 4 hygiene checks through a * cost-disciplined tiered escalation pipeline and emits BRAIN observations * tagged with 'hygiene:*' so the system self-organises without manual audits. * * ## Tiered escalation (T1679) * * Each finding passes through tiers cheapest-first. A tier that reaches a * confident conclusion stops the chain — the next tier is never called. * * Tier 1 — Filesystem (free): `existsSync`, file age checks. * Tier 2 — SQL (free): the 4 existing DB-backed scans (orphan, top-level, * content, premature-close-leak). * Tier 3 — Jaccard similarity (free, lexical): when a finding is ambiguous, * compute Jaccard coefficient between the task's title+description tokens * and a snapshot of recent activity tokens. Used as a fast classifier before * calling the LLM. * Tier 4 — LLM reasoning (paid, last resort): only when Jaccard leaves the * ambiguity score in the [0.4, 0.7) band. Calls the daemon-configured LLM * with a structured-output schema. Uses `resolveCredentials(provider)` from * `core/llm/credentials.ts` — NEVER reads `process.env` directly. * * ## LLM call structure * * - Provider resolved via `resolveLLMForRole('hygiene')`; falls back through * `llm.roles.hygiene` → `llm.default` → `llm.daemon` → implicit fallback. * The implicit fallback uses `HYGIENE_FALLBACK_MODEL` (see role-resolver.ts). * - Structured output schema (Zod): {@link HygieneEscalationResult}. * - `is_real_defect: boolean` — confident classification. * - `confidence: number` — 0..1 from the LLM. * - `recommended_action: 'auto-fix' | 'propose' | 'ignore'`. * - `reasoning: string` — short explanation. * * ## Action routing * * - `confidence >= 0.9` + non-destructive `auto-fix` → executed immediately. * - `confidence 0.7..0.9` → emit Tier-2 sentient proposal. * - `confidence < 0.7` → BRAIN observation tagged `hitl-required`. * * ## Cost cap * * `maxLlmCallsPerCycle` (default: {@link DEFAULT_MAX_LLM_CALLS_PER_CYCLE}) limits * total LLM calls across all scans. When the cap is reached, remaining ambiguous * findings are emitted as observations without LLM escalation. * * Scan 1 — orphan tasks * Tasks whose `parent_id` points to a done/cancelled/missing parent. These * tasks are orphaned and may never be picked. Emits observation tagged * 'hygiene:orphan'. * * Scan 2 — top-level type=task * Root-level tasks (no `parent_id`, type='task'). These should be promoted * to an epic or re-parented. Emits observation tagged 'hygiene:top-level-orphan'. * * Scan 3 — content quality * Tasks with missing acceptance criteria, missing files (for type=task), or * acceptance criteria shorter than 20 chars. Emits observation tagged * 'hygiene:content-defect'. * * Scan 4 — premature-close leaks (defensive) * Tasks whose status='done' but parent epic still has active/pending siblings. * The T1632 invariant should prevent this; this scan is a safety net. * Emits observation tagged 'hygiene:premature-close-leak' (CRITICAL severity). * * Cadence: configurable via {@link HygieneScanOptions.scanIntervalMs}. * Default: {@link HYGIENE_SCAN_INTERVAL_MS} (once per 4-hour dream cycle). * * Integration: called from `safeRunTick` in tick.ts (fire-and-forget). * Fully injectable: `db`, `observeMemory`, `isKilled`, and `callLlm` can be * overridden by tests without touching the real DB or LLM. * * @task T1636 * @task T1679 * @see T1632 — premature-close prevention (Scan 4 is its defensive shadow) * @see T1635 — stage-drift detector (pattern this module follows) * @see T1677 — centralized credential resolver * @see ADR-054 — Sentient Loop Tier-1/Tier-2 */ import type { DatabaseSync } from 'node:sqlite'; import { z } from 'zod'; /** * Default cadence between hygiene scan passes (4 hours in milliseconds). * Longer than stage-drift (30 min) because hygiene issues evolve slowly. * Configurable via {@link HygieneScanOptions.scanIntervalMs}. */ export declare const HYGIENE_SCAN_INTERVAL_MS: number; /** * Minimum acceptance criterion length (chars) below which a criterion is * classified as "vague" and triggers a content-defect observation. */ export declare const VAGUE_AC_CHAR_THRESHOLD = 20; /** * Maximum number of task IDs to embed in a single observation text to keep * observations readable. Excess IDs are truncated with a count suffix. */ export declare const MAX_TASK_IDS_IN_OBSERVATION = 10; /** * Default maximum number of LLM calls allowed per hygiene scan cycle. * When this cap is reached, remaining ambiguous findings skip the LLM tier * and are emitted as plain BRAIN observations. */ export declare const DEFAULT_MAX_LLM_CALLS_PER_CYCLE = 50; /** * Lower bound of the Jaccard ambiguity band. * Findings with similarity < JACCARD_AMBIGUITY_LOW are confidently "not real" * (well below recent activity — stale orphan). No LLM needed. */ export declare const JACCARD_AMBIGUITY_LOW = 0.4; /** * Upper bound of the Jaccard ambiguity band. * Findings with similarity >= JACCARD_AMBIGUITY_HIGH are confidently "real" * (closely related to recent active work). No LLM needed. */ export declare const JACCARD_AMBIGUITY_HIGH = 0.7; /** * Confidence threshold above which a non-destructive auto-fix is executed * immediately without human review. */ export declare const LLM_CONFIDENCE_AUTO_EXECUTE = 0.9; /** * Confidence threshold above which a Tier-2 sentient proposal is emitted * instead of a plain HITL observation. */ export declare const LLM_CONFIDENCE_PROPOSE = 0.7; /** * Default daemon LLM provider when none is configured. */ export declare const DEFAULT_DAEMON_PROVIDER: "anthropic"; /** * Zod schema for the structured output returned by the LLM escalation call. */ export declare const HygieneEscalationResultSchema: z.ZodObject<{ is_real_defect: z.ZodBoolean; confidence: z.ZodNumber; recommended_action: z.ZodEnum<{ ignore: "ignore"; "auto-fix": "auto-fix"; propose: "propose"; }>; reasoning: z.ZodString; }, z.core.$strip>; /** * Structured output returned by the LLM escalation tier. */ export type HygieneEscalationResult = z.infer; /** * A single ambiguous finding that may be escalated through Jaccard / LLM tiers. */ export interface AmbiguousFinding { /** Task ID that triggered the finding. */ taskId: string; /** Which scan produced this finding. */ scanKind: 'orphan' | 'top-level-orphan' | 'content-defect' | 'premature-close-leak'; /** Human-readable reason for the finding. */ reason: string; /** Task title (used for Jaccard tokenization). */ title: string; /** Task description (used for Jaccard tokenization, may be empty). */ description: string; /** ISO-8601 updated_at from the DB row (used for filesystem age check). */ updatedAt: string | null; } /** * Outcome of a single Jaccard + LLM escalation pass on a finding. */ export interface EscalationOutcome { /** Which tier resolved the finding. */ decidedBy: 'jaccard' | 'llm' | 'cap-exceeded'; /** Whether the finding was judged to be a genuine defect. */ isRealDefect: boolean; /** Confidence score (0..1). Jaccard-decided → Jaccard similarity or 1-similarity. */ confidence: number; /** Action taken or recommended. */ action: 'auto-fix' | 'propose' | 'observation' | 'ignored' | 'skipped'; /** Short explanation. */ reasoning: string; } /** * Injected LLM call function type (allows test mocking without real LLM). * * Takes a finding description and returns the structured output. * Should throw on hard errors; return low-confidence result on soft errors. */ export type LlmEscalateCallFn = (findingText: string, taskContext: string) => Promise; /** * Options for {@link runHygieneScan}. */ export interface HygieneScanOptions { /** Absolute path to the project root (contains `.cleo/`). */ projectRoot: string; /** Absolute path to sentient-state.json. */ statePath: string; /** * Override for the tasks.db handle. Injected by tests. * When omitted, `getNativeDb()` is called after ensuring the DB is open. */ db?: DatabaseSync | null; /** * Override for the memory-observe function. Injected by tests to avoid * writing to a real brain.db during unit tests. * * Signature matches `memoryObserve` from `@cleocode/core/internal`. */ observeMemory?: (params: { text: string; title: string; type?: string; }, projectRoot: string) => Promise; /** * Kill-switch check. Injected by tests. * When omitted, reads the state file via `readSentientState`. */ isKilled?: () => Promise; /** * Override for the LLM escalation call. Injected by tests to avoid real LLM calls. * When omitted, the real daemon-provider LLM is resolved from config. */ callLlm?: LlmEscalateCallFn; /** * Maximum number of LLM calls allowed per scan cycle. * Defaults to {@link DEFAULT_MAX_LLM_CALLS_PER_CYCLE}. */ maxLlmCallsPerCycle?: number; /** * Reference tokens for Jaccard similarity (recent active task titles + descriptions). * When omitted, queried from the DB. Injected by tests for determinism. */ recentActivityTokens?: Set; } /** * Per-check result within a {@link HygieneScanOutcome}. */ export interface HygieneScanCheckResult { /** Number of defective tasks found by this check. */ found: number; /** Number of observations emitted. */ observed: number; /** Human-readable detail line. */ detail: string; } /** * Stats for the LLM escalation tier across all scans. */ export interface LlmEscalationStats { /** Number of findings that were escalated to the LLM tier. */ escalated: number; /** Number of findings decided by Jaccard (LLM not called). */ decidedByJaccard: number; /** Number of findings skipped because the LLM call budget was exhausted. */ skippedBudgetCap: number; /** Number of findings auto-executed at high confidence. */ autoExecuted: number; /** Number of findings emitted as Tier-2 proposals. */ proposalsEmitted: number; /** Number of findings marked HITL-required. */ hitlRequired: number; } /** * Outcome of {@link runHygieneScan}. */ export interface HygieneScanOutcome { /** How the scan ended. */ kind: 'killed' | 'no-db' | 'scanned' | 'error'; /** Results per scan (orphan, top-level, content, premature-close). */ checks: { orphan: HygieneScanCheckResult; topLevelOrphan: HygieneScanCheckResult; contentDefect: HygieneScanCheckResult; prematureCloseLeak: HygieneScanCheckResult; }; /** Total observations emitted across all checks. */ totalObserved: number; /** LLM escalation statistics across all checks. */ llmStats: LlmEscalationStats; /** Human-readable summary line. */ detail: string; } /** * Tokenize a string for Jaccard similarity computation. * Lowercases, removes punctuation, splits on whitespace, filters short tokens. * Returns a Set of unique tokens. */ export declare function tokenize(text: string): Set; /** * Compute the Jaccard similarity coefficient between two token sets. * Returns 0 when both sets are empty. * * Jaccard(A, B) = |A ∩ B| / |A ∪ B| */ export declare function jaccardSimilarity(a: Set, b: Set): number; /** * Run all 4 hygiene scans in a single pass with tiered escalation (T1679). * * Steps: * 1. Kill-switch check → abort if active. * 2. Resolve tasks.db (injected or real). * 3. Resolve LLM call function (injected or real). * 4. Build Jaccard corpus from recent active tasks. * 5. Run Scan 1 (orphan), Scan 2 (top-level), Scan 3 (content), Scan 4 (premature-close). * 6. Escalate ambiguous findings through Jaccard → LLM tiers. * 7. Emit BRAIN observations for each check that found real defects. * * @param options - Scan options (see {@link HygieneScanOptions}) * @returns {@link HygieneScanOutcome} * * @task T1636 * @task T1679 */ export declare function runHygieneScan(options: HygieneScanOptions): Promise; /** * Safe wrapper for {@link runHygieneScan} — swallows unexpected exceptions. * * Used from `safeRunTick` in tick.ts as a fire-and-forget best-effort call. * Errors never propagate to the tick caller. * * @param options - Scan options * @returns Scan outcome or an error outcome on unexpected throw. * * @task T1636 * @task T1679 */ export declare function safeRunHygieneScan(options: HygieneScanOptions): Promise; //# sourceMappingURL=hygiene-scan.d.ts.map