Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/clauderizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/clauderizer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions src/clauderizer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)

Expand All @@ -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}"',
Expand Down Expand Up @@ -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,
)
26 changes: 26 additions & 0 deletions src/clauderizer/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)."""
Expand Down
117 changes: 117 additions & 0 deletions src/clauderizer/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
*,
Expand Down
6 changes: 6 additions & 0 deletions src/clauderizer/rituals/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
28 changes: 28 additions & 0 deletions src/clauderizer/rituals/status_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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."
Expand All @@ -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")

Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading