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:
parent
3b9c4fbd88
commit
cf14bb33f9
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ as slash commands (`/os-doc-hygiene:check`, `/os-doc-hygiene:clean`,
|
|||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). |
|
||||
| `check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.cc-os/dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). |
|
||||
| `check/workflows/classify-candidates.md` | Self-contained subagent workflow for the classification step. Defines the slim proposal object (`path`, `category` {class, subtype}, pass-through `signals`, `op`, `op_type`, optional `exact_edit`/`reducible_range`, `gloss`, `confidence`, `escalate`), the closed enums, the situation→kind→tier decision table, and the per-kind required `exact_edit` fields. The subagent reads each file and returns judgment only — it never authors hashes, token counts, safety tiers, or reversibility. |
|
||||
| `clean/SKILL.md` | The `clean` orchestration: **load → validate → filter → gate → preflight → apply-deterministic → apply-generative → commit → stamp**. (D) `StateStore` loads and re-validates the machine report. (logic) Incompatible-pair detection (files with both generative + deterministic ops) and partition by `safety_tier`/`op_type`. (M-GATE) Confirm gate for `confirm`-tier entries; auto-tier entries apply silently. (D) Git preflight: record baseline, detect untracked files (which get skipped). (D) `patch_applier.py` applies deterministic entries mechanically; mtime-guarded, output include `applied`/`skipped`/`failed`. (M) Sonnet subagent distills each generative entry via `workflows/distill.md` (LOOP-GUARD + SUBAGENT AUTHORIZATION — terminal, REPORT-AND-EXIT). (D) `git-context commit-apply` produces exactly one cleanup commit. (D) `StateStore.set_last_clean` stamps the commit instant. Scoped by `--scope` (glob-or-path) and `--category` (class or subtype), both entry-stage filters. On failure (rollback-worthy): all intermediate mutations reverted to baseline. |
|
||||
| `clean/workflows/distill.md` | Self-contained subagent workflow for generative distillation. Receives live file contents (not disk-read), category, op, and signals. Performs `distill` (condense in place, preserve frontmatter and structure, ~40–60% target length), `split` (extract to archive + pointer, archive path under `archive/`), or prose-rewrite ops (`provisional`, `contradicted`). Returns structured output (`DISTILL_RESULT_START`/`END` for single-file; `SPLIT_PRIMARY_START`/`END` + `SPLIT_ARCHIVE_DEST` + `SPLIT_ARCHIVE_START`/`END` for split). Never writes files or calls git; never re-reads disk. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,36 +63,91 @@ def write_config_value(root: Path, key: str, value: str) -> None:
|
|||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#") and "=" in stripped:
|
||||
existing_key = stripped.split("=", 1)[0].strip()
|
||||
existing_key, existing_value = stripped.split("=", 1)
|
||||
existing_key = existing_key.strip()
|
||||
if existing_key == key:
|
||||
out.append(f"{key} = {value}")
|
||||
out.append(f"{key}={value}")
|
||||
found = True
|
||||
else:
|
||||
out.append(f"{existing_key}={existing_value.strip()}")
|
||||
continue
|
||||
out.append(line)
|
||||
if not found:
|
||||
out.append(f"{key} = {value}")
|
||||
out.append(f"{key}={value}")
|
||||
config_path.parent.mkdir(exist_ok=True)
|
||||
config_path.write_text("\n".join(out) + "\n")
|
||||
|
||||
|
||||
LEGACY_STATE_GLOBS = ("snooze-*", "suppress-*", "state.json")
|
||||
|
||||
|
||||
class StateDir:
|
||||
"""The gitignored per-project .cc-os/ state directory."""
|
||||
"""The gitignored per-project .cc-os/status/ state directory (ADR-027).
|
||||
Reads fall back to the pre-ADR-027 .cc-os/ top level; the first write
|
||||
migrates legacy files up and removes them from the old location."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self._dir = root / ".cc-os"
|
||||
self._root = root
|
||||
self._dir = root / ".cc-os" / "status"
|
||||
self._legacy_dir = root / ".cc-os"
|
||||
|
||||
def _read_path(self, name: str) -> Path:
|
||||
"""The current path for `name` if it exists there, else the legacy
|
||||
top-level path (which may or may not exist — callers handle that)."""
|
||||
current = self._dir / name
|
||||
if current.exists():
|
||||
return current
|
||||
return self._legacy_dir / name
|
||||
|
||||
def _migrate(self) -> None:
|
||||
"""Move any legacy snooze-*/suppress-*/state.json files into
|
||||
.cc-os/status/ and remove them from .cc-os/. Idempotent; called
|
||||
before every write so migration happens transparently."""
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
for pattern in LEGACY_STATE_GLOBS:
|
||||
for legacy_file in self._legacy_dir.glob(pattern):
|
||||
if not legacy_file.is_file():
|
||||
continue
|
||||
target = self._dir / legacy_file.name
|
||||
if not target.exists():
|
||||
try:
|
||||
target.write_text(legacy_file.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
legacy_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def suppressed(self, check: str) -> bool:
|
||||
return (self._dir / f"suppress-{check}").exists()
|
||||
return self._read_path(f"suppress-{check}").exists()
|
||||
|
||||
def snoozed(self, check: str, today: date) -> bool:
|
||||
try:
|
||||
stamped = date.fromisoformat(
|
||||
(self._dir / f"snooze-{check}").read_text().strip()
|
||||
self._read_path(f"snooze-{check}").read_text().strip()
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return stamped == today
|
||||
|
||||
def stamp(self, check: str, today: date) -> None:
|
||||
self._dir.mkdir(exist_ok=True)
|
||||
self._migrate()
|
||||
(self._dir / f"snooze-{check}").write_text(today.isoformat())
|
||||
|
||||
def clear_snooze(self, check: str) -> None:
|
||||
self._migrate()
|
||||
clear_snooze(self._root, check)
|
||||
|
||||
|
||||
def clear_snooze(root: Path, check_name: str) -> None:
|
||||
"""Remove any existing snooze stamp for check_name, at both the current
|
||||
(.cc-os/status/) and legacy (.cc-os/) location. Self-healing: called by
|
||||
the SessionStart runner and /os-status:fix's checks.py --json runner
|
||||
whenever a check evaluates to ok/note, so stale snoozes from a prior
|
||||
warn don't outlive the condition they gated."""
|
||||
for base in (root / ".cc-os" / "status", root / ".cc-os"):
|
||||
try:
|
||||
(base / f"snooze-{check_name}").unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ existing per-plugin skills — it never reimplements them. Idempotent by constru
|
|||
re-running `fix` on an already-configured project is the update path, not a separate
|
||||
command.
|
||||
|
||||
**Scope: `fix` never stages or commits.** It edits files and reports what changed —
|
||||
staging and committing is always the user's call, even for mechanical remediations
|
||||
like a `.gitignore` addition or a config stamp.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Get machine-readable results.** From the project root, run:
|
||||
|
|
@ -23,9 +27,15 @@ command.
|
|||
outside a git project, same rule the SessionStart hook uses).
|
||||
|
||||
2. **All `ok` (and only `note`/`ok`)?** Report "this project is up to date" and go
|
||||
straight to step 4 (version stamp) — nothing else to do.
|
||||
straight to steps 3b–4 (gitignore + version stamp) — nothing else to remediate.
|
||||
|
||||
3. **Otherwise, walk the non-`ok` entries in this order** (mechanical → decision-
|
||||
3a. **Ensure `.cc-os/` is gitignored.** Check the project's `.gitignore` for a
|
||||
`.cc-os/` entry; add one if missing. Per ADR-027, this single entry covers every
|
||||
cc-os plugin's per-project state (os-status's `.cc-os/config` + `.cc-os/status/`,
|
||||
os-backlog's tracker key, etc.) — there is no separate `.os-adr/` entry to add
|
||||
anymore. This step runs every `fix` invocation, not just when a check flags it.
|
||||
|
||||
3b. **Otherwise, walk the non-`ok` entries in this order** (mechanical → decision-
|
||||
bearing, so autonomous fixes land before anything needing a human gate):
|
||||
|
||||
a. **`adr-system-present`** → invoke `/os-adr:init` (or `/os-adr:migrate` if the
|
||||
|
|
@ -53,9 +63,17 @@ command.
|
|||
f. **`config-version-current`** → resolved automatically by step 4 below; no
|
||||
separate action.
|
||||
|
||||
Re-run the JSON check after each remediation that plausibly changed state, so
|
||||
later steps see fresh results (e.g. don't act on a stale `vault-hub-note-present`
|
||||
warning after already creating the note).
|
||||
Re-run the JSON check after each remediation that plausibly changed check-registry
|
||||
state (creating a hub note, running `/os-adr:init`, setting a config key), so later
|
||||
steps see fresh results (e.g. don't act on a stale `vault-hub-note-present` warning
|
||||
after already creating the note). Remediations that don't affect check-registry
|
||||
state — step 3a's gitignore edit is the example — don't require a re-run.
|
||||
|
||||
Snoozed warns self-clear: whenever a check now evaluates to `ok`/`note` (whether
|
||||
because this skill just fixed it or it was already fine), the next SessionStart
|
||||
or `fix` run clears its stale `snooze-<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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue