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