From c1f7b2a9a39a47b028808452bed5c628fa979cbd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 20 Feb 2026 08:15:20 -0800 Subject: [PATCH] Add agent change process (C0/C1/C2) and docs-mikado tool (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Introduce C0/C1/C2 change classification based on the Mikado method, where documentation cards serve as persistent context for agents across sessions - Add `docs-mikado` mise task to visualize active Mikado dependency chains from `status: active` and `requires` frontmatter fields - Rename `zk-docs` task to `ai-docs` ## Changes - **New:** `docs/how-to/agent-change-process.md` — methodology card - **New:** `mise-tasks/docs-mikado` — Python uv script for dependency graph visualization - **Renamed:** `mise-tasks/zk-docs` → `mise-tasks/ai-docs` - **Updated:** `CLAUDE.md` — added Change Classification section, updated references - **Updated:** `ai-assistance-guide.md`, `exploring-the-docs.md`, `how-to.md` — updated references and index ## Verification - [x] `mise run ai-docs` works - [x] `mise run docs-mikado` runs (no active chains yet, as expected) - [x] `docs-check-links` — all valid - [x] `docs-check-index` — all indexed - [x] `docs-check-frontmatter` — all valid - [x] All pre-commit hooks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/225 --- CLAUDE.md | 16 +- .../feature-agent-change-process.feature.md | 1 + docs/how-to/agent-change-process.md | 107 +++++++ docs/how-to/how-to.md | 1 + docs/tutorials/ai-assistance-guide.md | 7 +- docs/tutorials/exploring-the-docs.md | 6 +- mise-tasks/{zk-docs => ai-docs} | 3 +- mise-tasks/docs-check-frontmatter | 39 ++- mise-tasks/docs-mikado | 273 ++++++++++++++++++ 9 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 docs/changelog.d/feature-agent-change-process.feature.md create mode 100644 docs/how-to/agent-change-process.md rename mise-tasks/{zk-docs => ai-docs} (91%) create mode 100755 mise-tasks/docs-mikado diff --git a/CLAUDE.md b/CLAUDE.md index c325c74..f1ba123 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest ## Rules -1. **Always run `mise run zk-docs -- --style=header --color=never --decorations=always` at session start** +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 @@ -25,6 +25,18 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest 9. **Never merge PRs or push to main without explicit request** 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 | + +See [[agent-change-process]] for the full methodology. + ## Project Structure ``` @@ -45,7 +57,7 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest ~/code/3rd/ # mirrored external projects ~/code/work # FORBIDDEN ``` -Other code paths will be listed via zk-docs, this is just an overview. When you +Other code paths will be listed via ai-docs, this is just an overview. When you encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards. ## Service Deployment diff --git a/docs/changelog.d/feature-agent-change-process.feature.md b/docs/changelog.d/feature-agent-change-process.feature.md new file mode 100644 index 0000000..98e50a3 --- /dev/null +++ b/docs/changelog.d/feature-agent-change-process.feature.md @@ -0,0 +1 @@ +Add agent change process (C0/C1/C2) documentation and `docs-mikado` tool for Mikado method dependency chain resolution. Rename `zk-docs` task to `ai-docs`. diff --git a/docs/how-to/agent-change-process.md b/docs/how-to/agent-change-process.md new file mode 100644 index 0000000..b478f6d --- /dev/null +++ b/docs/how-to/agent-change-process.md @@ -0,0 +1,107 @@ +--- +title: Agent Change Process +modified: 2026-02-20 +tags: + - how-to + - ai +--- + +# Agent Change Process + +How to classify and execute infrastructure changes, especially when working with AI agents that may lose context across sessions. + +## 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 | + +## 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: + +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 + +Single feature branch, squash-merge when complete. GitOps may require pushing to test — if a pushed commit breaks, revert it promptly. + +## C2 — Documented Change (Multi-Session) + +Like C1 but designed to survive agent context loss across sessions: + +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**, amending the working commit. On failure, revert the broken change and: + - Create/update prerequisite cards as how-to docs with `status: active` + - Add `requires: [prerequisite-stem, ...]` to the goal card's frontmatter + - Commit the doc updates (the documentation IS the Mikado graph) +3. **Work leaf nodes first** — cards with `status: active` and no unmet `requires` +4. **New agent sessions** pick up state by running `mise run docs-mikado` +5. When a card's change succeeds, remove `status: active` (or the entire field) from its frontmatter + +Documentation IS the Mikado graph. Each card captures what was learned from failed attempts, so the next agent session doesn't repeat mistakes. + +## Card Conventions + +### Frontmatter + +```yaml +--- +title: Deploy Authentik +status: active # omit when complete +requires: # explicit dependencies + - configure-postgres + - setup-redis +tags: + - how-to +--- +``` + +- `status: active` marks in-progress work; omit when done +- `requires` lists card stems (filenames without `.md`) that must be completed first +- `required-by` is NOT stored — it's computed by `docs-mikado` + +### Writing Cards + +- Cards live in `docs/how-to/` — they're how-to docs with lifecycle metadata +- Keep cards brief (<30 seconds to read) +- Link to other cards rather than inlining their content +- Document what was learned from failures, not just what to do + +### Git Discipline + +- Single feature branch per C1/C2 change +- Amend a single working commit as you iterate; keep the branch history clean +- 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 ` | Show dependency chain for a goal card | +| `mise run docs-mikado --all` | Include completed cards in full | + +## Related + +- [[ai-assistance-guide]] — General AI agent conventions +- [[exploring-the-docs]] — Documentation structure overview diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index a7ca5b1..356c2b2 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -34,6 +34,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 | ## Operations diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index 09ff0db..4360e37 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -17,12 +17,12 @@ This guide provides context for AI agents (like Claude Code) assisting with Blum These are non-negotiable for AI agents working in this repo: 1. **Always use `--context=minikube-indri` with kubectl** - Work contexts exist that must never be touched -2. **Run `mise run zk-docs` at session start** - Review current infrastructure state +2. **Run `mise run ai-docs` at session start** - Review current infrastructure state 3. **Never commit secrets** - The repo is public at github.com/eblume/blumeops 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`. +Full rules are in the repo's `CLAUDE.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology. ## Workflow Conventions @@ -84,7 +84,8 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | Task | When to Use | |------|-------------| -| `zk-docs` | At session start - review infrastructure documentation | +| `ai-docs` | At session start - review infrastructure documentation | +| `docs-mikado` | View active Mikado dependency chains for C1/C2 changes | | `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 | diff --git a/docs/tutorials/exploring-the-docs.md b/docs/tutorials/exploring-the-docs.md index 1e16084..4f6aca7 100644 --- a/docs/tutorials/exploring-the-docs.md +++ b/docs/tutorials/exploring-the-docs.md @@ -33,7 +33,7 @@ You probably want quick access to operational details: - [[plans]] captures migration and transition plans for future execution - [[reference]] has service URLs, commands, and config locations - [[ai-assistance-guide]] explains how to work effectively with Claude -- Run `mise run zk-docs` to prime AI context with key documentation +- Run `mise run ai-docs` to prime AI context with key documentation ### For Claude/AI Agents @@ -77,10 +77,10 @@ Pre-commit hooks automatically validate that all wiki-links point to existing fi ## AI Context Priming -The `zk-docs` mise task concatenates key documentation files for AI context: +The `ai-docs` mise task concatenates key documentation files for AI context: ```bash -mise run zk-docs -- --style=header --color=never --decorations=always +mise run ai-docs -- --style=header --color=never --decorations=always ``` This outputs the AI assistance guide, reference index, how-to index, architecture overview, and tutorials index - providing Claude with essential context for BlumeOps operations. diff --git a/mise-tasks/zk-docs b/mise-tasks/ai-docs similarity index 91% rename from mise-tasks/zk-docs rename to mise-tasks/ai-docs index d09e6c2..417f53a 100755 --- a/mise-tasks/zk-docs +++ b/mise-tasks/ai-docs @@ -1,5 +1,5 @@ #!/usr/bin/env bash -#MISE description="Prime AI context with key BlumeOps documentation" +#MISE description="Prime AI context with key BlumeOps documentation (formerly zk-docs)" set -euo pipefail @@ -8,6 +8,7 @@ DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docs" # Key files for AI context priming, in order of importance FILES=( "$DOCS_DIR/tutorials/ai-assistance-guide.md" + "$DOCS_DIR/how-to/agent-change-process.md" "$DOCS_DIR/index.md" "$DOCS_DIR/reference/reference.md" "$DOCS_DIR/how-to/how-to.md" diff --git a/mise-tasks/docs-check-frontmatter b/mise-tasks/docs-check-frontmatter index 6d1bcca..3571801 100755 --- a/mise-tasks/docs-check-frontmatter +++ b/mise-tasks/docs-check-frontmatter @@ -22,7 +22,10 @@ from rich.console import Console from rich.table import Table DOCS_DIR = Path(__file__).parent.parent / "docs" +HOWTO_DIR = DOCS_DIR / "how-to" REQUIRED_FIELDS = {"title", "tags", "modified"} +# These fields are only permitted in docs/how-to/ +HOWTO_ONLY_FIELDS = {"status", "requires"} # Match YAML frontmatter block FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) @@ -47,7 +50,8 @@ def main() -> int: console.print(f"Required fields: {', '.join(sorted(REQUIRED_FIELDS))}") console.print() - issues: list[tuple[str, set[str]]] = [] + missing_issues: list[tuple[str, set[str]]] = [] + misplaced_issues: list[tuple[str, set[str]]] = [] for md_file in sorted(DOCS_DIR.rglob("*.md")): if "changelog.d" in md_file.parts: @@ -57,25 +61,50 @@ def main() -> int: keys = extract_frontmatter_keys(md_file) if keys is None: - issues.append((rel_path, REQUIRED_FIELDS)) + missing_issues.append((rel_path, REQUIRED_FIELDS)) continue missing = REQUIRED_FIELDS - keys if missing: - issues.append((rel_path, missing)) + missing_issues.append((rel_path, missing)) - if issues: + # Check that status/requires only appear in how-to docs + is_howto = HOWTO_DIR in md_file.parents or md_file.parent == HOWTO_DIR + if not is_howto: + misplaced = keys & HOWTO_ONLY_FIELDS + if misplaced: + misplaced_issues.append((rel_path, misplaced)) + + has_issues = bool(missing_issues or misplaced_issues) + + if missing_issues: console.print("[bold red]Missing Required Frontmatter[/bold red]") console.print() table = Table(show_header=True, header_style="bold") table.add_column("File") table.add_column("Missing Fields") - for rel_path, missing in issues: + for rel_path, missing in missing_issues: table.add_row(rel_path, ", ".join(sorted(missing))) console.print(table) console.print() + + if misplaced_issues: + console.print("[bold red]Misplaced Frontmatter Fields[/bold red]") + console.print(f"[dim]These fields are only allowed in {HOWTO_DIR.relative_to(DOCS_DIR)}/[/dim]") + console.print() + table = Table(show_header=True, header_style="bold") + table.add_column("File") + table.add_column("Disallowed Fields") + + for rel_path, misplaced in misplaced_issues: + table.add_row(rel_path, ", ".join(sorted(misplaced))) + + console.print(table) + console.print() + + if has_issues: return 1 console.print("[bold green]All docs have required frontmatter![/bold green]") diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado new file mode 100755 index 0000000..cf091b1 --- /dev/null +++ b/mise-tasks/docs-mikado @@ -0,0 +1,273 @@ +#!/usr/bin/env -S uv run --script +# /// script +# 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" +#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. + +Scans all markdown files in docs/ for YAML frontmatter with ``status: active`` +and ``requires`` fields, then builds and displays the Mikado dependency graph. + +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 +""" + +import sys +from pathlib import Path +from typing import Annotated + +import typer +import yaml +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table + +DOCS_DIR = Path(__file__).parent.parent / "docs" + + +def extract_frontmatter(file_path: Path) -> dict | None: + """Extract YAML frontmatter from a markdown file.""" + content = file_path.read_text() + if not content.startswith("---"): + return None + + end_idx = content.find("---", 3) + if end_idx == -1: + return None + + frontmatter_text = content[3:end_idx].strip() + try: + return yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return None + + +def build_graph() -> dict[str, dict]: + """Build the dependency graph from all docs.""" + cards: dict[str, dict] = {} + + for md_file in sorted(DOCS_DIR.rglob("*.md")): + if "changelog.d" in md_file.parts: + continue + + frontmatter = extract_frontmatter(md_file) + if frontmatter is None: + continue + + stem = md_file.stem + cards[stem] = { + "path": md_file, + "title": frontmatter.get("title", stem), + "status": frontmatter.get("status"), + "requires": frontmatter.get("requires", []) or [], + "required_by": [], + } + + # Compute inverse relationships + for stem, card in cards.items(): + for req in card["requires"]: + if req in cards: + cards[req]["required_by"].append(stem) + + return cards + + +def detect_cycles(cards: dict[str, dict]) -> list[list[str]]: + """Detect circular dependencies in the graph. Returns list of cycles.""" + cycles: list[list[str]] = [] + visited: set[str] = set() + on_stack: set[str] = set() + + def dfs(stem: str, path: list[str]) -> None: + if stem in on_stack: + cycle_start = path.index(stem) + cycles.append(path[cycle_start:] + [stem]) + return + if stem in visited or stem not in cards: + return + visited.add(stem) + on_stack.add(stem) + path.append(stem) + for req in cards[stem]["requires"]: + dfs(req, path) + path.pop() + on_stack.discard(stem) + + for stem in cards: + dfs(stem, []) + + return cycles + + +def is_active(card: dict) -> bool: + """Check if a card has status: active.""" + return card.get("status") == "active" + + +def find_root_goals(cards: dict[str, dict]) -> list[str]: + """Find active cards that aren't required by another active card.""" + roots = [] + for stem, card in cards.items(): + if not is_active(card): + continue + # A root goal is not required by any other active card + has_active_parent = any( + is_active(cards[rb]) for rb in card["required_by"] if rb in cards + ) + if not has_active_parent: + roots.append(stem) + return sorted(roots) + + +def walk_chain( + cards: dict[str, dict], + stem: str, + console: Console, + show_all: bool, + visited: set[str] | None = None, + depth: int = 0, +) -> None: + """Walk the dependency tree depth-first from a card.""" + if visited is None: + visited = set() + + if stem in visited: + console.print(f"{' ' * depth}[dim](circular: {stem})[/dim]") + return + visited.add(stem) + + card = cards.get(stem) + if card is None: + console.print(f"{' ' * depth}[red](missing: {stem})[/red]") + return + + active = is_active(card) + + if active or show_all: + # Print full card content + console.print() + separator = "=" * 72 + console.print(f"[bold cyan]{separator}[/bold cyan]") + status_tag = "[active]" if active else "[complete]" + console.print( + f"[bold]{status_tag} {stem}[/bold] — {card['title']}" + ) + console.print(f"[dim]{card['path'].relative_to(DOCS_DIR)}[/dim]") + if card["requires"]: + console.print(f"[dim]requires: {', '.join(card['requires'])}[/dim]") + if card["required_by"]: + console.print( + f"[dim]required-by: {', '.join(card['required_by'])}[/dim]" + ) + console.print(f"[bold cyan]{separator}[/bold cyan]") + console.print() + content = card["path"].read_text() + console.print(content) + else: + # One-line summary for complete cards + console.print( + f"{' ' * depth}[green][complete][/green] {stem} — {card['title']}" + ) + + # Recurse into dependencies + for req in card["requires"]: + walk_chain(cards, req, console, show_all, visited, depth + 1) + + +def main( + card: Annotated[ + str | None, typer.Argument(help="Card stem to show chain for") + ] = None, + all: Annotated[ + bool, + typer.Option("--all", help="Show all cards in full, including complete ones"), + ] = False, +) -> None: + console = Console() + cards = build_graph() + + # Check for circular dependencies + cycles = detect_cycles(cards) + if cycles: + console.print("[bold red]Circular dependencies detected![/bold red]") + for cycle in cycles: + console.print(f" [red]{' → '.join(cycle)}[/red]") + console.print() + + if card is None: + # List all active Mikado chains + roots = find_root_goals(cards) + + if not roots: + console.print("[dim]No active Mikado chains found.[/dim]") + console.print( + "[dim]Cards need status: active in frontmatter to appear here.[/dim]" + ) + raise typer.Exit() + + table = Table( + title="Active Mikado Chains", show_header=True, header_style="bold" + ) + table.add_column("Goal Card") + table.add_column("Title") + table.add_column("Active Deps", justify="right") + table.add_column("Total Deps", justify="right") + + for stem in roots: + card_data = cards[stem] + + # Count dependencies + def count_deps(s: str, seen: set[str] | None = None) -> tuple[int, int]: + if seen is None: + seen = set() + if s in seen: + return 0, 0 + seen.add(s) + active_count = 0 + total_count = 0 + c = cards.get(s) + if c is None: + return 0, 0 + for req in c["requires"]: + total_count += 1 + req_card = cards.get(req) + if req_card and is_active(req_card): + active_count += 1 + sub_active, sub_total = count_deps(req, seen) + active_count += sub_active + total_count += sub_total + return active_count, total_count + + active_deps, total_deps = count_deps(stem) + table.add_row( + f"[bold]{stem}[/bold]", + card_data["title"], + str(active_deps), + str(total_deps), + ) + + console.print() + console.print(table) + console.print() + console.print( + "[dim]Run: mise run docs-mikado to see full chain[/dim]" + ) + else: + if card not in cards: + console.print(f"[red]Card not found: {card}[/red]") + console.print("[dim]Available cards with status: active:[/dim]") + for stem, data in sorted(cards.items()): + if is_active(data): + console.print(f" {stem} — {data['title']}") + raise typer.Exit(code=1) + + walk_chain(cards, card, console, show_all=all) + + +if __name__ == "__main__": + typer.run(main)