From 4276e4ce506a478ece36e65a8254aeaffd582a5b Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:15:25 -0400 Subject: [PATCH 01/13] chore: bump to 0.4.0.dev0 and branch for Phase 4 Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- src/cc_janitor/cli/__init__.py | 2 +- tests/unit/test_cli_skeleton.py | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c559a4d..c01a343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cc-janitor" -version = "0.3.3" +version = "0.4.0.dev0" description = "Tidy up your Claude Code environment — sessions, permissions, context, hooks, schedule." readme = "README.md" requires-python = ">=3.11" diff --git a/src/cc_janitor/cli/__init__.py b/src/cc_janitor/cli/__init__.py index a97bdb0..65e18f0 100644 --- a/src/cc_janitor/cli/__init__.py +++ b/src/cc_janitor/cli/__init__.py @@ -20,7 +20,7 @@ from .commands.undo import undo as _undo from .commands.watch import watch_app -__VERSION__ = "0.3.3" +__VERSION__ = "0.4.0.dev0" app = typer.Typer(no_args_is_help=False, help="cc-janitor — Tidy Claude Code") diff --git a/tests/unit/test_cli_skeleton.py b/tests/unit/test_cli_skeleton.py index a736563..75e3b88 100644 --- a/tests/unit/test_cli_skeleton.py +++ b/tests/unit/test_cli_skeleton.py @@ -6,7 +6,7 @@ def test_version(): r = CliRunner().invoke(app, ["--version"]) assert r.exit_code == 0 - assert "0.3.3" in r.stdout + assert "0.4.0.dev0" in r.stdout def test_help_works(): diff --git a/uv.lock b/uv.lock index 00d104a..4d62527 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "cc-janitor" -version = "0.3.3" +version = "0.4.0.dev0" source = { editable = "." } dependencies = [ { name = "croniter" }, From 12b3ca7200d327c43a1477e3e05028ea0653d0dc Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:17:38 -0400 Subject: [PATCH 02/13] feat(core): config.toml loader with documented defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All Phase 4 thresholds (dream_doctor disk/file/line limits, snapshot retention, hygiene regex extras) loaded from optional ~/.cc-janitor/config.toml. Missing or malformed file → DEFAULTS. Partial overrides preserve unspecified defaults. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/core/config.py | 72 ++++++++++++++++++++++++++++++++ tests/unit/test_config_loader.py | 48 +++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/cc_janitor/core/config.py create mode 100644 tests/unit/test_config_loader.py diff --git a/src/cc_janitor/core/config.py b/src/cc_janitor/core/config.py new file mode 100644 index 0000000..7628d19 --- /dev/null +++ b/src/cc_janitor/core/config.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field, replace +from pathlib import Path + +from .state import get_paths + + +@dataclass(frozen=True) +class DreamDoctorConfig: + disk_warning_mb: int = 100 + memory_file_count_threshold: int = 50 + memory_md_line_threshold: int = 180 + + +@dataclass(frozen=True) +class SnapshotsConfig: + raw_retention_days: int = 7 + tar_retention_days: int = 30 + + +@dataclass(frozen=True) +class HygieneConfig: + relative_date_terms_extra: tuple[str, ...] = () + contradiction_jaccard_threshold: float = 0.5 + + +@dataclass(frozen=True) +class Config: + dream_doctor: DreamDoctorConfig = field(default_factory=DreamDoctorConfig) + snapshots: SnapshotsConfig = field(default_factory=SnapshotsConfig) + hygiene: HygieneConfig = field(default_factory=HygieneConfig) + + +DEFAULTS = Config() + + +def _default_path() -> Path: + return get_paths().home / "config.toml" + + +def load_config(path: Path | None = None) -> Config: + p = path if path is not None else _default_path() + if not p.exists(): + return DEFAULTS + try: + data = tomllib.loads(p.read_text(encoding="utf-8")) + except (tomllib.TOMLDecodeError, OSError): + return DEFAULTS + dd = data.get("dream_doctor", {}) or {} + sn = data.get("snapshots", {}) or {} + hy = data.get("hygiene", {}) or {} + return Config( + dream_doctor=replace(DEFAULTS.dream_doctor, **{ + k: v for k, v in dd.items() + if k in {"disk_warning_mb", "memory_file_count_threshold", + "memory_md_line_threshold"} + }), + snapshots=replace(DEFAULTS.snapshots, **{ + k: v for k, v in sn.items() + if k in {"raw_retention_days", "tar_retention_days"} + }), + hygiene=HygieneConfig( + relative_date_terms_extra=tuple( + hy.get("relative_date_terms_extra", ()) + ), + contradiction_jaccard_threshold=float( + hy.get("contradiction_jaccard_threshold", 0.5) + ), + ), + ) diff --git a/tests/unit/test_config_loader.py b/tests/unit/test_config_loader.py new file mode 100644 index 0000000..983110b --- /dev/null +++ b/tests/unit/test_config_loader.py @@ -0,0 +1,48 @@ +from pathlib import Path +from cc_janitor.core.config import ( + Config, DreamDoctorConfig, SnapshotsConfig, HygieneConfig, + load_config, DEFAULTS, +) + + +def test_defaults_when_missing(tmp_path): + cfg = load_config(tmp_path / "nonexistent.toml") + assert cfg.dream_doctor.disk_warning_mb == 100 + assert cfg.dream_doctor.memory_file_count_threshold == 50 + assert cfg.dream_doctor.memory_md_line_threshold == 180 + assert cfg.snapshots.raw_retention_days == 7 + assert cfg.snapshots.tar_retention_days == 30 + + +def test_partial_override(tmp_path): + p = tmp_path / "config.toml" + p.write_text( + '[dream_doctor]\n' + 'disk_warning_mb = 500\n' + '[snapshots]\n' + 'raw_retention_days = 14\n', + encoding="utf-8", + ) + cfg = load_config(p) + assert cfg.dream_doctor.disk_warning_mb == 500 + assert cfg.dream_doctor.memory_md_line_threshold == 180 # default kept + assert cfg.snapshots.raw_retention_days == 14 + assert cfg.snapshots.tar_retention_days == 30 + + +def test_malformed_falls_back_to_defaults(tmp_path): + p = tmp_path / "config.toml" + p.write_text("this is not [valid toml", encoding="utf-8") + cfg = load_config(p) + assert cfg == DEFAULTS + + +def test_extra_relative_date_terms(tmp_path): + p = tmp_path / "config.toml" + p.write_text( + '[hygiene]\n' + 'relative_date_terms_extra = ["позавчера", "tomorrow"]\n', + encoding="utf-8", + ) + cfg = load_config(p) + assert "позавчера" in cfg.hygiene.relative_date_terms_extra From fb908871527e0a17a8518485cc9970f5b6a7b9ac Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:19:04 -0400 Subject: [PATCH 03/13] feat(core): dream snapshot lifecycle state machine + raw mirror Lock-file observer with NO_LOCK/LOCK_HELD transitions. snapshot_pre and snapshot_post copy ~/.claude/projects//memory/ trees to ~/.cc-janitor/backups/dream/-{pre,post}/. record_pair writes one JSONL record with file_count_delta, line_count_delta, has_diff, dream_pid_in_lock. Storage starts as "raw"; tar compaction (Task 10) flips it to "tar" later. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/core/dream_snapshot.py | 167 ++++++++++++++++++++++++++ tests/unit/test_dream_snapshot.py | 62 ++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/cc_janitor/core/dream_snapshot.py create mode 100644 tests/unit/test_dream_snapshot.py diff --git a/src/cc_janitor/core/dream_snapshot.py b/src/cc_janitor/core/dream_snapshot.py new file mode 100644 index 0000000..457ca0f --- /dev/null +++ b/src/cc_janitor/core/dream_snapshot.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass, asdict, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal + +from .state import get_paths + + +@dataclass +class LockState: + """Per-daemon-iteration map: memory_dir → currently-seen-lock-pid.""" + seen: dict[Path, int] = field(default_factory=dict) + + +@dataclass +class LockTransition: + kind: Literal["no_change", "lock_appeared", "lock_gone"] + memory_dir: Path | None = None + pid: int | None = None + + +@dataclass +class DreamSnapshotPair: + pair_id: str + project_slug: str + project_path: str + claude_memory_dir: str + ts_pre: str + ts_post: str | None + paths_in_pre: list[str] + paths_in_post: list[str] | None + file_count_delta: int | None + line_count_delta: int | None + has_diff: bool | None + dream_pid_in_lock: int | None + storage: Literal["raw", "tar"] = "raw" + + +def _dream_root() -> Path: + return get_paths().home / "backups" / "dream" + + +def _history_path() -> Path: + return get_paths().home / "dream-snapshots.jsonl" + + +def observe_lock(memory_dir: Path, state: LockState) -> LockTransition: + lock = memory_dir / ".consolidate-lock" + prev_pid = state.seen.get(memory_dir) + if lock.exists(): + try: + pid = int(lock.read_text(encoding="utf-8").strip() or "0") + except (OSError, ValueError): + pid = 0 + if prev_pid is None: + state.seen[memory_dir] = pid + return LockTransition("lock_appeared", memory_dir, pid) + return LockTransition("no_change", memory_dir, pid) + else: + if prev_pid is not None: + state.seen.pop(memory_dir, None) + return LockTransition("lock_gone", memory_dir, prev_pid) + return LockTransition("no_change", memory_dir, None) + + +def _copy_tree(src: Path, dst: Path) -> list[Path]: + dst.mkdir(parents=True, exist_ok=True) + rels: list[Path] = [] + for f in src.rglob("*"): + if not f.is_file(): + continue + rel = f.relative_to(src) + out = dst / rel + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, out) + rels.append(rel) + return rels + + +def snapshot_pre(pair_id: str, memory_dir: Path) -> Path: + out = _dream_root() / f"{pair_id}-pre" + _copy_tree(memory_dir, out) + return out + + +def snapshot_post(pair_id: str, memory_dir: Path) -> Path: + out = _dream_root() / f"{pair_id}-post" + _copy_tree(memory_dir, out) + return out + + +def _count_lines(d: Path) -> int: + total = 0 + for f in d.rglob("*.md"): + try: + total += sum(1 for _ in f.open("r", encoding="utf-8", errors="ignore")) + except OSError: + pass + return total + + +def record_pair( + pair_id: str, + memory_dir: Path, + *, + project_slug: str, + dream_pid_in_lock: int | None, + ts_pre: datetime, + ts_post: datetime | None, + pre_dir: Path, + post_dir: Path | None, +) -> DreamSnapshotPair: + pre_files = sorted(str(p.relative_to(pre_dir)) + for p in pre_dir.rglob("*") if p.is_file()) + post_files = (sorted(str(p.relative_to(post_dir)) + for p in post_dir.rglob("*") if p.is_file()) + if post_dir else None) + file_delta = (len(post_files) - len(pre_files)) if post_files is not None else None + line_delta = (_count_lines(post_dir) - _count_lines(pre_dir)) if post_dir else None + has_diff = (file_delta != 0 or line_delta != 0) if line_delta is not None else None + pair = DreamSnapshotPair( + pair_id=pair_id, + project_slug=project_slug, + project_path=str(memory_dir.parent.parent), + claude_memory_dir=str(memory_dir), + ts_pre=ts_pre.isoformat(), + ts_post=ts_post.isoformat() if ts_post else None, + paths_in_pre=pre_files, + paths_in_post=post_files, + file_count_delta=file_delta, + line_count_delta=line_delta, + has_diff=has_diff, + dream_pid_in_lock=dream_pid_in_lock, + storage="raw", + ) + hp = _history_path() + hp.parent.mkdir(parents=True, exist_ok=True) + with hp.open("a", encoding="utf-8") as f: + f.write(json.dumps(asdict(pair)) + "\n") + return pair + + +def history() -> list[DreamSnapshotPair]: + hp = _history_path() + if not hp.exists(): + return [] + out: list[DreamSnapshotPair] = [] + for line in hp.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + d = json.loads(line) + except json.JSONDecodeError: + continue + out.append(DreamSnapshotPair(**d)) + return out + + +def project_slug_from_memory_dir(memory_dir: Path) -> str: + """`.../projects/-home-u-proj/memory` → "proj" (last hyphen-segment).""" + parent = memory_dir.parent.name + parts = [p for p in parent.split("-") if p] + return parts[-1] if parts else parent diff --git a/tests/unit/test_dream_snapshot.py b/tests/unit/test_dream_snapshot.py new file mode 100644 index 0000000..6aa2397 --- /dev/null +++ b/tests/unit/test_dream_snapshot.py @@ -0,0 +1,62 @@ +import json +from datetime import datetime, timezone +from pathlib import Path +from cc_janitor.core.dream_snapshot import ( + DreamSnapshotPair, LockState, observe_lock, snapshot_pre, + snapshot_post, record_pair, history, +) + + +def _fake_memory(root: Path) -> Path: + mem = root / ".claude" / "projects" / "-home-u-proj" / "memory" + mem.mkdir(parents=True) + (mem / "MEMORY.md").write_text("a\nb\nc\n") + (mem / "x.md").write_text("x\n") + return mem + + +def test_observe_lock_appearing(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = _fake_memory(tmp_path) + lock = mem / ".consolidate-lock" + state = LockState() + transition = observe_lock(mem, state) + assert transition.kind == "no_change" + lock.write_text("38249") + transition = observe_lock(mem, state) + assert transition.kind == "lock_appeared" + assert transition.pid == 38249 + + +def test_snapshot_pre_writes_raw_mirror(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = _fake_memory(tmp_path) + pair_id = "20260511T120000Z-proj" + snapshot_pre(pair_id, mem) + pre = tmp_path / "jhome" / "backups" / "dream" / f"{pair_id}-pre" + assert (pre / "MEMORY.md").exists() + assert (pre / "MEMORY.md").read_text() == "a\nb\nc\n" + + +def test_full_pair_roundtrip(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = _fake_memory(tmp_path) + pair_id = "20260511T120000Z-proj" + pre = snapshot_pre(pair_id, mem) + # Auto Dream "ran" — mutate. + (mem / "MEMORY.md").write_text("a\nb\n") + (mem / "x.md").unlink() + post = snapshot_post(pair_id, mem) + pair = record_pair(pair_id, mem, project_slug="proj", + dream_pid_in_lock=38249, + ts_pre=datetime.now(timezone.utc), + ts_post=datetime.now(timezone.utc), + pre_dir=pre, post_dir=post) + assert pair.file_count_delta == -1 + assert pair.line_count_delta < 0 + # Reload from jsonl. + items = history() + assert any(p.pair_id == pair_id for p in items) From 615fbd7c76d226035033c2eb27aaa69c773b9aa3 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:21:30 -0400 Subject: [PATCH 04/13] feat(watcher): --dream mode polls .consolidate-lock lifecycle Extends the Phase 3 watcher daemon to optionally observe per-project .consolidate-lock files. On lock-appears: write pre-snapshot to ~/.cc-janitor/backups/dream/-pre/. On lock-gone: write post-snapshot and record a DreamSnapshotPair to dream-snapshots.jsonl. Opt-in via `cc-janitor watch start --dream`. mtime reinject watch remains the default; add --no-memory to disable it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/cli/commands/watch.py | 19 +++++- src/cc_janitor/core/watcher.py | 91 +++++++++++++++++++++++++--- src/cc_janitor/core/watcher_main.py | 4 +- tests/unit/test_watcher_dream.py | 33 ++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_watcher_dream.py diff --git a/src/cc_janitor/cli/commands/watch.py b/src/cc_janitor/cli/commands/watch.py index b52e1e0..90023ff 100644 --- a/src/cc_janitor/cli/commands/watch.py +++ b/src/cc_janitor/cli/commands/watch.py @@ -34,8 +34,21 @@ def _default_memory_dirs() -> list[Path]: @watch_app.command("start") def start( interval: int = typer.Option(30, "--interval", min=1), + dream: bool = typer.Option( + False, + "--dream/--no-dream", + help="Also snapshot around Auto Dream .consolidate-lock lifecycle.", + ), + no_memory: bool = typer.Option( + False, + "--no-memory", + help="Disable mtime reinject watch; useful with --dream.", + ), ) -> None: - with audit_action("watch start", [f"interval={interval}"]): + with audit_action( + "watch start", + [f"interval={interval}", f"dream={dream}", f"no_memory={no_memory}"], + ): try: require_confirmed() except NotConfirmedError as e: @@ -60,6 +73,10 @@ def start( os.environ["CC_JANITOR_WATCH_DIRS"] = os.pathsep.join( str(d) for d in dirs ) + if dream: + os.environ["CC_JANITOR_WATCH_DREAM"] = "1" + if no_memory: + os.environ["CC_JANITOR_WATCH_NO_MEMORY"] = "1" log = get_paths().home / "watcher.log" pid = w.spawn_daemon( [ diff --git a/src/cc_janitor/core/watcher.py b/src/cc_janitor/core/watcher.py index eb1f396..a40e808 100644 --- a/src/cc_janitor/core/watcher.py +++ b/src/cc_janitor/core/watcher.py @@ -11,6 +11,14 @@ from datetime import UTC, datetime from pathlib import Path +from .dream_snapshot import ( + LockState, + observe_lock, + project_slug_from_memory_dir, + record_pair, + snapshot_post, + snapshot_pre, +) from .state import get_paths @@ -133,18 +141,85 @@ def run_watcher_once( return changed -def run_watcher(memory_dirs: list[Path], interval: int) -> None: - """Main loop — invoked by the spawned daemon process.""" +def _new_pair_id(slug: str) -> str: + return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + f"-{slug}" + + +def run_dream_once( + memory_dirs: list[Path], + state: LockState, + pending: dict[Path, dict], +) -> None: + """Single dream-watch poll iteration. + + Observes ``.consolidate-lock`` lifecycle in each memory dir. On + lock-appears, writes a pre-snapshot and stores transition metadata in + ``pending``. On lock-gone, writes the post-snapshot and records a + ``DreamSnapshotPair`` to the JSONL history. + """ + for mem in memory_dirs: + t = observe_lock(mem, state) + if t.kind == "lock_appeared": + slug = project_slug_from_memory_dir(mem) + pair_id = _new_pair_id(slug) + pre_dir = snapshot_pre(pair_id, mem) + pending[mem] = { + "pair_id": pair_id, + "slug": slug, + "pre_dir": pre_dir, + "ts_pre": datetime.now(UTC), + "pid": t.pid, + } + elif t.kind == "lock_gone": + info = pending.pop(mem, None) + if info is None: + continue + post_dir = snapshot_post(info["pair_id"], mem) + record_pair( + info["pair_id"], + mem, + project_slug=info["slug"], + dream_pid_in_lock=info["pid"], + ts_pre=info["ts_pre"], + ts_post=datetime.now(UTC), + pre_dir=info["pre_dir"], + post_dir=post_dir, + ) + + +def run_watcher( + memory_dirs: list[Path], + interval: int, + *, + dream: bool = False, + memory: bool = True, +) -> None: + """Main loop — invoked by the spawned daemon process. + + Args: + memory_dirs: per-project ``memory/`` dirs to observe. + interval: poll interval, seconds. + dream: if True, also poll ``.consolidate-lock`` lifecycle and + snapshot pre/post around Auto Dream consolidation events. + memory: if False, the mtime reinject watch is skipped (useful when + running purely as a dream-snapshot daemon). + """ last_mtimes: dict[Path, float] = {} - for f in iter_watched_files(memory_dirs): - try: - last_mtimes[f] = f.stat().st_mtime - except OSError: - pass + if memory: + for f in iter_watched_files(memory_dirs): + try: + last_mtimes[f] = f.stat().st_mtime + except OSError: + pass + lock_state = LockState() if dream else None + pending: dict[Path, dict] = {} while True: try: time.sleep(interval) - run_watcher_once(memory_dirs, last_mtimes) + if memory: + run_watcher_once(memory_dirs, last_mtimes) + if dream and lock_state is not None: + run_dream_once(memory_dirs, lock_state, pending) except KeyboardInterrupt: return except Exception: diff --git a/src/cc_janitor/core/watcher_main.py b/src/cc_janitor/core/watcher_main.py index 4803778..5981034 100644 --- a/src/cc_janitor/core/watcher_main.py +++ b/src/cc_janitor/core/watcher_main.py @@ -14,7 +14,9 @@ def main() -> None: args = parser.parse_args() raw = os.environ.get("CC_JANITOR_WATCH_DIRS", "") dirs = [Path(p) for p in raw.split(os.pathsep) if p] - watcher.run_watcher(dirs, args.interval) + dream = os.environ.get("CC_JANITOR_WATCH_DREAM", "") == "1" + memory = os.environ.get("CC_JANITOR_WATCH_NO_MEMORY", "") != "1" + watcher.run_watcher(dirs, args.interval, dream=dream, memory=memory) if __name__ == "__main__": diff --git a/tests/unit/test_watcher_dream.py b/tests/unit/test_watcher_dream.py new file mode 100644 index 0000000..15b3fcd --- /dev/null +++ b/tests/unit/test_watcher_dream.py @@ -0,0 +1,33 @@ +from datetime import datetime, timezone +from pathlib import Path +from cc_janitor.core.dream_snapshot import LockState, history +from cc_janitor.core.watcher import run_dream_once + + +def test_run_dream_once_appears_then_gone(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = tmp_path / ".claude" / "projects" / "-proj" / "memory" + mem.mkdir(parents=True) + (mem / "MEMORY.md").write_text("a\n") + state = LockState() + pending: dict = {} + + # No lock yet. + run_dream_once([mem], state, pending) + assert not pending + + # Lock appears. + (mem / ".consolidate-lock").write_text("4711") + run_dream_once([mem], state, pending) + assert mem in pending + pair_id = pending[mem]["pair_id"] + assert (tmp_path / "jhome" / "backups" / "dream" / f"{pair_id}-pre").exists() + + # Lock disappears + content changed. + (mem / "MEMORY.md").write_text("a\nb\n") + (mem / ".consolidate-lock").unlink() + run_dream_once([mem], state, pending) + assert mem not in pending + h = history() + assert any(p.pair_id == pair_id for p in h) From 48aac2286eee190af3a8caf32a1d1515b60052c7 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:22:43 -0400 Subject: [PATCH 05/13] =?UTF-8?q?feat(core):=20dream=5Fdiff=20=E2=80=94=20?= =?UTF-8?q?file-level=20+=20unified-diff=20over=20pre/post=20mirrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DreamFileDelta classifies each path as added/removed/changed/unchanged, counts +/- lines, embeds difflib.unified_diff body. Summary dict aggregates counts. No semantic grouping (Phase 5). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/core/dream_diff.py | 129 ++++++++++++++++++++++++++++++ tests/unit/test_dream_diff.py | 27 +++++++ 2 files changed, 156 insertions(+) create mode 100644 src/cc_janitor/core/dream_diff.py create mode 100644 tests/unit/test_dream_diff.py diff --git a/src/cc_janitor/core/dream_diff.py b/src/cc_janitor/core/dream_diff.py new file mode 100644 index 0000000..b08aa7f --- /dev/null +++ b/src/cc_janitor/core/dream_diff.py @@ -0,0 +1,129 @@ +"""Structured pre/post comparison for Dream snapshot pairs. + +Walks the raw mirror trees written by ``dream_snapshot.snapshot_pre`` / +``snapshot_post`` and emits a per-file delta with status, +/- line counts, +and an embedded ``difflib.unified_diff`` body. No semantic grouping — +that lands in Phase 5. +""" + +from __future__ import annotations + +import difflib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + + +@dataclass +class DreamFileDelta: + rel_path: Path + status: Literal["added", "removed", "changed", "unchanged"] + lines_added: int + lines_removed: int + unified_diff: str | None + + +@dataclass +class DreamDiff: + pre_dir: Path + post_dir: Path + deltas: list[DreamFileDelta] + summary: dict + + +def _read_lines(p: Path) -> list[str]: + try: + return p.read_text(encoding="utf-8").splitlines(keepends=True) + except (OSError, UnicodeDecodeError): + return [] + + +def _walk_rel(d: Path) -> set[Path]: + if not d.exists(): + return set() + return {f.relative_to(d) for f in d.rglob("*") if f.is_file()} + + +def compute_diff(pre_dir: Path, post_dir: Path) -> DreamDiff: + """Compare two raw mirror directories and return a structured diff.""" + pre_set = _walk_rel(pre_dir) + post_set = _walk_rel(post_dir) + all_paths = sorted(pre_set | post_set, key=str) + deltas: list[DreamFileDelta] = [] + summary = { + "files_added": 0, + "files_removed": 0, + "files_changed": 0, + "files_unchanged": 0, + } + for rel in all_paths: + in_pre = rel in pre_set + in_post = rel in post_set + if in_pre and not in_post: + pre_lines = _read_lines(pre_dir / rel) + deltas.append( + DreamFileDelta( + rel_path=rel, + status="removed", + lines_added=0, + lines_removed=len(pre_lines), + unified_diff="".join( + difflib.unified_diff( + pre_lines, + [], + fromfile=str(rel), + tofile="/dev/null", + ) + ), + ) + ) + summary["files_removed"] += 1 + continue + if in_post and not in_pre: + post_lines = _read_lines(post_dir / rel) + deltas.append( + DreamFileDelta( + rel_path=rel, + status="added", + lines_added=len(post_lines), + lines_removed=0, + unified_diff="".join( + difflib.unified_diff( + [], + post_lines, + fromfile="/dev/null", + tofile=str(rel), + ) + ), + ) + ) + summary["files_added"] += 1 + continue + pre_lines = _read_lines(pre_dir / rel) + post_lines = _read_lines(post_dir / rel) + if pre_lines == post_lines: + deltas.append(DreamFileDelta(rel, "unchanged", 0, 0, None)) + summary["files_unchanged"] += 1 + continue + ud = "".join( + difflib.unified_diff( + pre_lines, + post_lines, + fromfile=str(rel), + tofile=str(rel), + n=3, + ) + ) + added = sum( + 1 + for ln in ud.splitlines() + if ln.startswith("+") and not ln.startswith("+++") + ) + removed = sum( + 1 + for ln in ud.splitlines() + if ln.startswith("-") and not ln.startswith("---") + ) + deltas.append(DreamFileDelta(rel, "changed", added, removed, ud)) + summary["files_changed"] += 1 + return DreamDiff(pre_dir, post_dir, deltas, summary) diff --git a/tests/unit/test_dream_diff.py b/tests/unit/test_dream_diff.py new file mode 100644 index 0000000..e6909d9 --- /dev/null +++ b/tests/unit/test_dream_diff.py @@ -0,0 +1,27 @@ +from pathlib import Path +from cc_janitor.core.dream_diff import compute_diff, DreamFileDelta + + +def _mk(path: Path, content: str): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def test_compute_diff_added_removed_changed(tmp_path): + pre = tmp_path / "pre" + post = tmp_path / "post" + _mk(pre / "MEMORY.md", "a\nb\nc\n") + _mk(pre / "removed.md", "x\n") + _mk(post / "MEMORY.md", "a\nB\nc\n") + _mk(post / "added.md", "y\n") + diff = compute_diff(pre, post) + by = {str(d.rel_path): d for d in diff.deltas} + assert by["MEMORY.md"].status == "changed" + assert by["MEMORY.md"].lines_added == 1 + assert by["MEMORY.md"].lines_removed == 1 + assert by["MEMORY.md"].unified_diff is not None + assert by["removed.md"].status == "removed" + assert by["added.md"].status == "added" + assert diff.summary["files_added"] == 1 + assert diff.summary["files_removed"] == 1 + assert diff.summary["files_changed"] == 1 From e920bccc7f3e38161dcfdb6dfadf45f6e51f73b9 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:27:15 -0400 Subject: [PATCH 06/13] =?UTF-8?q?feat(core):=20dream=5Fdoctor=20=E2=80=94?= =?UTF-8?q?=209-check=20diagnostic=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stale_lock (Issue #50694), autodream_enabled, server_gate (Issue #38461), last_dream_ts, backup_dir_health, memory_md_cap, disk_usage, memory_file_count, duplicate_summary. All thresholds from config.toml; cross-file dup check reuses Phase 1 find_duplicate_lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/core/dream_doctor.py | 219 ++++++++++++++++++++++++++++ tests/unit/test_dream_doctor.py | 28 ++++ 2 files changed, 247 insertions(+) create mode 100644 src/cc_janitor/core/dream_doctor.py create mode 100644 tests/unit/test_dream_doctor.py diff --git a/src/cc_janitor/core/dream_doctor.py b/src/cc_janitor/core/dream_doctor.py new file mode 100644 index 0000000..c5eb8d1 --- /dev/null +++ b/src/cc_janitor/core/dream_doctor.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from .config import load_config +from .dream_snapshot import history +from .memory import find_duplicate_lines +from .state import get_paths + +Severity = Literal["OK", "WARN", "FAIL", "INFO"] + + +@dataclass +class DoctorCheck: + id: str + title: str + severity: Severity + message: str + detail: dict | None = None + + +def _claude_home() -> Path: + return Path.home() / ".claude" + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + import psutil # type: ignore + return psutil.pid_exists(pid) + except ImportError: + pass + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError, OSError): + return False + + +def _check_stale_lock() -> DoctorCheck: + projects = _claude_home() / "projects" + if not projects.exists(): + return DoctorCheck("stale_lock", "Stale .consolidate-lock", + "OK", "No projects directory yet.") + stale: list[tuple[Path, int]] = [] + for proj in projects.iterdir(): + lock = proj / "memory" / ".consolidate-lock" + if not lock.exists(): + continue + try: + pid = int(lock.read_text(encoding="utf-8").strip() or "0") + except (OSError, ValueError): + pid = 0 + if not _pid_alive(pid): + stale.append((lock, pid)) + if stale: + return DoctorCheck( + "stale_lock", "Stale .consolidate-lock", "FAIL", + f"{len(stale)} stale lock file(s) found " + "(silently disables Auto Dream — Issue #50694).", + {"locks": [{"path": str(p), "pid": pid} for p, pid in stale]}, + ) + return DoctorCheck("stale_lock", "Stale .consolidate-lock", "OK", + "No stale lock files.") + + +def _check_autodream_enabled() -> DoctorCheck: + s = _claude_home() / "settings.json" + if not s.exists(): + return DoctorCheck("autodream_enabled", "autoDreamEnabled", "INFO", + "settings.json missing.") + try: + data = json.loads(s.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return DoctorCheck("autodream_enabled", "autoDreamEnabled", "WARN", + "settings.json unreadable.") + val = data.get("autoDreamEnabled", False) + if val: + return DoctorCheck("autodream_enabled", "autoDreamEnabled", "OK", + "Enabled in settings.json.") + return DoctorCheck("autodream_enabled", "autoDreamEnabled", "WARN", + "Auto Dream is disabled in settings.json.") + + +def _check_server_gate() -> DoctorCheck: + # Inference only — not invoked at doctor time, would need claude CLI. + return DoctorCheck("server_gate", "Server-gate inference", "INFO", + "Run `claude --print --headless \"/dream\"` to verify; " + "'Unknown skill' means flag is off server-side " + "(#38461).") + + +def _check_last_dream() -> DoctorCheck: + h = history() + if not h: + return DoctorCheck("last_dream_ts", "Last dream observed", + "INFO", "No paired snapshots yet.") + last = h[-1] + return DoctorCheck("last_dream_ts", "Last dream observed", "OK", + f"Last paired snapshot: {last.ts_pre} " + f"({last.project_slug}).", + {"pair_id": last.pair_id}) + + +def _dir_size_mb(d: Path) -> float: + if not d.exists(): + return 0.0 + return sum( + f.stat().st_size for f in d.rglob("*") if f.is_file() + ) / 1024 / 1024 + + +def _check_backup_dir_health() -> DoctorCheck: + d = get_paths().home / "backups" / "dream" + if not d.exists(): + return DoctorCheck("backup_dir_health", "Backup directory health", + "INFO", "No dream backups yet.") + return DoctorCheck("backup_dir_health", "Backup directory health", "OK", + f"Exists, {_dir_size_mb(d):.1f} MB.") + + +def _check_memory_md_cap(cfg) -> DoctorCheck: + threshold = cfg.dream_doctor.memory_md_line_threshold + projects = _claude_home() / "projects" + if not projects.exists(): + return DoctorCheck("memory_md_cap", "MEMORY.md cap usage", "OK", + "No projects.") + over: list[tuple[str, int]] = [] + for p in projects.iterdir(): + m = p / "memory" / "MEMORY.md" + if not m.exists(): + continue + n = sum(1 for _ in m.open("r", encoding="utf-8", errors="ignore")) + if n >= threshold: + over.append((p.name, n)) + if over: + return DoctorCheck( + "memory_md_cap", "MEMORY.md cap usage", "WARN", + f"{len(over)} project(s) within {threshold}-line warning band " + "(Anthropic hard cap ~200).", + {"projects": over}, + ) + return DoctorCheck("memory_md_cap", "MEMORY.md cap usage", "OK", + f"All MEMORY.md files under {threshold} lines.") + + +def _check_disk_usage(cfg) -> DoctorCheck: + threshold = cfg.dream_doctor.disk_warning_mb + used = _dir_size_mb(get_paths().home / "backups" / "dream") + sev: Severity = "WARN" if used > threshold else "OK" + return DoctorCheck("disk_usage", "Dream backup disk usage", sev, + f"{used:.1f} MB / threshold {threshold} MB.") + + +def _check_memory_file_count(cfg) -> DoctorCheck: + threshold = cfg.dream_doctor.memory_file_count_threshold + projects = _claude_home() / "projects" + if not projects.exists(): + return DoctorCheck("memory_file_count", "Memory file count", "OK", + "No projects.") + over: list[tuple[str, int]] = [] + for p in projects.iterdir(): + m = p / "memory" + if not m.is_dir(): + continue + cnt = sum(1 for _ in m.rglob("*.md")) + if cnt > threshold: + over.append((p.name, cnt)) + if over: + return DoctorCheck( + "memory_file_count", "Memory file count", "WARN", + f"{len(over)} project(s) over {threshold} memory files; " + "consider `cc-janitor memory archive --stale`.", + {"projects": over}, + ) + return DoctorCheck("memory_file_count", "Memory file count", "OK", + f"All projects under {threshold} memory files.") + + +def _check_duplicate_summary() -> DoctorCheck: + projects = _claude_home() / "projects" + if not projects.exists(): + return DoctorCheck("duplicate_summary", "Cross-file duplicates", + "OK", "No projects.") + all_paths: list[Path] = [] + for p in projects.iterdir(): + m = p / "memory" + if m.is_dir(): + all_paths.extend(m.rglob("*.md")) + dups = find_duplicate_lines(all_paths, min_length=8) + if not dups: + return DoctorCheck("duplicate_summary", "Cross-file duplicates", + "OK", "No cross-file duplicates >= 8 chars.") + top = sorted(dups, key=lambda d: -len(d.files))[:5] + return DoctorCheck( + "duplicate_summary", "Cross-file duplicates", "INFO", + f"{len(dups)} duplicated lines across memory files.", + {"top": [{"line": d.line[:80], "count": len(d.files)} for d in top]}, + ) + + +def run_checks() -> list[DoctorCheck]: + cfg = load_config() + return [ + _check_stale_lock(), + _check_autodream_enabled(), + _check_server_gate(), + _check_last_dream(), + _check_backup_dir_health(), + _check_memory_md_cap(cfg), + _check_disk_usage(cfg), + _check_memory_file_count(cfg), + _check_duplicate_summary(), + ] diff --git a/tests/unit/test_dream_doctor.py b/tests/unit/test_dream_doctor.py new file mode 100644 index 0000000..36ef8fe --- /dev/null +++ b/tests/unit/test_dream_doctor.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from cc_janitor.core.dream_doctor import run_checks + + +def test_doctor_runs_all_9_checks(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "settings.json").write_text( + '{"autoDreamEnabled": true}', encoding="utf-8") + checks = run_checks() + ids = {c.id for c in checks} + expected = {"stale_lock", "autodream_enabled", "server_gate", + "last_dream_ts", "backup_dir_health", "memory_md_cap", + "disk_usage", "memory_file_count", "duplicate_summary"} + assert expected.issubset(ids) + + +def test_stale_lock_with_dead_pid_fails(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = tmp_path / ".claude" / "projects" / "-proj" / "memory" + mem.mkdir(parents=True) + (mem / ".consolidate-lock").write_text("999999") # very unlikely-alive PID + (tmp_path / ".claude" / "settings.json").write_text("{}") + checks = {c.id: c for c in run_checks()} + assert checks["stale_lock"].severity == "FAIL" From 4c4e6722ad1e491be07743b1cc19c22d60969f26 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:29:19 -0400 Subject: [PATCH 07/13] =?UTF-8?q?feat(core):=20sleep=5Fhygiene=20=E2=80=94?= =?UTF-8?q?=204=20keyword/regex/dup=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory_md_size_lines, relative_date_density (en+ru regex over 12 default terms, user-extensible via config.toml), cross_file_dup_count (reuses Phase 1 find_duplicate_lines), contradicting_pairs (NEG/POS regex + Jaccard token overlap, threshold from config.toml). LLM-based semantic analysis explicitly deferred to Phase 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/core/sleep_hygiene.py | 148 +++++++++++++++++++++++++++ tests/unit/test_sleep_hygiene.py | 28 +++++ 2 files changed, 176 insertions(+) create mode 100644 src/cc_janitor/core/sleep_hygiene.py create mode 100644 tests/unit/test_sleep_hygiene.py diff --git a/src/cc_janitor/core/sleep_hygiene.py b/src/cc_janitor/core/sleep_hygiene.py new file mode 100644 index 0000000..0a87228 --- /dev/null +++ b/src/cc_janitor/core/sleep_hygiene.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from .config import load_config +from .memory import find_duplicate_lines + +DEFAULT_RELATIVE_TERMS = ( + "yesterday", "today", "recently", "now", "last week", + "вчера", "сегодня", "недавно", "на прошлой неделе", + "в прошлый раз", "в этот раз", "на днях", +) + +NEG_PATTERN = re.compile(r"(?i)\b(never|don'?t|stop|avoid)\b\s+(.+)") +POS_PATTERN = re.compile(r"(?i)\b(always|prefer|use)\b\s+(.+)") + + +@dataclass +class ProjectHygiene: + project_slug: str + memory_md_size_lines: int + memory_md_cap: int + relative_date_density: float + relative_date_matches: list[tuple[Path, int, str]] + cross_file_dup_count: int + contradicting_pairs: list[tuple[str, list[Path]]] + + +@dataclass +class HygieneReport: + generated_at: datetime + projects: list[ProjectHygiene] + totals: dict + + +def _scan_relative_dates( + paths: list[Path], + *, + extra_terms: tuple[str, ...], +) -> list[tuple[Path, int, str]]: + terms = tuple(DEFAULT_RELATIVE_TERMS) + tuple(extra_terms) + pattern = re.compile( + r"(? set[str]: + return {w.lower() for w in re.findall(r"\w+", s) if len(w) > 2} + + +def _jaccard(a: set[str], b: set[str]) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _extract_contradiction_subjects( + paths: list[Path], + *, + jaccard_threshold: float, +) -> list[tuple[str, list[Path]]]: + neg: list[tuple[str, Path]] = [] + pos: list[tuple[str, Path]] = [] + for f in paths: + try: + text = f.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for line in text.splitlines(): + mn = NEG_PATTERN.search(line) + if mn: + neg.append((mn.group(2).strip(), f)) + mp = POS_PATTERN.search(line) + if mp: + pos.append((mp.group(2).strip(), f)) + pairs: list[tuple[str, list[Path]]] = [] + for ns, nf in neg: + nt = _tokens(ns) + for ps, pf in pos: + if _jaccard(nt, _tokens(ps)) >= jaccard_threshold: + pairs.append((ns, [nf, pf])) + break + return pairs + + +def compute_project_hygiene(memory_dir: Path) -> ProjectHygiene: + cfg = load_config() + md_files = sorted(memory_dir.rglob("*.md")) + memory_md = memory_dir / "MEMORY.md" + total_lines = sum( + sum(1 for _ in f.open("r", encoding="utf-8", errors="ignore")) + for f in md_files + ) or 1 + rel_matches = _scan_relative_dates( + md_files, extra_terms=cfg.hygiene.relative_date_terms_extra, + ) + dups = find_duplicate_lines(md_files, min_length=8) + contradictions = _extract_contradiction_subjects( + md_files, + jaccard_threshold=cfg.hygiene.contradiction_jaccard_threshold, + ) + memory_md_lines = ( + sum(1 for _ in memory_md.open("r", encoding="utf-8", errors="ignore")) + if memory_md.exists() else 0 + ) + return ProjectHygiene( + project_slug=memory_dir.parent.name, + memory_md_size_lines=memory_md_lines, + memory_md_cap=cfg.dream_doctor.memory_md_line_threshold, + relative_date_density=len(rel_matches) / total_lines, + relative_date_matches=rel_matches, + cross_file_dup_count=len(dups), + contradicting_pairs=contradictions, + ) + + +def compute_report() -> HygieneReport: + projects_root = Path.home() / ".claude" / "projects" + projects: list[ProjectHygiene] = [] + if projects_root.exists(): + for p in projects_root.iterdir(): + mem = p / "memory" + if mem.is_dir(): + projects.append(compute_project_hygiene(mem)) + totals = { + "projects": len(projects), + "total_relative_date_matches": sum( + len(p.relative_date_matches) for p in projects), + "total_cross_file_dups": sum( + p.cross_file_dup_count for p in projects), + "total_contradiction_pairs": sum( + len(p.contradicting_pairs) for p in projects), + } + return HygieneReport(datetime.now(UTC), projects, totals) diff --git a/tests/unit/test_sleep_hygiene.py b/tests/unit/test_sleep_hygiene.py new file mode 100644 index 0000000..7897524 --- /dev/null +++ b/tests/unit/test_sleep_hygiene.py @@ -0,0 +1,28 @@ +from cc_janitor.core.sleep_hygiene import ( + _extract_contradiction_subjects, + _scan_relative_dates, +) + + +def test_relative_date_density_finds_en_and_ru(tmp_path): + f = tmp_path / "x.md" + f.write_text( + "yesterday we did X\nна прошлой неделе also Y\nstable text\n", # noqa: RUF001 + encoding="utf-8", + ) + matches = _scan_relative_dates([f], extra_terms=()) + terms = {m[2] for m in matches} + assert "yesterday" in terms + assert "на прошлой неделе" in terms + + +def test_contradiction_extraction(tmp_path): + a = tmp_path / "a.md" + b = tmp_path / "b.md" + a.write_text("never use openai apis directly\n", encoding="utf-8") + b.write_text("always use openai apis for embeddings\n", encoding="utf-8") + pairs = _extract_contradiction_subjects([a, b], jaccard_threshold=0.5) + assert pairs + subj, files = pairs[0] + assert "openai" in subj.lower() + assert len(files) >= 2 From 19f38653e84204b93dea55e5d8c5b2ffe4d18f81 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:32:57 -0400 Subject: [PATCH 08/13] feat(cli): cc-janitor dream history/diff/doctor/rollback/prune Read-only: history (list pairs), diff (file-level + unified diff), doctor (9 checks). Mutating: rollback (soft-deletes current state to trash, copies pre-mirror back; --apply gated by require_confirmed + audit_action), prune (removes artifacts older than N days). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/cli/__init__.py | 2 + src/cc_janitor/cli/commands/dream.py | 199 +++++++++++++++++++++++++++ tests/unit/test_cli_dream.py | 69 ++++++++++ 3 files changed, 270 insertions(+) create mode 100644 src/cc_janitor/cli/commands/dream.py create mode 100644 tests/unit/test_cli_dream.py diff --git a/src/cc_janitor/cli/__init__.py b/src/cc_janitor/cli/__init__.py index 65e18f0..bec3576 100644 --- a/src/cc_janitor/cli/__init__.py +++ b/src/cc_janitor/cli/__init__.py @@ -8,6 +8,7 @@ from .commands.config import config_app from .commands.context import context_app from .commands.doctor import doctor as _doctor +from .commands.dream import dream_app from .commands.hooks import hooks_app from .commands.install_hooks import install_hooks as _install_hooks from .commands.memory import memory_app @@ -51,6 +52,7 @@ def root( app.add_typer(completions_app, name="completions") app.add_typer(config_app, name="config") app.add_typer(context_app, name="context") +app.add_typer(dream_app, name="dream") app.add_typer(hooks_app, name="hooks") app.add_typer(memory_app, name="memory") app.add_typer(monorepo_app, name="monorepo") diff --git a/src/cc_janitor/cli/commands/dream.py b/src/cc_janitor/cli/commands/dream.py new file mode 100644 index 0000000..bdca006 --- /dev/null +++ b/src/cc_janitor/cli/commands/dream.py @@ -0,0 +1,199 @@ +"""`cc-janitor dream` — Auto Dream safety-net subapp. + +Read-only inspectors: ``history``, ``diff``, ``doctor``. +Mutating commands: ``rollback`` (restore from pre-mirror), ``prune`` +(drop old artifacts). Mutations default to dry-run and require +``CC_JANITOR_USER_CONFIRMED=1`` via ``require_confirmed()`` before any +filesystem move; the resulting action is recorded through +``audit_action(mode="cli")``. +""" +from __future__ import annotations + +import json +import shutil +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path + +import typer + +from ...core import dream_diff as dd +from ...core import dream_doctor as ddoc +from ...core.dream_snapshot import _dream_root, history +from ...core.safety import require_confirmed +from ...core.state import get_paths +from .._audit import audit_action + +dream_app = typer.Typer( + no_args_is_help=True, + help="Auto Dream safety net (snapshot/diff/doctor/rollback)", +) + + +@dream_app.command("history") +def history_cmd( + project: str | None = typer.Option(None, "--project"), + json_out: bool = typer.Option(False, "--json"), +) -> None: + items = history() + if project: + items = [p for p in items if p.project_slug == project] + if json_out: + typer.echo(json.dumps([asdict(p) for p in items], indent=2)) + return + typer.echo(f"{'PAIR_ID':<32} {'PROJECT':<20} {'DFILES':<8} {'DLINES':<8}") + for p in items: + typer.echo( + f"{p.pair_id:<32} {p.project_slug:<20} " + f"{str(p.file_count_delta or 0):<8} " + f"{str(p.line_count_delta or 0):<8}" + ) + + +def _find_pair(pair_id: str): + for p in history(): + if p.pair_id == pair_id: + return p + return None + + +@dream_app.command("diff") +def diff_cmd( + pair_id: str, + file: str | None = typer.Option(None, "--file"), + json_out: bool = typer.Option(False, "--json"), +) -> None: + pair = _find_pair(pair_id) + if pair is None: + typer.echo(f"No such pair: {pair_id}") + raise typer.Exit(code=1) + pre = _dream_root() / f"{pair_id}-pre" + post = _dream_root() / f"{pair_id}-post" + if not pre.exists() or not post.exists(): + typer.echo( + "Snapshot mirrors missing (tar storage not yet supported in dry-run)." + ) + raise typer.Exit(code=1) + diff = dd.compute_diff(pre, post) + if file: + diff.deltas = [d for d in diff.deltas if str(d.rel_path) == file] + if json_out: + typer.echo(json.dumps({ + "summary": diff.summary, + "deltas": [{ + "rel_path": str(d.rel_path), + "status": d.status, + "lines_added": d.lines_added, + "lines_removed": d.lines_removed, + "unified_diff": d.unified_diff, + } for d in diff.deltas], + }, indent=2)) + return + typer.echo(f"Pair: {pair_id} Summary: {diff.summary}") + for d in diff.deltas: + typer.echo( + f" [{d.status:<9}] {d.rel_path} " + f"+{d.lines_added} -{d.lines_removed}" + ) + for d in diff.deltas: + if d.unified_diff: + typer.echo("") + typer.echo(d.unified_diff) + + +@dream_app.command("doctor") +def doctor_cmd(json_out: bool = typer.Option(False, "--json")) -> None: + checks = ddoc.run_checks() + if json_out: + typer.echo(json.dumps([asdict(c) for c in checks], indent=2)) + return + typer.echo("cc-janitor dream doctor") + typer.echo("-" * 60) + for c in checks: + typer.echo(f" [{c.severity:<4}] {c.title}: {c.message}") + + +@dream_app.command("rollback") +def rollback_cmd( + pair_id: str, + apply: bool = typer.Option( + False, "--apply", help="Actually restore (otherwise dry-run)" + ), +) -> None: + pair = _find_pair(pair_id) + if pair is None: + typer.echo(f"No such pair: {pair_id}") + raise typer.Exit(code=1) + pre = _dream_root() / f"{pair_id}-pre" + target = Path(pair.claude_memory_dir) + if not apply: + typer.echo(f"[dry-run] Would restore {pre} -> {target}") + typer.echo( + " Current target post-state would be soft-deleted to trash." + ) + return + require_confirmed() + with audit_action( + cmd="dream rollback", args=[pair_id, "--apply"], mode="cli", + ) as changed: + trash = ( + get_paths().home / ".trash" + / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + / f"dream-rollback-{pair_id}" + ) + trash.mkdir(parents=True, exist_ok=True) + if target.exists(): + for f in target.rglob("*"): + if f.is_file(): + rel = f.relative_to(target) + out = trash / rel + out.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(f), str(out)) + target.mkdir(parents=True, exist_ok=True) + for f in pre.rglob("*"): + if f.is_file(): + rel = f.relative_to(pre) + out = target / rel + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, out) + changed["pair_id"] = pair_id + changed["files_restored"] = sum( + 1 for _ in pre.rglob("*") if _.is_file() + ) + changed["trash_path"] = str(trash) + typer.echo( + f"Restored {pair_id}; previous state preserved in {trash}." + ) + + +@dream_app.command("prune") +def prune_cmd( + older_than_days: int = typer.Option(30, "--older-than-days"), + apply: bool = typer.Option(False, "--apply"), +) -> None: + root = _dream_root() + if not root.exists(): + typer.echo("Nothing to prune.") + return + now = datetime.now(timezone.utc).timestamp() + cutoff = now - older_than_days * 86400 + victims = [d for d in root.iterdir() if d.stat().st_mtime < cutoff] + if not apply: + typer.echo( + f"[dry-run] Would remove {len(victims)} dream artifact(s) " + f"older than {older_than_days} days." + ) + return + require_confirmed() + with audit_action( + cmd="dream prune", + args=[f"--older-than-days={older_than_days}"], + mode="cli", + ) as ch: + for v in victims: + if v.is_dir(): + shutil.rmtree(v) + else: + v.unlink() + ch["removed"] = [str(v) for v in victims] + typer.echo(f"Removed {len(victims)} artifact(s).") diff --git a/tests/unit/test_cli_dream.py b/tests/unit/test_cli_dream.py new file mode 100644 index 0000000..4bc5b02 --- /dev/null +++ b/tests/unit/test_cli_dream.py @@ -0,0 +1,69 @@ +import json +from datetime import datetime, timezone +from pathlib import Path +from typer.testing import CliRunner +from cc_janitor.cli import app +from cc_janitor.core.dream_snapshot import ( + snapshot_pre, snapshot_post, record_pair, +) + +runner = CliRunner() + + +def _setup_pair(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = tmp_path / ".claude" / "projects" / "-proj" / "memory" + mem.mkdir(parents=True) + (mem / "MEMORY.md").write_text("a\nb\n") + pre = snapshot_pre("20260511T120000Z-proj", mem) + (mem / "MEMORY.md").write_text("a\n") + post = snapshot_post("20260511T120000Z-proj", mem) + record_pair("20260511T120000Z-proj", mem, project_slug="proj", + dream_pid_in_lock=4711, + ts_pre=datetime.now(timezone.utc), + ts_post=datetime.now(timezone.utc), + pre_dir=pre, post_dir=post) + return mem + + +def test_dream_history(tmp_path, monkeypatch): + _setup_pair(tmp_path, monkeypatch) + res = runner.invoke(app, ["dream", "history", "--json"]) + assert res.exit_code == 0 + data = json.loads(res.stdout) + assert any(d["pair_id"] == "20260511T120000Z-proj" for d in data) + + +def test_dream_diff(tmp_path, monkeypatch): + _setup_pair(tmp_path, monkeypatch) + res = runner.invoke(app, ["dream", "diff", "20260511T120000Z-proj"]) + assert res.exit_code == 0 + assert "MEMORY.md" in res.stdout + + +def test_dream_doctor_json(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "settings.json").write_text("{}") + res = runner.invoke(app, ["dream", "doctor", "--json"]) + assert res.exit_code == 0 + data = json.loads(res.stdout) + assert isinstance(data, list) + assert len(data) == 9 + + +def test_dream_rollback_requires_confirm(tmp_path, monkeypatch): + _setup_pair(tmp_path, monkeypatch) + monkeypatch.delenv("CC_JANITOR_USER_CONFIRMED", raising=False) + res = runner.invoke(app, ["dream", "rollback", "20260511T120000Z-proj", + "--apply"]) + assert res.exit_code != 0 + + +def test_dream_rollback_dry_run(tmp_path, monkeypatch): + _setup_pair(tmp_path, monkeypatch) + res = runner.invoke(app, ["dream", "rollback", "20260511T120000Z-proj"]) + assert res.exit_code == 0 + assert "dry" in res.stdout.lower() or "would" in res.stdout.lower() From f08ec105b22587b93bdd5c0c092368d253953b4a Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:34:10 -0400 Subject: [PATCH 09/13] feat(cli): cc-janitor stats sleep-hygiene Surfaces the 4 keyword/regex/dup metrics from core.sleep_hygiene as a per-project summary table or JSON document. Read-only; safe to invoke from inside a Claude Code session. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/cli/commands/stats.py | 38 ++++++++++++++++++++++++++++ tests/unit/test_cli_stats_hygiene.py | 28 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/unit/test_cli_stats_hygiene.py diff --git a/src/cc_janitor/cli/commands/stats.py b/src/cc_janitor/cli/commands/stats.py index edfcda5..6c7ff2e 100644 --- a/src/cc_janitor/cli/commands/stats.py +++ b/src/cc_janitor/cli/commands/stats.py @@ -9,6 +9,7 @@ import typer +from ...core.sleep_hygiene import compute_report from ...core.stats import ( load_snapshots, render_sparkline, @@ -78,3 +79,40 @@ def snapshot_cmd() -> None: s = take_snapshot() p = write_snapshot(s) typer.echo(f"Snapshot written: {p}") + + +@stats_app.command("sleep-hygiene") +def sleep_hygiene( + project: str | None = typer.Option(None, "--project"), + json_out: bool = typer.Option(False, "--json"), +) -> None: + report = compute_report() + projects = report.projects + if project: + projects = [p for p in projects if p.project_slug == project] + if json_out: + typer.echo(json.dumps({ + "generated_at": report.generated_at.isoformat(), + "totals": report.totals, + "projects": [{ + "project_slug": p.project_slug, + "memory_md_size_lines": p.memory_md_size_lines, + "memory_md_cap": p.memory_md_cap, + "relative_date_density": p.relative_date_density, + "relative_date_match_count": len(p.relative_date_matches), + "cross_file_dup_count": p.cross_file_dup_count, + "contradicting_pair_count": len(p.contradicting_pairs), + } for p in projects], + }, indent=2)) + return + typer.echo("Sleep hygiene report") + typer.echo("-" * 70) + for p in projects: + typer.echo( + f" {p.project_slug:<25} " + f"MEMORY.md {p.memory_md_size_lines}/{p.memory_md_cap} " + f"rel-date density {p.relative_date_density:.3f} " + f"dups {p.cross_file_dup_count} " + f"contradictions {len(p.contradicting_pairs)}" + ) + typer.echo(f"Totals: {report.totals}") diff --git a/tests/unit/test_cli_stats_hygiene.py b/tests/unit/test_cli_stats_hygiene.py new file mode 100644 index 0000000..466d2de --- /dev/null +++ b/tests/unit/test_cli_stats_hygiene.py @@ -0,0 +1,28 @@ +import json +from pathlib import Path +from typer.testing import CliRunner +from cc_janitor.cli import app + +runner = CliRunner() + + +def test_stats_sleep_hygiene_empty(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + res = runner.invoke(app, ["stats", "sleep-hygiene", "--json"]) + assert res.exit_code == 0 + data = json.loads(res.stdout) + assert "projects" in data + assert data["totals"]["projects"] == 0 + + +def test_stats_sleep_hygiene_with_data(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path, raising=False) + mem = tmp_path / ".claude" / "projects" / "-proj" / "memory" + mem.mkdir(parents=True) + (mem / "MEMORY.md").write_text( + "yesterday we did x\nrecently changed y\n", encoding="utf-8") + res = runner.invoke(app, ["stats", "sleep-hygiene", "--json"]) + data = json.loads(res.stdout) + assert data["totals"]["total_relative_date_matches"] >= 2 From f9138a47561ff2bf45a3638846ad96121dff4016 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:38:33 -0400 Subject: [PATCH 10/13] =?UTF-8?q?feat(tui):=208th=20Dream=20tab=20?= =?UTF-8?q?=E2=80=94=20snapshot=20list=20+=20diff=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DreamScreen lays out a DataTable (snapshot history) beside a Static diff viewer. Row highlight triggers compute_diff() for the selected pair_id and renders summary + per-file deltas + unified diff body. Read-only; future TUI-driven rollback will route through ConfirmModal from tui/_confirm.py (Phase 4 task 11 stretch / Phase 5). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/tui/app.py | 3 + src/cc_janitor/tui/screens/dream_screen.py | 72 ++++++++++++++++++++ tests/tui/test_app_smoke.py | 4 +- tests/tui/test_dream_screen.py | 77 ++++++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/cc_janitor/tui/screens/dream_screen.py create mode 100644 tests/tui/test_dream_screen.py diff --git a/src/cc_janitor/tui/app.py b/src/cc_janitor/tui/app.py index 76307c9..e586215 100644 --- a/src/cc_janitor/tui/app.py +++ b/src/cc_janitor/tui/app.py @@ -44,6 +44,9 @@ def compose(self) -> ComposeResult: with TabPane("Audit", id="audit"): from .screens.audit_screen import AuditScreen yield AuditScreen() + with TabPane("Dream", id="dream"): + from .screens.dream_screen import DreamScreen + yield DreamScreen() yield Footer() def action_toggle_lang(self) -> None: diff --git a/src/cc_janitor/tui/screens/dream_screen.py b/src/cc_janitor/tui/screens/dream_screen.py new file mode 100644 index 0000000..1c5d87f --- /dev/null +++ b/src/cc_janitor/tui/screens/dream_screen.py @@ -0,0 +1,72 @@ +"""8th-tab DreamScreen — snapshot history list + per-pair diff viewer. + +Read-only by design. Future mutations (rollback, prune) will route through +:class:`cc_janitor.tui._confirm.ConfirmModal` per the Phase 4 plan; this +screen only surfaces what :mod:`core.dream_snapshot` and +:mod:`core.dream_diff` already produce. +""" +from __future__ import annotations + +from textual.app import ComposeResult +from textual.widget import Widget +from textual.widgets import DataTable, Static + +from ...core.dream_diff import compute_diff +from ...core.dream_snapshot import _dream_root, history + + +class DreamScreen(Widget): + DEFAULT_CSS = """ + DreamScreen { layout: horizontal; height: 100%; } + DreamScreen DataTable { width: 60; } + DreamScreen Static { width: 1fr; padding: 0 1; } + """ + + def compose(self) -> ComposeResult: + yield DataTable(id="dream-list") + yield Static(id="dream-diff", expand=True) + + def on_mount(self) -> None: + table: DataTable = self.query_one("#dream-list", DataTable) + table.add_columns("Date", "Project", "ΔFiles", "ΔLines") + table.cursor_type = "row" + for pair in reversed(history()): + table.add_row( + pair.ts_pre[:19], + pair.project_slug, + str(pair.file_count_delta if pair.file_count_delta is not None else 0), + str(pair.line_count_delta if pair.line_count_delta is not None else 0), + key=pair.pair_id, + ) + self._show_diff_for(None) + + def on_data_table_row_highlighted( + self, event: DataTable.RowHighlighted + ) -> None: + key = event.row_key.value if event.row_key else None + self._show_diff_for(key) + + def _show_diff_for(self, pair_id: str | None) -> None: + diff_widget: Static = self.query_one("#dream-diff", Static) + if not pair_id: + diff_widget.update("Select a snapshot pair on the left.") + return + pre = _dream_root() / f"{pair_id}-pre" + post = _dream_root() / f"{pair_id}-post" + if not pre.exists() or not post.exists(): + diff_widget.update( + f"Mirrors missing for {pair_id} (may be in tar storage)." + ) + return + diff = compute_diff(pre, post) + body: list[str] = [f"Pair {pair_id} {diff.summary}\n"] + for d in diff.deltas: + body.append( + f" [{d.status}] {d.rel_path} " + f"+{d.lines_added} -{d.lines_removed}" + ) + for d in diff.deltas: + if d.unified_diff: + body.append("") + body.append(d.unified_diff) + diff_widget.update("\n".join(body)) diff --git a/tests/tui/test_app_smoke.py b/tests/tui/test_app_smoke.py index f174dbb..fdf7716 100644 --- a/tests/tui/test_app_smoke.py +++ b/tests/tui/test_app_smoke.py @@ -12,7 +12,7 @@ async def test_app_renders(): @pytest.mark.asyncio -async def test_app_has_seven_tabs(): +async def test_app_has_eight_tabs(): from textual.widgets import TabbedContent, TabPane from cc_janitor.tui.app import CcJanitorApp @@ -22,4 +22,4 @@ async def test_app_has_seven_tabs(): await pilot.pause() tabbed = app.query_one(TabbedContent) panes = list(tabbed.query(TabPane)) - assert len(panes) == 7 + assert len(panes) == 8 diff --git a/tests/tui/test_dream_screen.py b/tests/tui/test_dream_screen.py new file mode 100644 index 0000000..13cdc7d --- /dev/null +++ b/tests/tui/test_dream_screen.py @@ -0,0 +1,77 @@ +"""Tests for the 8th tab: ``DreamScreen`` — snapshot list + diff viewer.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest + + +@pytest.mark.asyncio +async def test_app_has_eight_tabs(): + from textual.widgets import TabbedContent, TabPane + + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + tabbed = app.query_one(TabbedContent) + panes = list(tabbed.query(TabPane)) + assert len(panes) == 8 + assert any(p.id == "dream" for p in panes) + + +@pytest.mark.asyncio +async def test_dream_screen_lists_history(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + # Seed two history entries. + from cc_janitor.core import dream_snapshot as ds + hp = ds._history_path() + hp.parent.mkdir(parents=True, exist_ok=True) + entry = { + "pair_id": "20260501T000000Z-proj", + "project_slug": "proj", + "project_path": "/x/proj", + "claude_memory_dir": "/x/proj/.claude/memory", + "ts_pre": "2026-05-01T00:00:00+00:00", + "ts_post": "2026-05-01T00:05:00+00:00", + "paths_in_pre": ["MEMORY.md"], + "paths_in_post": ["MEMORY.md"], + "file_count_delta": 0, + "line_count_delta": 1, + "has_diff": True, + "dream_pid_in_lock": 1234, + "storage": "raw", + } + hp.write_text(json.dumps(entry) + "\n", encoding="utf-8") + # Pre/post mirrors so the diff viewer can read them. + pre = ds._dream_root() / "20260501T000000Z-proj-pre" + post = ds._dream_root() / "20260501T000000Z-proj-post" + pre.mkdir(parents=True) + post.mkdir(parents=True) + (pre / "MEMORY.md").write_text("old\n", encoding="utf-8") + (post / "MEMORY.md").write_text("old\nnew\n", encoding="utf-8") + + from textual.widgets import DataTable, Static + + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + # Activate the dream tab. + from textual.widgets import TabbedContent + tabbed = app.query_one(TabbedContent) + tabbed.active = "dream" + await pilot.pause() + table = app.query_one("#dream-list", DataTable) + assert table.row_count == 1 + diff_widget = app.query_one("#dream-diff", Static) + # After mount, _show_diff_for(None) → placeholder text. + # On row highlight (auto on first row) → real diff content. + # Force highlight to first row. + table.move_cursor(row=0) + await pilot.pause() + rendered = str(diff_widget.render()) + assert "20260501T000000Z-proj" in rendered From 48e048a519f910059f7ffad4727164e500050d63 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:40:24 -0400 Subject: [PATCH 11/13] feat(scheduler): dream-tar-compact template + backups tar-compact CLI cc-janitor backups tar-compact --kind dream --older-than-days 7 --apply groups -pre / -post dirs into .tar.gz and removes raw mirrors. New scheduler template `dream-tar-compact` runs this weekly (Sunday 05:00). Audit-logged via audit_action; --apply gated behind require_confirmed(); default mode is dry-run. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/cli/commands/backups.py | 83 ++++++++++++++++++++ src/cc_janitor/core/schedule.py | 7 ++ tests/unit/test_tar_compact.py | 104 +++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 tests/unit/test_tar_compact.py diff --git a/src/cc_janitor/cli/commands/backups.py b/src/cc_janitor/cli/commands/backups.py index 6ed40f5..8aa6aaf 100644 --- a/src/cc_janitor/cli/commands/backups.py +++ b/src/cc_janitor/cli/commands/backups.py @@ -11,7 +11,9 @@ from __future__ import annotations import shutil +import tarfile from datetime import UTC, datetime, timedelta +from pathlib import Path import typer @@ -103,3 +105,84 @@ def prune_cmd( shutil.rmtree(bucket, ignore_errors=True) changed["deleted_buckets"] = [b.name for b in targets] typer.echo(f"Deleted {len(targets)} backup bucket(s).") + + +@backups_app.command("tar-compact") +def tar_compact_cmd( + kind: str = typer.Option( + "dream", + "--kind", + help="Backup subtree to compact (currently only `dream`).", + ), + older_than_days: int = typer.Option( + 7, + "--older-than-days", + help="Tar pairs whose pre/post mirrors are all older than N days.", + ), + apply: bool = typer.Option( + False, + "--apply", + help="Actually create tar.gz and remove raw mirrors (default: dry-run).", + ), +) -> None: + """Tar-compact aged pre/post mirror pairs into ``.tar.gz``. + + Groups ``-pre`` / ``-post`` directories under the chosen + backup subtree, archives them as ``.tar.gz`` with ``pre/`` and + ``post/`` prefixes, and removes the raw mirrors. Drives the weekly + ``dream-tar-compact`` scheduler template. + """ + root: Path = get_paths().home / "backups" / kind + if not root.exists(): + typer.echo("Nothing to compact.") + return + + cutoff = datetime.now(UTC).timestamp() - older_than_days * 86400 + pair_dirs: dict[str, list[Path]] = {} + for d in root.iterdir(): + if not d.is_dir(): + continue + name = d.name + if name.endswith("-pre"): + pair_dirs.setdefault(name[:-4], []).append(d) + elif name.endswith("-post"): + pair_dirs.setdefault(name[:-5], []).append(d) + old_pairs: dict[str, list[Path]] = { + pid: dirs + for pid, dirs in pair_dirs.items() + if dirs and all(d.stat().st_mtime < cutoff for d in dirs) + } + + if not apply: + typer.echo( + f"[dry-run] Would tar-compact {len(old_pairs)} pair(s) " + f"older than {older_than_days}d under {root}." + ) + for pid in sorted(old_pairs): + typer.echo(f" - {pid}") + return + + try: + require_confirmed() + except NotConfirmedError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=2) from e + + with audit_action( + "backups tar-compact", + [f"--kind={kind}", f"--older-than-days={older_than_days}"], + ) as changed: + archived: list[str] = [] + for pid, dirs in old_pairs.items(): + archive_path = root / f"{pid}.tar.gz" + with tarfile.open(archive_path, "w:gz") as tf: + for d in dirs: + arc = "pre" if d.name.endswith("-pre") else "post" + for f in d.rglob("*"): + if f.is_file(): + tf.add(f, arcname=f"{arc}/{f.relative_to(d)}") + for d in dirs: + shutil.rmtree(d, ignore_errors=True) + archived.append(pid) + changed["archived"] = archived + typer.echo(f"Compacted {len(old_pairs)} pair(s).") diff --git a/src/cc_janitor/core/schedule.py b/src/cc_janitor/core/schedule.py index 8e0191a..5631b25 100644 --- a/src/cc_janitor/core/schedule.py +++ b/src/cc_janitor/core/schedule.py @@ -52,6 +52,13 @@ class ScheduledJob: "default_cron": "0 4 * * 0", "command": "cc-janitor backups prune --older-than-days 30", }, + "dream-tar-compact": { + "default_cron": "0 5 * * 0", + "command": ( + "cc-janitor backups tar-compact --kind dream " + "--older-than-days 7 --apply" + ), + }, } MARKER_PREFIX = "# cc-janitor-job:" diff --git a/tests/unit/test_tar_compact.py b/tests/unit/test_tar_compact.py new file mode 100644 index 0000000..a7d7fd3 --- /dev/null +++ b/tests/unit/test_tar_compact.py @@ -0,0 +1,104 @@ +"""Tests for ``cc-janitor backups tar-compact`` + ``dream-tar-compact`` template.""" +from __future__ import annotations + +import os +import tarfile +import time + +from typer.testing import CliRunner + +from cc_janitor.cli import app + +runner = CliRunner() + + +def test_tar_compact_archives_old_pairs(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setenv("CC_JANITOR_USER_CONFIRMED", "1") + dream = tmp_path / "jhome" / "backups" / "dream" + pre = dream / "20260401T000000Z-old-pre" + post = dream / "20260401T000000Z-old-post" + pre.mkdir(parents=True) + post.mkdir(parents=True) + (pre / "MEMORY.md").write_text("a\n", encoding="utf-8") + (post / "MEMORY.md").write_text("b\n", encoding="utf-8") + old = time.time() - 30 * 86400 + for d in (pre, post): + for f in d.rglob("*"): + os.utime(f, (old, old)) + os.utime(d, (old, old)) + + res = runner.invoke( + app, + ["backups", "tar-compact", "--kind", "dream", + "--older-than-days", "7", "--apply"], + ) + assert res.exit_code == 0, res.output + tars = list(dream.glob("*.tar.gz")) + assert len(tars) == 1 + with tarfile.open(tars[0]) as tf: + names = tf.getnames() + assert any("pre/MEMORY.md" in n for n in names) + assert any("post/MEMORY.md" in n for n in names) + # Raw mirrors removed. + assert not pre.exists() + assert not post.exists() + + +def test_tar_compact_skips_recent_pairs(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setenv("CC_JANITOR_USER_CONFIRMED", "1") + dream = tmp_path / "jhome" / "backups" / "dream" + pre = dream / "20260510T000000Z-new-pre" + post = dream / "20260510T000000Z-new-post" + pre.mkdir(parents=True) + post.mkdir(parents=True) + (pre / "MEMORY.md").write_text("a\n", encoding="utf-8") + (post / "MEMORY.md").write_text("b\n", encoding="utf-8") + + res = runner.invoke( + app, + ["backups", "tar-compact", "--kind", "dream", + "--older-than-days", "7", "--apply"], + ) + assert res.exit_code == 0, res.output + assert list(dream.glob("*.tar.gz")) == [] + assert pre.exists() and post.exists() + + +def test_tar_compact_dry_run_default(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + dream = tmp_path / "jhome" / "backups" / "dream" + pre = dream / "20260401T000000Z-old-pre" + post = dream / "20260401T000000Z-old-post" + pre.mkdir(parents=True) + post.mkdir(parents=True) + (pre / "MEMORY.md").write_text("a\n", encoding="utf-8") + (post / "MEMORY.md").write_text("b\n", encoding="utf-8") + old = time.time() - 30 * 86400 + for d in (pre, post): + for f in d.rglob("*"): + os.utime(f, (old, old)) + os.utime(d, (old, old)) + + res = runner.invoke( + app, + ["backups", "tar-compact", "--kind", "dream", + "--older-than-days", "7"], + ) + assert res.exit_code == 0, res.output + assert "dry-run" in res.output.lower() + assert pre.exists() + assert list(dream.glob("*.tar.gz")) == [] + + +def test_dream_tar_compact_template_registered(): + from cc_janitor.core.schedule import TEMPLATES + + assert "dream-tar-compact" in TEMPLATES + tpl = TEMPLATES["dream-tar-compact"] + assert "command" in tpl + assert "default_cron" in tpl + assert "tar-compact" in tpl["command"] + assert "--kind" in tpl["command"] + assert "--apply" in tpl["command"] From 805c68547ce4bc6fa2e079f4dd23ea28c54eac6e Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:44:08 -0400 Subject: [PATCH 12/13] =?UTF-8?q?feat(core):=20settings=5Fobserver=20?= =?UTF-8?q?=E2=80=94=20detect=20autoDreamEnabled=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caches last-seen autoDreamEnabled value at ~/.cc-janitor/state/autodream-last-seen.json. On every dream doctor invocation, compares with current ~/.claude/settings.json and appends an audit-log entry (cmd=settings-observe, mode=observer) on flip. Surfaces as a "settings autoDream toggled" WARN row in dream doctor output, warning users to verify backups are configured before the next Dream cycle. Closes Phase 4 Task 11. +5 tests, total 267. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/core/dream_doctor.py | 32 +++++++++- src/cc_janitor/core/settings_observer.py | 74 ++++++++++++++++++++++++ tests/unit/test_settings_observer.py | 64 ++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/cc_janitor/core/settings_observer.py create mode 100644 tests/unit/test_settings_observer.py diff --git a/src/cc_janitor/core/dream_doctor.py b/src/cc_janitor/core/dream_doctor.py index c5eb8d1..40ca89e 100644 --- a/src/cc_janitor/core/dream_doctor.py +++ b/src/cc_janitor/core/dream_doctor.py @@ -9,6 +9,7 @@ from .config import load_config from .dream_snapshot import history from .memory import find_duplicate_lines +from .settings_observer import observe_autodream_change from .state import get_paths Severity = Literal["OK", "WARN", "FAIL", "INFO"] @@ -204,9 +205,35 @@ def _check_duplicate_summary() -> DoctorCheck: ) +def _check_settings_audit() -> DoctorCheck | None: + delta = observe_autodream_change() + if delta is None: + return None + old, new = delta + if new: + msg = ( + "autoDreamEnabled was toggled OFF -> ON since last check. " + "Ensure backups (`cc-janitor watch start --dream`) are running " + "before the next Dream cycle to avoid silent memory loss." + ) + else: + msg = ( + "autoDreamEnabled was toggled ON -> OFF since last check. " + "Auto Dream is now disabled." + ) + return DoctorCheck( + "settings_audit", "settings autoDream toggled", "WARN", msg, + {"old": old, "new": new}, + ) + + def run_checks() -> list[DoctorCheck]: cfg = load_config() - return [ + checks: list[DoctorCheck] = [] + audit = _check_settings_audit() + if audit is not None: + checks.append(audit) + checks.extend([ _check_stale_lock(), _check_autodream_enabled(), _check_server_gate(), @@ -216,4 +243,5 @@ def run_checks() -> list[DoctorCheck]: _check_disk_usage(cfg), _check_memory_file_count(cfg), _check_duplicate_summary(), - ] + ]) + return checks diff --git a/src/cc_janitor/core/settings_observer.py b/src/cc_janitor/core/settings_observer.py new file mode 100644 index 0000000..98e8462 --- /dev/null +++ b/src/cc_janitor/core/settings_observer.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .audit import AuditLog +from .state import get_paths + + +def _cache_path() -> Path: + state_dir = get_paths().home / "state" + return state_dir / "autodream-last-seen.json" + + +def _claude_settings() -> Path: + return Path.home() / ".claude" / "settings.json" + + +def observe_autodream_change() -> tuple[bool, bool] | None: + """Detect changes to ~/.claude/settings.json:autoDreamEnabled. + + Returns (old, new) if the flag has flipped since the last observation, + else None. On first observation, seeds the cache and returns None. + + Writes an audit-log entry (cmd=``settings-observe``, mode=``observer``) + on every detected flip so users can grep the audit log for + autoDreamEnabled toggles. + """ + s = _claude_settings() + if not s.exists(): + return None + try: + current = bool( + json.loads(s.read_text(encoding="utf-8")).get( + "autoDreamEnabled", False + ) + ) + except (OSError, json.JSONDecodeError): + return None + cache = _cache_path() + cache.parent.mkdir(parents=True, exist_ok=True) + if not cache.exists(): + cache.write_text( + json.dumps({"autoDreamEnabled": current}), encoding="utf-8" + ) + return None + try: + prev = bool( + json.loads(cache.read_text(encoding="utf-8")).get( + "autoDreamEnabled", False + ) + ) + except (OSError, json.JSONDecodeError): + prev = current + if prev == current: + return None + cache.write_text( + json.dumps({"autoDreamEnabled": current}), encoding="utf-8" + ) + log = AuditLog(get_paths().audit_log) + log.record( + mode="observer", + user_confirmed=False, + cmd="settings-observe", + args=[], + exit_code=0, + changed={ + "key": "autoDreamEnabled", + "old": prev, + "new": current, + "source": str(s), + }, + ) + return (prev, current) diff --git a/tests/unit/test_settings_observer.py b/tests/unit/test_settings_observer.py new file mode 100644 index 0000000..54c1973 --- /dev/null +++ b/tests/unit/test_settings_observer.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from cc_janitor.core.audit import AuditLog +from cc_janitor.core.settings_observer import observe_autodream_change +from cc_janitor.core.state import get_paths + + +def _setup(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "jhome")) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + (tmp_path / ".claude").mkdir() + + +def test_first_observation_no_change(tmp_path, monkeypatch): + _setup(tmp_path, monkeypatch) + (tmp_path / ".claude" / "settings.json").write_text( + '{"autoDreamEnabled": false}' + ) + assert observe_autodream_change() is None + + +def test_no_change_returns_none(tmp_path, monkeypatch): + _setup(tmp_path, monkeypatch) + s = tmp_path / ".claude" / "settings.json" + s.write_text('{"autoDreamEnabled": true}') + observe_autodream_change() + assert observe_autodream_change() is None + + +def test_change_detected_writes_audit(tmp_path, monkeypatch): + _setup(tmp_path, monkeypatch) + s = tmp_path / ".claude" / "settings.json" + s.write_text('{"autoDreamEnabled": false}') + observe_autodream_change() + s.write_text('{"autoDreamEnabled": true}') + delta = observe_autodream_change() + assert delta == (False, True) + + # Audit entry must have been recorded + log = AuditLog(get_paths().audit_log) + entries = list(log.read(cmd_glob="settings-observe")) + assert len(entries) == 1 + assert entries[0].changed["old"] is False + assert entries[0].changed["new"] is True + assert entries[0].changed["key"] == "autoDreamEnabled" + + +def test_missing_settings_returns_none(tmp_path, monkeypatch): + _setup(tmp_path, monkeypatch) + assert observe_autodream_change() is None + + +def test_cache_file_at_expected_path(tmp_path, monkeypatch): + _setup(tmp_path, monkeypatch) + (tmp_path / ".claude" / "settings.json").write_text( + '{"autoDreamEnabled": true}' + ) + observe_autodream_change() + cache = get_paths().home / "state" / "autodream-last-seen.json" + assert cache.exists() + assert json.loads(cache.read_text())["autoDreamEnabled"] is True From f6ea67f7d482c86b824c427e43506d9f2d2d9843 Mon Sep 17 00:00:00 2001 From: Creatman Date: Mon, 11 May 2026 10:46:58 -0400 Subject: [PATCH 13/13] docs: i18n + cookbook + CHANGELOG + bump to 0.4.0 i18n: new [dream] and [sleep_hygiene] tables in en.toml + ru.toml covering the new screens. Cookbook: three new recipes (#11 diff/rollback after Dream, #12 stale lock diagnosis via dream doctor, #13 scheduled snapshot setup with dream-tar-compact + config.toml override). CC_USAGE.md: Phase 4 section listing five read-only commands safe for Claude to call, plus five mutating ones that require CC_JANITOR_USER_CONFIRMED=1. CHANGELOG: [0.4.0] block above [0.3.3] documenting all six Phase 4 features and the closed upstream Issues (#47959, #50694, #38493, #38461). Version bumped to 0.4.0 in pyproject.toml + cli/__init__.py; CLI skeleton test assertion updated. README gains a Phase 4 hero section and marks Phase 2/3/4 done in the roadmap. Also drops 33 pre-existing ruff lints (RUF010 / UP017 / I001) across Phase 4 modules now that the suite is being shipped. Closes Phase 4 Task 12. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 45 +++++++++++++ README.md | 28 +++++++- docs/CC_USAGE.md | 25 +++++++ docs/cookbook.md | 96 ++++++++++++++++++++++++++- pyproject.toml | 2 +- src/cc_janitor/cli/__init__.py | 2 +- src/cc_janitor/cli/commands/dream.py | 10 +-- src/cc_janitor/core/dream_snapshot.py | 4 +- src/cc_janitor/i18n/en.toml | 19 ++++++ src/cc_janitor/i18n/ru.toml | 19 ++++++ tests/tui/test_dream_screen.py | 1 - tests/unit/test_cli_dream.py | 12 ++-- tests/unit/test_cli_skeleton.py | 2 +- tests/unit/test_cli_stats_hygiene.py | 2 + tests/unit/test_config_loader.py | 5 +- tests/unit/test_dream_diff.py | 3 +- tests/unit/test_dream_snapshot.py | 16 +++-- tests/unit/test_watcher_dream.py | 2 +- uv.lock | 2 +- 19 files changed, 264 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd9bce..3c7ab87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] — 2026-05-11 + +### Added — Phase 4: Dream safety net + +- **Dream snapshot harness.** New top-level `cc-janitor dream` subapp: + `history`, `diff `, `doctor`, `rollback --apply`, + `prune --older-than-days N --apply`. Snapshots are stored under + `~/.cc-janitor/backups/dream/-{pre,post}/`; pairs recorded + to `~/.cc-janitor/dream-snapshots.jsonl`. +- **Watcher `--dream` mode.** `cc-janitor watch start --dream` polls + every `~/.claude/projects/*/memory/.consolidate-lock`. On lock-appears + writes a raw mirror pre-snapshot; on lock-gone writes the post-snapshot + and records the pair. +- **`cc-janitor dream doctor` (10 checks).** Stale `.consolidate-lock` + detection, `autoDreamEnabled` state, server-gate inference hint, + last-dream timestamp, backup dir health + disk usage, MEMORY.md cap, + memory file count, cross-file duplicate summary, settings-audit + toggle warning. +- **`cc-janitor stats sleep-hygiene`.** Four keyword/regex/dup metrics + (MEMORY.md line count, relative-date density, cross-file duplicate + count, contradicting-feedback pairs). +- **`cc-janitor backups tar-compact --kind dream`** + new scheduler + template `dream-tar-compact`. Raw mirrors compacted to `.tar.gz` + after 7 days, tarballs purged after 30 days (thresholds configurable + via `~/.cc-janitor/config.toml`). +- **`~/.cc-janitor/config.toml`** (optional). User-tunable thresholds + for dream-doctor, snapshot retention, hygiene regex extras. +- **8th TUI tab: `Dream`.** Snapshot list pane + diff viewer pane. +- **Settings audit hook.** Caches `autoDreamEnabled` value at + `~/.cc-janitor/state/autodream-last-seen.json`; on flip, writes an + audit-log entry (`cmd=settings-observe`) and surfaces a WARN row in + `cc-janitor dream doctor` ("settings autoDream toggled"). +- **i18n `[dream]` and `[sleep_hygiene]` tables** for English and + Russian. +- **Three new cookbook recipes** covering snapshot-after-Dream + diff/rollback, stale-lock diagnosis, and scheduled snapshot setup. + +### Fixed + +- Closes verified user pain in upstream Claude Code Issues #47959 + (silent Auto Dream memory deletion), #50694 (stale + `.consolidate-lock` silently disables Auto Dream), #38493 + (missing `.dream-log.md`), #38461 (server-gate inference for + Auto Dream flag). + ## [0.3.3] — 2026-05-11 ### Added diff --git a/README.md b/README.md index a144961..6278fca 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,33 @@ cc-janitor never silently destroys data: cc-janitor is designed to be invoked by both you (TUI / CLI) and Claude Code itself (CLI), but only on your explicit request. See [docs/CC_USAGE.md](docs/CC_USAGE.md) for the reference Claude Code reads when deciding whether a subcommand is safe to call. +## Phase 4: Dream safety net + +**Dream safety net** — snapshot before Auto Dream, diff after, rollback if +needed. Closes upstream Issues #47959, #50694, #38493, #38461. + +```bash +# Opt-in: poll lock files and snapshot around every Auto Dream cycle +CC_JANITOR_USER_CONFIRMED=1 cc-janitor watch start --dream + +# Review what each Dream cycle changed +cc-janitor dream history +cc-janitor dream diff + +# 10 health checks covering stale locks, autoDream flag, disk, hygiene +cc-janitor dream doctor + +# Roll back if Dream rewrote something you wanted +CC_JANITOR_USER_CONFIRMED=1 cc-janitor dream rollback --apply +``` + ## Roadmap -- [x] **Phase 1** (this release): sessions / permissions / context inspector / CLI / TUI / safety primitives -- [ ] **Phase 2**: memory editor, reinject hook, hook debugger with simulation, scheduler (cron / Task Scheduler) -- [ ] **Phase 3**: monorepo nested .claude/ discovery, auto-reinject watcher, stats dashboard, export/import config +- [x] **Phase 1**: sessions / permissions / context inspector / CLI / TUI / safety primitives +- [x] **Phase 2**: memory editor, reinject hook, hook debugger with simulation, scheduler (cron / Task Scheduler) +- [x] **Phase 3**: monorepo nested .claude/ discovery, auto-reinject watcher, stats dashboard, export/import config +- [x] **Phase 4**: Dream safety net — snapshot/diff/doctor/rollback, sleep-hygiene metrics, settings audit hook +- [ ] **Phase 5**: cross-platform hook fixers, `dream fix-stale-lock`, deeper TUI for Dream pair review ## Contributing diff --git a/docs/CC_USAGE.md b/docs/CC_USAGE.md index 268ae36..7db39db 100644 --- a/docs/CC_USAGE.md +++ b/docs/CC_USAGE.md @@ -73,3 +73,28 @@ invocation is recorded in `~/.cc-janitor/audit.log`. Most mutating commands accept `--dry-run` to preview without applying. Prefer dry-run when explaining what cc-janitor would do. + +## Phase 4 — Auto Dream safety net (read-only commands safe for Claude) + +```bash +cc-janitor dream history [--project P] [--json] +cc-janitor dream diff [--file F] [--json] +cc-janitor dream doctor [--json] +cc-janitor stats sleep-hygiene [--project P] [--json] +cc-janitor watch status [--json] +``` + +Mutating (require `CC_JANITOR_USER_CONFIRMED=1`, user must explicitly OK): + +```bash +cc-janitor dream rollback --apply +cc-janitor dream prune --older-than-days N --apply +cc-janitor watch start --dream +cc-janitor backups tar-compact --kind dream +cc-janitor schedule add dream-tar-compact +``` + +If `cc-janitor dream doctor` shows a WARN row labelled +"settings autoDream toggled", advise the user to verify +`cc-janitor watch start --dream` is running before the next Dream +cycle, so memory edits will be snapshotted. diff --git a/docs/cookbook.md b/docs/cookbook.md index d997dde..e23ad0b 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,6 +1,6 @@ # Cookbook -Ten task-oriented recipes for everyday cc-janitor use. Each recipe follows +Thirteen task-oriented recipes for everyday cc-janitor use. Each recipe follows the same shape: **Problem → Command → Expected output → Next step.** --- @@ -303,3 +303,97 @@ to `~/.cc-janitor/backups/import-/` before overwrite. Or let cc-janitor write the file in the conventional location for you: CC_JANITOR_USER_CONFIRMED=1 cc-janitor completions install bash + +--- + +## 11. Auto Dream just rewrote my memory — how do I see what changed? + +**Problem:** Anthropic's Auto Dream rewrote `~/.claude/projects/*/memory/` +during your last session. You want to know exactly which lines moved or +disappeared before deciding whether to keep the new version. + +**Command:** + +```bash +# Find the pair that wraps the most recent Dream cycle +cc-janitor dream history + +# Diff pre vs post (unified diff, all files in the pair) +cc-janitor dream diff + +# Narrow to a single file +cc-janitor dream diff --file MEMORY.md + +# Regret it? Roll back to the pre-snapshot: +CC_JANITOR_USER_CONFIRMED=1 cc-janitor dream rollback --apply +``` + +**Expected output:** A coloured unified diff. `rollback --apply` restores +the pre-snapshot files in place and writes the displaced post-Dream copy +to `~/.cc-janitor/.trash//dream-rollback-/`. + +**Next step:** `cc-janitor stats sleep-hygiene` — surface the keyword, +duplicate, and stale-date counts that drove Dream to mutate so much. + +--- + +## 12. Auto Dream is silently disabled — diagnose it + +**Problem:** You enabled `autoDreamEnabled` in `~/.claude/settings.json` +but Dream never runs. Most common cause: a leftover `.consolidate-lock` +file from a crashed previous run (upstream Issue #50694). + +**Command:** + +```bash +cc-janitor dream doctor +``` + +**Expected output:** Ten check rows. Look for `FAIL` on `stale_lock`. If +present, manually `rm` the listed lock file(s) and rerun +`cc-janitor dream doctor`. Look for `WARN` on `settings autoDream +toggled` — that means the flag flipped since the last check; verify +backups are configured. + +**Next step:** `CC_JANITOR_USER_CONFIRMED=1 cc-janitor watch start --dream` +to enable lock-file polling + snapshots, so next time you can diff before +deciding. + +--- + +## 13. Set up scheduled snapshot-around-Dream so I never lose memory again + +**Problem:** You want guaranteed pre/post snapshots around every Auto +Dream cycle, with disk usage capped automatically. + +**Command:** + +```bash +# Start the watcher (polls ~/.claude/projects/*/memory/.consolidate-lock) +CC_JANITOR_USER_CONFIRMED=1 cc-janitor watch start --dream +cc-janitor watch status + +# Compact 7-day-old raw snapshot dirs to .tar.gz, purge 30-day-old tars +cc-janitor backups tar-compact --kind dream + +# Schedule the compaction nightly via OS-native scheduler +CC_JANITOR_USER_CONFIRMED=1 cc-janitor schedule add dream-tar-compact +cc-janitor schedule list +``` + +**Expected output:** A `dream-tar-compact` entry in the scheduler list +(cron / Task Scheduler) running nightly. Snapshots accumulate under +`~/.cc-janitor/backups/dream/` and compact themselves. + +**Next step:** Override defaults via `~/.cc-janitor/config.toml`: + +```toml +[dream_doctor] +disk_warning_mb = 1024 +memory_md_line_threshold = 180 +memory_file_count_threshold = 200 + +[backups] +dream_compact_after_days = 14 +dream_purge_after_days = 60 +``` diff --git a/pyproject.toml b/pyproject.toml index c01a343..798540c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cc-janitor" -version = "0.4.0.dev0" +version = "0.4.0" description = "Tidy up your Claude Code environment — sessions, permissions, context, hooks, schedule." readme = "README.md" requires-python = ">=3.11" diff --git a/src/cc_janitor/cli/__init__.py b/src/cc_janitor/cli/__init__.py index bec3576..8b2a6aa 100644 --- a/src/cc_janitor/cli/__init__.py +++ b/src/cc_janitor/cli/__init__.py @@ -21,7 +21,7 @@ from .commands.undo import undo as _undo from .commands.watch import watch_app -__VERSION__ = "0.4.0.dev0" +__VERSION__ = "0.4.0" app = typer.Typer(no_args_is_help=False, help="cc-janitor — Tidy Claude Code") diff --git a/src/cc_janitor/cli/commands/dream.py b/src/cc_janitor/cli/commands/dream.py index bdca006..b4c6dea 100644 --- a/src/cc_janitor/cli/commands/dream.py +++ b/src/cc_janitor/cli/commands/dream.py @@ -12,7 +12,7 @@ import json import shutil from dataclasses import asdict -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path import typer @@ -45,8 +45,8 @@ def history_cmd( for p in items: typer.echo( f"{p.pair_id:<32} {p.project_slug:<20} " - f"{str(p.file_count_delta or 0):<8} " - f"{str(p.line_count_delta or 0):<8}" + f"{p.file_count_delta or 0!s:<8} " + f"{p.line_count_delta or 0!s:<8}" ) @@ -138,7 +138,7 @@ def rollback_cmd( ) as changed: trash = ( get_paths().home / ".trash" - / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + / datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") / f"dream-rollback-{pair_id}" ) trash.mkdir(parents=True, exist_ok=True) @@ -175,7 +175,7 @@ def prune_cmd( if not root.exists(): typer.echo("Nothing to prune.") return - now = datetime.now(timezone.utc).timestamp() + now = datetime.now(UTC).timestamp() cutoff = now - older_than_days * 86400 victims = [d for d in root.iterdir() if d.stat().st_mtime < cutoff] if not apply: diff --git a/src/cc_janitor/core/dream_snapshot.py b/src/cc_janitor/core/dream_snapshot.py index 457ca0f..175a5bd 100644 --- a/src/cc_janitor/core/dream_snapshot.py +++ b/src/cc_janitor/core/dream_snapshot.py @@ -2,8 +2,8 @@ import json import shutil -from dataclasses import dataclass, asdict, field -from datetime import datetime, timezone +from dataclasses import asdict, dataclass, field +from datetime import datetime from pathlib import Path from typing import Literal diff --git a/src/cc_janitor/i18n/en.toml b/src/cc_janitor/i18n/en.toml index 09f5088..e50bea8 100644 --- a/src/cc_janitor/i18n/en.toml +++ b/src/cc_janitor/i18n/en.toml @@ -97,3 +97,22 @@ dry_run = "DRY RUN: would write {n} files. Re-run with --force to apply." title = "Shell completions" installed = "Wrote completion script to {target}" unknown_shell = "Unknown shell: {shell}" + +[dream] +title = "Dream" +list_header = "Date Project ΔFiles ΔLines" +no_pairs = "No snapshot pairs yet. Run `cc-janitor watch start --dream`." +diff_select = "Select a snapshot pair on the left." +doctor_running = "Running dream-doctor checks..." +autodream_toggled = "settings autoDream toggled" +autodream_toggled_warn = "Auto Dream was toggled. Verify backups are configured before the next Dream cycle." +rollback_confirm = "Rollback memory to pre-snapshot {pair_id}?" +prune_confirm = "Prune {count} snapshot pair(s) older than {days} days?" + +[sleep_hygiene] +title = "Sleep hygiene" +memory_md_size = "MEMORY.md size (lines)" +relative_dates = "Relative-date density" +duplicate_lines = "Cross-file duplicate lines" +contradicting_feedback = "Contradicting feedback pairs" +no_data = "No memory files to analyse." diff --git a/src/cc_janitor/i18n/ru.toml b/src/cc_janitor/i18n/ru.toml index 51b9cf9..56c477f 100644 --- a/src/cc_janitor/i18n/ru.toml +++ b/src/cc_janitor/i18n/ru.toml @@ -97,3 +97,22 @@ dry_run = "СУХОЙ ПРОГОН: будет записано {n} файлов title = "Автодополнения оболочки" installed = "Скрипт автодополнения записан в {target}" unknown_shell = "Неизвестная оболочка: {shell}" + +[dream] +title = "Сны" +list_header = "Дата Проект ΔФайлов ΔСтрок" +no_pairs = "Пар снимков пока нет. Запустите `cc-janitor watch start --dream`." +diff_select = "Выберите пару снимков слева." +doctor_running = "Запуск проверок dream-doctor..." +autodream_toggled = "переключён autoDream" +autodream_toggled_warn = "Auto Dream был переключён. Убедитесь в наличии резервных копий до следующего цикла Dream." +rollback_confirm = "Откатить память к снимку до {pair_id}?" +prune_confirm = "Удалить {count} пар(ы) снимков старше {days} дней?" + +[sleep_hygiene] +title = "Гигиена сна" +memory_md_size = "Размер MEMORY.md (строк)" +relative_dates = "Плотность относительных дат" +duplicate_lines = "Дубликаты строк между файлами" +contradicting_feedback = "Противоречащие пары feedback" +no_data = "Нет файлов памяти для анализа." diff --git a/tests/tui/test_dream_screen.py b/tests/tui/test_dream_screen.py index 13cdc7d..b43d672 100644 --- a/tests/tui/test_dream_screen.py +++ b/tests/tui/test_dream_screen.py @@ -2,7 +2,6 @@ from __future__ import annotations import json -from datetime import datetime, timezone import pytest diff --git a/tests/unit/test_cli_dream.py b/tests/unit/test_cli_dream.py index 4bc5b02..9e4e53d 100644 --- a/tests/unit/test_cli_dream.py +++ b/tests/unit/test_cli_dream.py @@ -1,10 +1,14 @@ import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path + from typer.testing import CliRunner + from cc_janitor.cli import app from cc_janitor.core.dream_snapshot import ( - snapshot_pre, snapshot_post, record_pair, + record_pair, + snapshot_post, + snapshot_pre, ) runner = CliRunner() @@ -21,8 +25,8 @@ def _setup_pair(tmp_path, monkeypatch): post = snapshot_post("20260511T120000Z-proj", mem) record_pair("20260511T120000Z-proj", mem, project_slug="proj", dream_pid_in_lock=4711, - ts_pre=datetime.now(timezone.utc), - ts_post=datetime.now(timezone.utc), + ts_pre=datetime.now(UTC), + ts_post=datetime.now(UTC), pre_dir=pre, post_dir=post) return mem diff --git a/tests/unit/test_cli_skeleton.py b/tests/unit/test_cli_skeleton.py index 75e3b88..2e4828e 100644 --- a/tests/unit/test_cli_skeleton.py +++ b/tests/unit/test_cli_skeleton.py @@ -6,7 +6,7 @@ def test_version(): r = CliRunner().invoke(app, ["--version"]) assert r.exit_code == 0 - assert "0.4.0.dev0" in r.stdout + assert "0.4.0" in r.stdout def test_help_works(): diff --git a/tests/unit/test_cli_stats_hygiene.py b/tests/unit/test_cli_stats_hygiene.py index 466d2de..e3f99da 100644 --- a/tests/unit/test_cli_stats_hygiene.py +++ b/tests/unit/test_cli_stats_hygiene.py @@ -1,6 +1,8 @@ import json from pathlib import Path + from typer.testing import CliRunner + from cc_janitor.cli import app runner = CliRunner() diff --git a/tests/unit/test_config_loader.py b/tests/unit/test_config_loader.py index 983110b..2e1e96b 100644 --- a/tests/unit/test_config_loader.py +++ b/tests/unit/test_config_loader.py @@ -1,7 +1,6 @@ -from pathlib import Path from cc_janitor.core.config import ( - Config, DreamDoctorConfig, SnapshotsConfig, HygieneConfig, - load_config, DEFAULTS, + DEFAULTS, + load_config, ) diff --git a/tests/unit/test_dream_diff.py b/tests/unit/test_dream_diff.py index e6909d9..85fce79 100644 --- a/tests/unit/test_dream_diff.py +++ b/tests/unit/test_dream_diff.py @@ -1,5 +1,6 @@ from pathlib import Path -from cc_janitor.core.dream_diff import compute_diff, DreamFileDelta + +from cc_janitor.core.dream_diff import compute_diff def _mk(path: Path, content: str): diff --git a/tests/unit/test_dream_snapshot.py b/tests/unit/test_dream_snapshot.py index 6aa2397..7edd117 100644 --- a/tests/unit/test_dream_snapshot.py +++ b/tests/unit/test_dream_snapshot.py @@ -1,9 +1,13 @@ -import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path + from cc_janitor.core.dream_snapshot import ( - DreamSnapshotPair, LockState, observe_lock, snapshot_pre, - snapshot_post, record_pair, history, + LockState, + history, + observe_lock, + record_pair, + snapshot_post, + snapshot_pre, ) @@ -52,8 +56,8 @@ def test_full_pair_roundtrip(tmp_path, monkeypatch): post = snapshot_post(pair_id, mem) pair = record_pair(pair_id, mem, project_slug="proj", dream_pid_in_lock=38249, - ts_pre=datetime.now(timezone.utc), - ts_post=datetime.now(timezone.utc), + ts_pre=datetime.now(UTC), + ts_post=datetime.now(UTC), pre_dir=pre, post_dir=post) assert pair.file_count_delta == -1 assert pair.line_count_delta < 0 diff --git a/tests/unit/test_watcher_dream.py b/tests/unit/test_watcher_dream.py index 15b3fcd..62253f4 100644 --- a/tests/unit/test_watcher_dream.py +++ b/tests/unit/test_watcher_dream.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone from pathlib import Path + from cc_janitor.core.dream_snapshot import LockState, history from cc_janitor.core.watcher import run_dream_once diff --git a/uv.lock b/uv.lock index 4d62527..0ea8724 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "cc-janitor" -version = "0.4.0.dev0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "croniter" },