/** * 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<Record<AcceptanceDriftKind, number>>; /** 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<string>; /** * 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