From 66b5b32f1d09cab48e9627eecf96523b2d1c560f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 23 Feb 2026 16:19:54 -0800 Subject: [PATCH] Formalize C0/C1/C2 change classification (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .pre-commit-config.yaml | 13 +- CLAUDE.md | 24 +- .../formalize-change-classification.doc.md | 1 + docs/how-to/agent-change-process.md | 242 +++++++++++--- docs/how-to/how-to.md | 2 +- docs/tutorials/ai-assistance-guide.md | 16 +- mise-tasks/docs-mikado | 309 +++++++++++++++++- mise-tasks/mikado-branch-invariant-check | 197 +++++++++++ 8 files changed, 732 insertions(+), 72 deletions(-) create mode 100644 docs/changelog.d/formalize-change-classification.doc.md create mode 100755 mise-tasks/mikado-branch-invariant-check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6152a70..758ca88 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index f1ba123..5cd758f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `** before proceeding 6. **Add changelog fragments** - `docs/changelog.d/..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/` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(): 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. diff --git a/docs/changelog.d/formalize-change-classification.doc.md b/docs/changelog.d/formalize-change-classification.doc.md new file mode 100644 index 0000000..de5b6b6 --- /dev/null +++ b/docs/changelog.d/formalize-change-classification.doc.md @@ -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(): plan/impl/close/finalize` commit messages, `mikado/` 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. diff --git a/docs/how-to/agent-change-process.md b/docs/how-to/agent-change-process.md index 236d9d3..fe7219a 100644 --- a/docs/how-to/agent-change-process.md +++ b/docs/how-to/agent-change-process.md @@ -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 --revision && argocd app sync ` + - **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) +``` + +**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/`, where `` 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(): +``` + +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/` 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(): 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(): impl ...`) that progress toward closing it + - **Verify the change works** (deploy from branch, run tests, etc.) before closing + - Commit the card closure (`C2(): 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(): plan` or `C2(): close` commit before your current `impl` commits +2. **Add a new commit** (`C2(): 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(): 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 ` +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/` 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/`, 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 ` | Show dependency chain for a goal card | | `mise run docs-mikado --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 ` | 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 diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index 8ff7aeb..81483cd 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -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 diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index a98fbbd..274dd48 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -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 | diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado index cf091b1..c993a1b 100755 --- a/mise-tasks/docs-mikado +++ b/mise-tasks/docs-mikado @@ -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 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 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]") diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check new file mode 100755 index 0000000..ee8a370 --- /dev/null +++ b/mise-tasks/mikado-branch-invariant-check @@ -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(): 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()