diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 63adca2..fb2ebad 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -185,6 +185,24 @@ with destination choice and live-history migration as named human gates in the s tracker value and never edits global settings.json. Rollout per #14 acceptance (onboard cc-os itself, then one more project) still pending. +**Fix tune-up + per-project state consolidation (2026-07-12, issues #23/#24, ADR-027):** the +credvault smoke-test of `/os-status:fix` (session cd1ae1cd) was audited against the SKILL.md +contract; the run was on-contract but surfaced four gaps (stale snoozes never cleared, +`.cc-os/config` format split between the Ruby and Python writers, ad-hoc gitignore hygiene, +an unprompted commit) plus root-dotdir sprawl. Fixes (both sonnet-delegated, in parallel): +**os-status** — `clear_snooze` helper + self-clearing snoozes when a check evaluates ok/note +(both SessionStart and `--json` runners); its own state (`snooze-*`, `suppress-*`, +`state.json`) relocated to `.cc-os/status/` with legacy fallback read + migrate-on-write +(shared `config` stays at `.cc-os/config`); `write_config_value` normalizes the whole file to +`key=value` (no spaces); fix SKILL.md now scopes out committing, adds an explicit +"`.cc-os/` in .gitignore" step, and clarifies when re-runs are needed; suite 80 tests (was +64). **os-backlog** — `Config` already emitted the normalized format; locked in by new tests +(58 runs, was 54). **os-doc-hygiene** — state moved `.dochygiene/` → `.cc-os/dochygiene/` +(ADR-027) with canonical-then-legacy fallback reads and auto-migration (+ legacy dir removal) +on first write; scanner self-excludes `.cc-os/`; all four SKILL.md files updated; suite 279 +passed. `graphify-out/` explicitly exempt from consolidation (ADR-027). Re-run of +`/os-status:fix` in credvault is the pending IRL verification. + **Remaining optional items:** additional project onboarding (one at a time, per ADR-013); bulk vault migration. All required build steps complete as of 2026-06-15. diff --git a/docs/memory-system/03-architecture-decisions.md b/docs/memory-system/03-architecture-decisions.md index 4d89916..84901e9 100644 --- a/docs/memory-system/03-architecture-decisions.md +++ b/docs/memory-system/03-architecture-decisions.md @@ -680,6 +680,17 @@ _Date: 2026-07-12_ - **Cross-references**: ADR-022 (check registry), ADR-023 (cross-plugin cooperation), `cc-os-plugin-skill-naming-convention.md`; Forgejo issue #14 (routing skill — the tracker remediation `fix` will delegate to). - **Status**: Accepted 2026-07-12. Implementation queued as a Forgejo issue; tracker remediation lands with or after issue #14. +## ADR-027 — All cc-os per-project state lives under `.cc-os//` + +_Date: 2026-07-12_ + +- **Context**: The credvault smoke-test of `/os-status:fix` (issue #21) surfaced per-project dotfile sprawl: `.cc-os/` (os-status config/snoozes/state, os-backlog tracker key), `.dochygiene/` (os-doc-hygiene state), a planned `.os-adr/`, plus non-cc-os dirs (`.claude/`, `graphify-out/`). Each cc-os plugin inventing its own root-level dotdir means N gitignore entries, N places to look for state, and ad-hoc gitignore hygiene per session (observed in the credvault audit, session cd1ae1cd). +- **Decision**: (1) All cc-os-owned per-project state lives under **`.cc-os/`**: the shared `config` file stays at `.cc-os/config` (cross-plugin key=value contract, ADR-026); each plugin's private state lives in **`.cc-os//`** (e.g. `.cc-os/dochygiene/`, `.cc-os/status/`, `.cc-os/adr/`). No new root-level dotdirs. (2) Existing plugins migrate with a **backward-compat fallback read** of the old path (auto-migrate on first write, then remove the old dir): os-doc-hygiene `.dochygiene/` → `.cc-os/dochygiene/`; os-status's own `snooze-*`/`state.json` → `.cc-os/status/`. (3) **`graphify-out/` is exempt** — external-tool output, disposable/rebuildable, path baked into os-vault hooks; moving it is deferred as aesthetic-only churn. `.claude/` is Claude Code's own dir, out of scope. (4) One gitignore entry — `.cc-os/` — covers all cc-os state; ensuring it is present becomes an explicit step of `/os-status:fix` (issue #23), not improvised per session. +- **Rationale**: One predictable home for the operating layer's per-project state matches the cc-os family framing (ADR-023: mutually aware, cooperating plugins) and extends the shared-config seam ADR-026 established. Fallback-read migration makes rollout painless across already-onboarded projects. +- **Alternatives rejected**: **Status quo (per-plugin root dotdirs)** — sprawl grows linearly with the plugin family. **Moving `graphify-out/` too** — touches the os-vault onboard skill, SessionStart reconcile, and docs for zero functional gain. +- **Cross-references**: ADR-023 (ecosystem framing), ADR-026 (`.cc-os/config` contract); Forgejo issues #23 (fix tune-up), #24 (this consolidation). +- **Status**: Accepted 2026-07-12. Implementation via issues #23/#24. + ## Rejected tools (summary) | Tool | Why rejected for our use | diff --git a/plugins/os-backlog/lib/backlog/config.rb b/plugins/os-backlog/lib/backlog/config.rb index 65c4376..9b5b441 100644 --- a/plugins/os-backlog/lib/backlog/config.rb +++ b/plugins/os-backlog/lib/backlog/config.rb @@ -1,10 +1,12 @@ module Backlog # The parsed contents of a repo's `.cc-os/config`. The file format is - # '#'-commented key=value lines — the contract is owned by os-status - # (plugins/os-status/hooks/state.py); legacy `key: value` YAML lines - # from earlier os-backlog versions are still read for compatibility. - # Never touches the filesystem itself — callers read the file and pass - # its contents (or nil, if absent) in. + # '#'-commented `key=value` lines, no spaces around `=` (ADR-026/#23) — + # the contract is owned by os-status (plugins/os-status/hooks/state.py). + # Reads tolerate legacy `key = value` spacing and legacy `key: value` + # YAML lines from earlier os-backlog versions; #merge always re-emits + # every key in the normalized `key=value` form, matching os-status's + # write_config_value. Never touches the filesystem itself — callers read + # the file and pass its contents (or nil, if absent) in. class Config def initialize(contents) @data = {} diff --git a/plugins/os-backlog/tests/config_test.rb b/plugins/os-backlog/tests/config_test.rb index 0d5e4e0..978cfd8 100644 --- a/plugins/os-backlog/tests/config_test.rb +++ b/plugins/os-backlog/tests/config_test.rb @@ -65,4 +65,32 @@ class ConfigTest < Minitest::Test updated = Backlog::Config.new(Backlog::Config.merge(existing, "tracker", "repo:new")) assert_equal "repo:new", updated.tracker end + + def test_reads_spaced_key_value_lines + config = Backlog::Config.new("version = 1\nhub = my-hub\n") + assert_equal "1", config.to_h["version"] + assert_equal "my-hub", config.to_h["hub"] + end + + def test_merge_output_has_no_spaces_around_equals + contents = Backlog::Config.merge(nil, "tracker", "forgejo:jared/cc-os") + assert_equal "tracker=forgejo:jared/cc-os\n", contents + refute_includes contents, " = " + refute_includes contents, "= " + refute_includes contents, " =" + end + + def test_merge_normalizes_existing_spaced_lines_on_rewrite + existing = "version = 1\nhub = my-hub\n" + updated_contents = Backlog::Config.merge(existing, "tracker", "planka:board") + updated_contents.each_line do |line| + next if line.strip.empty? + + refute_match(/\s=\s|\s=|=\s/, line, "expected normalized key=value, got #{line.inspect}") + end + updated = Backlog::Config.new(updated_contents) + assert_equal "1", updated.to_h["version"] + assert_equal "my-hub", updated.to_h["hub"] + assert_equal "planka:board", updated.tracker + end end diff --git a/plugins/os-doc-hygiene/.gitignore b/plugins/os-doc-hygiene/.gitignore index 1949c08..0a23c02 100644 --- a/plugins/os-doc-hygiene/.gitignore +++ b/plugins/os-doc-hygiene/.gitignore @@ -2,7 +2,8 @@ __pycache__/ *.pyc -# In-project hygiene state (per design invariant #3) +# In-project hygiene state (per design invariant #3, ADR-027) +.cc-os/ .dochygiene/ # Vendored tiktoken vocab (~2 MB; pre-warmed on demand, never committed — diff --git a/plugins/os-doc-hygiene/scripts/CONTEXT.md b/plugins/os-doc-hygiene/scripts/CONTEXT.md index 0d06c40..4e23914 100644 --- a/plugins/os-doc-hygiene/scripts/CONTEXT.md +++ b/plugins/os-doc-hygiene/scripts/CONTEXT.md @@ -9,9 +9,9 @@ for isolated testing with injected clocks/filesystems. | File | Purpose | |------|---------| -| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.dochygiene` (invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. | -| `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `/.dochygiene/` (invariant #3, never edits `.gitignore`). Lifecycle timestamps (`last_check`/`last_clean`/`last_reminded`), atomic write-temp→fsync→`os.replace` (D9), and report rollover keeping exactly one `.json`+`.md` pair (invariant #4). Injected clock. | -| `reminder.py` | `SessionStart` reminder. Pure `Reminder.decide(last_check, last_reminded, now)` decision (stale-and-not-snoozed ⇒ banner; calendar-day snooze keyed on `last_reminded`, invariants #1/#2). `ReminderRunner` wires it to the `StateStore`, emits a zero-token `systemMessage` JSON banner on stdout, and writes `last_reminded` as the *only* mutation, only on banner. Treats missing/corrupt state as never-checked (stale); always exits 0 — never blocks the session. **No-ops unless cwd is inside a real git project** (`_is_git_project` guard) so the user-scoped hook never creates `.dochygiene/` in an arbitrary dir like `~` (invariant #3) — flagged for human review. No scan, no model. `DEFAULT_THRESHOLD_DAYS` is an implementation constant, not a spec'd contract. Invoked by `hooks/hooks.json` via `${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py`. | +| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.cc-os/` and legacy `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.cc-os` (shared cc-os state dir, ADR-027), `.dochygiene` (legacy state dir, invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. | +| `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `/.cc-os/dochygiene/` (invariant #3, ADR-027, never edits `.gitignore`). Reads fall back to the legacy `/.dochygiene/` when the canonical dir has no state; the first write migrates legacy files in and removes the legacy dir. Lifecycle timestamps (`last_check`/`last_clean`/`last_reminded`), atomic write-temp→fsync→`os.replace` (D9), and report rollover keeping exactly one `.json`+`.md` pair (invariant #4). Injected clock. | +| `reminder.py` | `SessionStart` reminder. Pure `Reminder.decide(last_check, last_reminded, now)` decision (stale-and-not-snoozed ⇒ banner; calendar-day snooze keyed on `last_reminded`, invariants #1/#2). `ReminderRunner` wires it to the `StateStore`, emits a zero-token `systemMessage` JSON banner on stdout, and writes `last_reminded` as the *only* mutation, only on banner. Treats missing/corrupt state as never-checked (stale); always exits 0 — never blocks the session. **No-ops unless cwd is inside a real git project** (`_is_git_project` guard) so the user-scoped hook never creates `.cc-os/dochygiene/` in an arbitrary dir like `~` (invariant #3) — flagged for human review. No scan, no model. `DEFAULT_THRESHOLD_DAYS` is an implementation constant, not a spec'd contract. Invoked by `hooks/hooks.json` via `${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py`. | | `validate_report.py` | Schema-validator for machine-report JSON files. Enforces the frozen report contract: envelope fields, closed category enum, closed `exact_edit.kind` enum with per-kind required sub-fields, `op_type`↔`exact_edit` biconditional (invariant #11), and `safety_tier` derivation match (invariant #10). Collects all violations. Exit 0 = valid, exit 1 = invalid, exit 2 = usage error. | | `report_builder.py` | Deterministic finalize pass (no model). Consumes the scanner artifact + the subagent's slim proposals and assembles a schema-valid machine report plus a human-report skeleton. Fills the four guardrail fields the model must not author: computes each entry's `expected_sha256` over the live file, looks up `is_destructive`/`is_reversible` from the `KIND_TABLE` (keyed on `exact_edit.kind`), derives `safety_tier` via `validate_report`'s `derive_safety_tier` (invariant #10 — single source of truth), and sources `raw_tokens` from `token_estimator` (over the `reducible_range`/anchor text). Rejects malformed proposals with a structured stderr error (`{index, field, message}`, exit 1); `--out-json`/`--out-md` write files and suppress the stdout bundle. An empty proposals array yields a valid empty-entries report. | | `token_estimator.py` | Deterministic local token estimator (no model, no network — invariant #6). Produces the `token_estimate.raw_tokens` count for report entries. Pluggable `TokenEstimator` ABC with two backends: `HeuristicEstimator` (ALWAYS available, zero deps, `ceil(len/4)`) and `TiktokenEstimator` (optional accuracy upgrade, `o200k_base`, `disallowed_special=()`). `default_estimator(cache_dir=…)` selects tiktoken **only** when it is importable AND the vocab is vendored locally (detect-before-fetch: checks the sha1-named cache file exists before loading, so a cold cache never triggers a network download); otherwise returns the heuristic. Never raises; active backend is introspectable via `.name`. `estimate(text)→int`, `estimate_file(path)→int` (utf-8, `errors="replace"`), `estimate_for_report(text)→{raw_tokens, injection_frequency:null, weighted_tokens:null}` (v1 emits only `raw_tokens`; weighting is the v2 bonus). CLI: `python token_estimator.py ` prints JSON `{path, backend, token_estimate}`, exit 1 on missing file. **tiktoken vocab is NOT committed** (~2 MB; gitignored at `scripts/tiktoken_cache/`). Pre-warm once with: `TIKTOKEN_CACHE_DIR=scripts/tiktoken_cache python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"`. **Cache-filename gotcha:** tiktoken names the cache file `sha1(blobpath_url)` = `fb374d419588a4632f3f557e76b4b70aebbca790` (NOT the sha256 content hash); a vendored file under any other name never hits and falls back to network. | diff --git a/plugins/os-doc-hygiene/scripts/scanner.py b/plugins/os-doc-hygiene/scripts/scanner.py index 1e56b66..9791aaa 100644 --- a/plugins/os-doc-hygiene/scripts/scanner.py +++ b/plugins/os-doc-hygiene/scripts/scanner.py @@ -61,7 +61,8 @@ DEFAULT_EXCLUDED_DIRS: List[str] = [ "vendor", "archive", "graphify-out", - ".dochygiene", + ".cc-os", + ".dochygiene", # legacy state dir (pre-ADR-027); still excluded for old trees # Test-fixture dirs intentionally contain stale/bloated docs as scanner # inputs. Bare directory-*name* match (consistent with the entries above): # any child dir named "fixtures" at any depth is pruned — same breadth as @@ -529,13 +530,14 @@ class Scanner: ) -> None: self._root = root.resolve() self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS - # Always include .dochygiene in excluded dirs (self-exclusion invariant #9). - # Preserve insertion order so the artifact echoes the canonical default - # order (matches valid_report.json golden fixture). + # Always include .cc-os (canonical, ADR-027) and .dochygiene (legacy) in + # excluded dirs (self-exclusion invariant #9). Preserve insertion order + # so the artifact echoes the canonical default order (matches + # valid_report.json golden fixture). base_excluded = excluded_dirs if excluded_dirs is not None else DEFAULT_EXCLUDED_DIRS seen: set = set() deduped: List[str] = [] - for d in list(base_excluded) + [".dochygiene"]: + for d in list(base_excluded) + [".cc-os", ".dochygiene"]: if d not in seen: seen.add(d) deduped.append(d) diff --git a/plugins/os-doc-hygiene/scripts/state_store.py b/plugins/os-doc-hygiene/scripts/state_store.py index f615582..191f895 100644 --- a/plugins/os-doc-hygiene/scripts/state_store.py +++ b/plugins/os-doc-hygiene/scripts/state_store.py @@ -3,7 +3,13 @@ State store for doc-hygiene. Provides: - resolve_project_root(start_dir, fs) — pure function, no side effects - - StateStore — confines all writes to /.dochygiene/ + - StateStore — confines all writes to /.cc-os/dochygiene/ + +Per ADR-027, the canonical state directory is /.cc-os/dochygiene/. +The legacy path /.dochygiene/ is still supported for reads: if the +canonical directory has no state, reads fall back to the legacy directory. On the +first write, any existing legacy files are migrated into the canonical directory +and the legacy directory is removed. Design invariants honoured: #3 State lives in-project; no global index; never edit .gitignore. @@ -21,6 +27,7 @@ from __future__ import annotations import json import os +import shutil import tempfile from datetime import datetime, timezone from pathlib import Path @@ -113,9 +120,14 @@ class StateStore: """ Manages all persistent state for doc-hygiene within a single project. - All writes are confined to /.dochygiene/ (invariant #3). - No global index is maintained; each project has its own independent store. - The store never opens or edits .gitignore (invariant #3). + All writes are confined to /.cc-os/dochygiene/ (invariant #3, + ADR-027). No global index is maintained; each project has its own + independent store. The store never opens or edits .gitignore (invariant #3). + + Backward compatibility (ADR-027): the legacy location + /.dochygiene/ is read as a fallback when the canonical + directory has no state. The first write migrates any legacy files into the + canonical directory and removes the legacy directory. Parameters ---------- @@ -131,17 +143,42 @@ class StateStore: def __init__(self, project_root: Path, clock: Optional[_Clock] = None) -> None: self._root = Path(project_root) self._clock = clock or RealClock() - self._state_dir = self._root / ".dochygiene" + self._state_dir = self._root / ".cc-os" / "dochygiene" + self._legacy_dir = self._root / ".dochygiene" # ------------------------------------------------------------------ # Directory bootstrap # ------------------------------------------------------------------ def _ensure_state_dir(self) -> Path: - """Create .dochygiene/ if it does not exist. Never touches .gitignore.""" + """Create .cc-os/dochygiene/ if it does not exist, migrating any legacy + .dochygiene/ contents in first. Never touches .gitignore.""" + self._migrate_legacy() self._state_dir.mkdir(parents=True, exist_ok=True) return self._state_dir + def _migrate_legacy(self) -> None: + """ + One-time migration of /.dochygiene/ into /.cc-os/dochygiene/. + + No-op if the legacy directory does not exist. Copies every file found + in the legacy directory into the canonical directory (canonical files, + if any already exist, take precedence and are not overwritten), then + removes the legacy directory entirely. + """ + if not self._legacy_dir.is_dir(): + return + + self._state_dir.mkdir(parents=True, exist_ok=True) + for entry in self._legacy_dir.iterdir(): + if not entry.is_file(): + continue + target = self._state_dir / entry.name + if not target.exists(): + target.write_bytes(entry.read_bytes()) + + shutil.rmtree(self._legacy_dir, ignore_errors=True) + # ------------------------------------------------------------------ # Atomic write (task 3.4 / design D9) # ------------------------------------------------------------------ @@ -186,7 +223,7 @@ class StateStore: path.resolve().relative_to(self._state_dir.resolve()) except ValueError: raise ValueError( - f"StateStore attempted to write outside .dochygiene/: {path}" + f"StateStore attempted to write outside .cc-os/dochygiene/: {path}" ) # ------------------------------------------------------------------ @@ -197,10 +234,18 @@ class StateStore: return self._state_dir / _STATE_FILE def _read_state(self) -> dict: - """Read state.json; return {} if missing or unreadable (never raises).""" - path = self._state_path() + """Read state.json; return {} if missing or unreadable (never raises). + + Falls back to the legacy .dochygiene/state.json (ADR-027) if the + canonical file is absent. The legacy file is only *read* here — it is + migrated into the canonical location on the next write. + """ try: - return json.loads(path.read_bytes()) + return json.loads(self._state_path().read_bytes()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + try: + return json.loads((self._legacy_dir / _STATE_FILE).read_bytes()) except (FileNotFoundError, json.JSONDecodeError, OSError): return {} @@ -310,10 +355,21 @@ class StateStore: def read_report(self) -> Optional[tuple[str, str]]: """ Return (json_blob, md_blob) if a report exists, else None. + + Falls back to the legacy .dochygiene/ directory (ADR-027) if the + canonical directory has no report pair. """ - json_path = self._state_dir / _REPORT_JSON - md_path = self._state_dir / _REPORT_MD try: - return json_path.read_text(), md_path.read_text() + return ( + (self._state_dir / _REPORT_JSON).read_text(), + (self._state_dir / _REPORT_MD).read_text(), + ) + except FileNotFoundError: + pass + try: + return ( + (self._legacy_dir / _REPORT_JSON).read_text(), + (self._legacy_dir / _REPORT_MD).read_text(), + ) except FileNotFoundError: return None diff --git a/plugins/os-doc-hygiene/skills/CONTEXT.md b/plugins/os-doc-hygiene/skills/CONTEXT.md index bfef37f..38deb81 100644 --- a/plugins/os-doc-hygiene/skills/CONTEXT.md +++ b/plugins/os-doc-hygiene/skills/CONTEXT.md @@ -11,7 +11,7 @@ as slash commands (`/os-doc-hygiene:check`, `/os-doc-hygiene:clean`, | Path | Purpose | |------|---------| -| `check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). | +| `check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.cc-os/dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). | | `check/workflows/classify-candidates.md` | Self-contained subagent workflow for the classification step. Defines the slim proposal object (`path`, `category` {class, subtype}, pass-through `signals`, `op`, `op_type`, optional `exact_edit`/`reducible_range`, `gloss`, `confidence`, `escalate`), the closed enums, the situation→kind→tier decision table, and the per-kind required `exact_edit` fields. The subagent reads each file and returns judgment only — it never authors hashes, token counts, safety tiers, or reversibility. | | `clean/SKILL.md` | The `clean` orchestration: **load → validate → filter → gate → preflight → apply-deterministic → apply-generative → commit → stamp**. (D) `StateStore` loads and re-validates the machine report. (logic) Incompatible-pair detection (files with both generative + deterministic ops) and partition by `safety_tier`/`op_type`. (M-GATE) Confirm gate for `confirm`-tier entries; auto-tier entries apply silently. (D) Git preflight: record baseline, detect untracked files (which get skipped). (D) `patch_applier.py` applies deterministic entries mechanically; mtime-guarded, output include `applied`/`skipped`/`failed`. (M) Sonnet subagent distills each generative entry via `workflows/distill.md` (LOOP-GUARD + SUBAGENT AUTHORIZATION — terminal, REPORT-AND-EXIT). (D) `git-context commit-apply` produces exactly one cleanup commit. (D) `StateStore.set_last_clean` stamps the commit instant. Scoped by `--scope` (glob-or-path) and `--category` (class or subtype), both entry-stage filters. On failure (rollback-worthy): all intermediate mutations reverted to baseline. | | `clean/workflows/distill.md` | Self-contained subagent workflow for generative distillation. Receives live file contents (not disk-read), category, op, and signals. Performs `distill` (condense in place, preserve frontmatter and structure, ~40–60% target length), `split` (extract to archive + pointer, archive path under `archive/`), or prose-rewrite ops (`provisional`, `contradicted`). Returns structured output (`DISTILL_RESULT_START`/`END` for single-file; `SPLIT_PRIMARY_START`/`END` + `SPLIT_ARCHIVE_DEST` + `SPLIT_ARCHIVE_START`/`END` for split). Never writes files or calls git; never re-reads disk. | diff --git a/plugins/os-doc-hygiene/skills/check/SKILL.md b/plugins/os-doc-hygiene/skills/check/SKILL.md index 4f93ad0..8cafdc5 100644 --- a/plugins/os-doc-hygiene/skills/check/SKILL.md +++ b/plugins/os-doc-hygiene/skills/check/SKILL.md @@ -12,7 +12,7 @@ classification is a model step, dispatched to a **Sonnet** subagent. All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `python3` from the user's project directory (`cwd`), which is where the project root is resolved. Use the session scratchpad directory for all intermediate artifacts — -**never** write to `.dochygiene/` until the validated write step. +**never** write to `.cc-os/dochygiene/` until the validated write step. > **Precondition:** this skill requires the `CLAUDE_PLUGIN_ROOT` environment > variable to be set (Claude Code sets it at runtime). Every script path and the @@ -45,7 +45,10 @@ If no arguments are given, the scan uses defaults (`**/*.md`, default excludes). ### Step 0 — (D / M-GATE) Gitignore preflight -Check whether `.dochygiene/` is already git-ignored, and offer to add it if not. +Check whether `.cc-os/` is already git-ignored, and offer to add it if not. Per +ADR-027, `.cc-os/dochygiene/` is the canonical state dir and `.cc-os/` is the +single shared gitignore entry for all cc-os per-project state (not just +doc-hygiene's). ```bash ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="" @@ -54,10 +57,10 @@ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="" Three cases: - **No git root** (`ROOT` empty — project is not a git repo): skip silently. `resolve_project_root` falls back to cwd; no `.gitignore` offer is meaningful. -- **Already ignored** (`git -C "$ROOT" check-ignore -q .dochygiene` exits `0`): silent no-op. Proceed to Step 1. +- **Already ignored** (`git -C "$ROOT" check-ignore -q .cc-os` exits `0`): silent no-op. Proceed to Step 1. - **Not ignored** (exit `1`): present the one-line offer: - > `doc-hygiene` stores its state and report under `.dochygiene/` at the project root. Per invariant #3, this directory should be gitignored so it doesn't appear as untracked in your repo. Shall I append `.dochygiene/` to `/.gitignore`? (yes/no) + > `doc-hygiene` stores its state and report under `.cc-os/dochygiene/` at the project root. Per invariant #3 / ADR-027, this directory should be gitignored so it doesn't appear as untracked in your repo. Shall I append `.cc-os/` to `/.gitignore`? (yes/no) **Only on explicit confirmation ("yes"):** append as follows — never reorder or rewrite existing entries: @@ -65,12 +68,12 @@ Three cases: if [ -s "$ROOT/.gitignore" ] && [ -n "$(tail -c1 "$ROOT/.gitignore")" ]; then printf '\n' >> "$ROOT/.gitignore" fi - printf '.dochygiene/\n' >> "$ROOT/.gitignore" + printf '.cc-os/\n' >> "$ROOT/.gitignore" ``` (Creates `.gitignore` if absent; appends with a leading newline only when the file is non-empty and doesn't already end in one.) - **If the user declines:** proceed without editing. Note that `.dochygiene/` may appear as untracked/dirty in `git status` until ignored. + **If the user declines:** proceed without editing. Note that `.cc-os/dochygiene/` may appear as untracked/dirty in `git status` until ignored. > **Do NOT append without explicit user confirmation.** (Invariant #3.) @@ -84,7 +87,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs ...] > "$SCRA - Omit `--globs` when there is no `--scope`. - The scanner auto-resolves the project root from `cwd` and applies default - excludes (incl. `.dochygiene/`). Do not pass `--root`. + excludes (incl. `.cc-os/` and legacy `.dochygiene/`). Do not pass `--root`. The artifact is `{ project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals }`. `signals` is an object keyed by project-root-relative path: @@ -239,12 +242,12 @@ report = json.loads(json_blob) store = StateStore(resolve_project_root(Path(os.getcwd()))) store.write_report(json_blob, md_blob) # rollover: keeps exactly one pair store.set_last_check(datetime.fromisoformat(report["generated_at"])) -print("wrote .dochygiene/report.json + report.md; last_check=" + report["generated_at"]) +print("wrote .cc-os/dochygiene/report.json + report.md; last_check=" + report["generated_at"]) ' ``` (`SCRATCH` must be exported so the `-c` process can read it.) This writes -`.dochygiene/report.json` and `.dochygiene/report.md` (atomic, one pair) and stamps +`.cc-os/dochygiene/report.json` and `.cc-os/dochygiene/report.md` (atomic, one pair) and stamps `last_check`. ### Step 8 — Surface the result @@ -257,11 +260,11 @@ doc-hygiene check complete scope: category: - + Reports written: - /.dochygiene/report.json - /.dochygiene/report.md + /.cc-os/dochygiene/report.json + /.cc-os/dochygiene/report.md Run /os-doc-hygiene:clean to act on these (Phase 4), or /os-doc-hygiene:status for timestamps. ``` diff --git a/plugins/os-doc-hygiene/skills/clean/SKILL.md b/plugins/os-doc-hygiene/skills/clean/SKILL.md index 298e1d4..9f4df01 100644 --- a/plugins/os-doc-hygiene/skills/clean/SKILL.md +++ b/plugins/os-doc-hygiene/skills/clean/SKILL.md @@ -61,14 +61,21 @@ if result is None: print(json.dumps({"status": "no_report", "root": str(root)})) else: json_blob, md_blob = result - print(json.dumps({"status": "ok", "root": str(root), "report_path": str(root / ".dochygiene" / "report.json")})) + # Report path may still be the legacy .dochygiene/ location if it has not + # been migrated yet (migration happens lazily, on the next write) — ADR-027. + canonical = root / ".cc-os" / "dochygiene" / "report.json" + legacy = root / ".dochygiene" / "report.json" + report_path = canonical if canonical.is_file() else legacy + print(json.dumps({"status": "ok", "root": str(root), "report_path": str(report_path)})) ' ``` - If `status == "no_report"` → tell the user: "No hygiene report found. Run `/os-doc-hygiene:check` first to generate one." **STOP.** - If `status == "ok"` → record `PROJECT_ROOT` and `REPORT_PATH` (the canonical - `.dochygiene/report.json`) for use in subsequent steps. + `.cc-os/dochygiene/report.json`, or the legacy `.dochygiene/report.json` if + not yet migrated — the next `clean`/`check` write auto-migrates it, per + ADR-027) for use in subsequent steps. --- @@ -228,11 +235,11 @@ commit, no stamp).** ### Step 6 — (D) Git preflight -> **Gitignore offer (resolve before dirty-tree check):** If `.dochygiene/` is not +> **Gitignore offer (resolve before dirty-tree check):** If `.cc-os/` is not > yet gitignored, it will appear as untracked in `git status --porcelain` and may > trigger a needless WIP checkpoint. Before running `git status`, check: > ```bash -> git -C "$PROJECT_ROOT" check-ignore -q .dochygiene +> git -C "$PROJECT_ROOT" check-ignore -q .cc-os > ``` > Exit `0` → already ignored; proceed. Exit `1` → present the same one-line > confirmation offer described in the check skill's **Step 0**. Resolve it (or let @@ -257,7 +264,7 @@ BASELINE=$(git rev-parse HEAD) Auto-create a WIP checkpoint commit of the user's existing work: ```bash -git add -A # OK here: the cleanup commit uses precise staging; this is user-work, .dochygiene/ is gitignored +git add -A # OK here: the cleanup commit uses precise staging; this is user-work, .cc-os/ is gitignored git commit -m "wip: checkpoint before hygiene cleanup" BASELINE=$(git rev-parse HEAD~1) # baseline is BEFORE the checkpoint ``` diff --git a/plugins/os-doc-hygiene/skills/sweep/SKILL.md b/plugins/os-doc-hygiene/skills/sweep/SKILL.md index ec9064d..f4e69e9 100644 --- a/plugins/os-doc-hygiene/skills/sweep/SKILL.md +++ b/plugins/os-doc-hygiene/skills/sweep/SKILL.md @@ -22,4 +22,4 @@ The user will still be prompted to approve any entries that require it before any file mutation occurs. `sweep` produces at most one cleanup commit (the clean step); the check step -writes only to the gitignored `.dochygiene/` and does not commit. +writes only to the gitignored `.cc-os/dochygiene/` and does not commit. diff --git a/plugins/os-doc-hygiene/tests/CONTEXT.md b/plugins/os-doc-hygiene/tests/CONTEXT.md index 3b4e6df..37e4bcb 100644 --- a/plugins/os-doc-hygiene/tests/CONTEXT.md +++ b/plugins/os-doc-hygiene/tests/CONTEXT.md @@ -10,12 +10,12 @@ the real clock, a real session, or the network. | File | Covers | Key assertions | |------|--------|----------------| -| `test_scanner_exclusions.py` | Scanner exclusion pipeline (invariant #9) | A `hygiene: frozen` file, a `.dochygiene-ignore`-matched file, and append-only logs are absent from the shortlist; `.dochygiene/` and excluded dirs are never scanned. | +| `test_scanner_exclusions.py` | Scanner exclusion pipeline (invariant #9) | A `hygiene: frozen` file, a `.dochygiene-ignore`-matched file, and append-only logs are absent from the shortlist; `.cc-os/` and `.dochygiene/` and excluded dirs are never scanned. | | `test_scanner_signals.py` | Scanner per-path signals | One fixture per signal type asserts the expected signal on the right path, with no classification attached (objective facts only). | -| `test_state_store.py` | `resolve_project_root` + `StateStore` (invariants #3, #4) | Root resolution (git-root / cwd-fallback); writes confined to `.dochygiene/`; `.gitignore` never edited; atomic torn-write safety; report rollover keeps one pair; timestamp round-trip with injected clock. | +| `test_state_store.py` | `resolve_project_root` + `StateStore` (invariants #3, #4; ADR-027) | Root resolution (git-root / cwd-fallback); writes confined to `.cc-os/dochygiene/`; `.gitignore` never edited; atomic torn-write safety; report rollover keeps one pair; timestamp round-trip with injected clock; legacy `.dochygiene/` fallback reads and auto-migration on first write. | | `test_reminder.py` | `Reminder` decision + `ReminderRunner` (invariants #1, #2) | Pure decision: fresh→silent, stale→banner, same-day→snoozed, prior-day→re-banner, **calendar-day boundary across midnight** (20-min delta, different day → banner). Runner: only `last_reminded` mutated and only on banner; missing/corrupt state → never-checked; always exits 0. | | `test_validate_report.py` | `validate_report.py` (invariants #10, #11) | Golden fixtures end-to-end (PASS exit 0 / FAIL exit 1, not mutated); crafted-dict branch coverage for `raw_tokens` requirement, op_type↔exact_edit biconditional, and safety_tier derivation truth table. | -| `test_cross_seam.py` | scanner + state_store + reminder together (Group 5; invariant #6) | End-to-end on a tmp doc tree: scan produces a shortlist and self-excludes `.dochygiene/`; rollover keeps one report pair; reminder snooze behaves across simulated sessions with an injected clock. No model, no network. | +| `test_cross_seam.py` | scanner + state_store + reminder together (Group 5; invariant #6) | End-to-end on a tmp doc tree: scan produces a shortlist and self-excludes `.cc-os/`/`.dochygiene/`; rollover keeps one report pair; reminder snooze behaves across simulated sessions with an injected clock. No model, no network. | ## Fixtures diff --git a/plugins/os-doc-hygiene/tests/test_cross_seam.py b/plugins/os-doc-hygiene/tests/test_cross_seam.py index 5f408f6..6f4f6f0 100644 --- a/plugins/os-doc-hygiene/tests/test_cross_seam.py +++ b/plugins/os-doc-hygiene/tests/test_cross_seam.py @@ -4,7 +4,7 @@ Cross-seam integration tests for the deterministic core (Group 5, task 5.1/5.2). Exercises scanner + state_store + reminder together on a small fixture doc tree built in a tmp dir, verifying the end-to-end deterministic path: - - the scanner produces a shortlist (and self-excludes .dochygiene/), + - the scanner produces a shortlist (and self-excludes .cc-os/dochygiene/), - the state store records timestamps and rolls reports over (keeps one pair), - the reminder's snooze behaves across simulated sessions with an injected clock (silent when fresh; banner when stale; snoozed same calendar day; @@ -70,7 +70,7 @@ class TestScannerStateSeam: # The state store writes its dir first; the scanner must self-exclude it. store = StateStore(root) store.set_timestamp("last_check", _utc(2026, 6, 1)) - assert (root / ".dochygiene" / "state.json").exists() + assert (root / ".cc-os" / "dochygiene" / "state.json").exists() artifact = Scanner(root=root, git_log_fn=lambda p: []).run() @@ -80,8 +80,8 @@ class TestScannerStateSeam: # Exclusions honoured. assert "docs/frozen.md" not in shortlist assert "docs/secret.md" not in shortlist - # .dochygiene/ never scanned (self-exclusion). - assert not any(p.startswith(".dochygiene") for p in shortlist) + # .cc-os/dochygiene/ never scanned (self-exclusion). + assert not any(p.startswith(".cc-os") for p in shortlist) def test_report_rollover_keeps_one_pair(self, tmp_path): _build_doc_tree(tmp_path) @@ -91,7 +91,7 @@ class TestScannerStateSeam: store.write_report('{"v":1}', "# v1") store.write_report('{"v":2}', "# v2") - state_dir = root / ".dochygiene" + state_dir = root / ".cc-os" / "dochygiene" jsons = list(state_dir.glob("report*.json")) mds = list(state_dir.glob("report*.md")) assert len(jsons) == 1 diff --git a/plugins/os-doc-hygiene/tests/test_reminder.py b/plugins/os-doc-hygiene/tests/test_reminder.py index f348674..94d867b 100644 --- a/plugins/os-doc-hygiene/tests/test_reminder.py +++ b/plugins/os-doc-hygiene/tests/test_reminder.py @@ -205,7 +205,7 @@ class TestReminderRunner: def test_corrupt_state_treated_as_never_checked(self, tmp_path): """A corrupt timestamp string must not crash; treated as never-checked.""" now = _utc(2026, 6, 24) - state_dir = tmp_path / ".dochygiene" + state_dir = tmp_path / ".cc-os" / "dochygiene" state_dir.mkdir(parents=True, exist_ok=True) (state_dir / "state.json").write_text( json.dumps({"last_check": "not-a-date", "last_reminded": "garbage"}) @@ -237,7 +237,7 @@ class TestReminderRunner: # =========================================================================== -# Entry-point guard — never create .dochygiene/ outside a git project (#3) +# Entry-point guard — never create .cc-os/dochygiene/ outside a git project (#3) # =========================================================================== class TestGitProjectGuard: @@ -245,13 +245,14 @@ class TestGitProjectGuard: def test_non_git_cwd_stays_silent_and_writes_nothing(self, tmp_path, monkeypatch, capsys): """No .git anywhere → resolve falls back to cwd → reminder no-ops. - Guards invariant #3: must NOT create /.dochygiene/ in an + Guards invariant #3: must NOT create /.cc-os/dochygiene/ in an arbitrary directory (e.g. ~). """ monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path)) rc = reminder_main() assert rc == 0 assert capsys.readouterr().out == "" # no banner + assert not (tmp_path / ".cc-os").exists() # nothing written assert not (tmp_path / ".dochygiene").exists() # nothing written def test_git_project_banners_when_never_checked(self, tmp_path, monkeypatch, capsys): @@ -262,7 +263,7 @@ class TestGitProjectGuard: assert rc == 0 out = capsys.readouterr().out assert "systemMessage" in json.loads(out) - assert (tmp_path / ".dochygiene" / "state.json").exists() + assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").exists() # =========================================================================== diff --git a/plugins/os-doc-hygiene/tests/test_report_builder.py b/plugins/os-doc-hygiene/tests/test_report_builder.py index 6a95ca1..6da01c8 100644 --- a/plugins/os-doc-hygiene/tests/test_report_builder.py +++ b/plugins/os-doc-hygiene/tests/test_report_builder.py @@ -107,7 +107,7 @@ def scan_artifact(doc_tree: Path) -> dict: return { "project_root": str(doc_tree), "scope_globs": ["**/*.md"], - "excluded_dirs": ["build", ".dochygiene"], + "excluded_dirs": ["build", ".cc-os", ".dochygiene"], "files_scanned": 7, "shortlist": [ "CLAUDE.md", diff --git a/plugins/os-doc-hygiene/tests/test_scanner_exclusions.py b/plugins/os-doc-hygiene/tests/test_scanner_exclusions.py index 11622c5..35627c8 100644 --- a/plugins/os-doc-hygiene/tests/test_scanner_exclusions.py +++ b/plugins/os-doc-hygiene/tests/test_scanner_exclusions.py @@ -296,6 +296,36 @@ class TestExcludedDirs: assert not path.startswith(".dochygiene/") assert "live.md" in result["shortlist"] + def test_cc_os_self_excluded_always(self, tmp_path): + """Canonical .cc-os/ state dir (ADR-027) is always self-excluded.""" + _make_tree(tmp_path, { + ".cc-os/dochygiene/state.json": '{"last_check": null}', + ".cc-os/dochygiene/state_doc.md": "# This should never be scanned", + "real_doc.md": "# Real doc", + }) + result = _run(tmp_path) + for path in result["shortlist"]: + assert not path.startswith(".cc-os/"), ( + ".cc-os/ contents should never appear in shortlist" + ) + assert "real_doc.md" in result["shortlist"] + + def test_cc_os_excluded_even_if_not_in_config(self, tmp_path): + """Even with an empty excluded_dirs config, .cc-os/ is self-excluded.""" + _make_tree(tmp_path, { + ".cc-os/dochygiene/report.md": "# Should be excluded", + "live.md": "# Live", + }) + result = Scanner( + root=tmp_path, + excluded_dirs=[], # explicitly empty + git_log_fn=lambda p: [], + now_fn=lambda: 0.0, + ).run() + for path in result["shortlist"]: + assert not path.startswith(".cc-os/") + assert "live.md" in result["shortlist"] + def test_custom_excluded_dir_not_scanned(self, tmp_path): _make_tree(tmp_path, { "myexcluded/doc.md": "# Should not appear", diff --git a/plugins/os-doc-hygiene/tests/test_state_store.py b/plugins/os-doc-hygiene/tests/test_state_store.py index 4031690..6a3c07c 100644 --- a/plugins/os-doc-hygiene/tests/test_state_store.py +++ b/plugins/os-doc-hygiene/tests/test_state_store.py @@ -106,7 +106,7 @@ class TestResolveProjectRoot: class TestWriteConfinement: def test_all_writes_under_dochygiene(self, tmp_path): - """Every path written by the store is under .dochygiene/.""" + """Every path written by the store is under .cc-os/dochygiene/.""" (tmp_path / ".git").mkdir() store = StateStore(project_root=tmp_path) @@ -123,10 +123,10 @@ class TestWriteConfinement: store.set_last_reminded() store.write_report('{"ok": true}', "# Report\nDone") - state_dir = tmp_path / ".dochygiene" + state_dir = tmp_path / ".cc-os" / "dochygiene" for p in written_paths: assert str(p).startswith(str(state_dir)), ( - f"Write escaped .dochygiene/: {p}" + f"Write escaped .cc-os/dochygiene/: {p}" ) def test_no_global_index_created(self, tmp_path): @@ -137,7 +137,9 @@ class TestWriteConfinement: store.write_report("{}", "md") home_dh = Path.home() / ".dochygiene" + home_cc_os = Path.home() / ".cc-os" assert not home_dh.exists(), "Global ~/.dochygiene must not be created" + assert not home_cc_os.exists(), "Global ~/.cc-os must not be created" def test_gitignore_never_opened_or_written(self, tmp_path): """ @@ -173,9 +175,9 @@ class TestWriteConfinement: assert p.name != ".gitignore", f"Write went to .gitignore: {p}" def test_assert_confined_raises_for_out_of_root_path(self, tmp_path): - """_assert_confined must raise ValueError for a path outside .dochygiene.""" + """_assert_confined must raise ValueError for a path outside .cc-os/dochygiene.""" store = StateStore(project_root=tmp_path) - with pytest.raises(ValueError, match="outside .dochygiene"): + with pytest.raises(ValueError, match="outside .cc-os/dochygiene"): store._assert_confined(tmp_path / "other" / "file.txt") @@ -237,8 +239,8 @@ class TestTimestamps: def test_corrupt_state_file_reads_as_unset(self, tmp_path): """A corrupt state.json does not raise — timestamps read as None.""" - state_dir = tmp_path / ".dochygiene" - state_dir.mkdir() + state_dir = tmp_path / ".cc-os" / "dochygiene" + state_dir.mkdir(parents=True) (state_dir / "state.json").write_bytes(b"not json {{") store = StateStore(project_root=tmp_path) assert store.get_last_check() is None @@ -261,7 +263,7 @@ class TestAtomicWrites: not in /tmp — os.replace is only atomic within one filesystem. """ store = StateStore(project_root=tmp_path) - state_dir = tmp_path / ".dochygiene" + state_dir = tmp_path / ".cc-os" / "dochygiene" temp_paths_seen: list[Path] = [] original_replace = os.replace @@ -289,7 +291,7 @@ class TestAtomicWrites: We monkeypatch os.replace to intercept the call and check both invariants. """ store = StateStore(project_root=tmp_path) - state_path = tmp_path / ".dochygiene" / "state.json" + state_path = tmp_path / ".cc-os" / "dochygiene" / "state.json" # Write an initial "old" state. store.set_last_check(datetime(2026, 1, 1, tzinfo=timezone.utc)) @@ -334,8 +336,8 @@ class TestAtomicWrites: all writes go through the temp-then-replace path. """ store = StateStore(project_root=tmp_path) - state_dir = tmp_path / ".dochygiene" - state_dir.mkdir(exist_ok=True) + state_dir = tmp_path / ".cc-os" / "dochygiene" + state_dir.mkdir(parents=True, exist_ok=True) state_target = state_dir / "state.json" opened_paths_with_write_mode: list[str] = [] @@ -362,7 +364,7 @@ class TestAtomicWrites: class TestReportRollover: def _report_files(self, tmp_path: Path) -> list[Path]: - state_dir = tmp_path / ".dochygiene" + state_dir = tmp_path / ".cc-os" / "dochygiene" return sorted(state_dir.glob("report.*")) def test_first_write_creates_exactly_one_pair(self, tmp_path): @@ -382,9 +384,9 @@ class TestReportRollover: assert names == {"report.json", "report.md"}, ( f"Expected exactly one pair, found: {names}" ) - json_content = json.loads((tmp_path / ".dochygiene" / "report.json").read_text()) + json_content = json.loads((tmp_path / ".cc-os" / "dochygiene" / "report.json").read_text()) assert json_content["run"] == 2, "Old report JSON was not replaced" - md_content = (tmp_path / ".dochygiene" / "report.md").read_text() + md_content = (tmp_path / ".cc-os" / "dochygiene" / "report.md").read_text() assert "Run 2" in md_content, "Old report MD was not replaced" def test_many_writes_leave_exactly_one_pair(self, tmp_path): @@ -401,12 +403,12 @@ class TestReportRollover: store = StateStore(project_root=tmp_path) store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc)) - state_before = (tmp_path / ".dochygiene" / "state.json").read_bytes() + state_before = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes() store.write_report("{}", "md") store.write_report("{}", "md2") # second rollover - state_after = (tmp_path / ".dochygiene" / "state.json").read_bytes() + state_after = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes() assert state_before == state_after, ( "state.json was modified or deleted during report rollover" ) @@ -435,24 +437,25 @@ class TestRootResolutionConfinement: def test_writes_confined_after_git_root_resolution(self, tmp_path): """ - Scenario: git-root case — writes are under /.dochygiene/. + Scenario: git-root case — writes are under /.cc-os/dochygiene/. """ (tmp_path / ".git").mkdir() store = StateStore(project_root=tmp_path) store.set_last_check() store.write_report("{}", "md") - state_dir = tmp_path / ".dochygiene" + state_dir = tmp_path / ".cc-os" / "dochygiene" for child in state_dir.iterdir(): - # All files live inside .dochygiene + # All files live inside .cc-os/dochygiene assert child.parent == state_dir # Nothing written outside the project root + cc_os_dir = tmp_path / ".cc-os" unexpected = [ p for p in tmp_path.iterdir() - if p != state_dir and p.name != ".git" + if p != cc_os_dir and p.name != ".git" ] - assert not unexpected, f"Unexpected files outside .dochygiene: {unexpected}" + assert not unexpected, f"Unexpected files outside .cc-os/dochygiene: {unexpected}" def test_writes_confined_in_cwd_fallback(self, tmp_path): """ @@ -461,14 +464,14 @@ class TestRootResolutionConfinement: store = StateStore(project_root=tmp_path) # no .git created store.set_last_reminded() - state_dir = tmp_path / ".dochygiene" + state_dir = tmp_path / ".cc-os" / "dochygiene" assert state_dir.exists() for child in state_dir.iterdir(): assert child.parent == state_dir def test_gitignore_not_edited_when_creating_state_dir(self, tmp_path): """ - Creating .dochygiene/ for the first time must not touch .gitignore. + Creating .cc-os/dochygiene/ for the first time must not touch .gitignore. """ (tmp_path / ".git").mkdir() gitignore = tmp_path / ".gitignore" @@ -512,3 +515,106 @@ class TestFixtures: store = StateStore(project_root=proj) store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc)) assert store.get_last_check() == datetime(2026, 6, 18, tzinfo=timezone.utc) + + +# =========================================================================== +# ADR-027 — legacy .dochygiene/ fallback reads + auto-migration on first write +# =========================================================================== + +class TestLegacyFallbackAndMigration: + + def _write_legacy_state(self, tmp_path: Path, state: dict) -> Path: + legacy_dir = tmp_path / ".dochygiene" + legacy_dir.mkdir(parents=True, exist_ok=True) + (legacy_dir / "state.json").write_text(json.dumps(state)) + return legacy_dir + + def _write_legacy_report(self, tmp_path: Path, json_blob: str, md_blob: str) -> Path: + legacy_dir = tmp_path / ".dochygiene" + legacy_dir.mkdir(parents=True, exist_ok=True) + (legacy_dir / "report.json").write_text(json_blob) + (legacy_dir / "report.md").write_text(md_blob) + return legacy_dir + + def test_reads_fall_back_to_legacy_timestamp(self, tmp_path): + """A timestamp written only under legacy .dochygiene/ is still readable.""" + fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc) + self._write_legacy_state(tmp_path, {"last_check": fixed.isoformat()}) + + store = StateStore(project_root=tmp_path) + assert store.get_last_check() == fixed + # Canonical dir must NOT have been created by a pure read. + assert not (tmp_path / ".cc-os").exists() + + def test_reads_fall_back_to_legacy_report(self, tmp_path): + """A report written only under legacy .dochygiene/ is still readable.""" + self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy") + + store = StateStore(project_root=tmp_path) + pair = store.read_report() + assert pair is not None + json_blob, md_blob = pair + assert json.loads(json_blob)["v"] == "legacy" + assert "Legacy" in md_blob + assert not (tmp_path / ".cc-os").exists() + + def test_canonical_state_takes_precedence_over_legacy(self, tmp_path): + """If both canonical and legacy state exist, canonical wins.""" + legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + canonical_ts = datetime(2026, 6, 1, tzinfo=timezone.utc) + self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()}) + + store = StateStore(project_root=tmp_path) + store.set_last_check(canonical_ts) # triggers migration + then overwrites + assert store.get_last_check() == canonical_ts + + def test_first_write_migrates_legacy_state_and_removes_legacy_dir(self, tmp_path): + """ + First write triggers migration: pre-existing legacy files are copied into + the canonical dir, and the legacy dir is removed entirely. + """ + legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()}) + self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy") + + store = StateStore(project_root=tmp_path) + store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc)) + + legacy_dir = tmp_path / ".dochygiene" + canonical_dir = tmp_path / ".cc-os" / "dochygiene" + + assert not legacy_dir.exists(), "Legacy .dochygiene/ must be removed after migration" + assert canonical_dir.is_dir() + # Migrated last_check must be preserved alongside the newly-written last_clean. + assert store.get_last_check() == legacy_ts + assert store.get_last_clean() == datetime(2026, 6, 1, tzinfo=timezone.utc) + # Migrated report is preserved too. + pair = store.read_report() + assert pair is not None + assert json.loads(pair[0])["v"] == "legacy" + + def test_migration_does_not_clobber_existing_canonical_files(self, tmp_path): + """ + If the canonical dir already has a file with the same name as a legacy + file, the canonical file wins (never overwritten by the legacy copy). + """ + canonical_dir = tmp_path / ".cc-os" / "dochygiene" + canonical_dir.mkdir(parents=True) + (canonical_dir / "state.json").write_text( + json.dumps({"last_check": "2026-06-01T00:00:00+00:00"}) + ) + self._write_legacy_state(tmp_path, {"last_check": "2026-01-01T00:00:00+00:00"}) + + store = StateStore(project_root=tmp_path) + # Any write triggers the migration pass. + store.set_last_reminded(datetime(2026, 7, 1, tzinfo=timezone.utc)) + + assert not (tmp_path / ".dochygiene").exists() + assert store.get_last_check() == datetime(2026, 6, 1, tzinfo=timezone.utc) + + def test_new_project_with_no_legacy_dir_is_unaffected(self, tmp_path): + """No legacy dir present → normal operation, no migration attempted.""" + store = StateStore(project_root=tmp_path) + store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc)) + assert not (tmp_path / ".dochygiene").exists() + assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").is_file() diff --git a/plugins/os-status/hooks/checks.py b/plugins/os-status/hooks/checks.py index 8254e90..f6ff749 100644 --- a/plugins/os-status/hooks/checks.py +++ b/plugins/os-status/hooks/checks.py @@ -342,7 +342,15 @@ def build_ctx() -> "Ctx": def run_all_json(ctx: "Ctx") -> list: """Run every applicable check and return a list of plain dicts: {name, status, message, remediation}. project_scoped checks are skipped - when ctx.project_root is None, same rule as the SessionStart runner.""" + when ctx.project_root is None, same rule as the SessionStart runner. + + Self-healing (issue #23): whenever a check evaluates to ok/note, clear + any stale snooze file for it, same as the SessionStart runner — so a + /os-status:fix run cleans up snoozes left behind by checks it just + fixed, not just checks the next SessionStart happens to re-run. + """ + from state import clear_snooze # local import, no cycle + results = [] for check in REGISTRY: if check.project_scoped and ctx.project_root is None: @@ -353,6 +361,8 @@ def run_all_json(ctx: "Ctx") -> list: raise TypeError("check returned a non-CheckResult") except Exception: result = CheckResult("warn", f"status check '{check.name}' failed to run") + if result.status in ("ok", "note") and ctx.project_root is not None: + clear_snooze(ctx.project_root, check.name) results.append( { "name": check.name, diff --git a/plugins/os-status/hooks/session_start.py b/plugins/os-status/hooks/session_start.py index f7b8c3e..bdee7bb 100644 --- a/plugins/os-status/hooks/session_start.py +++ b/plugins/os-status/hooks/session_start.py @@ -62,6 +62,10 @@ def run(ctx: Ctx, state, today: date, out) -> None: if check.project_scoped and ctx.project_root is None: continue result = run_isolated(check, ctx) + if result.status in ("ok", "note") and state: + # Self-healing (issue #23): a check that flips to ok/note no + # longer needs a snooze stamp from a prior warn run. + state.clear_snooze(check.name) if result.status == "note": notes.append(result.message) elif result.status == "warn": diff --git a/plugins/os-status/hooks/state.py b/plugins/os-status/hooks/state.py index ced1cee..76afd36 100644 --- a/plugins/os-status/hooks/state.py +++ b/plugins/os-status/hooks/state.py @@ -1,9 +1,16 @@ """Per-project state for os-status (deep module). -Owns the gitignored .cc-os/ directory contract: key=value config, per-check -once-per-day snooze stamps (snooze-), and permanent suppress markers -(suppress-). State only, never code; nothing here writes outside -.cc-os/. Fail-open throughout: unreadable state reads as unset. +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 @@ -39,7 +46,13 @@ def read_config(root: Path) -> dict: 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.""" + /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() @@ -50,36 +63,91 @@ def write_config_value(root: Path, key: str, value: str) -> None: for line in lines: stripped = line.strip() if stripped and not stripped.startswith("#") and "=" in stripped: - existing_key = stripped.split("=", 1)[0].strip() + existing_key, existing_value = stripped.split("=", 1) + existing_key = existing_key.strip() if existing_key == key: - out.append(f"{key} = {value}") + out.append(f"{key}={value}") found = True - continue + else: + out.append(f"{existing_key}={existing_value.strip()}") + continue out.append(line) if not found: - out.append(f"{key} = {value}") + 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/ state directory.""" + """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._dir = root / ".cc-os" + 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._dir / f"suppress-{check}").exists() + return self._read_path(f"suppress-{check}").exists() def snoozed(self, check: str, today: date) -> bool: try: stamped = date.fromisoformat( - (self._dir / f"snooze-{check}").read_text().strip() + 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._dir.mkdir(exist_ok=True) + 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 diff --git a/plugins/os-status/invariants.md b/plugins/os-status/invariants.md index 67594be..7875d94 100644 --- a/plugins/os-status/invariants.md +++ b/plugins/os-status/invariants.md @@ -16,9 +16,12 @@ is a regression, not a refactor. Enforced by `tests/hook_test.py` (model-free; `warn` naming the check; it never hides other checks' results and never leaks exception text into the banner. 5. **State only in `.cc-os/`.** The hook writes nothing outside the project's `.cc-os/` - directory, and writes nothing at all outside a git project. `.cc-os/` holds state - (config, `snooze-`, `suppress-`), never code. Add `.cc-os/` to the - project's `.gitignore`. + directory, and writes nothing at all outside a git project. `.cc-os/` holds state, + never code: the shared `config` file at `.cc-os/config` (ADR-026), and os-status's own + `snooze-` / `suppress-` markers under `.cc-os/status/` (ADR-027). Reads + fall back to the pre-ADR-027 top-level location; the first write migrates and removes + the legacy files. Add `.cc-os/` to the project's `.gitignore` (one entry covers every + cc-os plugin's state). 6. **PRESENT_NOTE / ABSENT_NOTE byte-identical to os-adr.** `checks.py` carries verbatim copies of the wording in `plugins/os-adr/hooks/session_start.py` (the wording source of record, tuned by the Eval B campaign and validated by Eval C). Never paraphrase; if the diff --git a/plugins/os-status/skills/fix/SKILL.md b/plugins/os-status/skills/fix/SKILL.md index 150bce2..8205bae 100644 --- a/plugins/os-status/skills/fix/SKILL.md +++ b/plugins/os-status/skills/fix/SKILL.md @@ -10,6 +10,10 @@ existing per-plugin skills — it never reimplements them. Idempotent by constru re-running `fix` on an already-configured project is the update path, not a separate command. +**Scope: `fix` never stages or commits.** It edits files and reports what changed — +staging and committing is always the user's call, even for mechanical remediations +like a `.gitignore` addition or a config stamp. + ## Flow 1. **Get machine-readable results.** From the project root, run: @@ -23,9 +27,15 @@ command. outside a git project, same rule the SessionStart hook uses). 2. **All `ok` (and only `note`/`ok`)?** Report "this project is up to date" and go - straight to step 4 (version stamp) — nothing else to do. + straight to steps 3b–4 (gitignore + version stamp) — nothing else to remediate. -3. **Otherwise, walk the non-`ok` entries in this order** (mechanical → decision- +3a. **Ensure `.cc-os/` is gitignored.** Check the project's `.gitignore` for a + `.cc-os/` entry; add one if missing. Per ADR-027, this single entry covers every + cc-os plugin's per-project state (os-status's `.cc-os/config` + `.cc-os/status/`, + os-backlog's tracker key, etc.) — there is no separate `.os-adr/` entry to add + anymore. This step runs every `fix` invocation, not just when a check flags it. + +3b. **Otherwise, walk the non-`ok` entries in this order** (mechanical → decision- bearing, so autonomous fixes land before anything needing a human gate): a. **`adr-system-present`** → invoke `/os-adr:init` (or `/os-adr:migrate` if the @@ -53,9 +63,17 @@ command. f. **`config-version-current`** → resolved automatically by step 4 below; no separate action. - Re-run the JSON check after each remediation that plausibly changed state, so - later steps see fresh results (e.g. don't act on a stale `vault-hub-note-present` - warning after already creating the note). + Re-run the JSON check after each remediation that plausibly changed check-registry + state (creating a hub note, running `/os-adr:init`, setting a config key), so later + steps see fresh results (e.g. don't act on a stale `vault-hub-note-present` warning + after already creating the note). Remediations that don't affect check-registry + state — step 3a's gitignore edit is the example — don't require a re-run. + + Snoozed warns self-clear: whenever a check now evaluates to `ok`/`note` (whether + because this skill just fixed it or it was already fine), the next SessionStart + or `fix` run clears its stale `snooze-` file automatically (state.py's + `clear_snooze`) — nothing to do here beyond re-running the JSON check per the + rule above. 4. **Stamp the config version.** Once the mechanical/human-gated fixes above are done (or were already `ok`), write the current version into `.cc-os/config`, diff --git a/plugins/os-status/tests/hook_test.py b/plugins/os-status/tests/hook_test.py index f9137ec..0f350e5 100644 --- a/plugins/os-status/tests/hook_test.py +++ b/plugins/os-status/tests/hook_test.py @@ -35,7 +35,13 @@ from checks import ( # noqa: E402 tracker_configured, vault_hub_note_present, ) -from state import StateDir, find_project_root, read_config, write_config_value # noqa: E402 +from state import ( # noqa: E402 + StateDir, + clear_snooze, + find_project_root, + read_config, + write_config_value, +) TODAY = date(2026, 7, 6) YESTERDAY = date(2026, 7, 5) @@ -369,6 +375,21 @@ class JsonRunnerTest(unittest.TestCase): self.assertNotIn("adr-system-present", names) self.assertNotIn("tracker-configured", names) + def test_run_all_json_self_clears_stale_snooze_on_ok(self): + with TemporaryDirectory() as tmp: + root = git_project(tmp) + (root / ".cc-os" / "status").mkdir(parents=True) + (root / ".cc-os" / "status" / "snooze-adr-system-present").write_text( + date.today().isoformat() + ) + (root / "docs" / "adr").mkdir(parents=True) + (root / "docs" / "adr" / "README.md").write_text("index") + ctx = make_ctx(project_root=root) + run_all_json(ctx) + self.assertFalse( + (root / ".cc-os" / "status" / "snooze-adr-system-present").exists() + ) + def test_subprocess_json_flag_prints_valid_json_array(self): with TemporaryDirectory() as tmp: root = git_project(tmp) @@ -413,6 +434,35 @@ class WriteConfigValueTest(unittest.TestCase): self.assertEqual("1", config["version"]) self.assertEqual("my-hub", config["hub"]) + def test_new_key_written_without_spaces_around_equals(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + write_config_value(root, "version", "1") + raw = (root / ".cc-os" / "config").read_text() + self.assertEqual("version=1\n", raw) + + def test_normalizes_existing_spaced_lines_on_rewrite(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "config").write_text("version = 0\nhub = my-hub\n") + write_config_value(root, "version", "1") + raw = (root / ".cc-os" / "config").read_text() + for line in raw.splitlines(): + self.assertNotIn(" = ", line) + self.assertNotIn(" =", line) + self.assertNotIn("= ", line) + self.assertEqual({"version": "1", "hub": "my-hub"}, read_config(root)) + + def test_comments_and_blank_lines_untouched(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "config").write_text("# a comment\n\nhub = my-hub\n") + write_config_value(root, "version", "1") + raw = (root / ".cc-os" / "config").read_text() + self.assertIn("# a comment", raw) + class BannerWordingTest(unittest.TestCase): def test_banner_header_names_fix_skill(self): @@ -483,7 +533,7 @@ class RunnerTest(unittest.TestCase): ctx = make_ctx(project_root=root) first = self.run_with(registry, ctx, state) self.assertIn("boom", first["systemMessage"]) - self.assertTrue((root / ".cc-os" / "snooze-a").exists()) + self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists()) self.assertEqual({}, self.run_with(registry, ctx, state)) def test_next_day_warns_again(self): @@ -529,6 +579,32 @@ class RunnerTest(unittest.TestCase): self.assertNotIn("old news", banner) self.assertIn("new problem", banner) + def test_snooze_self_clears_when_check_flips_to_ok(self): + with TemporaryDirectory() as tmp: + root = git_project(tmp) + state = StateDir(root) + warn_registry = [self.check("a", CheckResult("warn", "boom"))] + ctx = make_ctx(project_root=root) + self.run_with(warn_registry, ctx, state) + self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists()) + + ok_registry = [self.check("a", CheckResult("ok"))] + self.run_with(ok_registry, ctx, state) + self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists()) + + def test_snooze_self_clears_when_check_flips_to_note(self): + with TemporaryDirectory() as tmp: + root = git_project(tmp) + state = StateDir(root) + warn_registry = [self.check("a", CheckResult("warn", "boom"))] + ctx = make_ctx(project_root=root) + self.run_with(warn_registry, ctx, state) + self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists()) + + note_registry = [self.check("a", CheckResult("note", "fine now"))] + self.run_with(note_registry, ctx, state) + self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists()) + class StateTest(unittest.TestCase): def test_read_config_parses_key_value_and_skips_comments(self): @@ -565,6 +641,93 @@ class StateTest(unittest.TestCase): with TemporaryDirectory() as tmp: self.assertIsNone(find_project_root(Path(tmp))) + def test_stamp_writes_to_status_subdir(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + StateDir(root).stamp("a", TODAY) + self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists()) + self.assertFalse((root / ".cc-os" / "snooze-a").exists()) + + def test_snoozed_falls_back_to_legacy_top_level_path(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat()) + self.assertTrue(StateDir(root).snoozed("a", TODAY)) + + def test_suppressed_falls_back_to_legacy_top_level_path(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "suppress-a").write_text("") + self.assertTrue(StateDir(root).suppressed("a")) + + def test_current_path_wins_over_legacy_when_both_exist(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os" / "status").mkdir(parents=True) + (root / ".cc-os" / "status" / "snooze-a").write_text(TODAY.isoformat()) + (root / ".cc-os" / "snooze-a").write_text(YESTERDAY.isoformat()) + self.assertTrue(StateDir(root).snoozed("a", TODAY)) + + def test_write_migrates_legacy_files_and_removes_them(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "snooze-b").write_text(YESTERDAY.isoformat()) + (root / ".cc-os" / "suppress-c").write_text("") + StateDir(root).stamp("a", TODAY) + # Legacy files migrated up and removed from the old location. + self.assertTrue((root / ".cc-os" / "status" / "snooze-b").exists()) + self.assertTrue((root / ".cc-os" / "status" / "suppress-c").exists()) + self.assertFalse((root / ".cc-os" / "snooze-b").exists()) + self.assertFalse((root / ".cc-os" / "suppress-c").exists()) + # config, if present, is left alone by migration. + self.assertFalse((root / ".cc-os" / "status" / "config").exists()) + + def test_migration_does_not_touch_config(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "config").write_text("tracker=repo:x\n") + (root / ".cc-os" / "snooze-a").write_text(YESTERDAY.isoformat()) + StateDir(root).stamp("a", TODAY) + self.assertTrue((root / ".cc-os" / "config").exists()) + self.assertEqual("tracker=repo:x\n", (root / ".cc-os" / "config").read_text()) + + +class ClearSnoozeTest(unittest.TestCase): + def test_clears_current_path_snooze(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os" / "status").mkdir(parents=True) + (root / ".cc-os" / "status" / "snooze-a").write_text(TODAY.isoformat()) + clear_snooze(root, "a") + self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists()) + + def test_clears_legacy_path_snooze(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os").mkdir() + (root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat()) + clear_snooze(root, "a") + self.assertFalse((root / ".cc-os" / "snooze-a").exists()) + + def test_missing_snooze_is_a_silent_noop(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + clear_snooze(root, "a") # must not raise + + def test_does_not_clear_other_checks_snooze(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cc-os" / "status").mkdir(parents=True) + (root / ".cc-os" / "status" / "snooze-a").write_text(TODAY.isoformat()) + (root / ".cc-os" / "status" / "snooze-b").write_text(TODAY.isoformat()) + clear_snooze(root, "a") + self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists()) + self.assertTrue((root / ".cc-os" / "status" / "snooze-b").exists()) + class WordingInvariantTest(unittest.TestCase): """PRESENT_NOTE / ABSENT_NOTE must stay byte-identical to os-adr's