Formalize C0/C1/C2 change classification (#259)

## Summary
- **C0 (Quick Fix):** Now explicitly allows direct-to-main commits with no PR required — for low-risk, fix-forward-safe changes
- **C1 (Human Review):** New docs-first workflow with branch deployment (ArgoCD `--revision`, Ansible from checkout). Includes upgrade criteria for escalation to C2
- **C2 (Mikado Chain):** Introduces the **Mikado Branch Invariant** — strict commit ordering where card-introducing commits come first, followed by code progress, followed by card closures. Branch resets required when new prerequisites are discovered

Updates CLAUDE.md rules (3, 4, 8, 9) to reflect that C0 bypasses branching/PR requirements. Also updates ai-assistance-guide, how-to index, and docs-mikado task description.

## Files changed
- `CLAUDE.md` — rules and classification table
- `docs/how-to/agent-change-process.md` — full process rewrite
- `docs/tutorials/ai-assistance-guide.md` — branching and pitfalls sections
- `docs/how-to/how-to.md` — index description
- `mise-tasks/docs-mikado` — task description
- `docs/changelog.d/formalize-change-classification.doc.md` — changelog fragment

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/259
This commit is contained in:
Erich Blume 2026-02-23 16:19:54 -08:00
commit 66b5b32f1d
8 changed files with 728 additions and 68 deletions

View file

@ -3,10 +3,11 @@
# requires-python = ">=3.12"
# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"]
# ///
#MISE description="View active Mikado dependency chains for C1/C2 changes"
#MISE description="View active Mikado dependency chains for C2 changes"
#USAGE arg "[card]" help="Card stem to show chain for"
#USAGE flag "--all" help="Show all cards in full, including complete ones"
"""View active Mikado dependency chains for C1/C2 changes.
#USAGE flag "--resume" help="Resume a chain: detect branch, show state and next steps"
"""View active Mikado dependency chains for C2 changes.
Scans all markdown files in docs/ for YAML frontmatter with ``status: active``
and ``requires`` fields, then builds and displays the Mikado dependency graph.
@ -15,8 +16,12 @@ Usage:
mise run docs-mikado # list all active chains
mise run docs-mikado deploy-authentik # show chain for a card
mise run docs-mikado deploy-authentik --all # include complete cards in full
mise run docs-mikado --resume # resume: detect branch, show state
mise run docs-mikado --resume deploy-authentik # resume specific chain
"""
import re
import subprocess
import sys
from pathlib import Path
from typing import Annotated
@ -25,9 +30,13 @@ import typer
import yaml
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
REPO_DIR = Path(__file__).parent.parent
C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$")
def extract_frontmatter(file_path: Path) -> dict | None:
@ -64,6 +73,7 @@ def build_graph() -> dict[str, dict]:
"path": md_file,
"title": frontmatter.get("title", stem),
"status": frontmatter.get("status"),
"branch": frontmatter.get("branch"),
"requires": frontmatter.get("requires", []) or [],
"required_by": [],
}
@ -124,6 +134,280 @@ def find_root_goals(cards: dict[str, dict]) -> list[str]:
return sorted(roots)
def find_ready_leaves(cards: dict[str, dict], root: str) -> list[str]:
"""Find active leaf nodes ready for work (no unmet active requires)."""
leaves = []
visited: set[str] = set()
def walk(stem: str) -> None:
if stem in visited or stem not in cards:
return
visited.add(stem)
card = cards[stem]
if not is_active(card):
return
# Check if all requires are met (not active)
unmet = [
r for r in card["requires"] if r in cards and is_active(cards[r])
]
if not unmet:
leaves.append(stem)
for req in card["requires"]:
walk(req)
walk(root)
return sorted(leaves)
def get_current_branch() -> str | None:
"""Get the current git branch name."""
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode == 0:
branch = result.stdout.strip()
return branch if branch != "HEAD" else None
return None
except FileNotFoundError:
return None
def get_branch_commits(branch: str) -> list[dict]:
"""Get commits on a branch since it diverged from main."""
try:
result = subprocess.run(
["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode != 0:
return []
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
sha, _, subject = line.partition(" ")
match = C2_COMMIT_RE.match(subject)
commits.append(
{
"sha": sha,
"subject": subject,
"chain": match.group(1) if match else None,
"verb": match.group(2) if match else None,
"description": match.group(3) if match else None,
"conventional": match is not None,
}
)
# git log returns newest first; reverse for chronological
commits.reverse()
return commits
except FileNotFoundError:
return []
def classify_branch_position(commits: list[dict]) -> str:
"""Determine where in the invariant the branch currently is."""
if not commits:
return "empty"
verbs = [c["verb"] for c in commits if c["conventional"]]
if not verbs:
return "unconventional"
last_verb = verbs[-1]
has_plan = "plan" in verbs
has_impl = "impl" in verbs
has_close = "close" in verbs
if last_verb == "finalize":
return "finalized"
if not has_impl and not has_close:
return "planning"
if last_verb == "close":
return "between-cycles"
if last_verb == "impl":
return "mid-cycle"
if last_verb == "plan" and has_impl:
# plan after impl — invariant violation
return "invariant-violation"
return "unknown"
def show_resume(
cards: dict[str, dict],
console: Console,
chain_name: str | None,
) -> None:
"""Show resume information for continuing C2 work."""
current_branch = get_current_branch()
roots = find_root_goals(cards)
# Find chain-to-branch mapping from goal cards
chain_branches: dict[str, str | None] = {}
for stem in roots:
card = cards[stem]
chain_branches[stem] = card.get("branch")
if chain_name:
# Explicit chain requested — validate
if chain_name not in cards:
console.print(f"[red]Chain not found: {chain_name}[/red]")
raise typer.Exit(code=1)
if not is_active(cards[chain_name]):
console.print(
f"[yellow]{chain_name} is not active (no status: active)[/yellow]"
)
raise typer.Exit(code=1)
expected_branch = cards[chain_name].get("branch")
if expected_branch and current_branch != expected_branch:
console.print(
f"[red]Branch mismatch:[/red] chain {chain_name} expects "
f"branch [bold]{expected_branch}[/bold] but you are on "
f"[bold]{current_branch}[/bold]"
)
console.print(
f"\n[dim]Run: git checkout {expected_branch}[/dim]"
)
raise typer.Exit(code=1)
_show_chain_resume(cards, console, chain_name, current_branch)
return
# No explicit chain — try to detect from branch
if current_branch and current_branch.startswith("mikado/"):
chain_stem = current_branch.removeprefix("mikado/")
# Try to match to a goal card
matched = None
for stem in roots:
if stem == chain_stem:
matched = stem
break
if cards[stem].get("branch") == current_branch:
matched = stem
break
if matched:
_show_chain_resume(cards, console, matched, current_branch)
return
else:
console.print(
f"[yellow]On branch {current_branch} but no matching "
f"active chain found for stem '{chain_stem}'[/yellow]"
)
# On main or unrecognized branch — list options
console.print()
if not roots:
console.print("[dim]No active Mikado chains to resume.[/dim]")
raise typer.Exit()
console.print("[bold]Active Mikado chains:[/bold]")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("Chain")
table.add_column("Title")
table.add_column("Branch")
table.add_column("Status")
table.add_column("Ready Leaves")
for stem in roots:
card = cards[stem]
branch = card.get("branch")
if branch:
branch_display = f"[green]{branch}[/green]"
status = "in progress"
else:
branch_display = "[dim]not started[/dim]"
status = "planned"
leaves = find_ready_leaves(cards, stem)
leaves_display = ", ".join(leaves) if leaves else "[dim]none[/dim]"
table.add_row(stem, card["title"], branch_display, status, leaves_display)
console.print(table)
console.print()
console.print(
"[dim]Run: mise run docs-mikado --resume <chain> to resume a specific chain[/dim]"
)
def _show_chain_resume(
cards: dict[str, dict],
console: Console,
chain: str,
current_branch: str | None,
) -> None:
"""Show detailed resume info for a specific chain."""
card = cards[chain]
branch = card.get("branch") or current_branch
console.print()
console.print(
Panel(
f"[bold]{chain}[/bold] — {card['title']}\n"
f"Branch: [green]{branch or 'not set'}[/green]",
title="Resuming Mikado Chain",
)
)
# Show branch position if we can read commits
if branch and current_branch == branch:
commits = get_branch_commits(branch)
position = classify_branch_position(commits)
position_labels = {
"empty": "[dim]No commits on branch yet[/dim]",
"planning": "[cyan]Planning phase[/cyan] — still adding cards",
"mid-cycle": "[yellow]Mid-cycle[/yellow] — impl in progress, leaf not yet closed",
"between-cycles": "[green]Between cycles[/green] — ready for next leaf",
"finalized": "[green]Finalized[/green] — chain complete, awaiting merge",
"unconventional": "[yellow]Unconventional commits[/yellow] — commits don't follow C2() convention",
"invariant-violation": "[red]Invariant violation[/red] — plan commit found after impl",
"unknown": "[dim]Unknown position[/dim]",
}
console.print(f"\nBranch position: {position_labels.get(position, position)}")
if commits:
console.print("\n[bold]Recent commits:[/bold]")
# Show last 10 commits
for c in commits[-10:]:
if c["conventional"]:
verb_colors = {
"plan": "cyan",
"impl": "yellow",
"close": "green",
"finalize": "magenta",
}
color = verb_colors.get(c["verb"], "white")
console.print(
f" {c['sha'][:8]} [{color}]{c['verb']}[/{color}] {c['description']}"
)
else:
console.print(
f" {c['sha'][:8]} [dim]{c['subject']}[/dim]"
)
# Show ready leaves
leaves = find_ready_leaves(cards, chain)
if leaves:
console.print("\n[bold]Ready leaf nodes (no unmet dependencies):[/bold]")
for leaf in leaves:
leaf_card = cards[leaf]
console.print(f" [green]→[/green] {leaf} — {leaf_card['title']}")
else:
console.print("\n[dim]No ready leaf nodes — all leaves have unmet dependencies[/dim]")
console.print()
def walk_chain(
cards: dict[str, dict],
stem: str,
@ -158,6 +442,8 @@ def walk_chain(
f"[bold]{status_tag} {stem}[/bold] — {card['title']}"
)
console.print(f"[dim]{card['path'].relative_to(DOCS_DIR)}[/dim]")
if card.get("branch"):
console.print(f"[dim]branch: {card['branch']}[/dim]")
if card["requires"]:
console.print(f"[dim]requires: {', '.join(card['requires'])}[/dim]")
if card["required_by"]:
@ -187,6 +473,10 @@ def main(
bool,
typer.Option("--all", help="Show all cards in full, including complete ones"),
] = False,
resume: Annotated[
bool,
typer.Option("--resume", help="Resume a chain: detect branch, show state"),
] = False,
) -> None:
console = Console()
cards = build_graph()
@ -199,6 +489,10 @@ def main(
console.print(f" [red]{' → '.join(cycle)}[/red]")
console.print()
if resume:
show_resume(cards, console, chain_name=card)
return
if card is None:
# List all active Mikado chains
roots = find_root_goals(cards)
@ -215,6 +509,7 @@ def main(
)
table.add_column("Goal Card")
table.add_column("Title")
table.add_column("Branch")
table.add_column("Active Deps", justify="right")
table.add_column("Total Deps", justify="right")
@ -244,9 +539,16 @@ def main(
return active_count, total_count
active_deps, total_deps = count_deps(stem)
branch = card_data.get("branch")
if branch:
branch_display = branch
else:
branch_display = "[dim]not started[/dim]"
table.add_row(
f"[bold]{stem}[/bold]",
card_data["title"],
branch_display,
str(active_deps),
str(total_deps),
)
@ -257,6 +559,9 @@ def main(
console.print(
"[dim]Run: mise run docs-mikado <card> to see full chain[/dim]"
)
console.print(
"[dim]Run: mise run docs-mikado --resume to resume work on a chain[/dim]"
)
else:
if card not in cards:
console.print(f"[red]Card not found: {card}[/red]")

View file

@ -0,0 +1,197 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Validate Mikado Branch Invariant on mikado/* branches"
"""Validate the Mikado Branch Invariant for C2 change branches.
Runs as a commit-msg hook on mikado/* branches. Receives the commit message
file as its first argument, classifies the incoming commit, appends it to the
existing branch history, and validates the full sequence.
Checks:
1. All commits follow the C2(<chain>): <verb> <description> convention
2. The invariant ordering is maintained: plan commits come before impl/close
3. No plan commits appear after any impl or close commits
4. Close commits don't appear before impl commits in the same cycle
5. The chain stem in commit messages matches the branch name
Can also be run standalone (no arguments) to validate existing branch history.
Exit code 0 if valid (or not on a mikado/* branch), 1 if violations found.
"""
import re
import subprocess
import sys
from pathlib import Path
from rich.console import Console
REPO_DIR = Path(__file__).parent.parent
C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$")
def get_current_branch() -> str | None:
"""Get the current git branch name."""
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode == 0:
branch = result.stdout.strip()
return branch if branch != "HEAD" else None
return None
def get_branch_commits(branch: str) -> list[dict]:
"""Get commits on a branch since it diverged from main."""
result = subprocess.run(
["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"],
capture_output=True,
text=True,
cwd=REPO_DIR,
)
if result.returncode != 0:
return []
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
sha, _, subject = line.partition(" ")
match = C2_COMMIT_RE.match(subject)
commits.append(
{
"sha": sha,
"subject": subject,
"chain": match.group(1) if match else None,
"verb": match.group(2) if match else None,
"description": match.group(3) if match else None,
"conventional": match is not None,
}
)
# git log returns newest first; reverse for chronological
commits.reverse()
return commits
def parse_commit_message(msg_path: str) -> str:
"""Read and return the first line of a commit message file."""
with open(msg_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
return line
return ""
def make_pending_commit(subject: str) -> dict:
"""Create a pseudo-commit dict for the incoming (not yet created) commit."""
match = C2_COMMIT_RE.match(subject)
return {
"sha": "(pending)",
"subject": subject,
"chain": match.group(1) if match else None,
"verb": match.group(2) if match else None,
"description": match.group(3) if match else None,
"conventional": match is not None,
}
def check_invariant(commits: list[dict], chain_stem: str) -> list[str]:
"""Check the Mikado Branch Invariant. Returns list of violation messages."""
errors: list[str] = []
if not commits:
return errors
seen_impl = False
seen_close = False
for i, commit in enumerate(commits):
sha_short = commit["sha"][:8]
if not commit["conventional"]:
errors.append(
f"{sha_short}: commit doesn't follow C2() convention: "
f"{commit['subject']}"
)
continue
if commit["chain"] != chain_stem:
errors.append(
f"{sha_short}: chain mismatch — expected C2({chain_stem}) "
f"but found C2({commit['chain']})"
)
verb = commit["verb"]
if verb == "plan":
if seen_impl or seen_close:
errors.append(
f"{sha_short}: plan commit after impl/close — "
f"violates the Mikado Branch Invariant. "
f"New prerequisites require a branch reset."
)
elif verb == "impl":
seen_impl = True
elif verb == "close":
seen_close = True
if not seen_impl:
errors.append(
f"{sha_short}: close commit without preceding impl — "
f"leaf nodes should be closed after implementation work"
)
elif verb == "finalize":
# finalize is the permitted exception — must be last
if i != len(commits) - 1:
errors.append(
f"{sha_short}: finalize must be the last commit on the branch"
)
return errors
def main() -> None:
console = Console(stderr=True)
branch = get_current_branch()
if not branch or not branch.startswith("mikado/"):
# Not on a mikado branch — nothing to check
sys.exit(0)
chain_stem = branch.removeprefix("mikado/")
commits = get_branch_commits(branch)
# If called with a commit message file (commit-msg hook), include the
# pending commit in the validation
if len(sys.argv) > 1:
subject = parse_commit_message(sys.argv[1])
if subject:
commits.append(make_pending_commit(subject))
if not commits:
# No commits on branch yet — valid (length-zero case)
sys.exit(0)
errors = check_invariant(commits, chain_stem)
if errors:
console.print("[bold red]Mikado Branch Invariant violations:[/bold red]")
for error in errors:
console.print(f" [red]✗[/red] {error}")
console.print()
console.print(
"[dim]See: docs/how-to/agent-change-process.md "
"§ The Mikado Branch Invariant[/dim]"
)
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()