Add agent change process (C0/C1/C2) and docs-mikado tool #225

Merged
eblume merged 1 commit from feature/agent-change-process into main 2026-02-20 08:15:20 -08:00
9 changed files with 439 additions and 14 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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 <card>` | Show dependency chain for a goal card |
| `mise run docs-mikado <card> --all` | Include completed cards in full |
## Related
- [[ai-assistance-guide]] — General AI agent conventions
- [[exploring-the-docs]] — Documentation structure overview

View file

@ -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

View file

@ -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 |

View file

@ -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.

View file

@ -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"

View file

@ -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]")

273
mise-tasks/docs-mikado Executable file
View file

@ -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 <card> 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)