Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to Curator are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.2] — 2026-07-28

### Added
- **The ledger is backed up before a schema migration.** Opening `.curator/` with a newer
Curator applies pending migrations automatically; it now copies the ledger to
`.curator/archive/…-pre-migration.sqlite` first. The copy uses SQLite's online backup
API rather than a file copy, so data still sitting in the write-ahead log is preserved.
A ledger with nothing to migrate — the usual case, since every command opens it — is not
copied.
- **`curator doctor` reports pending migrations and the newest backup.** It lists what the
next open will apply without applying it, and prints a ready-to-paste `cp` command to
restore the most recent backup. The backup lookup reads the directory, not the ledger, so
it still answers when the ledger will not open.

### Fixed
- **A failed backup is never offered as a restore point.** `sqlite3.connect` creates the
destination up front, so a backup interrupted by a full disk, an I/O error, or a killed
process left a truncated file carrying the real name. Being the newest, it became what
`curator doctor` advertised as the ledger to restore — and copying it over a live ledger
would have destroyed it. Backups are now written under a name the restore path ignores and
renamed into place only once complete, and an unusable file is never advertised.
- **A failed migration no longer leaves a half-migrated ledger.** Pending migrations and
their `schema_version` rows now share one transaction, so a failure rolls the schema
changes back with it instead of stopping in an intermediate state and raises
`CuratorStateError`. Previously each migration committed on its own.

## [0.1.1] — 2026-07-28

### Fixed
Expand Down Expand Up @@ -54,5 +80,6 @@ Serial single-writer (local `flock`, no cross-host coordination); the decisions/
— not the provider transcript — are the system of record; macOS primary, Linux in CI, Windows
via WSL2 only.

[0.1.2]: https://github.com/JasonZQH/CURATOR/releases/tag/v0.1.2
[0.1.1]: https://github.com/JasonZQH/CURATOR/releases/tag/v0.1.1
[0.1.0]: https://github.com/JasonZQH/CURATOR/releases/tag/v0.1.0
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ curl -fsSL https://raw.githubusercontent.com/JasonZQH/CURATOR/main/install.sh |
Or with pipx / uv:

```bash
pipx install "git+https://github.com/JasonZQH/CURATOR.git@v0.1.1"
uvx --from "git+https://github.com/JasonZQH/CURATOR.git@v0.1.1" curator
pipx install "git+https://github.com/JasonZQH/CURATOR.git@v0.1.2"
uvx --from "git+https://github.com/JasonZQH/CURATOR.git@v0.1.2" curator
```

> Pin a released tag (`@v0.1.1`) for a reproducible install, or drop it to track `main`.
> Pin a released tag (`@v0.1.2`) for a reproducible install, or drop it to track `main`.
> On Windows, run inside WSL2.

**Open it** — from any project directory:
Expand Down Expand Up @@ -106,8 +106,9 @@ Small requests start immediately; `/gate on` reviews the goal proposal first.

| Version | Status | What it is |
|---|---|---|
| **v0.1.1** | Current | Patch on Phase 0. A Codex tool call is counted once rather than once per lifecycle event, so the activity block no longer reads ~2× high. Docs numbering folded onto a single `v0.1.x` roadmap. |
| v0.1.0 | Previous | Phase 0 — local · single-writer · sequential. `Goal → writer → deterministic verifier → fresh-context reviewer → human confirm`, on a durable SQLite ledger. Opt-in `/resume stash` for the clean-tree guard. |
| **v0.1.2** | Current | Migration safety. Upgrading backs the ledger up before any schema migration touches it, migrations apply as one all-or-nothing transaction, and `curator doctor` shows what is pending plus how to restore. |
| v0.1.1 | Previous | Patch on Phase 0. A Codex tool call is counted once rather than once per lifecycle event, so the activity block no longer reads ~2× high. Docs numbering folded onto a single `v0.1.x` roadmap. |
| v0.1.0 | | Phase 0 — local · single-writer · sequential. `Goal → writer → deterministic verifier → fresh-context reviewer → human confirm`, on a durable SQLite ledger. Opt-in `/resume stash` for the clean-tree guard. |

v0.1.0 highlights: full-screen first-run trust & setup, keyboard-selectable slash commands and
proposal actions, PM/Engineer/Reviewer seat labels, persistent history with Tab completion and
Expand Down Expand Up @@ -138,7 +139,7 @@ Full history in [CHANGELOG.md](CHANGELOG.md).
| `curator provider add <name>` | Detect and register a provider CLI (`claude-code` / `codex`) |
| `curator provider list` | List configured provider profiles |
| `curator status` | Show current project state |
| `curator doctor` | Run environment and readiness checks |
| `curator doctor` | Run environment and readiness checks, including pending schema migrations |
| `curator reset` | Archive the ledger and clear runtime state (`--hard` removes `.curator/`) |
| `curator contract validate` | Validate editable role contracts |
| `curator --version` | Print the version |
Expand All @@ -157,6 +158,16 @@ Full history in [CHANGELOG.md](CHANGELOG.md).
TUI adds Up/Down history, Tab completion, Shift+Enter/Ctrl+J continuation lines, Esc
interruption, and two-stage Ctrl+C shutdown.

**Upgrades and your ledger.** Opening `.curator/` with a newer Curator applies any pending
schema migrations automatically. Before it touches anything, the ledger is copied to
`.curator/archive/…-pre-migration.sqlite`, and the migration runs as a single transaction —
if it fails, the ledger is left exactly as it was. `curator doctor` shows what is pending
before it runs and, afterwards, prints the newest backup with the command to restore it:

```bash
cp .curator/archive/curator-<timestamp>-pre-migration.sqlite .curator/curator.sqlite
```

## Known limitations

Phase 0 is deliberately narrow. Where a claim above could be read more broadly than the code
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "hatchling.build"

[project]
name = "curator"
version = "0.1.1"
version = "0.1.2"
description = "Local-first coding-agent workbench with real provider orchestration."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/curator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Expose package-level metadata for Curator Phase 0."""

__version__ = "0.1.1"
__version__ = "0.1.2"
14 changes: 12 additions & 2 deletions src/curator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from curator.rendering.terminal import (
render_contract_validation_report,
render_doctor_report,
render_migration_outcome,
render_status_report,
)
from curator.providers.setup import add_provider_profile, resolve_provider_name
Expand Down Expand Up @@ -66,6 +67,13 @@ def _echo_init_summary(created_files_count: int, skipped_files_count: int) -> No
typer.echo(f"Skipped existing files: {skipped_files_count}")


def _echo_migration(outcome) -> None:
"""Print the migration notice when opening the ledger actually migrated it."""
message = render_migration_outcome(outcome)
if message:
typer.echo(message)


def _ensure_curator_state(root: Path, yes: bool) -> None:
"""Create Curator state when requested or raise for interactive approval."""
if not yes:
Expand Down Expand Up @@ -232,10 +240,11 @@ def provider_add_command(
raise typer.Exit(1)
connection = connect_database(paths.database)
try:
initialize_database(connection)
migration = initialize_database(connection)
result = add_provider_profile(connection, name)
finally:
connection.close()
_echo_migration(migration)
typer.echo(result.message)
if not result.created:
raise typer.Exit(1)
Expand All @@ -255,10 +264,11 @@ def provider_list_command() -> None:
return
connection = connect_database(paths.database)
try:
initialize_database(connection)
migration = initialize_database(connection)
profiles = load_provider_profiles(connection)
finally:
connection.close()
_echo_migration(migration)
if not profiles:
typer.echo("Providers:\n- none (run curator provider add <name>)")
return
Expand Down
49 changes: 48 additions & 1 deletion src/curator/diagnostics/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from curator.core.paths import build_curator_paths
from curator.diagnostics.models import DoctorCheck, DoctorReport
from curator.providers.profiles import resolve_runtime_mode
from curator.state.db import connect_database
from curator.state.backup import latest_pre_migration_backup
from curator.state.db import connect_database, pending_migrations


def _check_existing_path(path: Path, ok_detail: str, missing_detail: str) -> DoctorCheck:
Expand Down Expand Up @@ -56,10 +57,56 @@ def inspect_project_health(project_root: Path | str) -> DoctorReport:
"package": DoctorCheck(status="ok", detail=f"curator {__version__}"),
"state": state_check,
"database": database_check,
"migrations": _migrations_check(paths.database) if initialized else _NO_LEDGER_CHECK,
"backup": _backup_check(paths.curator_dir),
},
)


_NO_LEDGER_CHECK = DoctorCheck(status="missing", detail="No ledger yet.")


def _migrations_check(database: Path) -> DoctorCheck:
"""Report which migrations the next open will apply, without applying them."""
try:
connection = connect_database(database)
try:
pending = pending_migrations(connection)
finally:
connection.close()
except sqlite3.DatabaseError as error:
return DoctorCheck(status="fail", detail=f"Cannot read the ledger: {error}")

if not pending:
return DoctorCheck(status="ok", detail="Schema is current.")

versions = ", ".join(str(version) for version in pending)
return DoctorCheck(
status="pending",
detail=(
f"{len(pending)} migration(s) pending ({versions}); the next command that opens "
"the ledger applies them, backing it up to .curator/archive/ first."
),
)


def _backup_check(curator_dir: Path) -> DoctorCheck:
"""Point at the newest pre-migration backup and how to restore it.

Reads the directory rather than the ledger, so this still answers when the ledger will
not open — which is when someone actually needs it.
"""
backup = latest_pre_migration_backup(curator_dir)
if backup is None:
return DoctorCheck(status="none", detail="No pre-migration backup taken yet.")

database = curator_dir / "curator.sqlite"
return DoctorCheck(
status="ok",
detail=f"Newest pre-migration backup: {backup}\n restore: cp {backup} {database}",
)


def _resolve_mode(database: Path) -> str:
"""Return the runtime mode label for an existing database."""
try:
Expand Down
8 changes: 4 additions & 4 deletions src/curator/init/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import shutil
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import datetime
from pathlib import Path

from curator.core.paths import build_curator_paths
from curator.core.schema import CuratorPaths
from curator.state.backup import archive_stamp, ledger_archive_dir


@dataclass(frozen=True)
Expand Down Expand Up @@ -67,10 +68,9 @@ def reset_curator_state(

archived_database = None
if paths.database.exists():
stamp = (now or datetime.now(UTC)).strftime("%Y%m%dT%H%M%SZ")
archive_dir = paths.curator_dir / "archive"
archive_dir = ledger_archive_dir(paths.curator_dir)
archive_dir.mkdir(parents=True, exist_ok=True)
archived_database = archive_dir / f"curator-{stamp}.sqlite"
archived_database = archive_dir / f"curator-{archive_stamp(now)}.sqlite"
shutil.move(str(paths.database), str(archived_database))

removed_paths = []
Expand Down
23 changes: 22 additions & 1 deletion src/curator/rendering/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,29 @@ def render_doctor_report(report: DoctorReport) -> str:
f"State: {report.checks['state'].status}",
f"Database: {report.checks['database'].status}",
f"Mode: {report.mode}",
f"Recommended next step: {report.recommended_next_step}",
]
migrations = report.checks.get("migrations")
if migrations is not None and migrations.status != "missing":
lines.append(f"Migrations: {migrations.status} — {migrations.detail}")
backup = report.checks.get("backup")
if backup is not None and backup.status == "ok":
lines.append(f"Backup: {backup.detail}")
lines.append(f"Recommended next step: {report.recommended_next_step}")
return "\n".join(lines)


def render_migration_outcome(outcome) -> str:
"""Render what a just-applied migration did, or "" when nothing was migrated.

Migrating a ledger is not something to do silently: the user should learn that their
schema moved and where the copy of the old one is, at the moment it happens.
"""
if outcome is None:
return ""
versions = ", ".join(str(version) for version in outcome.applied)
lines = [f"Migrated ledger: schema v{outcome.from_version} → v{outcome.to_version} ({versions})."]
if outcome.backup is not None:
lines.append(f"Previous ledger backed up to {outcome.backup}")
return "\n".join(lines)


Expand Down
96 changes: 96 additions & 0 deletions src/curator/state/backup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Copy the Curator ledger aside before a schema migration rewrites it."""

import sqlite3
from datetime import UTC, datetime
from pathlib import Path

# Migration backups and reset archives are both "a ledger as it was", so they share one
# directory and one timestamp format. The suffix is what tells them apart on disk.
_ARCHIVE_DIR_NAME = "archive"
_STAMP_FORMAT = "%Y%m%dT%H%M%SZ"
PRE_MIGRATION_SUFFIX = "-pre-migration"


def ledger_archive_dir(curator_dir: Path) -> Path:
"""Return the directory holding superseded copies of the ledger."""
return Path(curator_dir) / _ARCHIVE_DIR_NAME


def archive_stamp(now: datetime | None = None) -> str:
"""Return the UTC timestamp used to name an archived ledger."""
return (now or datetime.now(UTC)).strftime(_STAMP_FORMAT)


def backup_ledger(
connection: sqlite3.Connection,
curator_dir: Path,
now: datetime | None = None,
) -> Path:
"""Copy the open ledger into the archive directory and return the backup path.

Uses SQLite's online backup API rather than copying the file. The ledger runs in WAL
mode, so a file copy can miss pages still sitting in the write-ahead log and produce a
backup that is silently short of the data it is supposed to preserve.
"""
directory = ledger_archive_dir(curator_dir)
directory.mkdir(parents=True, exist_ok=True)

destination_path = _free_backup_path(directory, archive_stamp(now))
# Write under a name the restore path ignores, then publish by renaming. sqlite3.connect
# creates the file up front, so a backup that dies half way — disk full, I/O error,
# killed process — would otherwise leave a truncated file carrying the real name. That
# file is the newest, so it becomes what doctor offers as the restore point, and copying
# it over a live ledger destroys the ledger. Only a completed backup gets the real name.
partial_path = destination_path.with_suffix(".partial")
try:
destination = sqlite3.connect(partial_path)
try:
connection.backup(destination)
finally:
destination.close()
partial_path.replace(destination_path)
except BaseException:
partial_path.unlink(missing_ok=True)
raise
return destination_path


def latest_pre_migration_backup(curator_dir: Path) -> Path | None:
"""Return the newest pre-migration backup, or None when none has been taken.

Reads the directory rather than the ledger, so it still answers when the ledger itself
will not open — which is exactly when someone is looking for a backup.
"""
directory = ledger_archive_dir(curator_dir)
if not directory.is_dir():
return None

# An empty file is never a usable ledger, and this path tells someone what to copy over
# a live one, so anything that cannot be a backup is skipped rather than offered.
backups = [
path
for path in directory.glob(f"curator-*{PRE_MIGRATION_SUFFIX}.sqlite")
if path.stat().st_size > 0
]
if not backups:
return None
# By mtime, not by name: a same-second backup is disambiguated with an index that
# sorts before the plain name, so lexicographic order would report the oldest.
return max(backups, key=lambda path: path.stat().st_mtime)


def _free_backup_path(directory: Path, stamp: str) -> Path:
"""Return a backup path that does not already exist.

Two migrations inside the same second would otherwise resolve to one filename and the
second would overwrite the first — discarding the older ledger state.
"""
candidate = directory / f"curator-{stamp}{PRE_MIGRATION_SUFFIX}.sqlite"
if not candidate.exists():
return candidate

for index in range(2, 100):
candidate = directory / f"curator-{stamp}-{index}{PRE_MIGRATION_SUFFIX}.sqlite"
if not candidate.exists():
return candidate
raise FileExistsError(f"cannot find a free backup name in {directory}")
Loading
Loading