@cleocode/contracts 2026.5.108 → 2026.5.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/__tests__/db-inventory.test.d.ts +25 -0
  2. package/dist/__tests__/db-inventory.test.d.ts.map +1 -0
  3. package/dist/__tests__/db-inventory.test.js +148 -0
  4. package/dist/__tests__/db-inventory.test.js.map +1 -0
  5. package/dist/__tests__/memory-wire-shapes.test.js.map +1 -1
  6. package/dist/__tests__/task-type-narrowing.test.d.ts +14 -0
  7. package/dist/__tests__/task-type-narrowing.test.d.ts.map +1 -0
  8. package/dist/__tests__/task-type-narrowing.test.js +51 -0
  9. package/dist/__tests__/task-type-narrowing.test.js.map +1 -0
  10. package/dist/backup-verify.d.ts +136 -0
  11. package/dist/backup-verify.d.ts.map +1 -0
  12. package/dist/backup-verify.js +29 -0
  13. package/dist/backup-verify.js.map +1 -0
  14. package/dist/brain.d.ts +103 -0
  15. package/dist/brain.d.ts.map +1 -1
  16. package/dist/db-inventory.d.ts +200 -0
  17. package/dist/db-inventory.d.ts.map +1 -0
  18. package/dist/db-inventory.js +76 -0
  19. package/dist/db-inventory.js.map +1 -0
  20. package/dist/db-inventory.json +165 -0
  21. package/dist/db-recovery.d.ts +141 -0
  22. package/dist/db-recovery.d.ts.map +1 -0
  23. package/dist/db-recovery.js +29 -0
  24. package/dist/db-recovery.js.map +1 -0
  25. package/dist/doctor.d.ts +605 -0
  26. package/dist/doctor.d.ts.map +1 -1
  27. package/dist/index.d.ts +6 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/memory/observe.d.ts +8 -0
  32. package/dist/memory/observe.d.ts.map +1 -1
  33. package/dist/operations/tasks.d.ts +4 -4
  34. package/dist/operations/tasks.d.ts.map +1 -1
  35. package/dist/task.d.ts +13 -2
  36. package/dist/task.d.ts.map +1 -1
  37. package/dist/task.js.map +1 -1
  38. package/package.json +5 -4
  39. package/src/__tests__/db-inventory.test.ts +184 -0
  40. package/src/__tests__/memory-wire-shapes.test.ts +1 -0
  41. package/src/__tests__/task-type-narrowing.test.ts +56 -0
  42. package/src/backup-verify.ts +140 -0
  43. package/src/brain.ts +114 -0
  44. package/src/db-inventory.json +165 -0
  45. package/src/db-inventory.ts +234 -0
  46. package/src/db-recovery.ts +144 -0
  47. package/src/doctor.ts +634 -0
  48. package/src/index.ts +46 -0
  49. package/src/memory/observe.ts +8 -0
  50. package/src/operations/tasks.ts +4 -4
  51. package/src/task.ts +13 -2
package/src/doctor.ts CHANGED
@@ -286,6 +286,491 @@ export interface SagaAuditResult {
286
286
  driftCount: number;
287
287
  }
288
288
 
289
+ // ============================================================================
290
+ // DB-Substrate Survey (T10307 — Saga T10281 SG-BRAIN-DB-RESILIENCE / Epic T10282)
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Per-database substrate-audit findings.
295
+ *
296
+ * Produced by `surveyProjectDbSubstrate` for every entry in the
297
+ * `DB_INVENTORY` SSoT. Fields that depend on the file existing on disk
298
+ * (`integrityOK`, `rowCounts`, `lastWriteMs`, `sizeBytes`) are `null` when
299
+ * the file is missing. `error` is non-null when integrity_check or the
300
+ * open / row-count round-trip threw — corrupt DBs surface here so the
301
+ * envelope's `summary.corrupt` counter increments.
302
+ *
303
+ * `suggestedFix` is a stable, machine-readable repair command (typically
304
+ * `cleo backup recover <role>` — the verb introduced by T10304). Always
305
+ * populated when `integrityOK === false` so the operator has a one-line
306
+ * remediation path.
307
+ *
308
+ * @task T10307
309
+ * @task T10312 — added `quarantinedTo`, `integrityCheckMs`, `timedOut`
310
+ * for auto-quarantine + bounded-timeout behaviour.
311
+ * @epic T10282
312
+ * @saga T10281
313
+ */
314
+ export interface DbSubstrateEntry {
315
+ /** Absolute on-disk path the inventory template resolved to. */
316
+ filePath: string;
317
+ /** `true` iff the file currently exists on disk. */
318
+ exists: boolean;
319
+ /**
320
+ * Result of `PRAGMA integrity_check`. `true` when the pragma returned
321
+ * exactly one `'ok'` row; `false` when it returned anything else or
322
+ * the call threw; `null` when the file did not exist (no integrity
323
+ * check attempted).
324
+ */
325
+ integrityOK: boolean | null;
326
+ /**
327
+ * Row counts for up to 3 representative tables (alphabetically first
328
+ * non-meta tables). `null` when the file is missing or the count
329
+ * round-trip threw.
330
+ */
331
+ rowCounts: Readonly<Record<string, number>> | null;
332
+ /** `fs.statSync(filePath).mtimeMs`, or `null` when the file is missing. */
333
+ lastWriteMs: number | null;
334
+ /** `fs.statSync(filePath).size`, or `null` when the file is missing. */
335
+ sizeBytes: number | null;
336
+ /**
337
+ * Stable error string surfaced from a failed integrity_check or open.
338
+ * `null` when the survey succeeded (including when the file simply
339
+ * does not exist — that is `exists: false`, not an error).
340
+ */
341
+ error: string | null;
342
+ /**
343
+ * Suggested repair command when `integrityOK === false`. `null`
344
+ * otherwise. Typically `cleo backup recover <role>` per T10304.
345
+ *
346
+ * When auto-quarantine fires (T10312) the suggestedFix is augmented
347
+ * with the quarantine path in the same string for human readability,
348
+ * while the machine-readable path lives in {@link quarantinedTo}.
349
+ */
350
+ suggestedFix: string | null;
351
+ /**
352
+ * Drizzle migration coverage cross-check (T10311). `null` when:
353
+ * - the inventory entry has `migrationsDir === null` (derived or
354
+ * reserved roles); OR
355
+ * - the DB file does not exist; OR
356
+ * - the DB exists but has no `__drizzle_migrations` table yet (e.g.
357
+ * reserved opener that never ran migrations).
358
+ *
359
+ * Otherwise carries a populated {@link DbSubstrateMigrationCoverage}
360
+ * with the orphan-row / missing-file diffs.
361
+ *
362
+ * @task T10311
363
+ */
364
+ migrationCoverage: DbSubstrateMigrationCoverage | null;
365
+ /**
366
+ * Pragma drift entries — one per pragma whose actual value (read from
367
+ * a raw read-only snapshot of this DB, no pragma application) differs
368
+ * from the SSoT expectation in `specs/sqlite-pragmas.json`.
369
+ *
370
+ * @remarks
371
+ * `null` when the DB does not exist on disk (no snapshot to query) OR
372
+ * the integrity_check failed (a corrupt DB cannot be reliably queried
373
+ * for pragmas). Empty array `[]` means the DB matches the canonical
374
+ * pragma SSoT exactly.
375
+ *
376
+ * @task T10310
377
+ */
378
+ pragmaDrift: PragmaDriftItem[] | null;
379
+ /**
380
+ * Absolute path to the quarantine directory when `inspectDbFile`
381
+ * auto-quarantined a corrupt DB. `null` when the entry is healthy OR
382
+ * when auto-quarantine was disabled by the caller.
383
+ *
384
+ * Quarantine naming: `<projectRoot>/.cleo/quarantine/<role>-malformed-<iso>/`.
385
+ * The corrupt DB lands at `<quarantineDir>/<basename>.malformed`, and
386
+ * any sidecar `-wal` / `-shm` files are preserved alongside it.
387
+ *
388
+ * @task T10312
389
+ */
390
+ quarantinedTo: string | null;
391
+ /**
392
+ * Wall-clock duration of the `PRAGMA integrity_check` call in
393
+ * milliseconds, or `null` when the file did not exist OR the open
394
+ * itself threw before the pragma could run.
395
+ *
396
+ * @task T10312
397
+ */
398
+ integrityCheckMs: number | null;
399
+ /**
400
+ * `true` when the integrity_check wall-clock duration exceeded the
401
+ * configured `integrityCheckTimeoutMs`. Because `node:sqlite` is fully
402
+ * synchronous and offers no interrupt(), the check itself runs to
403
+ * completion — `timedOut: true` flags the DB as slow and is treated
404
+ * as an integrity failure for downstream gating (the entry's
405
+ * `integrityOK` is forced to `false` and `summary.corrupt` increments).
406
+ *
407
+ * @task T10312
408
+ */
409
+ timedOut: boolean;
410
+ }
411
+
412
+ /**
413
+ * One pragma drift item surfaced in {@link DbSubstrateEntry.pragmaDrift}.
414
+ *
415
+ * @remarks
416
+ * Compared values are normalised to lower-case strings before equality
417
+ * is asserted, matching SQLite's pragma-output conventions
418
+ * (`journal_mode` returns `wal` not `WAL`, `synchronous` returns
419
+ * `2` for `NORMAL`, etc. — see {@link PRAGMA_VALUE_NORMALISERS}).
420
+ *
421
+ * The triple `(pragma, expected, actual)` is stable across runs so
422
+ * downstream consumers can deduplicate / aggregate drift reports.
423
+ *
424
+ * @task T10310
425
+ * @epic T10283
426
+ * @saga T10281
427
+ */
428
+ export interface PragmaDriftItem {
429
+ /** Name of the pragma (e.g. `journal_mode`, `busy_timeout`). */
430
+ pragma: string;
431
+ /**
432
+ * Expected value as declared in `specs/sqlite-pragmas.json`. Always a
433
+ * string — values are stored as strings in the SSoT so a future
434
+ * `0x12345` application_id literal does not lose its hex form to a
435
+ * number cast.
436
+ */
437
+ expected: string;
438
+ /**
439
+ * Actual value as returned by `PRAGMA <name>` on a raw read-only
440
+ * snapshot. `null` when the pragma query threw (e.g. an exotic pragma
441
+ * name not implemented by this SQLite build).
442
+ */
443
+ actual: string | null;
444
+ }
445
+
446
+ /**
447
+ * One orphan row in `__drizzle_migrations` — a journal entry whose hash
448
+ * does not match any migration file currently on disk for this role.
449
+ *
450
+ * @remarks
451
+ * Orphans indicate one of:
452
+ * - The DB was last written by a newer CLEO version whose migrations
453
+ * have since been deleted/renamed; OR
454
+ * - A hand-edited journal entry (manual SQL bootstrap that bypassed
455
+ * Drizzle); OR
456
+ * - A migration file was deleted from disk while its journal row
457
+ * remained — a charter violation that breaks T9686-class
458
+ * reconciliation.
459
+ *
460
+ * @task T10311
461
+ */
462
+ export interface DbSubstrateMigrationOrphan {
463
+ /** SHA-256 hash recorded in `__drizzle_migrations.hash`. */
464
+ hash: string;
465
+ /**
466
+ * `__drizzle_migrations.created_at` (Drizzle convention: ms-since-epoch
467
+ * derived from the folder timestamp prefix). `null` when the column was
468
+ * present but `NULL`, or the column did not exist on this DB.
469
+ */
470
+ createdAt: number | null;
471
+ }
472
+
473
+ /**
474
+ * One missing-file entry — a migration file present in `migrationsDir`
475
+ * whose hash is NOT recorded in `__drizzle_migrations`.
476
+ *
477
+ * @remarks
478
+ * Missing-file entries indicate the migration has not been applied yet
479
+ * (which is benign for a fresh DB) OR was applied without a journal write
480
+ * (the bug class `reconcileJournal` Scenario 3 was designed to catch).
481
+ *
482
+ * @task T10311
483
+ */
484
+ export interface DbSubstrateMigrationMissing {
485
+ /** Migration directory name (e.g. `20260318205539_initial`). */
486
+ name: string;
487
+ /** SHA-256 hash of the migration's `migration.sql` (Drizzle convention). */
488
+ hash: string;
489
+ }
490
+
491
+ /**
492
+ * Per-DB Drizzle migration coverage diff produced by
493
+ * `cleo doctor db-substrate`.
494
+ *
495
+ * @remarks
496
+ * The substrate audit cross-references `__drizzle_migrations` rows (the
497
+ * applied set) with files in `migrationsDir` (the expected set). Both
498
+ * directions are surfaced so operators can distinguish:
499
+ *
500
+ * - **Orphan rows** (`orphanRows.length > 0`): the DB has applied
501
+ * migrations that the local checkout does not provide. Risky — a
502
+ * `reconcileJournal` Scenario 2A signal.
503
+ * - **Missing files** (`missingFiles.length > 0`): the local checkout
504
+ * has migrations the DB has not applied. Drizzle's `migrate()` will
505
+ * pick these up on next open; benign for a fresh DB, suspicious for
506
+ * a live one.
507
+ *
508
+ * Healthy coverage: `orphanRows.length === 0 && missingFiles.length === 0`
509
+ * AND `applied === expected`.
510
+ *
511
+ * @task T10311
512
+ */
513
+ export interface DbSubstrateMigrationCoverage {
514
+ /** Number of rows in `__drizzle_migrations`. */
515
+ applied: number;
516
+ /** Number of migration directories in `migrationsDir`. */
517
+ expected: number;
518
+ /**
519
+ * Rows in `__drizzle_migrations` whose hash does not match any
520
+ * migration file on disk. Always present (may be empty).
521
+ */
522
+ orphanRows: DbSubstrateMigrationOrphan[];
523
+ /**
524
+ * Migration files whose hash is not recorded in
525
+ * `__drizzle_migrations`. Always present (may be empty).
526
+ */
527
+ missingFiles: DbSubstrateMigrationMissing[];
528
+ }
529
+
530
+ /**
531
+ * Warning kinds surfaced by `surveyDbSubstrate` via `envelope.meta.warnings`.
532
+ *
533
+ * @remarks
534
+ * Surfacing as warnings (not violations) means they do NOT drive a
535
+ * non-zero exit code on their own — operators should review and remediate,
536
+ * but the substrate is otherwise healthy.
537
+ *
538
+ * - `orphan-project-root` — a `.cleo/` directory found at a project-PARENT
539
+ * path (e.g. `/mnt/projects/.cleo/`). Regression class T9550.
540
+ * - `nested-nexus-duplicate` — `~/.local/share/cleo/nexus/{nexus,signaldock}.db`
541
+ * exists alongside the canonical flat `nexus.db` / `signaldock.db` files,
542
+ * indicating a structural duplicate from an older XDG-path resolution.
543
+ *
544
+ * @task T10307
545
+ */
546
+ export type DbSubstrateWarningKind = 'orphan-project-root' | 'nested-nexus-duplicate';
547
+
548
+ /**
549
+ * One warning entry surfaced in the envelope's `meta.warnings` list.
550
+ *
551
+ * @task T10307
552
+ * @task T10308 — added `parentWorkspace` for orphan-project-root attribution.
553
+ */
554
+ export interface DbSubstrateWarning {
555
+ /** Machine-readable warning class. */
556
+ kind: DbSubstrateWarningKind;
557
+ /** Absolute path of the offending file or directory. */
558
+ path: string;
559
+ /**
560
+ * `fs.statSync(path).mtimeMs` for the offending file/directory.
561
+ * `null` when stat() threw (e.g. ephemeral race during scan).
562
+ */
563
+ lastWriteMs: number | null;
564
+ /**
565
+ * For `orphan-project-root` warnings only: the `workspace` field read
566
+ * from `<path>/.context-state.json` when present. Identifies which
567
+ * outside workspace last wrote into this orphan `.cleo/` directory
568
+ * (audit trail for the T9550 regression class). `null` when the
569
+ * file is absent, unreadable, or carries no `workspace` field.
570
+ *
571
+ * @task T10308
572
+ */
573
+ parentWorkspace?: string | null;
574
+ }
575
+
576
+ /**
577
+ * Survey result for one CLEO project (i.e. one `.cleo/` directory) plus
578
+ * the global tier of databases owned by it.
579
+ *
580
+ * @remarks
581
+ * `dbs` is keyed by the canonical role names from `DB_INVENTORY`. Every
582
+ * entry is populated regardless of whether the file exists — the caller
583
+ * can iterate every role and decide how to surface absent rows.
584
+ *
585
+ * @task T10307
586
+ */
587
+ export interface DbSubstrateProjectSurvey {
588
+ /** Absolute path to the project root that was surveyed. */
589
+ projectRoot: string;
590
+ /**
591
+ * Stable identifier for the project — currently the `base64url(path)`
592
+ * truncated to 32 chars, matching the convention used by `cleo nexus`
593
+ * project resolution.
594
+ */
595
+ projectId: string;
596
+ /** Per-role survey result, keyed by canonical role name. */
597
+ dbs: Readonly<Record<string, DbSubstrateEntry>>;
598
+ }
599
+
600
+ /**
601
+ * Aggregate counters surfaced in `envelope.data.summary`.
602
+ *
603
+ * @task T10307
604
+ */
605
+ export interface DbSubstrateSummary {
606
+ /** Total inventory entries surveyed across all projects + global tier. */
607
+ totalDbs: number;
608
+ /** Entries where `exists && integrityOK === true`. */
609
+ healthy: number;
610
+ /** Entries where `!exists`. */
611
+ missing: number;
612
+ /** Entries where `exists && integrityOK === false`. */
613
+ corrupt: number;
614
+ }
615
+
616
+ /**
617
+ * Top-level result payload returned by `surveyDbSubstrate`.
618
+ *
619
+ * @remarks
620
+ * `scope === 'project'` means the survey covered exactly one project plus
621
+ * the global tier. `scope === 'fleet'` means the survey walked every
622
+ * `.cleo/` directory discoverable from a fleet-root search path.
623
+ *
624
+ * Carried as the `data` field of the LAFS envelope; the corresponding
625
+ * `meta` field carries `meta.warnings` per `DbSubstrateWarning` and the
626
+ * canonical `operation: 'doctor.db-substrate.run'` identifier.
627
+ *
628
+ * @task T10307
629
+ */
630
+ export interface DbSubstrateAuditResult {
631
+ /** Survey scope. `'project'` = current project; `'fleet'` = many projects. */
632
+ scope: 'project' | 'fleet';
633
+ /** Per-project surveys. One entry for `scope='project'`; ≥1 for `scope='fleet'`. */
634
+ projects: DbSubstrateProjectSurvey[];
635
+ /** Aggregate counters across all surveyed entries. */
636
+ summary: DbSubstrateSummary;
637
+ /**
638
+ * Structural warnings the survey detected outside of any inventory
639
+ * entry — orphan project-root `.cleo/` directories, nested-nexus
640
+ * duplicates, etc. ALWAYS present (may be empty).
641
+ */
642
+ warnings: DbSubstrateWarning[];
643
+ /**
644
+ * Cross-DB orphan-row reports — one entry per invariant the survey
645
+ * was able to check. ALWAYS present (may be empty when no DBs exist,
646
+ * or when every invariant returned zero orphans).
647
+ *
648
+ * Surfaced to the envelope's `meta.crossDbOrphans` array by the
649
+ * `cleo doctor db-substrate` command via `pushWarning`. Per-invariant
650
+ * sample row IDs are capped at the first 5 to keep envelopes small.
651
+ *
652
+ * @task T10323
653
+ */
654
+ crossDbOrphans: DbCrossDbOrphanReport[];
655
+ }
656
+
657
+ /**
658
+ * Canonical identifiers for the cross-DB referential invariants surfaced by
659
+ * `cleo doctor db-substrate` (T10323). The catalogue is owned by the T10320
660
+ * ADR draft — every ID here MUST round-trip with the ADR enumeration.
661
+ *
662
+ * | ID | Source | Target |
663
+ * |----|---------------------------------------|---------------------------------------------|
664
+ * | I1 | `brain_memory_links.task_id` | `tasks.id` (tasks.db) |
665
+ * | I2 | `manifest.db blob_attachments.doc_slug` | `tasks.id` (tasks.db) |
666
+ * | I3 | `nexus.db project_registry.project_id`| `.cleo/project-context.json` (or computed) |
667
+ * | I4 | `llmtxt.db documents.doc_slug` (or any `session_id` column) | `tasks.id` / `sessions.id` |
668
+ * | I5 | `conduit.db dead_letters.job_id` | `tasks.id` / `brain_*.id` |
669
+ *
670
+ * Invariants I1 / I2 / I3 / I5 always run when both source + target DBs
671
+ * exist; I4 runs only when the source schema actually declares a candidate
672
+ * column (the live llmtxt schema has not yet introduced `session_id` — the
673
+ * check stays schema-aware to avoid false positives).
674
+ *
675
+ * @task T10323
676
+ * @epic T10285
677
+ * @saga T10281
678
+ */
679
+ export type DbCrossDbInvariantId = 'I1' | 'I2' | 'I3' | 'I4' | 'I5';
680
+
681
+ /**
682
+ * One cross-DB orphan-row finding produced by `walkCrossDbInvariants`.
683
+ *
684
+ * @remarks
685
+ * Every reported invariant carries a bounded sample (first 5 orphan keys)
686
+ * so operators can immediately see WHICH rows triggered the finding. The
687
+ * underlying SQL queries are capped at LIMIT 100 — a sample of 5 is
688
+ * sufficient for triage, and the full count is reported via `orphanCount`.
689
+ *
690
+ * `suggestedFix` is the canonical repair command (or hint) for the
691
+ * invariant class. When the invariant has no programmatic fix it carries
692
+ * a human-readable instruction.
693
+ *
694
+ * `skipped` indicates the invariant could not be checked (e.g. the source
695
+ * DB is missing, the column has not yet been added to the schema, or the
696
+ * snapshot opener threw). When `skipped === true`, `orphanCount === 0`
697
+ * and `sample === []`.
698
+ *
699
+ * @task T10323
700
+ */
701
+ export interface DbCrossDbOrphanReport {
702
+ /** Canonical invariant identifier — see {@link DbCrossDbInvariantId}. */
703
+ invariant: DbCrossDbInvariantId;
704
+ /**
705
+ * Human-readable description of which (source → target) reference class
706
+ * this invariant tracks. Stable text — operators should be able to grep
707
+ * this in audit logs.
708
+ */
709
+ description: string;
710
+ /**
711
+ * Total number of orphan rows detected by the bounded query. When the
712
+ * query hit its `LIMIT 100` cap, this value is the cap; the actual
713
+ * orphan population may be larger.
714
+ */
715
+ orphanCount: number;
716
+ /**
717
+ * First N (≤ 5) orphan identifiers — usually the foreign-key column
718
+ * value. Order matches the source DB's natural read order (no ORDER BY).
719
+ * `[]` when `orphanCount === 0` OR `skipped === true`.
720
+ */
721
+ sample: string[];
722
+ /**
723
+ * Canonical repair command or hint. Stable text — wired into release
724
+ * notes and ADR-068 evidence atoms.
725
+ */
726
+ suggestedFix: string;
727
+ /**
728
+ * `true` when the invariant could not be checked (missing DB, missing
729
+ * column, opener threw). `orphanCount === 0` and `sample === []` when
730
+ * skipped. Carries the skip reason for triage.
731
+ */
732
+ skipped: boolean;
733
+ /**
734
+ * Free-form reason populated when `skipped === true`. Empty string
735
+ * otherwise. Stable text — operators rely on this for diagnostics.
736
+ */
737
+ skipReason: string;
738
+ }
739
+
740
+ /**
741
+ * Caller-supplied tuning knobs for a substrate survey.
742
+ *
743
+ * @remarks
744
+ * - `integrityCheckTimeoutMs` bounds the wall-clock duration the survey
745
+ * will wait for `PRAGMA integrity_check` on a single DB. Because
746
+ * `node:sqlite` is fully synchronous, the check cannot be aborted
747
+ * mid-flight — the timer is consulted AFTER the call returns, and a
748
+ * DB that exceeded the budget is flagged via {@link DbSubstrateEntry.timedOut}.
749
+ * - `autoQuarantine` (default `true`) controls whether
750
+ * `inspectDbFile` moves a corrupt DB and its `-wal`/`-shm` sidecars
751
+ * into `<projectRoot>/.cleo/quarantine/<role>-malformed-<iso>/`. Set
752
+ * to `false` for read-only diagnostic surveys.
753
+ *
754
+ * @task T10312
755
+ * @epic T10283
756
+ * @saga T10281
757
+ */
758
+ export interface DbSubstrateSurveyOptions {
759
+ /**
760
+ * Wall-clock budget for the `PRAGMA integrity_check` call on each
761
+ * DB, in milliseconds. Default: 60000 (60 s).
762
+ *
763
+ * Pass `0` to disable the timeout (the survey waits indefinitely).
764
+ */
765
+ integrityCheckTimeoutMs?: number;
766
+ /**
767
+ * `true` (default) → auto-quarantine corrupt DBs into
768
+ * `<projectRoot>/.cleo/quarantine/<role>-malformed-<iso>/`.
769
+ * `false` → leave the corrupt DB in place; only report it.
770
+ */
771
+ autoQuarantine?: boolean;
772
+ }
773
+
289
774
  /**
290
775
  * One line appended to `.cleo/audit/worktree-prune.jsonl` per prune
291
776
  * operation. Extends the existing audit-log schema (timestamp +
@@ -318,3 +803,152 @@ export interface PruneAuditEntry {
318
803
  /** Whether this entry represents a dry-run plan (no actual removal). */
319
804
  dryRun: boolean;
320
805
  }
806
+
807
+ // ============================================================================
808
+ // Legacy-Backup Walker (T10309 — Saga T10281 SG-BRAIN-DB-RESILIENCE / Epic T10282)
809
+ // ============================================================================
810
+
811
+ /**
812
+ * Origin hint inferred from a legacy backup file's name.
813
+ *
814
+ * Identifies which legacy migration produced the file. Used by the operator
815
+ * to decide whether the artefact is safe to delete. Derived purely from the
816
+ * filename (and parent-directory name) — no DB inspection is performed.
817
+ *
818
+ * Codes:
819
+ * - `pre-cleo-migration` — `*-pre-cleo.db.bak` files written by the
820
+ * pre-cleo→cleo migration when the SDK still used legacy file shapes.
821
+ * - `brain-dup-fix` — `brain.db.PRE-DUP-FIX-*` written by the BRAIN
822
+ * dedup repair (T7xxx series).
823
+ * - `pre-untrack` — `*.pre-untrack-*` written by T5158 when the four
824
+ * runtime files were `git rm --cached`-ed in 2026-04-07.
825
+ * - `quarantine-snapshot` — files captured under
826
+ * `<projectRoot>/.cleo/quarantine/` by historical quarantine sweeps
827
+ * (lafs-, studio-, adapters-, core-, runtime-, cleo-os- timestamped
828
+ * directories). NEVER auto-pruned (may be active forensic artefacts).
829
+ * - `brain-malformed` — files under `.cleo/quarantine/brain-malformed-*`
830
+ * (2026-05-23 P0 brain.db malformation incident). Treated as
831
+ * `quarantine-snapshot` for retention.
832
+ * - `db-backup-rotation` — files under `.cleo/backups/sqlite/` older
833
+ * than the 10-snapshot rotation cap (overflow candidates).
834
+ * - `unknown` — file matches the suffix pattern but no specific origin
835
+ * could be inferred from the path. Defaults to safe retention.
836
+ *
837
+ * @task T10309
838
+ */
839
+ export type LegacyBackupOriginHint =
840
+ | 'pre-cleo-migration'
841
+ | 'brain-dup-fix'
842
+ | 'pre-untrack'
843
+ | 'quarantine-snapshot'
844
+ | 'brain-malformed'
845
+ | 'db-backup-rotation'
846
+ | 'unknown';
847
+
848
+ /**
849
+ * Retention recommendation for one legacy backup file.
850
+ *
851
+ * `keep` — file is younger than the soft retention window (default 30
852
+ * days), OR the file lives under a path that is unconditionally
853
+ * preserved (quarantine artefacts).
854
+ *
855
+ * `compress` — file is 30-90 days old and large enough that gzip-ing it
856
+ * reduces disk pressure. The current implementation surfaces this
857
+ * recommendation but the `--prune` flag does NOT compress (it only
858
+ * deletes); a future T10311+ task will wire the compressor.
859
+ *
860
+ * `delete` — file is older than the hard retention window (default 90
861
+ * days) AND does NOT live under `.cleo/quarantine/`. The `--prune`
862
+ * verb removes these (or simulates removal in dry-run mode).
863
+ *
864
+ * @task T10309
865
+ */
866
+ export type LegacyBackupRecommendation = 'keep' | 'compress' | 'delete';
867
+
868
+ /**
869
+ * One legacy backup artefact discovered by the walker.
870
+ *
871
+ * Carries enough provenance for the operator to act without re-reading
872
+ * the file from disk. `ageDays` is rounded DOWN to match the retention
873
+ * window semantics (a file exactly 30.5 days old is `30` days, still
874
+ * inside the keep window).
875
+ *
876
+ * @task T10309
877
+ */
878
+ export interface LegacyBackupEntry {
879
+ /** Absolute path to the legacy backup file. */
880
+ path: string;
881
+ /** `fs.statSync(path).size`. */
882
+ sizeBytes: number;
883
+ /** `fs.statSync(path).mtimeMs`. */
884
+ mtimeMs: number;
885
+ /** Whole-day age of the file at scan time (`floor((now - mtimeMs) / 86_400_000)`). */
886
+ ageDays: number;
887
+ /** Inferred origin (which legacy migration created this artefact). */
888
+ originHint: LegacyBackupOriginHint;
889
+ /** Retention verdict for this file. */
890
+ recommendation: LegacyBackupRecommendation;
891
+ /**
892
+ * Free-form reason explaining the recommendation. Stable enough to
893
+ * surface to operators; do NOT machine-parse — switch on
894
+ * `originHint` + `recommendation` instead.
895
+ */
896
+ reason: string;
897
+ }
898
+
899
+ /**
900
+ * Result returned by the legacy-backup walker (T10309).
901
+ *
902
+ * Lists every discovered artefact plus aggregate counters. When
903
+ * `--prune` was requested the `pruned` and `kept` arrays partition the
904
+ * total set; otherwise both arrays are empty and `entries` is the only
905
+ * populated list.
906
+ *
907
+ * @remarks
908
+ * `softRetentionDays` and `hardRetentionDays` echo the configured
909
+ * retention thresholds back to the operator so the envelope is
910
+ * self-documenting.
911
+ *
912
+ * @task T10309
913
+ */
914
+ export interface LegacyBackupScanResult {
915
+ /** Absolute path to the project root that was scanned. */
916
+ projectRoot: string;
917
+ /** Absolute path to the CLEO home (`<projectRoot>/../.local/share/cleo` style) that was scanned. */
918
+ cleoHome: string;
919
+ /** All artefacts discovered, sorted by `path` ascending. */
920
+ entries: LegacyBackupEntry[];
921
+ /** Total bytes across `entries`. */
922
+ totalBytes: number;
923
+ /** Soft retention window in days (files younger than this are always `keep`). */
924
+ softRetentionDays: number;
925
+ /** Hard retention window in days (files older than this are `delete`). */
926
+ hardRetentionDays: number;
927
+ /**
928
+ * `true` when this result represents a `--prune` invocation; `false`
929
+ * when it is a pure scan. Drives whether `pruned`/`kept` are
930
+ * populated.
931
+ */
932
+ prune: boolean;
933
+ /**
934
+ * `true` when `--prune` was requested but `--dry-run` was active
935
+ * (the implementation defaults `--prune` to dry-run). `false` for
936
+ * actual deletions or when `prune === false`.
937
+ */
938
+ dryRun: boolean;
939
+ /**
940
+ * Entries that were (or would be) deleted. Always empty when
941
+ * `prune === false`.
942
+ */
943
+ pruned: LegacyBackupEntry[];
944
+ /**
945
+ * Entries the prune operation skipped (recommendation !== 'delete'
946
+ * or path under quarantine). Always empty when `prune === false`.
947
+ */
948
+ kept: LegacyBackupEntry[];
949
+ /**
950
+ * Errors encountered while removing pruneable files. Empty in
951
+ * dry-run mode and on pure scans.
952
+ */
953
+ errors: Array<{ path: string; error: string }>;
954
+ }