"""Per-project state for os-status (deep module). Owns the gitignored .cc-os/ directory contract: the shared key=value config at .cc-os/config (cross-plugin contract, ADR-026), and os-status's own state — per-check once-per-day snooze stamps (snooze-) and permanent suppress markers (suppress-) — under .cc-os/status/ (ADR-027). State only, never code; nothing here writes outside .cc-os/. Fail-open throughout: unreadable state reads as unset. Pre-ADR-027 projects kept snooze-*/suppress-*/state.json at the .cc-os/ top level, alongside config. Reads fall back to that legacy path; the first write migrates any legacy files into .cc-os/status/ and removes them from the old location (StateDir._migrate). """ from __future__ import annotations from datetime import date from pathlib import Path from typing import Optional def find_project_root(cwd: Path) -> Optional[Path]: """Nearest ancestor (including cwd) containing .git, else None.""" for candidate in [cwd, *cwd.parents]: if (candidate / ".git").exists(): return candidate return None def read_config(root: Path) -> dict: """Parse .cc-os/config as '#'-commented key = value lines.""" config = {} try: text = (root / ".cc-os" / "config").read_text() except Exception: return config for line in text.splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) config[key.strip()] = value.strip() return config def write_config_value(root: Path, key: str, value: str) -> None: """Set key=value in .cc-os/config, preserving every other line (comments included) and appending the key if absent. Used by /os-status:fix to stamp the config 'version' key. Normalizes to the single cross-plugin format 'key=value' — no spaces around '=' (issue #23) — matching os-backlog's Ruby config-write. Any existing key=value lines are re-emitted in the same normalized spacing while every other line (comments, blanks) is left untouched. """ config_path = root / ".cc-os" / "config" try: lines = config_path.read_text().splitlines() except Exception: lines = [] found = False out = [] for line in lines: stripped = line.strip() if stripped and not stripped.startswith("#") and "=" in stripped: existing_key, existing_value = stripped.split("=", 1) existing_key = existing_key.strip() if existing_key == key: out.append(f"{key}={value}") found = True else: out.append(f"{existing_key}={existing_value.strip()}") continue out.append(line) if not found: out.append(f"{key}={value}") config_path.parent.mkdir(exist_ok=True) config_path.write_text("\n".join(out) + "\n") LEGACY_STATE_GLOBS = ("snooze-*", "suppress-*", "state.json") class StateDir: """The gitignored per-project .cc-os/status/ state directory (ADR-027). Reads fall back to the pre-ADR-027 .cc-os/ top level; the first write migrates legacy files up and removes them from the old location.""" def __init__(self, root: Path) -> None: self._root = root self._dir = root / ".cc-os" / "status" self._legacy_dir = root / ".cc-os" def _read_path(self, name: str) -> Path: """The current path for `name` if it exists there, else the legacy top-level path (which may or may not exist — callers handle that).""" current = self._dir / name if current.exists(): return current return self._legacy_dir / name def _migrate(self) -> None: """Move any legacy snooze-*/suppress-*/state.json files into .cc-os/status/ and remove them from .cc-os/. Idempotent; called before every write so migration happens transparently.""" self._dir.mkdir(parents=True, exist_ok=True) for pattern in LEGACY_STATE_GLOBS: for legacy_file in self._legacy_dir.glob(pattern): if not legacy_file.is_file(): continue target = self._dir / legacy_file.name if not target.exists(): try: target.write_text(legacy_file.read_text()) except Exception: continue try: legacy_file.unlink() except Exception: pass def suppressed(self, check: str) -> bool: return self._read_path(f"suppress-{check}").exists() def snoozed(self, check: str, today: date) -> bool: try: stamped = date.fromisoformat( self._read_path(f"snooze-{check}").read_text().strip() ) except Exception: return False return stamped == today def stamp(self, check: str, today: date) -> None: self._migrate() (self._dir / f"snooze-{check}").write_text(today.isoformat()) def clear_snooze(self, check: str) -> None: self._migrate() clear_snooze(self._root, check) def clear_snooze(root: Path, check_name: str) -> None: """Remove any existing snooze stamp for check_name, at both the current (.cc-os/status/) and legacy (.cc-os/) location. Self-healing: called by the SessionStart runner and /os-status:fix's checks.py --json runner whenever a check evaluates to ok/note, so stale snoozes from a prior warn don't outlive the condition they gated.""" for base in (root / ".cc-os" / "status", root / ".cc-os"): try: (base / f"snooze-{check_name}").unlink() except Exception: pass