Formalize C0/C1/C2 change classification #259

Merged
eblume merged 8 commits from formalize-change-classification into main 2026-02-23 16:19:54 -08:00
8 changed files with 728 additions and 68 deletions

View file

@ -1,7 +1,7 @@
---
# See https://pre-commit.com for more information
# Run: uvx pre-commit run --all-files
# Install: uvx pre-commit install
# Install: uvx pre-commit install && uvx pre-commit install --hook-type commit-msg
repos:
# General file hygiene
@ -109,6 +109,17 @@ repos:
files: ^(containers/|service-versions\.yaml)
pass_filenames: false
# Mikado Branch Invariant (C2 changes)
- repo: local
hooks:
- id: mikado-branch-invariant-check
name: mikado-branch-invariant-check
entry: mise run mikado-branch-invariant-check
language: system
always_run: true
pass_filenames: false
stages: [commit-msg]
# Documentation validation
- repo: local
hooks:

View file

@ -15,25 +15,31 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest
1. **Always run `mise run ai-docs -- --style=header --color=never --decorations=always` at session start**
This will refresh your context with important information you will be assumed to know and follow.
2. **Always use `--context=minikube-indri` with kubectl** (or `--context=k3s-ringtail` for ringtail services) - work contexts must never be touched
3. **Feature branches only** - checkout main, pull, create branch, commit often
4. **Create PRs via `tea pr create`** - user reviews before deploy, merges after
3. **Classify the change as C0/C1/C2 before starting** (see below) — this determines branching and PR requirements
4. **Feature branches + PRs for C1/C2** - checkout main, pull, create branch, open PR via `tea pr create`. C0 goes direct to main.
5. **Check PR comments with `mise run pr-comments <pr_number>`** before proceeding
6. **Add changelog fragments** - `docs/changelog.d/<branch>.<type>.md`
Types: `feature`, `bugfix`, `infra`, `doc`, `ai`, `misc`
7. **Test before applying** - dry runs (`--check --diff`), syntax checks, `ssh indri '...'`
8. **Wait for user review before deploying**
9. **Never merge PRs or push to main without explicit request**
8. **Wait for user review before deploying** (C1/C2)
9. **Never merge PRs or push to main without explicit request** (C0 commits to main are fine)
10. **Verify deployments** - `mise run services-check`
## Change Classification
Before starting work, classify the change:
| Class | Scope | Process |
|-------|-------|---------|
| **C0** | Quick fix, single-file, obvious | Read `ai-docs`, implement directly |
| **C1** | Moderate, potential hidden complexity | Mikado method, single session, single PR |
| **C2** | Complex, multi-session | Mikado method, documentation-driven, single PR |
| Class | Name | When to use | Key trait |
|-------|------|-------------|-----------|
| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR |
| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first |
| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant |
**C0** — commit directly to main. No branch or PR needed. Fix forward if problems arise.
**C1** — feature branch with early PR. Search related docs first, write documentation changes before code, deploy from the unmerged branch (ArgoCD `--revision`, Ansible from checkout). Upgrade to C2 if complexity spirals.
**C2** — branch `mikado/<chain-stem>` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(<chain>): plan/impl/close/finalize` convention. Reset the branch when new prerequisites are discovered. Resume with `mise run docs-mikado --resume`.
See [[agent-change-process]] for the full methodology.

View file

@ -0,0 +1 @@
Formalize C0/C1/C2 change classification: C0 allows direct-to-main commits, C1 adds docs-first workflow with branch deployment, C2 introduces the Mikado Branch Invariant for strict commit ordering on multi-phase changes. Add C2 conventions: `C2(<chain>): plan/impl/close/finalize` commit messages, `mikado/<chain-stem>` branch naming, and `branch:` frontmatter on goal cards. New tooling: `docs-mikado --resume` for cold-start session pickup and `mikado-branch-invariant-check` pre-commit hook.

View file

@ -1,7 +1,7 @@
---
title: Agent Change Process
modified: 2026-02-20
last-reviewed: 2026-02-22
modified: 2026-02-23
last-reviewed: 2026-02-23
tags:
- how-to
- ai
@ -15,71 +15,202 @@ How to classify and execute infrastructure changes, especially when working with
Before starting work, classify the change:
| Class | Scope | Process |
|-------|-------|---------|
| **C0** | Quick fix, single-file, obvious | Read `ai-docs`, implement directly |
| **C1** | Moderate, potential hidden complexity | Mikado method, single session, single PR |
| **C2** | Complex, multi-session | Mikado method, documentation-driven, single PR |
| Class | Name | When to use | Key trait |
|-------|------|-------------|-----------|
| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR |
| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first |
| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant |
When in doubt, start at C1. Upgrade to C2 if complexity spirals or the user requests it.
## C0 — Quick Fix
1. Run `mise run ai-docs` to load context
2. Implement the change directly
3. Commit, push, create PR
Examples: fix a typo, bump a version, add a simple config value.
## C1 — Guided Change (Single Session)
Use the [Mikado method](https://mikadomethod.info/) within a single session:
A change where the risk is low enough that problems can be quickly fixed forward.
1. Run `mise run ai-docs` to load context
2. Attempt the change on a feature branch, amending a single commit as you iterate
3. **If it works:** push and create PR
4. **If it fails:** revert the broken change (`git revert`), then:
- Amend or add a commit with documentation updates noting what prerequisite was discovered
- Update frontmatter: add `requires: [prerequisite-card]` to the goal card
- Work the leaf nodes (prerequisites with no further dependencies) first
- Repeat until the goal succeeds
2. Implement the change directly on main
3. Commit and push
Single feature branch, squash-merge when complete. GitOps may require pushing to test — if a pushed commit breaks, revert it promptly.
No feature branch or PR required. If something goes wrong, fix forward with another commit.
## C2 — Documented Change (Multi-Session)
Examples: fix a typo, bump a version, add a simple config value, update a doc.
Like C1 but designed to survive agent context loss across sessions:
## C1 — Human Review
A change with enough complexity or risk that a human should review it, but not so much that a formal multi-phase approach is needed.
### Process
1. Run `mise run ai-docs` to load context
2. **Search related docs** — read existing documentation and reference cards related to the change area
3. **Create a feature branch** and open a PR early (draft is fine)
4. **Documentation first** — commit doc changes reflecting the desired end state before writing code. This helps the reviewer understand intent and catches design issues early
5. **Implement** — commit code changes, pushing as you go. The PR gets updated along the way and the user can review and comment at any point
6. **Deploy from the branch** — do not wait for merge:
- **ArgoCD:** `argocd app set <service> --revision <branch> && argocd app sync <service>`
- **Ansible:** run playbooks directly from the branch checkout
- **Workflows:** point workflow triggers at the branch if needed
7. After user review and successful deployment, the user merges the PR
8. **After merge:** reset ArgoCD revisions back to main, re-sync
### Upgrading to C2
Upgrade to C2 if any of these happen during a C1 change:
- You discover the change requires multiple prerequisite changes that must be sequenced
- The change is spiraling in complexity beyond a single session
- The user requests it
- During planning you realize this is a multi-phase project
## C2 — Mikado Chain
A complex, multi-session change managed through the [Mikado method](https://mikadomethod.info/) with a strict branch discipline called the **Mikado Branch Invariant**.
### Planning and research
Before writing any code, invest in understanding the problem:
1. Run `mise run ai-docs` to load context
2. Search related docs, reference cards, and existing how-to guides for the change area
3. Think through the dependency graph — what prerequisites exist? What could go wrong?
4. Create Mikado cards for everything you can anticipate (you'll discover more later — that's the point of the method)
This planning phase can span multiple sessions. Cards introduced during planning are merged to main and become the foundation for work cycles later.
### The Mikado Branch Invariant
The invariant governs how commits are ordered on a C2 feature branch. The branch must always have this structure:
```
main ← [plan commits] ← [impl, close] ← [impl, close] ← ... ← [finalize]
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Planning layer Repeating work cycles
(cards only) (impl then close, one leaf at a time)
```
eblume marked this conversation as resolved

honestly this feels like a weaker version of the previous rule, no? Let's think about how we can express this invariant as succinctly as possible. It occurs to me that there's another possibility we haven't precluded, which is a commit that closes a mikado leaf node PRIOR to the code which resolves that leaf node. This would imply that we closed a leaf node that wasn't actually done. Not sure it needs to be called out... the rule is just "don't close mikado leaf nodes that aren't done" I guess? Probably not worth calling out.

honestly this feels like a weaker version of the previous rule, no? Let's think about how we can express this invariant as succinctly as possible. It occurs to me that there's another possibility we haven't precluded, which is a commit that closes a mikado leaf node PRIOR to the code which resolves that leaf node. This would imply that we closed a leaf node that wasn't actually done. Not sure it needs to be called out... the rule is just "don't close mikado leaf nodes that aren't done" I guess? Probably not worth calling out.
**Rules:**
1. The first N commits on the branch (after diverging from main) must ALL be commits that **only introduce or modify Mikado cards** — no code changes
2. After the card layer, work proceeds in **cycles**: each cycle is one or more code commits followed by one or more commits closing leaf nodes
3. A cycle should target a single leaf node (though closing multiple in one cycle is acceptable if the code supports it)
4. Cycles repeat until the chain is complete
**The one rule:** No Mikado card may be introduced after any code or card-closing commit. New cards require a branch reset (see below).
**The length-zero case:** It is valid for the "planning layer" to have zero commits on the branch — this happens when all Mikado cards were introduced in earlier sessions and are already in main's history. The invariant is satisfied.
**Exception — finalize:** The terminal commit of a completed chain rewrites Mikado cards to historical documentation. This is a card modification after code commits, and is the only permitted violation of the one rule (see "Completing a chain" below).
### Conventions
#### Branch naming
C2 branches must be named `mikado/<chain-stem>`, where `<chain-stem>` is the filename stem of the goal card. Example: goal card `deploy-authentik.md` → branch `mikado/deploy-authentik`.
#### Goal card `branch:` frontmatter
The goal card of a C2 chain must include a `branch:` field once work begins:
```yaml
---
title: Deploy Authentik
status: active
branch: mikado/deploy-authentik
requires:
- configure-postgres
- setup-redis
tags:
- how-to
---
```
A goal card with `status: active` but no `branch:` field indicates a chain that has been planned but not yet started — the planning-phase cards exist but no implementation branch has been created.
#### Commit message convention
All commits on a `mikado/*` branch must use this format:
```
C2(<chain-stem>): <verb> <short description>
```
Verbs and their meanings:
| Verb | Phase | What it means |
|------|-------|---------------|
| `plan` | Planning layer | Introduces or modifies a Mikado card (no code changes) |
| `impl` | Work cycle | Code progress toward closing a leaf node (no card changes) |
| `close` | Work cycle | Closes a leaf node by removing `status: active` |
| `finalize` | Terminal | Rewrites cards to historical docs, adds changelog |
Examples:
```
C2(deploy-authentik): plan add postgres and redis prerequisite cards
C2(deploy-authentik): impl configure external-secrets for authentik
C2(deploy-authentik): close configure-postgres
C2(deploy-authentik): finalize rewrite cards as historical documentation
```
The `mikado-branch-invariant-check` commit-msg hook validates this convention and the invariant ordering.
### Process
1. **Goal card:** Create a how-to doc in `docs/how-to/` describing the desired end state
- Add `status: active` to frontmatter
2. **Attempt the change** — GitOps may require pushing code to test (e.g., ArgoCD sync). When the attempt fails:
- **First**, reset the failed code changes (the branch should not carry broken code forward)
- **Then**, create/update prerequisite cards as how-to docs with `status: active`
- Add `requires: [prerequisite-stem, ...]` to the goal card's frontmatter
- Commit only the doc updates (the documentation IS the Mikado graph)
3. **Work leaf nodes first** — cards with `status: active` and no unmet `requires`
4. **Re-attempt the goal** after leaf nodes are resolved — code from the attempt comes back here
5. **New agent sessions** pick up state by running `mise run docs-mikado`
6. When a card's change succeeds, remove `status: active` (or the entire field) from its frontmatter
- Add `status: active` and `branch: mikado/<chain-stem>` to frontmatter
- Create prerequisite cards discovered during planning, each with `status: active`
- Commit all cards together (or in a sequence of card-only commits) using `C2(<chain>): plan ...` messages
2. **Open a PR** after the first card commits so the user can review the Mikado graph
3. **Work leaf nodes** — pick a leaf (a card with `status: active` and no unmet `requires`):
- Commit code changes (`C2(<chain>): impl ...`) that progress toward closing it
- **Verify the change works** (deploy from branch, run tests, etc.) before closing
- Commit the card closure (`C2(<chain>): close ...`) — remove `status: active`
- Push to origin — this is the save point
4. **Repeat** until the chain is complete
5. **New agent sessions** pick up state by running `mise run docs-mikado --resume`
Documentation IS the Mikado graph. Each card captures what was learned from failed attempts, so the next agent session doesn't repeat mistakes.
### Discovering new prerequisites
### Handling failed attempts
When you discover a new prerequisite during code work, you must restore the Mikado Branch Invariant:
When an attempt fails and you discover prerequisites, the branch must be cleaned up before documenting what you learned:
1. **Reset the branch** back to the top of the Mikado commit stack — the last `C2(<chain>): plan` or `C2(<chain>): close` commit before your current `impl` commits
2. **Add a new commit** (`C2(<chain>): plan ...`) introducing the new prerequisite card (and updating `requires` on existing cards if needed)
3. **Replay the Mikado process** from the new state of the card stack
1. Reset to before the code attempt (`git reset --hard`)
2. Commit the new prerequisite cards and frontmatter updates
3. If you already committed docs mixed with code, cherry-pick the doc commits onto a clean base rather than reverting (avoids noisy add/revert history)
**Saving work across resets:** It is acceptable to cherry-pick or rebase code commits from before the reset back onto the branch after adding the new card. This is a pragmatic exception — use it only when you are confident the saved work is still valid given the new prerequisite. When in doubt, redo the work from scratch.
The branch between attempts should contain only documentation. Code returns when prerequisites are satisfied and the next attempt succeeds.
### Completing a chain
When the final leaf node is closed and no `status: active` cards remain:
1. **Rewrite all Mikado cards** to reflect their nature as historical documentation:
- Remove transient technical details (specific version numbers, temporary workarounds) that won't matter in the future
- Frame the content as "what to do if someone wanted to repeat this process"
- Add appropriate context about what was learned
- Remove `branch:` from the goal card frontmatter
2. **Add changelog information** in `docs/changelog.d/`
3. Commit as `C2(<chain>): finalize ...` — this is the one permitted exception to the invariant's "no card changes after code" rule
4. The user reviews and merges the PR
### Cold-start: resuming a chain in a new session
When starting a new session to continue C2 work:
1. Run `mise run ai-docs` to load context
2. Run `mise run docs-mikado --resume` — this will:
- Detect the current branch and match it to an active chain
- Show the chain state, ready leaf nodes, and current position in the invariant
- If on main, list active chains and suggest which to resume
3. Check PR comments with `mise run pr-comments <pr_number>`
4. Pick the next ready leaf node and continue with a work cycle
### Build artifacts
Mikado resets apply to branch code, not build artifacts. Container images in the registry are independent of branch lifecycle:
- **Registry images** are build outputs cached in zot — tagged with commit SHAs, so each build is unique and traceable.
- **Automatic builds** trigger when container changes merge to main. Use `mise run container-build-and-release` for manual dispatch.
- **If a build succeeds but deployment fails**, the image is fine; the problem is elsewhere. Document what you learned and try again.
- **If a build fails in CI**, no image is pushed. Fix the nix/dockerfile and re-merge or re-dispatch.
- **Registry images** are build outputs cached in zot — tagged with commit SHAs, so each build is unique and traceable
- **Automatic builds** trigger when container changes merge to main. Use `mise run container-build-and-release` for manual dispatch
- **If a build succeeds but deployment fails**, the image is fine; the problem is elsewhere. Document what you learned and try again
- **If a build fails in CI**, no image is pushed. Fix the nix/dockerfile and re-merge or re-dispatch
## Card Conventions
@ -89,6 +220,7 @@ Mikado resets apply to branch code, not build artifacts. Container images in the
---
title: Deploy Authentik
status: active # omit when complete
branch: mikado/deploy-authentik # goal cards only; omit when complete
requires: # explicit dependencies
- configure-postgres
- setup-redis
@ -98,6 +230,7 @@ tags:
```
- `status: active` marks in-progress work; remove when done (this is the ONLY way a card is marked complete)
- `branch` is set on goal cards only, linking the card to its `mikado/<chain-stem>` branch. A goal card with `status: active` but no `branch` indicates a chain that is planned but not yet started. Remove `branch` when the chain is finalized.
- `requires` lists card stems (filenames without `.md`) that must be completed first. **Keep `requires` permanently** even after prerequisites are done — it documents the dependency graph history
- `required-by` is NOT stored — it's computed by `docs-mikado`
@ -111,20 +244,23 @@ tags:
### Git Discipline
- Single feature branch per C1/C2 change
- **Create a PR early** — open a draft PR after the first doc commit so the user can review the Mikado graph as it evolves between iterations.
- **Push after every iteration** — after completing a leaf node or documenting a failed attempt, push to origin. This is the save point for multi-session work.
- Amend a single working commit as you iterate; keep the branch history clean
- **C0:** Commit directly to main
- **C1:** Single feature branch, PR early, push often
- **C2:** Branch named `mikado/<chain-stem>`, Mikado Branch Invariant enforced, `C2()` commit convention, PR early, push after every leaf-node closure
- **Deploy from branches** — C1 and C2 changes deploy from the unmerged branch (ArgoCD `--revision`, Ansible from checkout, etc.). Reset to main after merge.
- GitOps requires pushing to test — if a pushed commit breaks, revert it promptly
- Commit doc updates noting what was learned from failures
## Tools
| Command | Purpose |
|---------|---------|
| `mise run docs-mikado` | List all active Mikado chains |
| `mise run docs-mikado` | List all active Mikado chains with branch status |
| `mise run docs-mikado <card>` | Show dependency chain for a goal card |
| `mise run docs-mikado <card> --all` | Include completed cards in full |
| `mise run docs-mikado --resume` | Resume a chain: detect branch, show state and next steps |
| `mise run docs-mikado --resume <chain>` | Resume a specific chain with branch consistency check |
The `mikado-branch-invariant-check` commit-msg hook runs automatically on `mikado/*` branches, validating commit message conventions and invariant ordering. Requires `uvx pre-commit install --hook-type commit-msg`.
## Related

View file

@ -35,7 +35,7 @@ Task-oriented instructions for common BlumeOps operations. These guides assume y
|-------|-------------|
| [[review-documentation]] | Periodically review and maintain documentation |
| [[review-services]] | Periodically review services for version freshness |
| [[agent-change-process]] | C0/C1/C2 change classification and Mikado method for agents |
| [[agent-change-process]] | C0/C1/C2 change classification and Mikado Branch Invariant |
## Operations

View file

@ -1,6 +1,6 @@
---
title: AI Assistance Guide
modified: 2026-02-09
modified: 2026-02-23
tags:
- tutorials
- ai
@ -22,13 +22,16 @@ These are non-negotiable for AI agents working in this repo:
4. **Wait for user review before deploying** - Create PRs, don't auto-deploy
5. **Never merge PRs without explicit request** - The user merges after review
Full rules are in the repo's `CLAUDE.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology.
Full rules are in the repo's `CLAUDE.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology — C0 (direct to main), C1 (feature branch + PR), C2 (Mikado Branch Invariant).
## Workflow Conventions
### Feature Branches
### Branching
All work happens on feature branches:
Branching depends on change classification (see [[agent-change-process]]):
- **C0 (Quick Fix):** Commit directly to main — no branch or PR needed
- **C1/C2:** Feature branch required:
```bash
git checkout main && git pull
git checkout -b feature/descriptive-name
@ -85,7 +88,8 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail
| Task | When to Use |
|------|-------------|
| `ai-docs` | At session start - review infrastructure documentation |
| `docs-mikado` | View active Mikado dependency chains for C1/C2 changes |
| `docs-mikado` | View active Mikado dependency chains for C2 changes |
| `docs-mikado --resume` | Resume a C2 chain: detect branch, show state and next steps |
| `provision-indri` | Deploy changes to [[indri]]-hosted services via Ansible |
| `services-check` | After deployments - verify all services are healthy |
| `pr-comments` | Check unresolved PR comments during review |
@ -131,5 +135,5 @@ Credentials live in 1Password. Never retrieve them directly - use existing patte
| Missing kubectl context | Always add `--context=minikube-indri` |
| Deploying without review | Create PR first, wait for user approval |
| Re-explaining reference material | Link to reference cards instead |
| Committing to main | Use feature branches |
| Committing to main without classifying | Classify as C0/C1/C2 first — only C0 goes to main |
| Guessing at credentials | Ask user or check 1Password patterns |

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()