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
cc-os itself, then one more project) still pending.
**Fix tune-up + per-project state consolidation (2026-07-12, issues #23/#24, ADR-027):** the
credvault smoke-test of `/os-status:fix` (session cd1ae1cd) was audited against the SKILL.md
contract; the run was on-contract but surfaced four gaps (stale snoozes never cleared,
`.cc-os/config` format split between the Ruby and Python writers, ad-hoc gitignore hygiene,
an unprompted commit) plus root-dotdir sprawl. Fixes (both sonnet-delegated, in parallel):
**os-status** — `clear_snooze` helper + self-clearing snoozes when a check evaluates ok/note
(both SessionStart and `--json` runners); its own state (`snooze-*`, `suppress-*`,
`state.json`) relocated to `.cc-os/status/` with legacy fallback read + migrate-on-write
(shared `config` stays at `.cc-os/config`); `write_config_value` normalizes the whole file to
`key=value` (no spaces); fix SKILL.md now scopes out committing, adds an explicit
"`.cc-os/` in .gitignore" step, and clarifies when re-runs are needed; suite 80 tests (was
64). **os-backlog**`Config` already emitted the normalized format; locked in by new tests
(58 runs, was 54). **os-doc-hygiene** — state moved `.dochygiene/``.cc-os/dochygiene/`
(ADR-027) with canonical-then-legacy fallback reads and auto-migration (+ legacy dir removal)
on first write; scanner self-excludes `.cc-os/`; all four SKILL.md files updated; suite 279
passed. `graphify-out/` explicitly exempt from consolidation (ADR-027). Re-run of
`/os-status:fix` in credvault is the pending IRL verification.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-013); bulk
vault migration. All required build steps complete as of 2026-06-15.

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).
- **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)
| Tool | Why rejected for our use |

View File

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

View File

@ -65,4 +65,32 @@ class ConfigTest < Minitest::Test
updated = Backlog::Config.new(Backlog::Config.merge(existing, "tracker", "repo:new"))
assert_equal "repo:new", updated.tracker
end
def test_reads_spaced_key_value_lines
config = Backlog::Config.new("version = 1\nhub = my-hub\n")
assert_equal "1", config.to_h["version"]
assert_equal "my-hub", config.to_h["hub"]
end
def test_merge_output_has_no_spaces_around_equals
contents = Backlog::Config.merge(nil, "tracker", "forgejo:jared/cc-os")
assert_equal "tracker=forgejo:jared/cc-os\n", contents
refute_includes contents, " = "
refute_includes contents, "= "
refute_includes contents, " ="
end
def test_merge_normalizes_existing_spaced_lines_on_rewrite
existing = "version = 1\nhub = my-hub\n"
updated_contents = Backlog::Config.merge(existing, "tracker", "planka:board")
updated_contents.each_line do |line|
next if line.strip.empty?
refute_match(/\s=\s|\s=|=\s/, line, "expected normalized key=value, got #{line.inspect}")
end
updated = Backlog::Config.new(updated_contents)
assert_equal "1", updated.to_h["version"]
assert_equal "my-hub", updated.to_h["hub"]
assert_equal "planka:board", updated.tracker
end
end

View File

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

View File

@ -9,9 +9,9 @@ for isolated testing with injected clocks/filesystems.
| File | Purpose |
|------|---------|
| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.dochygiene` (invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. |
| `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `<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. |
| `reminder.py` | `SessionStart` reminder. Pure `Reminder.decide(last_check, last_reminded, now)` decision (stale-and-not-snoozed ⇒ banner; calendar-day snooze keyed on `last_reminded`, invariants #1/#2). `ReminderRunner` wires it to the `StateStore`, emits a zero-token `systemMessage` JSON banner on stdout, and writes `last_reminded` as the *only* mutation, only on banner. Treats missing/corrupt state as never-checked (stale); always exits 0 — never blocks the session. **No-ops unless cwd is inside a real git project** (`_is_git_project` guard) so the user-scoped hook never creates `.dochygiene/` in an arbitrary dir like `~` (invariant #3) — flagged for human review. No scan, no model. `DEFAULT_THRESHOLD_DAYS` is an implementation constant, not a spec'd contract. Invoked by `hooks/hooks.json` via `${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py`. |
| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.cc-os/` and legacy `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.cc-os` (shared cc-os state dir, ADR-027), `.dochygiene` (legacy state dir, invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. |
| `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `<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 `.cc-os/dochygiene/` in an arbitrary dir like `~` (invariant #3) — flagged for human review. No scan, no model. `DEFAULT_THRESHOLD_DAYS` is an implementation constant, not a spec'd contract. Invoked by `hooks/hooks.json` via `${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py`. |
| `validate_report.py` | Schema-validator for machine-report JSON files. Enforces the frozen report contract: envelope fields, closed category enum, closed `exact_edit.kind` enum with per-kind required sub-fields, `op_type`↔`exact_edit` biconditional (invariant #11), and `safety_tier` derivation match (invariant #10). Collects all violations. Exit 0 = valid, exit 1 = invalid, exit 2 = usage error. |
| `report_builder.py` | Deterministic finalize pass (no model). Consumes the scanner artifact + the subagent's slim proposals and assembles a schema-valid machine report plus a human-report skeleton. Fills the four guardrail fields the model must not author: computes each entry's `expected_sha256` over the live file, looks up `is_destructive`/`is_reversible` from the `KIND_TABLE` (keyed on `exact_edit.kind`), derives `safety_tier` via `validate_report`'s `derive_safety_tier` (invariant #10 — single source of truth), and sources `raw_tokens` from `token_estimator` (over the `reducible_range`/anchor text). Rejects malformed proposals with a structured stderr error (`{index, field, message}`, exit 1); `--out-json`/`--out-md` write files and suppress the stdout bundle. An empty proposals array yields a valid empty-entries report. |
| `token_estimator.py` | Deterministic local token estimator (no model, no network — invariant #6). Produces the `token_estimate.raw_tokens` count for report entries. Pluggable `TokenEstimator` ABC with two backends: `HeuristicEstimator` (ALWAYS available, zero deps, `ceil(len/4)`) and `TiktokenEstimator` (optional accuracy upgrade, `o200k_base`, `disallowed_special=()`). `default_estimator(cache_dir=…)` selects tiktoken **only** when it is importable AND the vocab is vendored locally (detect-before-fetch: checks the sha1-named cache file exists before loading, so a cold cache never triggers a network download); otherwise returns the heuristic. Never raises; active backend is introspectable via `.name`. `estimate(text)→int`, `estimate_file(path)→int` (utf-8, `errors="replace"`), `estimate_for_report(text)→{raw_tokens, injection_frequency:null, weighted_tokens:null}` (v1 emits only `raw_tokens`; weighting is the v2 bonus). CLI: `python token_estimator.py <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",
"archive",
"graphify-out",
".dochygiene",
".cc-os",
".dochygiene", # legacy state dir (pre-ADR-027); still excluded for old trees
# Test-fixture dirs intentionally contain stale/bloated docs as scanner
# inputs. Bare directory-*name* match (consistent with the entries above):
# any child dir named "fixtures" at any depth is pruned — same breadth as
@ -529,13 +530,14 @@ class Scanner:
) -> None:
self._root = root.resolve()
self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS
# Always include .dochygiene in excluded dirs (self-exclusion invariant #9).
# Preserve insertion order so the artifact echoes the canonical default
# order (matches valid_report.json golden fixture).
# Always include .cc-os (canonical, ADR-027) and .dochygiene (legacy) in
# excluded dirs (self-exclusion invariant #9). Preserve insertion order
# so the artifact echoes the canonical default order (matches
# valid_report.json golden fixture).
base_excluded = excluded_dirs if excluded_dirs is not None else DEFAULT_EXCLUDED_DIRS
seen: set = set()
deduped: List[str] = []
for d in list(base_excluded) + [".dochygiene"]:
for d in list(base_excluded) + [".cc-os", ".dochygiene"]:
if d not in seen:
seen.add(d)
deduped.append(d)

View File

@ -3,7 +3,13 @@ State store for doc-hygiene.
Provides:
- resolve_project_root(start_dir, fs) pure function, no side effects
- StateStore confines all writes to <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:
#3 State lives in-project; no global index; never edit .gitignore.
@ -21,6 +27,7 @@ from __future__ import annotations
import json
import os
import shutil
import tempfile
from datetime import datetime, timezone
from pathlib import Path
@ -113,9 +120,14 @@ class StateStore:
"""
Manages all persistent state for doc-hygiene within a single project.
All writes are confined to <project_root>/.dochygiene/ (invariant #3).
No global index is maintained; each project has its own independent store.
The store never opens or edits .gitignore (invariant #3).
All writes are confined to <project_root>/.cc-os/dochygiene/ (invariant #3,
ADR-027). No global index is maintained; each project has its own
independent store. The store never opens or edits .gitignore (invariant #3).
Backward compatibility (ADR-027): the legacy location
<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
----------
@ -131,17 +143,42 @@ class StateStore:
def __init__(self, project_root: Path, clock: Optional[_Clock] = None) -> None:
self._root = Path(project_root)
self._clock = clock or RealClock()
self._state_dir = self._root / ".dochygiene"
self._state_dir = self._root / ".cc-os" / "dochygiene"
self._legacy_dir = self._root / ".dochygiene"
# ------------------------------------------------------------------
# Directory bootstrap
# ------------------------------------------------------------------
def _ensure_state_dir(self) -> Path:
"""Create .dochygiene/ if it does not exist. Never touches .gitignore."""
"""Create .cc-os/dochygiene/ if it does not exist, migrating any legacy
.dochygiene/ contents in first. Never touches .gitignore."""
self._migrate_legacy()
self._state_dir.mkdir(parents=True, exist_ok=True)
return self._state_dir
def _migrate_legacy(self) -> None:
"""
One-time migration of <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)
# ------------------------------------------------------------------
@ -186,7 +223,7 @@ class StateStore:
path.resolve().relative_to(self._state_dir.resolve())
except ValueError:
raise ValueError(
f"StateStore attempted to write outside .dochygiene/: {path}"
f"StateStore attempted to write outside .cc-os/dochygiene/: {path}"
)
# ------------------------------------------------------------------
@ -197,10 +234,18 @@ class StateStore:
return self._state_dir / _STATE_FILE
def _read_state(self) -> dict:
"""Read state.json; return {} if missing or unreadable (never raises)."""
path = self._state_path()
"""Read state.json; return {} if missing or unreadable (never raises).
Falls back to the legacy .dochygiene/state.json (ADR-027) if the
canonical file is absent. The legacy file is only *read* here it is
migrated into the canonical location on the next write.
"""
try:
return json.loads(path.read_bytes())
return json.loads(self._state_path().read_bytes())
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
try:
return json.loads((self._legacy_dir / _STATE_FILE).read_bytes())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
@ -310,10 +355,21 @@ class StateStore:
def read_report(self) -> Optional[tuple[str, str]]:
"""
Return (json_blob, md_blob) if a report exists, else None.
Falls back to the legacy .dochygiene/ directory (ADR-027) if the
canonical directory has no report pair.
"""
json_path = self._state_dir / _REPORT_JSON
md_path = self._state_dir / _REPORT_MD
try:
return json_path.read_text(), md_path.read_text()
return (
(self._state_dir / _REPORT_JSON).read_text(),
(self._state_dir / _REPORT_MD).read_text(),
)
except FileNotFoundError:
pass
try:
return (
(self._legacy_dir / _REPORT_JSON).read_text(),
(self._legacy_dir / _REPORT_MD).read_text(),
)
except FileNotFoundError:
return None

View File

@ -11,7 +11,7 @@ as slash commands (`/os-doc-hygiene:check`, `/os-doc-hygiene:clean`,
| Path | Purpose |
|------|---------|
| `check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). |
| `check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.cc-os/dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). |
| `check/workflows/classify-candidates.md` | Self-contained subagent workflow for the classification step. Defines the slim proposal object (`path`, `category` {class, subtype}, pass-through `signals`, `op`, `op_type`, optional `exact_edit`/`reducible_range`, `gloss`, `confidence`, `escalate`), the closed enums, the situation→kind→tier decision table, and the per-kind required `exact_edit` fields. The subagent reads each file and returns judgment only — it never authors hashes, token counts, safety tiers, or reversibility. |
| `clean/SKILL.md` | The `clean` orchestration: **load → validate → filter → gate → preflight → apply-deterministic → apply-generative → commit → stamp**. (D) `StateStore` loads and re-validates the machine report. (logic) Incompatible-pair detection (files with both generative + deterministic ops) and partition by `safety_tier`/`op_type`. (M-GATE) Confirm gate for `confirm`-tier entries; auto-tier entries apply silently. (D) Git preflight: record baseline, detect untracked files (which get skipped). (D) `patch_applier.py` applies deterministic entries mechanically; mtime-guarded, output include `applied`/`skipped`/`failed`. (M) Sonnet subagent distills each generative entry via `workflows/distill.md` (LOOP-GUARD + SUBAGENT AUTHORIZATION — terminal, REPORT-AND-EXIT). (D) `git-context commit-apply` produces exactly one cleanup commit. (D) `StateStore.set_last_clean` stamps the commit instant. Scoped by `--scope` (glob-or-path) and `--category` (class or subtype), both entry-stage filters. On failure (rollback-worthy): all intermediate mutations reverted to baseline. |
| `clean/workflows/distill.md` | Self-contained subagent workflow for generative distillation. Receives live file contents (not disk-read), category, op, and signals. Performs `distill` (condense in place, preserve frontmatter and structure, ~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`
from the user's project directory (`cwd`), which is where the project root is
resolved. Use the session scratchpad directory for all intermediate artifacts —
**never** write to `.dochygiene/` until the validated write step.
**never** write to `.cc-os/dochygiene/` until the validated write step.
> **Precondition:** this skill requires the `CLAUDE_PLUGIN_ROOT` environment
> variable to be set (Claude Code sets it at runtime). Every script path and the
@ -45,7 +45,10 @@ If no arguments are given, the scan uses defaults (`**/*.md`, default excludes).
### Step 0 — (D / M-GATE) Gitignore preflight
Check whether `.dochygiene/` is already git-ignored, and offer to add it if not.
Check whether `.cc-os/` is already git-ignored, and offer to add it if not. Per
ADR-027, `.cc-os/dochygiene/` is the canonical state dir and `.cc-os/` is the
single shared gitignore entry for all cc-os per-project state (not just
doc-hygiene's).
```bash
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT=""
@ -54,10 +57,10 @@ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT=""
Three cases:
- **No git root** (`ROOT` empty — project is not a git repo): skip silently. `resolve_project_root` falls back to cwd; no `.gitignore` offer is meaningful.
- **Already ignored** (`git -C "$ROOT" check-ignore -q .dochygiene` exits `0`): silent no-op. Proceed to Step 1.
- **Already ignored** (`git -C "$ROOT" check-ignore -q .cc-os` exits `0`): silent no-op. Proceed to Step 1.
- **Not ignored** (exit `1`): present the one-line offer:
> `doc-hygiene` stores its state and report under `.dochygiene/` at the project root. Per invariant #3, this directory should be gitignored so it doesn't appear as untracked in your repo. Shall I append `.dochygiene/` to `<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:
@ -65,12 +68,12 @@ Three cases:
if [ -s "$ROOT/.gitignore" ] && [ -n "$(tail -c1 "$ROOT/.gitignore")" ]; then
printf '\n' >> "$ROOT/.gitignore"
fi
printf '.dochygiene/\n' >> "$ROOT/.gitignore"
printf '.cc-os/\n' >> "$ROOT/.gitignore"
```
(Creates `.gitignore` if absent; appends with a leading newline only when the file is non-empty and doesn't already end in one.)
**If the user declines:** proceed without editing. Note that `.dochygiene/` may appear as untracked/dirty in `git status` until ignored.
**If the user declines:** proceed without editing. Note that `.cc-os/dochygiene/` may appear as untracked/dirty in `git status` until ignored.
> **Do NOT append without explicit user confirmation.** (Invariant #3.)
@ -84,7 +87,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs <glob> ...] > "$SCRA
- Omit `--globs` when there is no `--scope`.
- The scanner auto-resolves the project root from `cwd` and applies default
excludes (incl. `.dochygiene/`). Do not pass `--root`.
excludes (incl. `.cc-os/` and legacy `.dochygiene/`). Do not pass `--root`.
The artifact is `{ project_root, scope_globs, excluded_dirs, files_scanned,
shortlist, signals }`. `signals` is an object keyed by project-root-relative path:
@ -239,12 +242,12 @@ report = json.loads(json_blob)
store = StateStore(resolve_project_root(Path(os.getcwd())))
store.write_report(json_blob, md_blob) # rollover: keeps exactly one pair
store.set_last_check(datetime.fromisoformat(report["generated_at"]))
print("wrote .dochygiene/report.json + report.md; last_check=" + report["generated_at"])
print("wrote .cc-os/dochygiene/report.json + report.md; last_check=" + report["generated_at"])
'
```
(`SCRATCH` must be exported so the `-c` process can read it.) This writes
`.dochygiene/report.json` and `.dochygiene/report.md` (atomic, one pair) and stamps
`.cc-os/dochygiene/report.json` and `.cc-os/dochygiene/report.md` (atomic, one pair) and stamps
`last_check`.
### Step 8 — Surface the result
@ -257,11 +260,11 @@ doc-hygiene check complete
scope: <effective scope, e.g. **/*.md or the bare-path prefix>
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:
<project-root>/.dochygiene/report.json
<project-root>/.dochygiene/report.md
<project-root>/.cc-os/dochygiene/report.json
<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.
```

View File

@ -61,14 +61,21 @@ if result is None:
print(json.dumps({"status": "no_report", "root": str(root)}))
else:
json_blob, md_blob = result
print(json.dumps({"status": "ok", "root": str(root), "report_path": str(root / ".dochygiene" / "report.json")}))
# Report path may still be the legacy .dochygiene/ location if it has not
# been migrated yet (migration happens lazily, on the next write) — ADR-027.
canonical = root / ".cc-os" / "dochygiene" / "report.json"
legacy = root / ".dochygiene" / "report.json"
report_path = canonical if canonical.is_file() else legacy
print(json.dumps({"status": "ok", "root": str(root), "report_path": str(report_path)}))
'
```
- If `status == "no_report"` → tell the user: "No hygiene report found. Run
`/os-doc-hygiene:check` first to generate one." **STOP.**
- If `status == "ok"` → record `PROJECT_ROOT` and `REPORT_PATH` (the canonical
`.dochygiene/report.json`) for use in subsequent steps.
`.cc-os/dochygiene/report.json`, or the legacy `.dochygiene/report.json` if
not yet migrated — the next `clean`/`check` write auto-migrates it, per
ADR-027) for use in subsequent steps.
---
@ -228,11 +235,11 @@ commit, no stamp).**
### Step 6 — (D) Git preflight
> **Gitignore offer (resolve before dirty-tree check):** If `.dochygiene/` is not
> **Gitignore offer (resolve before dirty-tree check):** If `.cc-os/` is not
> yet gitignored, it will appear as untracked in `git status --porcelain` and may
> trigger a needless WIP checkpoint. Before running `git status`, check:
> ```bash
> git -C "$PROJECT_ROOT" check-ignore -q .dochygiene
> git -C "$PROJECT_ROOT" check-ignore -q .cc-os
> ```
> Exit `0` → already ignored; proceed. Exit `1` → present the same one-line
> confirmation offer described in the check skill's **Step 0**. Resolve it (or let
@ -257,7 +264,7 @@ BASELINE=$(git rev-parse HEAD)
Auto-create a WIP checkpoint commit of the user's existing work:
```bash
git add -A # OK here: the cleanup commit uses precise staging; this is user-work, .dochygiene/ is gitignored
git add -A # OK here: the cleanup commit uses precise staging; this is user-work, .cc-os/ is gitignored
git commit -m "wip: checkpoint before hygiene cleanup"
BASELINE=$(git rev-parse HEAD~1) # baseline is BEFORE the checkpoint
```

View File

@ -22,4 +22,4 @@ The user will still be prompted to approve any entries that require it before
any file mutation occurs.
`sweep` produces at most one cleanup commit (the clean step); the check step
writes only to the gitignored `.dochygiene/` and does not commit.
writes only to the gitignored `.cc-os/dochygiene/` and does not commit.

View File

@ -10,12 +10,12 @@ the real clock, a real session, or the network.
| File | Covers | Key assertions |
|------|--------|----------------|
| `test_scanner_exclusions.py` | Scanner exclusion pipeline (invariant #9) | A `hygiene: frozen` file, a `.dochygiene-ignore`-matched file, and append-only logs are absent from the shortlist; `.dochygiene/` and excluded dirs are never scanned. |
| `test_scanner_exclusions.py` | Scanner exclusion pipeline (invariant #9) | A `hygiene: frozen` file, a `.dochygiene-ignore`-matched file, and append-only logs are absent from the shortlist; `.cc-os/` and `.dochygiene/` and excluded dirs are never scanned. |
| `test_scanner_signals.py` | Scanner per-path signals | One fixture per signal type asserts the expected signal on the right path, with no classification attached (objective facts only). |
| `test_state_store.py` | `resolve_project_root` + `StateStore` (invariants #3, #4) | Root resolution (git-root / cwd-fallback); writes confined to `.dochygiene/`; `.gitignore` never edited; atomic torn-write safety; report rollover keeps one pair; timestamp round-trip with injected clock. |
| `test_state_store.py` | `resolve_project_root` + `StateStore` (invariants #3, #4; ADR-027) | Root resolution (git-root / cwd-fallback); writes confined to `.cc-os/dochygiene/`; `.gitignore` never edited; atomic torn-write safety; report rollover keeps one pair; timestamp round-trip with injected clock; legacy `.dochygiene/` fallback reads and auto-migration on first write. |
| `test_reminder.py` | `Reminder` decision + `ReminderRunner` (invariants #1, #2) | Pure decision: fresh→silent, stale→banner, same-day→snoozed, prior-day→re-banner, **calendar-day boundary across midnight** (20-min delta, different day → banner). Runner: only `last_reminded` mutated and only on banner; missing/corrupt state → never-checked; always exits 0. |
| `test_validate_report.py` | `validate_report.py` (invariants #10, #11) | Golden fixtures end-to-end (PASS exit 0 / FAIL exit 1, not mutated); crafted-dict branch coverage for `raw_tokens` requirement, op_type↔exact_edit biconditional, and safety_tier derivation truth table. |
| `test_cross_seam.py` | scanner + state_store + reminder together (Group 5; invariant #6) | End-to-end on a tmp doc tree: scan produces a shortlist and self-excludes `.dochygiene/`; rollover keeps one report pair; reminder snooze behaves across simulated sessions with an injected clock. No model, no network. |
| `test_cross_seam.py` | scanner + state_store + reminder together (Group 5; invariant #6) | End-to-end on a tmp doc tree: scan produces a shortlist and self-excludes `.cc-os/`/`.dochygiene/`; rollover keeps one report pair; reminder snooze behaves across simulated sessions with an injected clock. No model, no network. |
## Fixtures

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

View File

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

View File

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

View File

@ -296,6 +296,36 @@ class TestExcludedDirs:
assert not path.startswith(".dochygiene/")
assert "live.md" in result["shortlist"]
def test_cc_os_self_excluded_always(self, tmp_path):
"""Canonical .cc-os/ state dir (ADR-027) is always self-excluded."""
_make_tree(tmp_path, {
".cc-os/dochygiene/state.json": '{"last_check": null}',
".cc-os/dochygiene/state_doc.md": "# This should never be scanned",
"real_doc.md": "# Real doc",
})
result = _run(tmp_path)
for path in result["shortlist"]:
assert not path.startswith(".cc-os/"), (
".cc-os/ contents should never appear in shortlist"
)
assert "real_doc.md" in result["shortlist"]
def test_cc_os_excluded_even_if_not_in_config(self, tmp_path):
"""Even with an empty excluded_dirs config, .cc-os/ is self-excluded."""
_make_tree(tmp_path, {
".cc-os/dochygiene/report.md": "# Should be excluded",
"live.md": "# Live",
})
result = Scanner(
root=tmp_path,
excluded_dirs=[], # explicitly empty
git_log_fn=lambda p: [],
now_fn=lambda: 0.0,
).run()
for path in result["shortlist"]:
assert not path.startswith(".cc-os/")
assert "live.md" in result["shortlist"]
def test_custom_excluded_dir_not_scanned(self, tmp_path):
_make_tree(tmp_path, {
"myexcluded/doc.md": "# Should not appear",

View File

@ -106,7 +106,7 @@ class TestResolveProjectRoot:
class TestWriteConfinement:
def test_all_writes_under_dochygiene(self, tmp_path):
"""Every path written by the store is under .dochygiene/."""
"""Every path written by the store is under .cc-os/dochygiene/."""
(tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path)
@ -123,10 +123,10 @@ class TestWriteConfinement:
store.set_last_reminded()
store.write_report('{"ok": true}', "# Report\nDone")
state_dir = tmp_path / ".dochygiene"
state_dir = tmp_path / ".cc-os" / "dochygiene"
for p in written_paths:
assert str(p).startswith(str(state_dir)), (
f"Write escaped .dochygiene/: {p}"
f"Write escaped .cc-os/dochygiene/: {p}"
)
def test_no_global_index_created(self, tmp_path):
@ -137,7 +137,9 @@ class TestWriteConfinement:
store.write_report("{}", "md")
home_dh = Path.home() / ".dochygiene"
home_cc_os = Path.home() / ".cc-os"
assert not home_dh.exists(), "Global ~/.dochygiene must not be created"
assert not home_cc_os.exists(), "Global ~/.cc-os must not be created"
def test_gitignore_never_opened_or_written(self, tmp_path):
"""
@ -173,9 +175,9 @@ class TestWriteConfinement:
assert p.name != ".gitignore", f"Write went to .gitignore: {p}"
def test_assert_confined_raises_for_out_of_root_path(self, tmp_path):
"""_assert_confined must raise ValueError for a path outside .dochygiene."""
"""_assert_confined must raise ValueError for a path outside .cc-os/dochygiene."""
store = StateStore(project_root=tmp_path)
with pytest.raises(ValueError, match="outside .dochygiene"):
with pytest.raises(ValueError, match="outside .cc-os/dochygiene"):
store._assert_confined(tmp_path / "other" / "file.txt")
@ -237,8 +239,8 @@ class TestTimestamps:
def test_corrupt_state_file_reads_as_unset(self, tmp_path):
"""A corrupt state.json does not raise — timestamps read as None."""
state_dir = tmp_path / ".dochygiene"
state_dir.mkdir()
state_dir = tmp_path / ".cc-os" / "dochygiene"
state_dir.mkdir(parents=True)
(state_dir / "state.json").write_bytes(b"not json {{")
store = StateStore(project_root=tmp_path)
assert store.get_last_check() is None
@ -261,7 +263,7 @@ class TestAtomicWrites:
not in /tmp os.replace is only atomic within one filesystem.
"""
store = StateStore(project_root=tmp_path)
state_dir = tmp_path / ".dochygiene"
state_dir = tmp_path / ".cc-os" / "dochygiene"
temp_paths_seen: list[Path] = []
original_replace = os.replace
@ -289,7 +291,7 @@ class TestAtomicWrites:
We monkeypatch os.replace to intercept the call and check both invariants.
"""
store = StateStore(project_root=tmp_path)
state_path = tmp_path / ".dochygiene" / "state.json"
state_path = tmp_path / ".cc-os" / "dochygiene" / "state.json"
# Write an initial "old" state.
store.set_last_check(datetime(2026, 1, 1, tzinfo=timezone.utc))
@ -334,8 +336,8 @@ class TestAtomicWrites:
all writes go through the temp-then-replace path.
"""
store = StateStore(project_root=tmp_path)
state_dir = tmp_path / ".dochygiene"
state_dir.mkdir(exist_ok=True)
state_dir = tmp_path / ".cc-os" / "dochygiene"
state_dir.mkdir(parents=True, exist_ok=True)
state_target = state_dir / "state.json"
opened_paths_with_write_mode: list[str] = []
@ -362,7 +364,7 @@ class TestAtomicWrites:
class TestReportRollover:
def _report_files(self, tmp_path: Path) -> list[Path]:
state_dir = tmp_path / ".dochygiene"
state_dir = tmp_path / ".cc-os" / "dochygiene"
return sorted(state_dir.glob("report.*"))
def test_first_write_creates_exactly_one_pair(self, tmp_path):
@ -382,9 +384,9 @@ class TestReportRollover:
assert names == {"report.json", "report.md"}, (
f"Expected exactly one pair, found: {names}"
)
json_content = json.loads((tmp_path / ".dochygiene" / "report.json").read_text())
json_content = json.loads((tmp_path / ".cc-os" / "dochygiene" / "report.json").read_text())
assert json_content["run"] == 2, "Old report JSON was not replaced"
md_content = (tmp_path / ".dochygiene" / "report.md").read_text()
md_content = (tmp_path / ".cc-os" / "dochygiene" / "report.md").read_text()
assert "Run 2" in md_content, "Old report MD was not replaced"
def test_many_writes_leave_exactly_one_pair(self, tmp_path):
@ -401,12 +403,12 @@ class TestReportRollover:
store = StateStore(project_root=tmp_path)
store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc))
state_before = (tmp_path / ".dochygiene" / "state.json").read_bytes()
state_before = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes()
store.write_report("{}", "md")
store.write_report("{}", "md2") # second rollover
state_after = (tmp_path / ".dochygiene" / "state.json").read_bytes()
state_after = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes()
assert state_before == state_after, (
"state.json was modified or deleted during report rollover"
)
@ -435,24 +437,25 @@ class TestRootResolutionConfinement:
def test_writes_confined_after_git_root_resolution(self, tmp_path):
"""
Scenario: git-root case writes are under <git-root>/.dochygiene/.
Scenario: git-root case writes are under <git-root>/.cc-os/dochygiene/.
"""
(tmp_path / ".git").mkdir()
store = StateStore(project_root=tmp_path)
store.set_last_check()
store.write_report("{}", "md")
state_dir = tmp_path / ".dochygiene"
state_dir = tmp_path / ".cc-os" / "dochygiene"
for child in state_dir.iterdir():
# All files live inside .dochygiene
# All files live inside .cc-os/dochygiene
assert child.parent == state_dir
# Nothing written outside the project root
cc_os_dir = tmp_path / ".cc-os"
unexpected = [
p for p in tmp_path.iterdir()
if p != state_dir and p.name != ".git"
if p != cc_os_dir and p.name != ".git"
]
assert not unexpected, f"Unexpected files outside .dochygiene: {unexpected}"
assert not unexpected, f"Unexpected files outside .cc-os/dochygiene: {unexpected}"
def test_writes_confined_in_cwd_fallback(self, tmp_path):
"""
@ -461,14 +464,14 @@ class TestRootResolutionConfinement:
store = StateStore(project_root=tmp_path) # no .git created
store.set_last_reminded()
state_dir = tmp_path / ".dochygiene"
state_dir = tmp_path / ".cc-os" / "dochygiene"
assert state_dir.exists()
for child in state_dir.iterdir():
assert child.parent == state_dir
def test_gitignore_not_edited_when_creating_state_dir(self, tmp_path):
"""
Creating .dochygiene/ for the first time must not touch .gitignore.
Creating .cc-os/dochygiene/ for the first time must not touch .gitignore.
"""
(tmp_path / ".git").mkdir()
gitignore = tmp_path / ".gitignore"
@ -512,3 +515,106 @@ class TestFixtures:
store = StateStore(project_root=proj)
store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc))
assert store.get_last_check() == datetime(2026, 6, 18, tzinfo=timezone.utc)
# ===========================================================================
# ADR-027 — legacy .dochygiene/ fallback reads + auto-migration on first write
# ===========================================================================
class TestLegacyFallbackAndMigration:
def _write_legacy_state(self, tmp_path: Path, state: dict) -> Path:
legacy_dir = tmp_path / ".dochygiene"
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "state.json").write_text(json.dumps(state))
return legacy_dir
def _write_legacy_report(self, tmp_path: Path, json_blob: str, md_blob: str) -> Path:
legacy_dir = tmp_path / ".dochygiene"
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "report.json").write_text(json_blob)
(legacy_dir / "report.md").write_text(md_blob)
return legacy_dir
def test_reads_fall_back_to_legacy_timestamp(self, tmp_path):
"""A timestamp written only under legacy .dochygiene/ is still readable."""
fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
self._write_legacy_state(tmp_path, {"last_check": fixed.isoformat()})
store = StateStore(project_root=tmp_path)
assert store.get_last_check() == fixed
# Canonical dir must NOT have been created by a pure read.
assert not (tmp_path / ".cc-os").exists()
def test_reads_fall_back_to_legacy_report(self, tmp_path):
"""A report written only under legacy .dochygiene/ is still readable."""
self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy")
store = StateStore(project_root=tmp_path)
pair = store.read_report()
assert pair is not None
json_blob, md_blob = pair
assert json.loads(json_blob)["v"] == "legacy"
assert "Legacy" in md_blob
assert not (tmp_path / ".cc-os").exists()
def test_canonical_state_takes_precedence_over_legacy(self, tmp_path):
"""If both canonical and legacy state exist, canonical wins."""
legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
canonical_ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()})
store = StateStore(project_root=tmp_path)
store.set_last_check(canonical_ts) # triggers migration + then overwrites
assert store.get_last_check() == canonical_ts
def test_first_write_migrates_legacy_state_and_removes_legacy_dir(self, tmp_path):
"""
First write triggers migration: pre-existing legacy files are copied into
the canonical dir, and the legacy dir is removed entirely.
"""
legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()})
self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy")
store = StateStore(project_root=tmp_path)
store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc))
legacy_dir = tmp_path / ".dochygiene"
canonical_dir = tmp_path / ".cc-os" / "dochygiene"
assert not legacy_dir.exists(), "Legacy .dochygiene/ must be removed after migration"
assert canonical_dir.is_dir()
# Migrated last_check must be preserved alongside the newly-written last_clean.
assert store.get_last_check() == legacy_ts
assert store.get_last_clean() == datetime(2026, 6, 1, tzinfo=timezone.utc)
# Migrated report is preserved too.
pair = store.read_report()
assert pair is not None
assert json.loads(pair[0])["v"] == "legacy"
def test_migration_does_not_clobber_existing_canonical_files(self, tmp_path):
"""
If the canonical dir already has a file with the same name as a legacy
file, the canonical file wins (never overwritten by the legacy copy).
"""
canonical_dir = tmp_path / ".cc-os" / "dochygiene"
canonical_dir.mkdir(parents=True)
(canonical_dir / "state.json").write_text(
json.dumps({"last_check": "2026-06-01T00:00:00+00:00"})
)
self._write_legacy_state(tmp_path, {"last_check": "2026-01-01T00:00:00+00:00"})
store = StateStore(project_root=tmp_path)
# Any write triggers the migration pass.
store.set_last_reminded(datetime(2026, 7, 1, tzinfo=timezone.utc))
assert not (tmp_path / ".dochygiene").exists()
assert store.get_last_check() == datetime(2026, 6, 1, tzinfo=timezone.utc)
def test_new_project_with_no_legacy_dir_is_unaffected(self, tmp_path):
"""No legacy dir present → normal operation, no migration attempted."""
store = StateStore(project_root=tmp_path)
store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc))
assert not (tmp_path / ".dochygiene").exists()
assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").is_file()

View File

@ -342,7 +342,15 @@ def build_ctx() -> "Ctx":
def run_all_json(ctx: "Ctx") -> list:
"""Run every applicable check and return a list of plain dicts:
{name, status, message, remediation}. project_scoped checks are skipped
when ctx.project_root is None, same rule as the SessionStart runner."""
when ctx.project_root is None, same rule as the SessionStart runner.
Self-healing (issue #23): whenever a check evaluates to ok/note, clear
any stale snooze file for it, same as the SessionStart runner so a
/os-status:fix run cleans up snoozes left behind by checks it just
fixed, not just checks the next SessionStart happens to re-run.
"""
from state import clear_snooze # local import, no cycle
results = []
for check in REGISTRY:
if check.project_scoped and ctx.project_root is None:
@ -353,6 +361,8 @@ def run_all_json(ctx: "Ctx") -> list:
raise TypeError("check returned a non-CheckResult")
except Exception:
result = CheckResult("warn", f"status check '{check.name}' failed to run")
if result.status in ("ok", "note") and ctx.project_root is not None:
clear_snooze(ctx.project_root, check.name)
results.append(
{
"name": check.name,

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:
continue
result = run_isolated(check, ctx)
if result.status in ("ok", "note") and state:
# Self-healing (issue #23): a check that flips to ok/note no
# longer needs a snooze stamp from a prior warn run.
state.clear_snooze(check.name)
if result.status == "note":
notes.append(result.message)
elif result.status == "warn":

View File

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

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
text into the banner.
5. **State only in `.cc-os/`.** The hook writes nothing outside the project's `.cc-os/`
directory, and writes nothing at all outside a git project. `.cc-os/` holds state
(config, `snooze-<check>`, `suppress-<check>`), never code. Add `.cc-os/` to the
project's `.gitignore`.
directory, and writes nothing at all outside a git project. `.cc-os/` holds state,
never code: the shared `config` file at `.cc-os/config` (ADR-026), and os-status's own
`snooze-<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
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

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
command.
**Scope: `fix` never stages or commits.** It edits files and reports what changed —
staging and committing is always the user's call, even for mechanical remediations
like a `.gitignore` addition or a config stamp.
## Flow
1. **Get machine-readable results.** From the project root, run:
@ -23,9 +27,15 @@ command.
outside a git project, same rule the SessionStart hook uses).
2. **All `ok` (and only `note`/`ok`)?** Report "this project is up to date" and go
straight to step 4 (version stamp) — nothing else to do.
straight to steps 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):
a. **`adr-system-present`** → invoke `/os-adr:init` (or `/os-adr:migrate` if the
@ -53,9 +63,17 @@ command.
f. **`config-version-current`** → resolved automatically by step 4 below; no
separate action.
Re-run the JSON check after each remediation that plausibly changed state, so
later steps see fresh results (e.g. don't act on a stale `vault-hub-note-present`
warning after already creating the note).
Re-run the JSON check after each remediation that plausibly changed check-registry
state (creating a hub note, running `/os-adr:init`, setting a config key), so later
steps see fresh results (e.g. don't act on a stale `vault-hub-note-present` warning
after already creating the note). Remediations that don't affect check-registry
state — step 3a's gitignore edit is the example — don't require a re-run.
Snoozed warns self-clear: whenever a check now evaluates to `ok`/`note` (whether
because this skill just fixed it or it was already fine), the next SessionStart
or `fix` run clears its stale `snooze-<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
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,
vault_hub_note_present,
)
from state import StateDir, find_project_root, read_config, write_config_value # noqa: E402
from state import ( # noqa: E402
StateDir,
clear_snooze,
find_project_root,
read_config,
write_config_value,
)
TODAY = date(2026, 7, 6)
YESTERDAY = date(2026, 7, 5)
@ -369,6 +375,21 @@ class JsonRunnerTest(unittest.TestCase):
self.assertNotIn("adr-system-present", names)
self.assertNotIn("tracker-configured", names)
def test_run_all_json_self_clears_stale_snooze_on_ok(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / ".cc-os" / "status").mkdir(parents=True)
(root / ".cc-os" / "status" / "snooze-adr-system-present").write_text(
date.today().isoformat()
)
(root / "docs" / "adr").mkdir(parents=True)
(root / "docs" / "adr" / "README.md").write_text("index")
ctx = make_ctx(project_root=root)
run_all_json(ctx)
self.assertFalse(
(root / ".cc-os" / "status" / "snooze-adr-system-present").exists()
)
def test_subprocess_json_flag_prints_valid_json_array(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
@ -413,6 +434,35 @@ class WriteConfigValueTest(unittest.TestCase):
self.assertEqual("1", config["version"])
self.assertEqual("my-hub", config["hub"])
def test_new_key_written_without_spaces_around_equals(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
write_config_value(root, "version", "1")
raw = (root / ".cc-os" / "config").read_text()
self.assertEqual("version=1\n", raw)
def test_normalizes_existing_spaced_lines_on_rewrite(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("version = 0\nhub = my-hub\n")
write_config_value(root, "version", "1")
raw = (root / ".cc-os" / "config").read_text()
for line in raw.splitlines():
self.assertNotIn(" = ", line)
self.assertNotIn(" =", line)
self.assertNotIn("= ", line)
self.assertEqual({"version": "1", "hub": "my-hub"}, read_config(root))
def test_comments_and_blank_lines_untouched(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("# a comment\n\nhub = my-hub\n")
write_config_value(root, "version", "1")
raw = (root / ".cc-os" / "config").read_text()
self.assertIn("# a comment", raw)
class BannerWordingTest(unittest.TestCase):
def test_banner_header_names_fix_skill(self):
@ -483,7 +533,7 @@ class RunnerTest(unittest.TestCase):
ctx = make_ctx(project_root=root)
first = self.run_with(registry, ctx, state)
self.assertIn("boom", first["systemMessage"])
self.assertTrue((root / ".cc-os" / "snooze-a").exists())
self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists())
self.assertEqual({}, self.run_with(registry, ctx, state))
def test_next_day_warns_again(self):
@ -529,6 +579,32 @@ class RunnerTest(unittest.TestCase):
self.assertNotIn("old news", banner)
self.assertIn("new problem", banner)
def test_snooze_self_clears_when_check_flips_to_ok(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
state = StateDir(root)
warn_registry = [self.check("a", CheckResult("warn", "boom"))]
ctx = make_ctx(project_root=root)
self.run_with(warn_registry, ctx, state)
self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists())
ok_registry = [self.check("a", CheckResult("ok"))]
self.run_with(ok_registry, ctx, state)
self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists())
def test_snooze_self_clears_when_check_flips_to_note(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
state = StateDir(root)
warn_registry = [self.check("a", CheckResult("warn", "boom"))]
ctx = make_ctx(project_root=root)
self.run_with(warn_registry, ctx, state)
self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists())
note_registry = [self.check("a", CheckResult("note", "fine now"))]
self.run_with(note_registry, ctx, state)
self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists())
class StateTest(unittest.TestCase):
def test_read_config_parses_key_value_and_skips_comments(self):
@ -565,6 +641,93 @@ class StateTest(unittest.TestCase):
with TemporaryDirectory() as tmp:
self.assertIsNone(find_project_root(Path(tmp)))
def test_stamp_writes_to_status_subdir(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
StateDir(root).stamp("a", TODAY)
self.assertTrue((root / ".cc-os" / "status" / "snooze-a").exists())
self.assertFalse((root / ".cc-os" / "snooze-a").exists())
def test_snoozed_falls_back_to_legacy_top_level_path(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat())
self.assertTrue(StateDir(root).snoozed("a", TODAY))
def test_suppressed_falls_back_to_legacy_top_level_path(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "suppress-a").write_text("")
self.assertTrue(StateDir(root).suppressed("a"))
def test_current_path_wins_over_legacy_when_both_exist(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os" / "status").mkdir(parents=True)
(root / ".cc-os" / "status" / "snooze-a").write_text(TODAY.isoformat())
(root / ".cc-os" / "snooze-a").write_text(YESTERDAY.isoformat())
self.assertTrue(StateDir(root).snoozed("a", TODAY))
def test_write_migrates_legacy_files_and_removes_them(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "snooze-b").write_text(YESTERDAY.isoformat())
(root / ".cc-os" / "suppress-c").write_text("")
StateDir(root).stamp("a", TODAY)
# Legacy files migrated up and removed from the old location.
self.assertTrue((root / ".cc-os" / "status" / "snooze-b").exists())
self.assertTrue((root / ".cc-os" / "status" / "suppress-c").exists())
self.assertFalse((root / ".cc-os" / "snooze-b").exists())
self.assertFalse((root / ".cc-os" / "suppress-c").exists())
# config, if present, is left alone by migration.
self.assertFalse((root / ".cc-os" / "status" / "config").exists())
def test_migration_does_not_touch_config(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("tracker=repo:x\n")
(root / ".cc-os" / "snooze-a").write_text(YESTERDAY.isoformat())
StateDir(root).stamp("a", TODAY)
self.assertTrue((root / ".cc-os" / "config").exists())
self.assertEqual("tracker=repo:x\n", (root / ".cc-os" / "config").read_text())
class ClearSnoozeTest(unittest.TestCase):
def test_clears_current_path_snooze(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os" / "status").mkdir(parents=True)
(root / ".cc-os" / "status" / "snooze-a").write_text(TODAY.isoformat())
clear_snooze(root, "a")
self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists())
def test_clears_legacy_path_snooze(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat())
clear_snooze(root, "a")
self.assertFalse((root / ".cc-os" / "snooze-a").exists())
def test_missing_snooze_is_a_silent_noop(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
clear_snooze(root, "a") # must not raise
def test_does_not_clear_other_checks_snooze(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os" / "status").mkdir(parents=True)
(root / ".cc-os" / "status" / "snooze-a").write_text(TODAY.isoformat())
(root / ".cc-os" / "status" / "snooze-b").write_text(TODAY.isoformat())
clear_snooze(root, "a")
self.assertFalse((root / ".cc-os" / "status" / "snooze-a").exists())
self.assertTrue((root / ".cc-os" / "status" / "snooze-b").exists())
class WordingInvariantTest(unittest.TestCase):
"""PRESENT_NOTE / ABSENT_NOTE must stay byte-identical to os-adr's