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..0714ade --- /dev/null +++ b/docs/how-to/agent-change-process.md @@ -0,0 +1,109 @@ +--- +title: Agent Change Process +modified: 2026-02-20 +tags: + - how-to + - ai +--- + +# Agent Change Process + +> **Audiences:** AI, Owner + +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 +3. **If it works:** commit and create PR +4. **If it fails:** revert the working tree (`git checkout .`), then: + - Commit only 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. + +## 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.** On failure, revert working tree changes 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 +- Always revert broken working tree changes — don't leave broken code committed +- Broken commits may happen (GitOps requires pushing to test) but must be reverted 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-mikado b/mise-tasks/docs-mikado new file mode 100755 index 0000000..dc9ee1f --- /dev/null +++ b/mise-tasks/docs-mikado @@ -0,0 +1,236 @@ +#!/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" +"""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 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() + + 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)