/** * Per-parent Proposal Deduplication Gate (T1592). * * Prevents the T1555-style burst failure mode where the sentient proposer * runs twice on the same audit output and creates near-identical pairs of * proposed tasks (e.g. T1544/T1550, T1545/T1551, ...). * * Strategy * -------- * Before {@link transactionalInsertProposal} is called, the propose tick * computes a stable `dedupHash` from * * sha256(`${normalizedParentId}${normalizedTitle}${normalizedRationale}`) * * where `normalize = lowercase + strip punctuation + collapse whitespace`. * * The hash is then checked against existing tier-2 proposals in tasks.db * within a 24-hour window: * - parent scope: same `parent_id` (or "" if both null) AND same hash * - timestamp window: `created_at >= datetime('now', '-1 day')` * - tier-2 only: `labels_json LIKE '%sentient-tier2%'` * * If a match is found the proposal is REJECTED, the rejection is appended * to `.cleo/audit/sentient-dedup.jsonl`, and the candidate is dropped. * * If no match is found the hash is embedded into `tasks.notes_json` (as part * of the existing `proposal-meta` envelope, see {@link ProposedTaskMeta}) and * future ticks will see it via the same query path. This avoids a schema * migration — the existing proposal-meta JSON is the single source of truth. * * @task T1592 (Foundation Lockdown · Wave A · Worker 4) * @see ADR-054 — Sentient Loop Tier-2 */ import type { DatabaseSync } from 'node:sqlite'; /** Path (relative to projectRoot) of the dedup-rejection audit log. */ export declare const SENTIENT_DEDUP_AUDIT_FILE = ".cleo/audit/sentient-dedup.jsonl"; /** * Default look-back window for dedup checks, in hours. * Mirrors the wording of T1592: "within last 24h". */ export declare const DEFAULT_DEDUP_WINDOW_HOURS = 24; /** Inputs for {@link computeDedupHash}. */ export interface DedupHashInput { /** Parent task ID. `null` / `undefined` are coerced to a sentinel. */ parentId: string | null | undefined; /** Proposal title (will be normalized). */ title: string; /** * Proposal acceptance criteria, rationale, or description. * The mission spec calls this `normalizedAcceptance`; for the Tier-2 * proposer this is the `rationale` field of {@link ProposalCandidate}. */ acceptance: string; } /** Outcome of {@link checkDedupCollision}. */ export interface DedupCheckResult { /** `true` when a dup is detected and the proposal MUST be skipped. */ isDuplicate: boolean; /** When `isDuplicate=true`, the existing task ID that owns the matching hash. */ existingTaskId?: string; /** Stable dedup hash regardless of outcome (callers persist it on insert). */ dedupHash: string; } /** Persisted shape inside `.cleo/audit/sentient-dedup.jsonl`. */ export interface DedupRejectionRecord { /** ISO-8601 timestamp of the rejection. */ timestamp: string; /** Reason discriminant. */ reason: 'per-parent-dedup'; /** The dedup hash that collided. */ dedupHash: string; /** Parent task ID (null preserved in JSON for clarity). */ parentId: string | null; /** The would-have-been-inserted candidate title. */ title: string; /** Proposal source (brain | nexus | test). */ source: string; /** External source ID (brain entry id, nexus node id, etc.). */ sourceId: string; /** ID of the existing task whose hash matched. */ existingTaskId: string; /** Look-back window applied, in hours. */ windowHours: number; } /** * Normalize a free-text field for hash-stable comparison. * * Steps (in order): * 1. Lower-case * 2. Strip every code point NOT in `[a-z0-9\s]` * (Unicode-aware — accented Latin letters are stripped, matching the * "strip punctuation" intent for English-language proposal titles.) * 3. Collapse runs of whitespace to a single space * 4. Trim leading/trailing whitespace * * @param raw - Input text. `null` / `undefined` become the empty string. * @returns Normalized form, suitable for use as a hash input segment. */ export declare function normalizeForDedup(raw: string | null | undefined): string; /** * Compute the per-parent dedup hash. * * The hash is sha-256 over `parent | title | acceptance` (all normalized, * separated by ``). Stable across processes and machines — same input * always yields the same hex digest. * * @param input - Parent + title + acceptance. * @returns 64-char lowercase hex sha-256 digest. */ export declare function computeDedupHash(input: DedupHashInput): string; /** Options for {@link checkDedupCollision}. */ export interface DedupCheckOptions { /** Open DatabaseSync handle to tasks.db. May be null (then no-op). */ tasksDb: DatabaseSync | null; /** The candidate hash inputs. */ candidate: DedupHashInput; /** * Look-back window in hours. Defaults to {@link DEFAULT_DEDUP_WINDOW_HOURS}. * Set to `0` to disable the time bound (useful for tests). */ windowHours?: number; } /** * Query tasks.db for an existing tier-2 proposal whose * `notes_json` contains the same `dedupHash` AND that lives under the same * parent (or root scope) AND was created within the look-back window. * * The check is deliberately conservative: * - parent scope: rows are filtered by `parent_id IS :parentId` (with the * `` sentinel mapped to `IS NULL`) * - time bound: `created_at >= datetime('now', '-N hours')` * - tier-2 only: `labels_json LIKE '%sentient-tier2%'` * - hash match: `notes_json LIKE '%"dedupHash":""%'` (substring match * is safe — the hex digest is 64 chars of `[0-9a-f]` and cannot collide * with any other JSON value). * * @param opts - Check options. * @returns DedupCheckResult; `isDuplicate=true` when a collision is found. */ export declare function checkDedupCollision(opts: DedupCheckOptions): DedupCheckResult; /** Inputs for {@link recordDedupRejection}. */ export interface DedupRejectionInput { /** Project root (audit log is written under `/.cleo/audit/`). */ projectRoot: string; /** Parent task ID (null preserved). */ parentId: string | null; /** Proposal title. */ title: string; /** Source (brain | nexus | test). */ source: string; /** External source ID. */ sourceId: string; /** Hash that collided. */ dedupHash: string; /** Existing task ID that owns the matching hash. */ existingTaskId: string; /** Window applied (defaults to {@link DEFAULT_DEDUP_WINDOW_HOURS}). */ windowHours?: number; } /** * Append a single NDJSON line to `.cleo/audit/sentient-dedup.jsonl` * documenting a rejected proposal. * * The directory is created if missing. The write is best-effort — failures * are surfaced via the returned promise (callers should `await` and log on * failure but MUST NOT propagate the error into the propose tick result). * * @param input - Rejection details. */ export declare function recordDedupRejection(input: DedupRejectionInput): Promise; //# sourceMappingURL=proposal-dedup.d.ts.map