From 5584a4e4979f09b628d6d113a331e31f48cb3ffe Mon Sep 17 00:00:00 2001 From: Collin Cusce Date: Sat, 6 Jun 2026 01:54:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20state-mutation=20surface=20?= =?UTF-8?q?=E2=80=94=20phase=20lifecycle,=20finding=20resolve,=20drift=20h?= =?UTF-8?q?int,=20init=20fixes=20(0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a second dogfooding session's P0-P4: - cz_transition_phase: blessed phase-status write (fixes cz_status freezing at Phase 0) - cz_resolve_finding: update finding status/note without hand-editing HARDENING - status drift hint: flag planned entities while phases are complete - preflight_advisory + init --workflow: clean_tree/tests advisory for docs/audit - init resolves engine cmd from sys.executable bindir; stops clobbering profile.lock - dogfood: marked Clauderizer's own Phase 0 complete via the new tool - +12 tests (84 total) --- CHANGELOG.md | 28 ++++ README.md | 6 +- .../CHAT-HANDOFF-INDEX.md | 2 +- .../PHASE-STATUS.md | 2 +- pyproject.toml | 2 +- src/clauderizer/__init__.py | 2 +- src/clauderizer/cli.py | 3 + src/clauderizer/config.py | 4 + src/clauderizer/mcp_server.py | 26 +++ src/clauderizer/mutations.py | 117 +++++++++++++ src/clauderizer/rituals/preflight.py | 6 + src/clauderizer/rituals/status_bundle.py | 28 ++++ src/clauderizer/scaffold/init.py | 36 +++- src/clauderizer/tools_list.py | 2 + tests/test_engine_hardening.py | 22 ++- tests/test_state_mutations.py | 154 ++++++++++++++++++ 16 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 tests/test_state_mutations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a35e5a9..b7f7bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to Clauderizer are documented here. +## [0.3.0] — 2026-06-05 + +Fixes the **state-mutation surface** — the gaps a second dogfooding session found +where structured state drifted because the blessed write was missing or destructive. + +### Added +- **`cz_transition_phase`** — phases finally get a lifecycle write + (not_started/ready/in_progress/complete/blocked/failed, with aliases + auto-dated + Started/Completed). Without it, `cz_status` froze at "Phase 0" on finished work + because nothing could advance a phase. The single highest-leverage fix. +- **`cz_resolve_finding`** — update a finding's status + dated resolution note in + `HARDENING.md`, satisfying its own "mark resolved, never delete" policy through a + blessed path instead of a forbidden hand-edit. +- **Drift hint** — `cz_status` / the SessionStart digest now flag entities still + `planned` while phases are complete ("⚠ Drift: … cz_transition_status to reconcile"). + Conservative: fires only when there's completed work *and* untouched entities. +- **`init --workflow {code,docs,audit}`** + `preflight_advisory` config — makes + `clean_tree` (and, for audits, `tests`) advisory rather than fatal, so a + deliverable-accumulating workflow stops failing preflight on every resume. + +### Fixed +- `init` resolves the engine command from the **running interpreter's bin dir** + (`sys.executable`) before falling back to PATH/uvx — reliable for venv/WSL even + when the bin dir isn't on PATH. +- `init` **no longer clobbers `profile.lock.toml`** on re-run — per-project command + overrides (read back by `detect.load_for_repo`) are preserved. Delete the lock to + re-derive it. + ## [0.2.1] — 2026-06-05 ### Fixed diff --git a/README.md b/README.md index d350388..0ea59d4 100644 --- a/README.md +++ b/README.md @@ -266,9 +266,9 @@ clauderize mcp # launch the MCP server (stdio) **Read** · `cz_status` · `cz_next_phase_context` · `cz_graph_query` **Rituals** · `cz_preflight` · `cz_cascade` · `cz_write_handoff` -**Mutations** · `cz_create_gameplan` · `cz_add_phase` · `cz_add_amendment` · `cz_add_decision` -· `cz_add_invariant` · `cz_add_finding` · `cz_add_lesson` · `cz_add_correction` -· `cz_upsert_entity` · `cz_transition_status` +**Mutations** · `cz_create_gameplan` · `cz_add_phase` · `cz_transition_phase` · `cz_add_amendment` +· `cz_add_decision` · `cz_add_invariant` · `cz_add_finding` · `cz_resolve_finding` · `cz_add_lesson` +· `cz_add_correction` · `cz_upsert_entity` · `cz_transition_status` **Resources** · `clauderizer://status` · `clauderizer://procedure` · `clauderizer://entity/{id}` The tools are deliberately separate and self-describing rather than one generic `mutate` — that's diff --git a/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/CHAT-HANDOFF-INDEX.md b/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/CHAT-HANDOFF-INDEX.md index 8a06ac2..e6fecdf 100644 --- a/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/CHAT-HANDOFF-INDEX.md +++ b/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/CHAT-HANDOFF-INDEX.md @@ -27,7 +27,7 @@ Run `cz_preflight` before any code. If any enabled check fails: STOP, report. | Phase | Name | Status | Started | Completed | Handoff | |-------|------|--------|---------|-----------|---------| -| 0 | Bootstrap | ⬜ READY | — | — | handoffs/PHASE-0-HANDOFF.md | +| 0 | Bootstrap | ✅ COMPLETE | 2026-06-06 | 2026-06-06 | handoffs/PHASE-0-HANDOFF.md | **Status legend**: ⬜ NOT STARTED · 🟢 READY · 🟡 IN PROGRESS · ✅ COMPLETE · ⚠️ BLOCKED · 🔴 FAILED diff --git a/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/PHASE-STATUS.md b/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/PHASE-STATUS.md index aa187f1..78cb278 100644 --- a/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/PHASE-STATUS.md +++ b/docs/gameplans/2026-05-30-clauderizer-v1-bootstrap/PHASE-STATUS.md @@ -7,7 +7,7 @@ | Phase | Name | Status | Started | Completed | Handoff | |-------|------|--------|---------|-----------|---------| -| 0 | Bootstrap | ⬜ READY | — | — | handoffs/PHASE-0-HANDOFF.md | +| 0 | Bootstrap | ✅ COMPLETE | 2026-06-06 | 2026-06-06 | handoffs/PHASE-0-HANDOFF.md | ## Outputs Registry diff --git a/pyproject.toml b/pyproject.toml index b31e625..d7c764b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "clauderizer" -version = "0.2.1" +version = "0.3.0" description = "Drop-in, MCP-native working memory for AI agents: gameplans, phases, a dependency graph, and post-hoc cascade — over plain markdown." readme = "README.md" requires-python = ">=3.11" diff --git a/src/clauderizer/__init__.py b/src/clauderizer/__init__.py index a99b67d..cfa1754 100644 --- a/src/clauderizer/__init__.py +++ b/src/clauderizer/__init__.py @@ -4,7 +4,7 @@ server, the rituals) is derived from it and can be rebuilt at any time. """ -__version__ = "0.2.1" +__version__ = "0.3.0" # The version of the gameplan procedure this engine was built against. The # engine ships GAMEPLAN-PROCEDURE.md verbatim; `clauderize doctor` warns if a diff --git a/src/clauderizer/cli.py b/src/clauderizer/cli.py index 29a3ea9..f619869 100644 --- a/src/clauderizer/cli.py +++ b/src/clauderizer/cli.py @@ -42,6 +42,7 @@ def cmd_init(args: argparse.Namespace) -> int: profile=args.profile, gameplan=args.gameplan, run_cmd=run_cmd, + workflow=args.workflow, ) print(f"Clauderized {report.repo}") print(f" size={report.size} host profile={report.host_profile}") @@ -228,6 +229,8 @@ def build_parser() -> argparse.ArgumentParser: pi.add_argument("--gameplan", default=None, help="also create a first gameplan with this name") pi.add_argument("--run-cmd", default=None, help="how the repo invokes the engine (default: 'uvx --from clauderizer')") + pi.add_argument("--workflow", choices=["code", "docs", "audit"], default="code", + help="docs/audit make clean_tree (and test) checks advisory, not fatal") pi.add_argument("-v", "--verbose", action="store_true") pi.set_defaults(func=cmd_init) diff --git a/src/clauderizer/config.py b/src/clauderizer/config.py index f693d9a..9aca5d6 100644 --- a/src/clauderizer/config.py +++ b/src/clauderizer/config.py @@ -84,6 +84,7 @@ class Config: modules: list[str] = field(default_factory=list) rituals: dict[str, bool] = field(default_factory=dict) preflight_checks: list[str] = field(default_factory=list) + preflight_advisory: list[str] = field(default_factory=list) active_gameplan: str | None = None @classmethod @@ -119,6 +120,7 @@ def load(cls, path: Path) -> "Config": modules=list(modules.get("enabled", [])), rituals={k: bool(v) for k, v in rituals.items()}, preflight_checks=list(cz.get("preflight_checks", [])), + preflight_advisory=list(cz.get("preflight_advisory", [])), active_gameplan=(active.get("id") or None), ) @@ -128,6 +130,7 @@ def to_toml(self) -> str: f'version = "{self.version}"', f'size = "{self.size}"', _toml_kv("preflight_checks", self.preflight_checks), + _toml_kv("preflight_advisory", self.preflight_advisory), "", "[host]", f'profile = "{self.host_profile}"', @@ -171,5 +174,6 @@ def merge_missing(existing: Config, defaults: Config) -> Config: modules=existing.modules or defaults.modules, rituals=existing.rituals or defaults.rituals, preflight_checks=existing.preflight_checks or defaults.preflight_checks, + preflight_advisory=existing.preflight_advisory or defaults.preflight_advisory, active_gameplan=existing.active_gameplan or defaults.active_gameplan, ) diff --git a/src/clauderizer/mcp_server.py b/src/clauderizer/mcp_server.py index ec4ee26..23a19ad 100644 --- a/src/clauderizer/mcp_server.py +++ b/src/clauderizer/mcp_server.py @@ -164,6 +164,20 @@ def cz_add_phase(name: str, goal: str, depends_on_phases: list[str] | None = Non return mutations.add_phase(paths, gameplan_id=gid, name=name, goal=goal, depends_on_phases=depends_on_phases) + @mcp.tool() + def cz_transition_phase(phase_n: str, to_status: str, gameplan_id: str = "") -> dict: + """Advance a phase's lifecycle status so cz_status reflects reality. + + to_status: not_started | ready | in_progress | complete | blocked | failed + (aliases like start/done/block accepted). Stamps Started/Completed dates. + Use this at phase boundaries — it's the blessed write for phase status, which + otherwise has no tool and freezes cz_status at the first phase. + """ + paths, config = _ctx() + gid = gameplan_id or config.active_gameplan + return mutations.transition_phase(paths, gameplan_id=gid, phase_n=phase_n, + to_status=to_status) + @mcp.tool() def cz_add_amendment(title: str, affected_sections: str, affected_phases: str, triggered_by: str, what: str, why: str, gameplan_id: str = "") -> dict: @@ -221,6 +235,18 @@ def cz_add_finding( status=status, ) + @mcp.tool() + def cz_resolve_finding(finding_id: str, status: str = "resolved", note: str = "") -> dict: + """Update a finding's status + dated resolution note in HARDENING (append-only). + + The tracker's policy is "mark resolved with a date, never delete" — this is the + blessed write for that, instead of a forbidden hand-edit. e.g. + cz_resolve_finding("H-03", "resolved", "owner confirmed 3-of-5 Safe"). + """ + paths, _ = _ctx() + return mutations.resolve_finding(paths, finding_id=finding_id, status=status, + note=note or None) + @mcp.tool() def cz_add_lesson(text: str, category: str = "Process", gameplan_id: str = "") -> dict: """Add an accumulated lesson (rolls into every future handoff).""" diff --git a/src/clauderizer/mutations.py b/src/clauderizer/mutations.py index a530ff6..0957da2 100644 --- a/src/clauderizer/mutations.py +++ b/src/clauderizer/mutations.py @@ -184,6 +184,48 @@ def add_finding( add_risk = add_finding +def resolve_finding(paths: RepoPaths, *, finding_id: str, status: str = "resolved", + note: str | None = None, today: str | None = None) -> dict: + """Update a finding's status (and an optional dated resolution note) in place. + + HARDENING is append-only — findings are "resolved by updating status + a date, + never deleted". Without this, doing so means a forbidden hand-edit of a tracked + log. Updates the ``**Status**`` line of the ``H-NN`` block and upserts a + ``**Resolution**`` line; the entry itself is never removed. + """ + path = paths.doc("HARDENING") + text = writer.full_text(path) + sec = sections.get_section(text, "Risks") + if sec is None or f"### {finding_id} " not in sec: + return {"ok": False, "summary": f"finding {finding_id} not found in HARDENING.md"} + today = _today(today) + lines = sec.splitlines() + start = next(i for i, ln in enumerate(lines) if ln.startswith(f"### {finding_id} ")) + end = next((j for j in range(start + 1, len(lines)) if lines[j].startswith("### ")), + len(lines)) + block = lines[start:end] + new_status = f"- **Status**: {status.strip()} ({today})" + res_line = f"- **Resolution**: {note.strip()}" if note else None + new_block, did_status, did_res = [], False, False + for ln in block: + st = ln.strip() + if st.startswith("- **Status**:"): + new_block.append(new_status); did_status = True + elif st.startswith("- **Resolution**:") and res_line: + new_block.append(res_line); did_res = True + else: + new_block.append(ln) + if not did_status: + new_block.insert(1, new_status) + if res_line and not did_res: + while new_block and not new_block[-1].strip(): + new_block.pop() + new_block.append(res_line) + writer.upsert_section(path, "Risks", "\n".join(lines[:start] + new_block + lines[end:])) + return {"ok": True, "id": finding_id, "path": str(path), "files_changed": [str(path)], + "summary": f"{finding_id} → {status.strip()}"} + + def add_lesson( paths: RepoPaths, *, gameplan_id: str, text: str, category: str = "Process" ) -> dict: @@ -296,6 +338,81 @@ def add_phase( "summary": f"added Phase {n}: {name}"} +# Phase status lives in the markdown phase tables (not the entity graph), so it +# needs its own blessed mutation — without this, advancing a phase means a +# forbidden hand-edit, and cz_status freezes at "Phase 0" on finished work. +_PHASE_DISPLAY = { + "not_started": "⬜ NOT STARTED", + "ready": "🟢 READY", + "in_progress": "🟡 IN PROGRESS", + "complete": "✅ COMPLETE", + "blocked": "⚠️ BLOCKED", + "failed": "🔴 FAILED", +} +_PHASE_ALIASES = { + "start": "in_progress", "started": "in_progress", "active": "in_progress", + "wip": "in_progress", "begin": "in_progress", + "done": "complete", "completed": "complete", "finish": "complete", "finished": "complete", + "todo": "not_started", "pending": "not_started", "block": "blocked", "fail": "failed", +} + + +def _set_phase_row(path, heading: str, phase_n: str, display: str, norm: str, + today: str) -> bool: + """Rewrite the status (and dates) of one phase row in a tracker table.""" + text = writer.full_text(path) + sec = sections.get_section(text, heading) + if sec is None: + return False + out, changed = [], False + for line in sec.splitlines(): + s = line.strip() + if s.startswith("|"): + cells = [c.strip() for c in s.strip("|").split("|")] + if len(cells) >= 6 and cells[0] == phase_n: + cells[2] = display + if norm in ("in_progress", "complete") and cells[3] in ("—", ""): + cells[3] = today + if norm == "complete": + cells[4] = today + rebuilt = "| " + " | ".join(cells) + " |" + if rebuilt != s: + line, changed = rebuilt, True + out.append(line) + if changed: + writer.upsert_section(path, heading, "\n".join(out)) + return changed + + +def transition_phase(paths: RepoPaths, *, gameplan_id: str, phase_n: str, + to_status: str, today: str | None = None) -> dict: + """Move a phase's lifecycle status in the gameplan trackers. + + ``to_status`` accepts the normalized words (not_started, ready, in_progress, + complete, blocked, failed) or friendly aliases (start, done, block, …). + Starting/completing stamps the Started/Completed dates. This is the write + that keeps ``cz_status`` / ``cz_next_phase_context`` honest. + """ + norm = _PHASE_ALIASES.get(to_status.strip().lower(), to_status.strip().lower()) + if norm not in _PHASE_DISPLAY: + return {"ok": False, + "summary": f"unknown phase status {to_status!r}; use one of: " + f"{', '.join(_PHASE_DISPLAY)}"} + today = _today(today) + display = _PHASE_DISPLAY[norm] + files: list[str] = [] + for fname, heading in (("CHAT-HANDOFF-INDEX.md", "Phase Status Table"), + ("PHASE-STATUS.md", "Phase Status")): + path = paths.gameplan_dir(gameplan_id) / fname + if path.exists() and _set_phase_row(path, heading, str(phase_n), display, norm, today): + files.append(str(path)) + if not files: + return {"ok": False, + "summary": f"phase {phase_n} not found (or already {norm}) in trackers"} + return {"ok": True, "phase": str(phase_n), "to_status": norm, + "files_changed": files, "summary": f"Phase {phase_n} → {norm}"} + + def add_amendment( paths: RepoPaths, *, diff --git a/src/clauderizer/rituals/preflight.py b/src/clauderizer/rituals/preflight.py index 38728e0..ffeb76e 100644 --- a/src/clauderizer/rituals/preflight.py +++ b/src/clauderizer/rituals/preflight.py @@ -81,10 +81,16 @@ def run( root = paths.root result = PreflightResult() enabled = config.preflight_checks or ["clean_tree", "tests"] + advisory = set(config.preflight_advisory or []) n = 0 def add(name: str, status: str, detail: str = "") -> None: nonlocal n + # An advisory check is informational: a failure is downgraded to "warn" + # and never fails preflight — so a docs/audit workflow can keep e.g. + # clean_tree visible without crying wolf (a dirty tree is normal there). + if status == "fail" and name in advisory: + status, detail = "warn", f"(advisory) {detail}" n += 1 result.checks.append(Check(n, name, status, detail)) if status == "fail": diff --git a/src/clauderizer/rituals/status_bundle.py b/src/clauderizer/rituals/status_bundle.py index 59b4cb7..1a9255b 100644 --- a/src/clauderizer/rituals/status_bundle.py +++ b/src/clauderizer/rituals/status_bundle.py @@ -35,6 +35,30 @@ def _baseline_tests(index_text: str) -> str | None: return m.group(1) if m else None +def _drift_warnings(paths: RepoPaths, rows: list) -> list[str]: + """Surface the most common silent drift: phases marked complete while graph + entities are still 'planned' (the status-transition step was skipped). + + Conservative on purpose — it only fires when there *is* completed work AND + untouched entities, so it informs without crying wolf. Best-effort; never raises. + """ + completed = [r for r in rows if r.status == "complete"] + if not completed: + return [] + try: + from ..graph import index + planned = [e.id for e in index.build(paths.docs).all() + if getattr(e, "status", None) == "planned"] + except Exception: + return [] + if not planned: + return [] + sample = ", ".join(planned[:3]) + ("…" if len(planned) > 3 else "") + noun = "entity" if len(planned) == 1 else "entities" + return [f"{len(planned)} {noun} still 'planned' while {len(completed)} phase(s) " + f"complete ({sample}) — cz_transition_status to reconcile."] + + def compute(paths: RepoPaths, config: Config) -> dict: gid = config.active_gameplan bundle: dict = { @@ -48,6 +72,7 @@ def compute(paths: RepoPaths, config: Config) -> dict: "baseline_tests": None, "pending_cascades": [], "blockers": [], + "drift": [], } if not gid: bundle["summary"] = "No active gameplan. Use cz_create_gameplan to start one." @@ -73,6 +98,7 @@ def compute(paths: RepoPaths, config: Config) -> dict: if nxt: bundle["next_phase"] = {"number": nxt.number, "name": nxt.name} bundle["blockers"] = [r.name for r in rows if r.status == "blocked"] + bundle["drift"] = _drift_warnings(paths, rows) bundle["pending_cascades"] = _pending_cascades(gdir / "_cascade-reports") @@ -111,6 +137,8 @@ def render_digest(bundle: dict, tools: list[str] | None = None) -> str: lines.append(f"Pending cascades: {len(pc)}." + (f" {', '.join(pc)}" if pc else "")) if bundle.get("blockers"): lines.append("Blocked: " + ", ".join(bundle["blockers"])) + for warn in bundle.get("drift") or []: + lines.append(f"⚠ Drift: {warn}") lines.append(f"Next: {bundle.get('next_action', '')}") if tools: lines.append("Tools: " + ", ".join(tools)) diff --git a/src/clauderizer/scaffold/init.py b/src/clauderizer/scaffold/init.py index 3f81b23..2001c58 100644 --- a/src/clauderizer/scaffold/init.py +++ b/src/clauderizer/scaffold/init.py @@ -9,6 +9,7 @@ import json import shutil +import sys from dataclasses import dataclass, field from pathlib import Path @@ -35,10 +36,19 @@ def _resolve_invocation(run_cmd: list[str] | None) -> tuple[list[str], list[str] """ if run_cmd: return [*run_cmd, "clauderizer-mcp"], [*run_cmd, "clauderizer-hook"] - mcp = shutil.which("clauderizer-mcp") - hook = shutil.which("clauderizer-hook") - if mcp and hook: - return [mcp], [hook] + # Prefer the console scripts that sit next to the *running* interpreter — the + # most reliable hit for a venv/pipx install, even when that bin dir isn't on + # PATH (the Windows→WSL / unactivated-venv case shutil.which misses). Fall back + # to PATH lookup, then to zero-install uvx. + bindir = Path(sys.executable).parent + mcp = bindir / "clauderizer-mcp" + hook = bindir / "clauderizer-hook" + if mcp.exists() and hook.exists(): + return [str(mcp)], [str(hook)] + which_mcp = shutil.which("clauderizer-mcp") + which_hook = shutil.which("clauderizer-hook") + if which_mcp and which_hook: + return [which_mcp], [which_hook] return [*DEFAULT_RUN, "clauderizer-mcp"], [*DEFAULT_RUN, "clauderizer-hook"] @@ -57,6 +67,16 @@ def note(self, action: str, path: Path | str, changed: bool) -> None: self.changed.append(str(path)) +# A "docs"/"audit" workflow accumulates deliverables across phases, so a dirty +# tree and a missing host test runner are normal — these checks become advisory +# (still shown, never fatal) instead of crying wolf on every resume. +WORKFLOW_ADVISORY = { + "code": [], + "docs": ["clean_tree", "branch_base", "branch_creation"], + "audit": ["clean_tree", "branch_base", "branch_creation", "tests"], +} + + def init( root: Path, *, @@ -64,6 +84,7 @@ def init( profile: str = "auto", gameplan: str | None = None, run_cmd: list[str] | None = None, + workflow: str = "code", ) -> InitReport: root = root.resolve() paths = resolve(root) @@ -79,6 +100,7 @@ def init( # 3. size -> default config defaults = Config.for_size(size, host_profile=prof.name) + defaults.preflight_advisory = list(WORKFLOW_ADVISORY.get(workflow, [])) report.size = size # 4. config.toml (merge missing on re-run) @@ -92,8 +114,10 @@ def init( ) report.note("config", paths.config_file, changed) - # 5. profile.lock.toml - changed = _rewrite_if_diff(paths.profile_lock, prof.to_lock_toml()) + # 5. profile.lock.toml — write once, then PRESERVE. It's the project's editable + # per-project command override (read back by detect.load_for_repo); regenerating + # it on every run would clobber those edits. Delete it to re-derive from a profile. + changed = writer.create_if_absent(paths.profile_lock, prof.to_lock_toml()) report.note("profile.lock", paths.profile_lock, changed) # 6. scaffold docs (enabled modules only; never clobber) diff --git a/src/clauderizer/tools_list.py b/src/clauderizer/tools_list.py index 1ea2774..9ba1fde 100644 --- a/src/clauderizer/tools_list.py +++ b/src/clauderizer/tools_list.py @@ -16,9 +16,11 @@ "cz_add_decision", "cz_add_invariant", "cz_add_finding", + "cz_resolve_finding", "cz_add_lesson", "cz_add_correction", "cz_create_gameplan", "cz_add_phase", + "cz_transition_phase", "cz_add_amendment", ] diff --git a/tests/test_engine_hardening.py b/tests/test_engine_hardening.py index 8cdefd8..a88e292 100644 --- a/tests/test_engine_hardening.py +++ b/tests/test_engine_hardening.py @@ -103,17 +103,25 @@ def test_resolve_invocation_explicit_prefix(): assert hook == ["uvx", "--from", "clauderizer", "clauderizer-hook"] -def test_resolve_invocation_prefers_installed_scripts(monkeypatch): - monkeypatch.setattr(scaffold_init.shutil, "which", lambda name: f"/venv/bin/{name}") +def test_resolve_invocation_prefers_scripts_next_to_interpreter(monkeypatch, tmp_path): + # scripts sitting next to the running interpreter win — the reliable venv hit, + # even when shutil.which would miss (bin dir off PATH). + (tmp_path / "clauderizer-mcp").write_text("") + (tmp_path / "clauderizer-hook").write_text("") + monkeypatch.setattr(scaffold_init.sys, "executable", str(tmp_path / "python")) + monkeypatch.setattr(scaffold_init.shutil, "which", lambda name: None) # PATH misses mcp, hook = _resolve_invocation(None) - assert mcp == ["/venv/bin/clauderizer-mcp"] - assert hook == ["/venv/bin/clauderizer-hook"] + assert mcp == [str(tmp_path / "clauderizer-mcp")] + assert hook == [str(tmp_path / "clauderizer-hook")] -def test_resolve_invocation_falls_back_to_uvx(monkeypatch): +def test_resolve_invocation_falls_back_to_path_then_uvx(monkeypatch, tmp_path): + # nothing next to the interpreter -> PATH lookup, then uvx. + monkeypatch.setattr(scaffold_init.sys, "executable", str(tmp_path / "python")) + monkeypatch.setattr(scaffold_init.shutil, "which", lambda name: f"/venv/bin/{name}") + assert _resolve_invocation(None)[0] == ["/venv/bin/clauderizer-mcp"] monkeypatch.setattr(scaffold_init.shutil, "which", lambda name: None) - mcp, _hook = _resolve_invocation(None) - assert mcp == ["uvx", "--from", "clauderizer", "clauderizer-mcp"] + assert _resolve_invocation(None)[0] == ["uvx", "--from", "clauderizer", "clauderizer-mcp"] # --- #1 doctor command executability ----------------------------------------- diff --git a/tests/test_state_mutations.py b/tests/test_state_mutations.py new file mode 100644 index 0000000..9f204c8 --- /dev/null +++ b/tests/test_state_mutations.py @@ -0,0 +1,154 @@ +"""Tests for the 0.3.0 state-mutation surface fixes: + +- cz_transition_phase: phases get a blessed lifecycle write (P0) +- preflight advisory: checks can be informational, not fatal (P2) +- init: profile.lock overrides preserved; --workflow sets advisory (P2/P3) +- cz_resolve_finding: update a finding's status without a hand-edit (P4) +- status drift hint: planned entities + completed phases gets surfaced (P1) +""" +from clauderizer import config as cfg +from clauderizer import mutations as M +from clauderizer import paths as P +from clauderizer.profiles.detect import Profile +from clauderizer.rituals import _tables, preflight, status_bundle +from clauderizer.scaffold.init import init + +GID = "2026-05-01-bootstrap" + + +def _pc(repo): + paths = P.resolve(repo) + return paths, cfg.Config.load(paths.config_file) + + +# --- P0: cz_transition_phase ------------------------------------------------- + +def test_transition_phase_updates_both_trackers_and_dates(temp_repo): + paths, _ = _pc(temp_repo) + r = M.transition_phase(paths, gameplan_id=GID, phase_n="1", to_status="complete", + today="2026-06-05") + assert r["ok"] and r["to_status"] == "complete" + for fname in ("CHAT-HANDOFF-INDEX.md", "PHASE-STATUS.md"): + text = (paths.gameplan_dir(GID) / fname).read_text() + rows = {row.number: row.status for row in _tables.parse_phase_table(text)} + assert rows["1"] == "complete", fname + assert "| 1 | Wire it up | ✅ COMPLETE | 2026-05-01 | 2026-06-05 |" in text + + +def test_transition_phase_alias_and_unknown(temp_repo): + paths, _ = _pc(temp_repo) + assert M.transition_phase(paths, gameplan_id=GID, phase_n="1", + to_status="done")["to_status"] == "complete" + assert M.transition_phase(paths, gameplan_id=GID, phase_n="1", + to_status="bogus")["ok"] is False + + +def test_transition_phase_unknown_phase(temp_repo): + paths, _ = _pc(temp_repo) + assert M.transition_phase(paths, gameplan_id=GID, phase_n="99", + to_status="complete")["ok"] is False + + +def test_cz_status_reflects_phase_transition(temp_repo): + # The headline fix: advancing a phase makes cz_status move off it. + paths, config = _pc(temp_repo) + M.transition_phase(paths, gameplan_id=GID, phase_n="1", to_status="complete", + today="2026-06-05") + bundle = status_bundle.compute(paths, config) + assert bundle["current_phase"] is None # phase 1 no longer in progress + done = [p for p in bundle["phases"] if p["status"] == "complete"] + assert {"0", "1"} <= {p["number"] for p in done} + + +# --- P2: preflight advisory -------------------------------------------------- + +def _runner(responses): + def run(cmd, _cwd): + for key, val in responses.items(): + if key in cmd: + return val + return (0, "") + return run + + +def test_preflight_advisory_downgrades_fail_to_warn(sample_repo): + paths, config = _pc(sample_repo) + config.preflight_checks = ["clean_tree", "tests"] + config.preflight_advisory = ["clean_tree"] + prof = Profile(name="python", commands={"test": "pytest -q"}, + baseline_test_regex=r"(\d+) passed") + runner = _runner({"git status": (0, " M dirty.py"), "pytest": (0, "5 passed")}) + res = preflight.run(paths, config, prof, runner=runner) + by = {c.name: c.status for c in res.checks} + assert by["clean_tree"] == "warn" # would be fail, downgraded + assert res.passed is True # advisory never fails preflight + + +# --- P3: init preserves profile.lock + workflow ------------------------------ + +def test_init_preserves_profile_lock_overrides(empty_python_repo): + init(empty_python_repo, size="standard") + lock = empty_python_repo / ".clauderizer" / "profile.lock.toml" + lock.write_text('profile = "python"\n[commands]\ntest = "MY-PINNED-CMD"\n', encoding="utf-8") + init(empty_python_repo, size="standard") # re-run must not clobber + assert "MY-PINNED-CMD" in lock.read_text() + + +def test_init_workflow_audit_sets_advisory(empty_python_repo): + init(empty_python_repo, size="standard", workflow="audit") + text = (empty_python_repo / ".clauderizer" / "config.toml").read_text() + assert "preflight_advisory" in text + assert "clean_tree" in text.split("preflight_advisory")[1].split("\n")[0] + + +# --- P4: cz_resolve_finding -------------------------------------------------- + +def test_resolve_finding_updates_status_and_keeps_entry(temp_repo): + paths, _ = _pc(temp_repo) + M.add_finding(paths, title="Reentrancy risk", severity="HIGH", impact="x", + today="2026-06-05") + r = M.resolve_finding(paths, finding_id="H-01", status="resolved", + note="owner confirmed 3-of-5 Safe", today="2026-06-06") + assert r["ok"] + text = paths.doc("HARDENING").read_text() + assert "### H-01 — Reentrancy risk" in text # append-only: entry kept + assert "**Status**: resolved (2026-06-06)" in text + assert "**Resolution**: owner confirmed 3-of-5 Safe" in text + + +def test_resolve_finding_resolution_is_idempotent(temp_repo): + paths, _ = _pc(temp_repo) + M.add_finding(paths, title="f", severity="LOW", impact="x", today="2026-06-05") + M.resolve_finding(paths, finding_id="H-01", note="first", today="2026-06-06") + M.resolve_finding(paths, finding_id="H-01", note="second", today="2026-06-07") + text = paths.doc("HARDENING").read_text() + assert text.count("**Resolution**:") == 1 # replaced, not stacked + assert "second" in text and "first" not in text + + +def test_resolve_finding_missing(temp_repo): + paths, _ = _pc(temp_repo) + assert M.resolve_finding(paths, finding_id="H-99")["ok"] is False + + +# --- P1: drift hint ---------------------------------------------------------- + +def test_drift_hint_fires_on_planned_entity_with_completed_phase(temp_repo): + paths, config = _pc(temp_repo) + # fixture already has phase 0 complete; add an untouched 'planned' entity + M.upsert_entity(paths, id="subsys.untouched", type="subsystem", + version="1.0.0", status="planned") + bundle = status_bundle.compute(paths, config) + assert bundle["drift"] + assert any("planned" in w and "cz_transition_status" in w for w in bundle["drift"]) + + +def test_no_drift_hint_without_completed_phases(temp_repo): + # Reset all phases to not-started -> no completed phase -> no drift noise. + paths, config = _pc(temp_repo) + M.transition_phase(paths, gameplan_id=GID, phase_n="0", to_status="not_started") + M.transition_phase(paths, gameplan_id=GID, phase_n="1", to_status="not_started") + M.upsert_entity(paths, id="subsys.untouched", type="subsystem", + version="1.0.0", status="planned") + bundle = status_bundle.compute(paths, config) + assert bundle["drift"] == []