/**
* Detect tasks whose acceptance criteria disagree between their two stores.
*
* ## The structural fact (gh#1290)
*
* Acceptance criteria live in **two places**, with a projection mechanism
* between them, and until now nothing asserted they agree:
*
* tasks_tasks.acceptance_json the JSON column — what `cleo show` reads
* tasks_task_acceptance_criteria typed rows, one per criterion, keyed by `kind`
* tasks_acceptance_projection_dirty/_state the projection mechanism
*
* Two representations of one fact, a mechanism to keep them in sync, and no
* check that it worked.
*
* ## The composition rule, measured rather than assumed
*
* It is tempting to state the rule as *"the JSON column holds **text** criteria;
* the rows table holds those **plus** `child_task` projections"* — i.e.
* `json == text`. That is what a sample of older containers suggests, and it is
* **wrong for the live convention**.
*
* The JSON column does carry child projections, serialised as text. `T001`'s
* only JSON entry is the string `"Complete child T9092: …"`, and 476 tasks
* carry at least one such string. Of 582 tasks with `child_task` rows, 446 have
* `json == text + child`.
*
* Measured across a 5,067-task store, the convention **changed during May 2026**
* and has been uniform since:
*
* month children IN json children NOT in json
* 2026-04 14 66
* 2026-05 363 38
* 2026-06+ 68 0
*
* tasks created >= 2026-06-01: 608, violating `json == text + child`: 1
* tasks created < 2026-06-01: 4459, violating `json == text + child`: 163
*
* So the live rule is `json == text + child`, honoured in 607 of 608 tasks
* created since the change. The pre-change rows are a legacy convention, not
* drift, and are reported separately so that a real regression is not buried
* under them.
*
* ## What this must NOT be built on
*
* `tasks_acceptance_projection_state` is the obvious place to ask "is the
* projection fresh?", and it lies. Measured 2026-09-12:
*
* projection_key status last_projected_at dirty rows
* task_acceptance fresh 2026-05-26 08:48:55 0
*
* The projection had not run in three and a half months, reported itself
* `fresh`, and had an empty dirty queue — while acceptance rows continued to be
* written the whole time by a different, inline path. A freshness marker that is
* *written* rather than *derived* is a claim, not a measurement. This scan
* therefore derives the answer from the data every time and never reads that
* table.
*
* ## This is an OPERATOR command, NOT a CI gate — deliberately
*
* The baseline is per-install and lives at `.cleo/acceptance-drift-baseline.json`,
* which `.cleo/.gitignore` excludes by design (line 25 is a blanket `*` with an
* explicit allow list, and this file is correctly not on it — see
* {@link ACCEPTANCE_DRIFT_BASELINE_FILE} for why it must not be tracked).
*
* So **in CI, or in any fresh clone, no baseline exists, every entry reports
* unbaselined, and this command exits non-zero.** That is not a bug to route
* around; it follows from the baseline being about rows in one store rather
* than about source files. Every other baselined check in this repo IS a CI
* gate whose baseline is tracked, so the next reader who sees
* `--update-baseline` and a `.cleo/*-baseline.json` will reasonably
* pattern-match this into `cleo check arch`. Do not.
*
* What CI *can* assert is the invariant that needs no per-install state: **no
* task created since the convention settled violates `json == text + child`.**
* That is a single query over the same data and it is clean in a fresh clone.
*
* ## Scope: cardinality, not content
*
* This compares COUNTS — how many criteria each store holds. It cannot see a
* task whose JSON has the right number of entries but the wrong text, which is
* what a renamed child produces, since JSON entries are literal
* `"Complete child T9092:
"` strings.
*
* That gap was measured rather than assumed. Comparing the sorted multiset of
* JSON strings against the row texts, restricted to tasks whose counts already
* agree: **601 current-era tasks, 0 content differences; 4,053 legacy tasks, 1**
* — `T11011`. So count identity is an excellent proxy in this store, and
* content comparison would earn one legacy row. Named as out of scope rather
* than left for a reader to discover.
*
* @task T12157
* @see ADR-088 — PM-Core V2 containers
* @see ADR-092 — failure geometries; "a status surface that reports a state it
* does not measure"
*/
/**
* Where a project records the drift it has decided to live with.
*
* Per-INSTALL, not per-repo. The entries are task ids from this project's own
* store, so a baseline committed to the cleo repository would bake cleocode's
* task ids into a CLI that ships to everyone else.
*/
export declare const ACCEPTANCE_DRIFT_BASELINE_FILE = "acceptance-drift-baseline.json";
/**
* The date from which `json == text + child` has been honoured uniformly.
*
* Not a guess: containers created in 2026-04 omitted child projections from the
* JSON column 66 times against 14 that included them; 2026-05 is the crossover
* (363 vs 38); from 2026-06 onward it is 68 vs 0. Across ALL tasks created on or
* after this date, exactly **one** violates the rule out of 608.
*
* Rows older than this are reported as `legacy` so a reader can see WHY an entry
* is in the baseline. It is a REPORTING attribute only and does not decide what
* fails — see {@link AcceptanceDriftScanResult.unbaselined} for why.
*/
export declare const ACCEPTANCE_CONVENTION_SETTLED_AT = "2026-06-01";
/**
* How a task's two acceptance stores disagree, once the container/projection
* composition rule has been applied.
*/
export type AcceptanceDriftKind =
/** JSON holds criteria; the rows table has none at all. */
'json-never-projected'
/** Rows exist while the JSON column is empty — `cleo show` reads the JSON, so
* a read cannot see criteria that exist. The only data-losing shape. */
| 'rows-unreadable'
/** JSON holds exactly the TEXT criteria and omits the child projections —
* the pre-2026-06 convention, expected on older rows, a regression on new
* ones. */
| 'legacy-children-omitted'
/** Both populated and disagreeing by something other than the child
* projections. */
| 'count-mismatch'
/** Equal populations carry different criterion text or ordering. */
| 'content-mismatch';
/** One task whose acceptance stores disagree. */
export interface AcceptanceDriftEntry {
/** Task id, e.g. `T11176`. */
readonly taskId: string;
/** `epic` | `task` | `subtask` | `saga`. */
readonly type: string;
/** How the two stores disagree. */
readonly kind: AcceptanceDriftKind;
/** Number of entries in `acceptance_json`. */
readonly jsonCount: number;
/** Non-child rows in `tasks_task_acceptance_criteria` (text or evidence-bound). */
readonly textRowCount: number;
/** Rows with `kind='child_task'` — container projections, not text. */
readonly childRowCount: number;
/**
* Whether `created_at` carries the application's ISO-8601 `T` separator.
*
* `false` means the value came from SQLite's `CURRENT_TIMESTAMP` column
* default — the fingerprint of a write that bypassed the application layer.
* Measured across a 5,067-task store: the application path had **0 failures
* in 4,771 writes**, the bypass path 24 in 26. So this field usually answers
* "why" without further investigation.
*/
readonly appWritten: boolean;
/** The task's `created_at`, verbatim. */
readonly createdAt: string | null;
/**
* `true` when the task predates {@link ACCEPTANCE_CONVENTION_SETTLED_AT}, so
* its shape reflects the older convention rather than a regression.
*/
readonly legacyEra: boolean;
}
/** The result of a whole-store acceptance consistency scan. */
export interface AcceptanceDriftScanResult {
/** Absolute path of the store that was scanned. */
readonly storePath: string;
/** `false` when there is no `.cleo/cleo.db` to scan; everything else is 0. */
readonly storeExists: boolean;
/** Tasks examined. */
readonly tasksScanned: number;
/**
* Tasks that disagree on a RAW count comparison, before the composition rule.
* Reported only so the difference from `entries.length` is visible — a reader
* who has run the raw query themselves should be able to see why this number
* is larger.
*/
readonly rawDisagreements: number;
/** The genuine residue, after applying the container/projection rule. */
readonly entries: readonly AcceptanceDriftEntry[];
/** One line per drift kind, for a summary line. */
readonly byKind: Readonly>;
/** Entries created on or after {@link ACCEPTANCE_CONVENTION_SETTLED_AT}. */
readonly currentEraDrift: number;
/**
* Entries whose task id is NOT in the baseline — the number that gates.
*
* Creation date deliberately does not appear in this calculation. Acceptance
* rows are written throughout a task's life (reparenting, child completion,
* an edited criterion), so drift is introduced by a WRITE while a birthday is
* fixed forever. Keying the gate on creation date exempts every old task from
* every future regression: measured on this store, **223 tasks created before
* the convention settled have been updated since**, and a date-keyed gate is
* blind to new drift across 4,459 of 5,068 rows — 88% of the store.
*
* A baseline of known ids fixes that. It is wrong about the past on purpose
* so it can be right about the future: the 163 historical rows stay quiet,
* and a pre-June task that drifts tomorrow is a net-add and fails.
*/
readonly unbaselined: readonly AcceptanceDriftEntry[];
/** Baselined ids that no longer drift — safe to prune. */
readonly staleBaselineIds: readonly string[];
/** Absolute path of the baseline file, whether or not it exists. */
readonly baselinePath: string;
}
/**
* Read the per-install baseline of task ids whose drift is accepted.
*
* A missing or unreadable file yields an empty set, so a first run reports
* everything rather than silently passing. Failing open here would reproduce
* the defect this module exists to catch.
*/
export declare function readAcceptanceDriftBaseline(baselinePath: string): ReadonlySet;
/**
* Write the baseline from a scan, accepting every current entry.
*
* @param scan - a completed scan; its entries become the accepted set.
* @returns the number of ids recorded.
*/
export declare function writeAcceptanceDriftBaseline(scan: AcceptanceDriftScanResult): number;
/**
* Classify one task, or `null` when its two stores are consistent.
*
* Exported for testing: the composition rule is the load-bearing part of this
* module and deserves direct coverage rather than coverage through a DB.
*
* @param row - one task's JSON count and per-kind row counts.
* @returns the drift kind, or `null` when the task is consistent.
*/
export declare function classifyAcceptanceDrift(row: {
jsonCount: number;
textRowCount: number;
childRowCount: number;
/** Whether persisted criterion text and ordering agree when assessed. */
contentMatches?: boolean;
}): AcceptanceDriftKind | null;
/**
* Scan a project's store for acceptance drift.
*
* Read-only: opens a snapshot handle through {@link openCleoDbSnapshot} rather
* than the live chokepoint, so a survey never registers a writable handle or
* competes for the WAL.
*
* @param projectRoot - absolute path to the project root.
* @returns the survey; `entries` is empty for a consistent store.
*
* @example
* ```ts
* const scan = scanAcceptanceDrift('/mnt/projects/cleocode');
* console.log(scan.entries.length, 'of', scan.rawDisagreements, 'raw');
* // 58 of 164 raw
* ```
*
* @task T12157
*/
export declare function scanAcceptanceDrift(projectRoot: string): AcceptanceDriftScanResult;
//# sourceMappingURL=acceptance-drift.d.ts.map