os-status/os-backlog/os-doc-hygiene: snooze lifecycle + config normalization + .cc-os/<plugin> state consolidation (closes #23, #24; ADR-027)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFjUjvdsuqjarpNTKKX7pq
This commit is contained in:
jared 2026-07-12 17:47:01 -04:00
parent 3b9c4fbd88
commit cf14bb33f9
24 changed files with 638 additions and 107 deletions

View File

@ -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 tracker value and never edits global settings.json. Rollout per #14 acceptance (onboard
cc-os itself, then one more project) still pending. 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 **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. vault migration. All required build steps complete as of 2026-06-15.

View File

@ -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). - **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. - **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/<plugin>/`
_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/<plugin-domain>/`** (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) ## Rejected tools (summary)
| Tool | Why rejected for our use | | Tool | Why rejected for our use |

View File

@ -1,10 +1,12 @@
module Backlog module Backlog
# The parsed contents of a repo's `.cc-os/config`. The file format is # 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 # '#'-commented `key=value` lines, no spaces around `=` (ADR-026/#23) —
# (plugins/os-status/hooks/state.py); legacy `key: value` YAML lines # the contract is owned by os-status (plugins/os-status/hooks/state.py).
# from earlier os-backlog versions are still read for compatibility. # Reads tolerate legacy `key = value` spacing and legacy `key: value`
# Never touches the filesystem itself — callers read the file and pass # YAML lines from earlier os-backlog versions; #merge always re-emits
# its contents (or nil, if absent) in. # 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 class Config
def initialize(contents) def initialize(contents)
@data = {} @data = {}

View File

@ -65,4 +65,32 @@ class ConfigTest < Minitest::Test
updated = Backlog::Config.new(Backlog::Config.merge(existing, "tracker", "repo:new")) updated = Backlog::Config.new(Backlog::Config.merge(existing, "tracker", "repo:new"))
assert_equal "repo:new", updated.tracker assert_equal "repo:new", updated.tracker
end 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 end

View File

@ -2,7 +2,8 @@
__pycache__/ __pycache__/
*.pyc *.pyc
# In-project hygiene state (per design invariant #3) # In-project hygiene state (per design invariant #3, ADR-027)
.cc-os/
.dochygiene/ .dochygiene/
# Vendored tiktoken vocab (~2 MB; pre-warmed on demand, never committed — # Vendored tiktoken vocab (~2 MB; pre-warmed on demand, never committed —

View File

@ -9,9 +9,9 @@ for isolated testing with injected clocks/filesystems.
| File | Purpose | | 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. | | `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 `<root>/.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. | | `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `<root>/.cc-os/dochygiene/` (invariant #3, ADR-027, never edits `.gitignore`). Reads fall back to the legacy `<root>/.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 `.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`. | | `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. | | `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. | | `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 <path>` 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. | | `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 <path>` 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. |

View File

@ -61,7 +61,8 @@ DEFAULT_EXCLUDED_DIRS: List[str] = [
"vendor", "vendor",
"archive", "archive",
"graphify-out", "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 # Test-fixture dirs intentionally contain stale/bloated docs as scanner
# inputs. Bare directory-*name* match (consistent with the entries above): # inputs. Bare directory-*name* match (consistent with the entries above):
# any child dir named "fixtures" at any depth is pruned — same breadth as # any child dir named "fixtures" at any depth is pruned — same breadth as
@ -529,13 +530,14 @@ class Scanner:
) -> None: ) -> None:
self._root = root.resolve() self._root = root.resolve()
self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS 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). # Always include .cc-os (canonical, ADR-027) and .dochygiene (legacy) in
# Preserve insertion order so the artifact echoes the canonical default # excluded dirs (self-exclusion invariant #9). Preserve insertion order
# order (matches valid_report.json golden fixture). # 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 base_excluded = excluded_dirs if excluded_dirs is not None else DEFAULT_EXCLUDED_DIRS
seen: set = set() seen: set = set()
deduped: List[str] = [] deduped: List[str] = []
for d in list(base_excluded) + [".dochygiene"]: for d in list(base_excluded) + [".cc-os", ".dochygiene"]:
if d not in seen: if d not in seen:
seen.add(d) seen.add(d)
deduped.append(d) deduped.append(d)

View File

@ -3,7 +3,13 @@ State store for doc-hygiene.
Provides: Provides:
- resolve_project_root(start_dir, fs) pure function, no side effects - resolve_project_root(start_dir, fs) pure function, no side effects
- StateStore confines all writes to <project_root>/.dochygiene/ - StateStore confines all writes to <project_root>/.cc-os/dochygiene/
Per ADR-027, the canonical state directory is <project_root>/.cc-os/dochygiene/.
The legacy path <project_root>/.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: Design invariants honoured:
#3 State lives in-project; no global index; never edit .gitignore. #3 State lives in-project; no global index; never edit .gitignore.
@ -21,6 +27,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -113,9 +120,14 @@ class StateStore:
""" """
Manages all persistent state for doc-hygiene within a single project. Manages all persistent state for doc-hygiene within a single project.
All writes are confined to <project_root>/.dochygiene/ (invariant #3). All writes are confined to <project_root>/.cc-os/dochygiene/ (invariant #3,
No global index is maintained; each project has its own independent store. ADR-027). No global index is maintained; each project has its own
The store never opens or edits .gitignore (invariant #3). independent store. The store never opens or edits .gitignore (invariant #3).
Backward compatibility (ADR-027): the legacy location
<project_root>/.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 Parameters
---------- ----------
@ -131,17 +143,42 @@ class StateStore:
def __init__(self, project_root: Path, clock: Optional[_Clock] = None) -> None: def __init__(self, project_root: Path, clock: Optional[_Clock] = None) -> None:
self._root = Path(project_root) self._root = Path(project_root)
self._clock = clock or RealClock() 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 # Directory bootstrap
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _ensure_state_dir(self) -> Path: 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) self._state_dir.mkdir(parents=True, exist_ok=True)
return self._state_dir return self._state_dir
def _migrate_legacy(self) -> None:
"""
One-time migration of <root>/.dochygiene/ into <root>/.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) # Atomic write (task 3.4 / design D9)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -186,7 +223,7 @@ class StateStore:
path.resolve().relative_to(self._state_dir.resolve()) path.resolve().relative_to(self._state_dir.resolve())
except ValueError: except ValueError:
raise 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 return self._state_dir / _STATE_FILE
def _read_state(self) -> dict: def _read_state(self) -> dict:
"""Read state.json; return {} if missing or unreadable (never raises).""" """Read state.json; return {} if missing or unreadable (never raises).
path = self._state_path()
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: 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): except (FileNotFoundError, json.JSONDecodeError, OSError):
return {} return {}
@ -310,10 +355,21 @@ class StateStore:
def read_report(self) -> Optional[tuple[str, str]]: def read_report(self) -> Optional[tuple[str, str]]:
""" """
Return (json_blob, md_blob) if a report exists, else None. 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: 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: except FileNotFoundError:
return None return None

View File

@ -11,7 +11,7 @@ as slash commands (`/os-doc-hygiene:check`, `/os-doc-hygiene:clean`,
| Path | Purpose | | 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. | | `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/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, ~4060% 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. | | `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, ~4060% 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. |

View File

@ -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` 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 from the user's project directory (`cwd`), which is where the project root is
resolved. Use the session scratchpad directory for all intermediate artifacts — 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 > **Precondition:** this skill requires the `CLAUDE_PLUGIN_ROOT` environment
> variable to be set (Claude Code sets it at runtime). Every script path and the > 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 ### 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 ```bash
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="" 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: 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. - **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: - **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 `<ROOT>/.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 `<ROOT>/.gitignore`? (yes/no)
**Only on explicit confirmation ("yes"):** append as follows — never reorder or rewrite existing entries: **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 if [ -s "$ROOT/.gitignore" ] && [ -n "$(tail -c1 "$ROOT/.gitignore")" ]; then
printf '\n' >> "$ROOT/.gitignore" printf '\n' >> "$ROOT/.gitignore"
fi 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.) (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.) > **Do NOT append without explicit user confirmation.** (Invariant #3.)
@ -84,7 +87,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs <glob> ...] > "$SCRA
- Omit `--globs` when there is no `--scope`. - Omit `--globs` when there is no `--scope`.
- The scanner auto-resolves the project root from `cwd` and applies default - 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, The artifact is `{ project_root, scope_globs, excluded_dirs, files_scanned,
shortlist, signals }`. `signals` is an object keyed by project-root-relative path: 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 = StateStore(resolve_project_root(Path(os.getcwd())))
store.write_report(json_blob, md_blob) # rollover: keeps exactly one pair store.write_report(json_blob, md_blob) # rollover: keeps exactly one pair
store.set_last_check(datetime.fromisoformat(report["generated_at"])) 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 (`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`. `last_check`.
### Step 8 — Surface the result ### Step 8 — Surface the result
@ -257,11 +260,11 @@ doc-hygiene check complete
scope: <effective scope, e.g. **/*.md or the bare-path prefix> scope: <effective scope, e.g. **/*.md or the bare-path prefix>
category: <the --category filter, or "all"> category: <the --category filter, or "all">
<contents of .dochygiene/report.md, or its header + per-group bullet lines> <contents of .cc-os/dochygiene/report.md, or its header + per-group bullet lines>
Reports written: Reports written:
<project-root>/.dochygiene/report.json <project-root>/.cc-os/dochygiene/report.json
<project-root>/.dochygiene/report.md <project-root>/.cc-os/dochygiene/report.md
Run /os-doc-hygiene:clean to act on these (Phase 4), or /os-doc-hygiene:status for timestamps. Run /os-doc-hygiene:clean to act on these (Phase 4), or /os-doc-hygiene:status for timestamps.
``` ```

View File

@ -61,14 +61,21 @@ if result is None:
print(json.dumps({"status": "no_report", "root": str(root)})) print(json.dumps({"status": "no_report", "root": str(root)}))
else: else:
json_blob, md_blob = result 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 - If `status == "no_report"` → tell the user: "No hygiene report found. Run
`/os-doc-hygiene:check` first to generate one." **STOP.** `/os-doc-hygiene:check` first to generate one." **STOP.**
- If `status == "ok"` → record `PROJECT_ROOT` and `REPORT_PATH` (the canonical - 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 ### 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 > yet gitignored, it will appear as untracked in `git status --porcelain` and may
> trigger a needless WIP checkpoint. Before running `git status`, check: > trigger a needless WIP checkpoint. Before running `git status`, check:
> ```bash > ```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 > 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 > 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: Auto-create a WIP checkpoint commit of the user's existing work:
```bash ```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" git commit -m "wip: checkpoint before hygiene cleanup"
BASELINE=$(git rev-parse HEAD~1) # baseline is BEFORE the checkpoint BASELINE=$(git rev-parse HEAD~1) # baseline is BEFORE the checkpoint
``` ```

View File

@ -22,4 +22,4 @@ The user will still be prompted to approve any entries that require it before
any file mutation occurs. any file mutation occurs.
`sweep` produces at most one cleanup commit (the clean step); the check step `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.

View File

@ -10,12 +10,12 @@ the real clock, a real session, or the network.
| File | Covers | Key assertions | | 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_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_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_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 ## Fixtures

View File

@ -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 Exercises scanner + state_store + reminder together on a small fixture doc tree
built in a tmp dir, verifying the end-to-end deterministic path: 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 state store records timestamps and rolls reports over (keeps one pair),
- the reminder's snooze behaves across simulated sessions with an injected - the reminder's snooze behaves across simulated sessions with an injected
clock (silent when fresh; banner when stale; snoozed same calendar day; 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. # The state store writes its dir first; the scanner must self-exclude it.
store = StateStore(root) store = StateStore(root)
store.set_timestamp("last_check", _utc(2026, 6, 1)) 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() artifact = Scanner(root=root, git_log_fn=lambda p: []).run()
@ -80,8 +80,8 @@ class TestScannerStateSeam:
# Exclusions honoured. # Exclusions honoured.
assert "docs/frozen.md" not in shortlist assert "docs/frozen.md" not in shortlist
assert "docs/secret.md" not in shortlist assert "docs/secret.md" not in shortlist
# .dochygiene/ never scanned (self-exclusion). # .cc-os/dochygiene/ never scanned (self-exclusion).
assert not any(p.startswith(".dochygiene") for p in shortlist) assert not any(p.startswith(".cc-os") for p in shortlist)
def test_report_rollover_keeps_one_pair(self, tmp_path): def test_report_rollover_keeps_one_pair(self, tmp_path):
_build_doc_tree(tmp_path) _build_doc_tree(tmp_path)
@ -91,7 +91,7 @@ class TestScannerStateSeam:
store.write_report('{"v":1}', "# v1") store.write_report('{"v":1}', "# v1")
store.write_report('{"v":2}', "# v2") store.write_report('{"v":2}', "# v2")
state_dir = root / ".dochygiene" state_dir = root / ".cc-os" / "dochygiene"
jsons = list(state_dir.glob("report*.json")) jsons = list(state_dir.glob("report*.json"))
mds = list(state_dir.glob("report*.md")) mds = list(state_dir.glob("report*.md"))
assert len(jsons) == 1 assert len(jsons) == 1

View File

@ -205,7 +205,7 @@ class TestReminderRunner:
def test_corrupt_state_treated_as_never_checked(self, tmp_path): def test_corrupt_state_treated_as_never_checked(self, tmp_path):
"""A corrupt timestamp string must not crash; treated as never-checked.""" """A corrupt timestamp string must not crash; treated as never-checked."""
now = _utc(2026, 6, 24) 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.mkdir(parents=True, exist_ok=True)
(state_dir / "state.json").write_text( (state_dir / "state.json").write_text(
json.dumps({"last_check": "not-a-date", "last_reminded": "garbage"}) 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: class TestGitProjectGuard:
@ -245,13 +245,14 @@ class TestGitProjectGuard:
def test_non_git_cwd_stays_silent_and_writes_nothing(self, tmp_path, monkeypatch, capsys): 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. """No .git anywhere → resolve falls back to cwd → reminder no-ops.
Guards invariant #3: must NOT create <cwd>/.dochygiene/ in an Guards invariant #3: must NOT create <cwd>/.cc-os/dochygiene/ in an
arbitrary directory (e.g. ~). arbitrary directory (e.g. ~).
""" """
monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path)) monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path))
rc = reminder_main() rc = reminder_main()
assert rc == 0 assert rc == 0
assert capsys.readouterr().out == "" # no banner assert capsys.readouterr().out == "" # no banner
assert not (tmp_path / ".cc-os").exists() # nothing written
assert not (tmp_path / ".dochygiene").exists() # nothing written assert not (tmp_path / ".dochygiene").exists() # nothing written
def test_git_project_banners_when_never_checked(self, tmp_path, monkeypatch, capsys): def test_git_project_banners_when_never_checked(self, tmp_path, monkeypatch, capsys):
@ -262,7 +263,7 @@ class TestGitProjectGuard:
assert rc == 0 assert rc == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "systemMessage" in json.loads(out) assert "systemMessage" in json.loads(out)
assert (tmp_path / ".dochygiene" / "state.json").exists() assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").exists()
# =========================================================================== # ===========================================================================

View File

@ -107,7 +107,7 @@ def scan_artifact(doc_tree: Path) -> dict:
return { return {
"project_root": str(doc_tree), "project_root": str(doc_tree),
"scope_globs": ["**/*.md"], "scope_globs": ["**/*.md"],
"excluded_dirs": ["build", ".dochygiene"], "excluded_dirs": ["build", ".cc-os", ".dochygiene"],
"files_scanned": 7, "files_scanned": 7,
"shortlist": [ "shortlist": [
"CLAUDE.md", "CLAUDE.md",

View File

@ -296,6 +296,36 @@ class TestExcludedDirs:
assert not path.startswith(".dochygiene/") assert not path.startswith(".dochygiene/")
assert "live.md" in result["shortlist"] 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): def test_custom_excluded_dir_not_scanned(self, tmp_path):
_make_tree(tmp_path, { _make_tree(tmp_path, {
"myexcluded/doc.md": "# Should not appear", "myexcluded/doc.md": "# Should not appear",

View File

@ -106,7 +106,7 @@ class TestResolveProjectRoot:
class TestWriteConfinement: class TestWriteConfinement:
def test_all_writes_under_dochygiene(self, tmp_path): 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() (tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path) store = StateStore(project_root=tmp_path)
@ -123,10 +123,10 @@ class TestWriteConfinement:
store.set_last_reminded() store.set_last_reminded()
store.write_report('{"ok": true}', "# Report\nDone") store.write_report('{"ok": true}', "# Report\nDone")
state_dir = tmp_path / ".dochygiene" state_dir = tmp_path / ".cc-os" / "dochygiene"
for p in written_paths: for p in written_paths:
assert str(p).startswith(str(state_dir)), ( 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): def test_no_global_index_created(self, tmp_path):
@ -137,7 +137,9 @@ class TestWriteConfinement:
store.write_report("{}", "md") store.write_report("{}", "md")
home_dh = Path.home() / ".dochygiene" 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_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): 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}" assert p.name != ".gitignore", f"Write went to .gitignore: {p}"
def test_assert_confined_raises_for_out_of_root_path(self, tmp_path): 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) 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") 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): def test_corrupt_state_file_reads_as_unset(self, tmp_path):
"""A corrupt state.json does not raise — timestamps read as None.""" """A corrupt state.json does not raise — timestamps read as None."""
state_dir = tmp_path / ".dochygiene" state_dir = tmp_path / ".cc-os" / "dochygiene"
state_dir.mkdir() state_dir.mkdir(parents=True)
(state_dir / "state.json").write_bytes(b"not json {{") (state_dir / "state.json").write_bytes(b"not json {{")
store = StateStore(project_root=tmp_path) store = StateStore(project_root=tmp_path)
assert store.get_last_check() is None assert store.get_last_check() is None
@ -261,7 +263,7 @@ class TestAtomicWrites:
not in /tmp os.replace is only atomic within one filesystem. not in /tmp os.replace is only atomic within one filesystem.
""" """
store = StateStore(project_root=tmp_path) store = StateStore(project_root=tmp_path)
state_dir = tmp_path / ".dochygiene" state_dir = tmp_path / ".cc-os" / "dochygiene"
temp_paths_seen: list[Path] = [] temp_paths_seen: list[Path] = []
original_replace = os.replace original_replace = os.replace
@ -289,7 +291,7 @@ class TestAtomicWrites:
We monkeypatch os.replace to intercept the call and check both invariants. We monkeypatch os.replace to intercept the call and check both invariants.
""" """
store = StateStore(project_root=tmp_path) 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. # Write an initial "old" state.
store.set_last_check(datetime(2026, 1, 1, tzinfo=timezone.utc)) 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. all writes go through the temp-then-replace path.
""" """
store = StateStore(project_root=tmp_path) store = StateStore(project_root=tmp_path)
state_dir = tmp_path / ".dochygiene" state_dir = tmp_path / ".cc-os" / "dochygiene"
state_dir.mkdir(exist_ok=True) state_dir.mkdir(parents=True, exist_ok=True)
state_target = state_dir / "state.json" state_target = state_dir / "state.json"
opened_paths_with_write_mode: list[str] = [] opened_paths_with_write_mode: list[str] = []
@ -362,7 +364,7 @@ class TestAtomicWrites:
class TestReportRollover: class TestReportRollover:
def _report_files(self, tmp_path: Path) -> list[Path]: 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.*")) return sorted(state_dir.glob("report.*"))
def test_first_write_creates_exactly_one_pair(self, tmp_path): def test_first_write_creates_exactly_one_pair(self, tmp_path):
@ -382,9 +384,9 @@ class TestReportRollover:
assert names == {"report.json", "report.md"}, ( assert names == {"report.json", "report.md"}, (
f"Expected exactly one pair, found: {names}" 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" 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" assert "Run 2" in md_content, "Old report MD was not replaced"
def test_many_writes_leave_exactly_one_pair(self, tmp_path): def test_many_writes_leave_exactly_one_pair(self, tmp_path):
@ -401,12 +403,12 @@ class TestReportRollover:
store = StateStore(project_root=tmp_path) store = StateStore(project_root=tmp_path)
store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc)) 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("{}", "md")
store.write_report("{}", "md2") # second rollover 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, ( assert state_before == state_after, (
"state.json was modified or deleted during report rollover" "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): def test_writes_confined_after_git_root_resolution(self, tmp_path):
""" """
Scenario: git-root case writes are under <git-root>/.dochygiene/. Scenario: git-root case writes are under <git-root>/.cc-os/dochygiene/.
""" """
(tmp_path / ".git").mkdir() (tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path) store = StateStore(project_root=tmp_path)
store.set_last_check() store.set_last_check()
store.write_report("{}", "md") store.write_report("{}", "md")
state_dir = tmp_path / ".dochygiene" state_dir = tmp_path / ".cc-os" / "dochygiene"
for child in state_dir.iterdir(): for child in state_dir.iterdir():
# All files live inside .dochygiene # All files live inside .cc-os/dochygiene
assert child.parent == state_dir assert child.parent == state_dir
# Nothing written outside the project root # Nothing written outside the project root
cc_os_dir = tmp_path / ".cc-os"
unexpected = [ unexpected = [
p for p in tmp_path.iterdir() 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): 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 = StateStore(project_root=tmp_path) # no .git created
store.set_last_reminded() store.set_last_reminded()
state_dir = tmp_path / ".dochygiene" state_dir = tmp_path / ".cc-os" / "dochygiene"
assert state_dir.exists() assert state_dir.exists()
for child in state_dir.iterdir(): for child in state_dir.iterdir():
assert child.parent == state_dir assert child.parent == state_dir
def test_gitignore_not_edited_when_creating_state_dir(self, tmp_path): 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() (tmp_path / ".git").mkdir()
gitignore = tmp_path / ".gitignore" gitignore = tmp_path / ".gitignore"
@ -512,3 +515,106 @@ class TestFixtures:
store = StateStore(project_root=proj) store = StateStore(project_root=proj)
store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc)) store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc))
assert store.get_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()

View File

@ -342,7 +342,15 @@ def build_ctx() -> "Ctx":
def run_all_json(ctx: "Ctx") -> list: def run_all_json(ctx: "Ctx") -> list:
"""Run every applicable check and return a list of plain dicts: """Run every applicable check and return a list of plain dicts:
{name, status, message, remediation}. project_scoped checks are skipped {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 = [] results = []
for check in REGISTRY: for check in REGISTRY:
if check.project_scoped and ctx.project_root is None: 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") raise TypeError("check returned a non-CheckResult")
except Exception: except Exception:
result = CheckResult("warn", f"status check '{check.name}' failed to run") 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( results.append(
{ {
"name": check.name, "name": check.name,

View File

@ -62,6 +62,10 @@ def run(ctx: Ctx, state, today: date, out) -> None:
if check.project_scoped and ctx.project_root is None: if check.project_scoped and ctx.project_root is None:
continue continue
result = run_isolated(check, ctx) 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": if result.status == "note":
notes.append(result.message) notes.append(result.message)
elif result.status == "warn": elif result.status == "warn":

View File

@ -1,9 +1,16 @@
"""Per-project state for os-status (deep module). """Per-project state for os-status (deep module).
Owns the gitignored .cc-os/ directory contract: key=value config, per-check Owns the gitignored .cc-os/ directory contract: the shared key=value config
once-per-day snooze stamps (snooze-<check>), and permanent suppress markers at .cc-os/config (cross-plugin contract, ADR-026), and os-status's own state
(suppress-<check>). State only, never code; nothing here writes outside per-check once-per-day snooze stamps (snooze-<check>) and permanent
.cc-os/. Fail-open throughout: unreadable state reads as unset. suppress markers (suppress-<check>) 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 __future__ import annotations
@ -39,7 +46,13 @@ def read_config(root: Path) -> dict:
def write_config_value(root: Path, key: str, value: str) -> None: def write_config_value(root: Path, key: str, value: str) -> None:
"""Set key=value in .cc-os/config, preserving every other line """Set key=value in .cc-os/config, preserving every other line
(comments included) and appending the key if absent. Used by (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" config_path = root / ".cc-os" / "config"
try: try:
lines = config_path.read_text().splitlines() 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: for line in lines:
stripped = line.strip() stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped: 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: if existing_key == key:
out.append(f"{key} = {value}") out.append(f"{key}={value}")
found = True found = True
continue else:
out.append(f"{existing_key}={existing_value.strip()}")
continue
out.append(line) out.append(line)
if not found: if not found:
out.append(f"{key} = {value}") out.append(f"{key}={value}")
config_path.parent.mkdir(exist_ok=True) config_path.parent.mkdir(exist_ok=True)
config_path.write_text("\n".join(out) + "\n") config_path.write_text("\n".join(out) + "\n")
LEGACY_STATE_GLOBS = ("snooze-*", "suppress-*", "state.json")
class StateDir: 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: 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: 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: def snoozed(self, check: str, today: date) -> bool:
try: try:
stamped = date.fromisoformat( stamped = date.fromisoformat(
(self._dir / f"snooze-{check}").read_text().strip() self._read_path(f"snooze-{check}").read_text().strip()
) )
except Exception: except Exception:
return False return False
return stamped == today return stamped == today
def stamp(self, check: str, today: date) -> None: 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()) (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

View File

@ -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 `warn` naming the check; it never hides other checks' results and never leaks exception
text into the banner. text into the banner.
5. **State only in `.cc-os/`.** The hook writes nothing outside the project's `.cc-os/` 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 directory, and writes nothing at all outside a git project. `.cc-os/` holds state,
(config, `snooze-<check>`, `suppress-<check>`), never code. Add `.cc-os/` to the never code: the shared `config` file at `.cc-os/config` (ADR-026), and os-status's own
project's `.gitignore`. `snooze-<check>` / `suppress-<check>` 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 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 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 record, tuned by the Eval B campaign and validated by Eval C). Never paraphrase; if the

View File

@ -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 re-running `fix` on an already-configured project is the update path, not a separate
command. 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 ## Flow
1. **Get machine-readable results.** From the project root, run: 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). 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 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 3b4 (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): 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 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 f. **`config-version-current`** → resolved automatically by step 4 below; no
separate action. separate action.
Re-run the JSON check after each remediation that plausibly changed state, so Re-run the JSON check after each remediation that plausibly changed check-registry
later steps see fresh results (e.g. don't act on a stale `vault-hub-note-present` state (creating a hub note, running `/os-adr:init`, setting a config key), so later
warning after already creating the note). 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-<check>` 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 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`, done (or were already `ok`), write the current version into `.cc-os/config`,

View File

@ -35,7 +35,13 @@ from checks import ( # noqa: E402
tracker_configured, tracker_configured,
vault_hub_note_present, 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) TODAY = date(2026, 7, 6)
YESTERDAY = date(2026, 7, 5) YESTERDAY = date(2026, 7, 5)
@ -369,6 +375,21 @@ class JsonRunnerTest(unittest.TestCase):
self.assertNotIn("adr-system-present", names) self.assertNotIn("adr-system-present", names)
self.assertNotIn("tracker-configured", 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): def test_subprocess_json_flag_prints_valid_json_array(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = git_project(tmp) root = git_project(tmp)
@ -413,6 +434,35 @@ class WriteConfigValueTest(unittest.TestCase):
self.assertEqual("1", config["version"]) self.assertEqual("1", config["version"])
self.assertEqual("my-hub", config["hub"]) 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): class BannerWordingTest(unittest.TestCase):
def test_banner_header_names_fix_skill(self): def test_banner_header_names_fix_skill(self):
@ -483,7 +533,7 @@ class RunnerTest(unittest.TestCase):
ctx = make_ctx(project_root=root) ctx = make_ctx(project_root=root)
first = self.run_with(registry, ctx, state) first = self.run_with(registry, ctx, state)
self.assertIn("boom", first["systemMessage"]) 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)) self.assertEqual({}, self.run_with(registry, ctx, state))
def test_next_day_warns_again(self): def test_next_day_warns_again(self):
@ -529,6 +579,32 @@ class RunnerTest(unittest.TestCase):
self.assertNotIn("old news", banner) self.assertNotIn("old news", banner)
self.assertIn("new problem", 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): class StateTest(unittest.TestCase):
def test_read_config_parses_key_value_and_skips_comments(self): def test_read_config_parses_key_value_and_skips_comments(self):
@ -565,6 +641,93 @@ class StateTest(unittest.TestCase):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
self.assertIsNone(find_project_root(Path(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): class WordingInvariantTest(unittest.TestCase):
"""PRESENT_NOTE / ABSENT_NOTE must stay byte-identical to os-adr's """PRESENT_NOTE / ABSENT_NOTE must stay byte-identical to os-adr's