Add agent change process (C0/C1/C2) and docs-mikado tool (#225)
## 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
This commit is contained in:
parent
c3748a0638
commit
c1f7b2a9a3
9 changed files with 439 additions and 14 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -12,7 +12,7 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest
|
||||||
|
|
||||||
## Rules
|
## 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.
|
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
|
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
|
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**
|
9. **Never merge PRs or push to main without explicit request**
|
||||||
10. **Verify deployments** - `mise run services-check`
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -45,7 +57,7 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest
|
||||||
~/code/3rd/ # mirrored external projects
|
~/code/3rd/ # mirrored external projects
|
||||||
~/code/work # FORBIDDEN
|
~/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.
|
encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards.
|
||||||
|
|
||||||
## Service Deployment
|
## Service Deployment
|
||||||
|
|
|
||||||
1
docs/changelog.d/feature-agent-change-process.feature.md
Normal file
1
docs/changelog.d/feature-agent-change-process.feature.md
Normal 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`.
|
||||||
107
docs/how-to/agent-change-process.md
Normal file
107
docs/how-to/agent-change-process.md
Normal 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
|
||||||
|
|
@ -34,6 +34,7 @@ Task-oriented instructions for common BlumeOps operations. These guides assume y
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [[review-documentation]] | Periodically review and maintain documentation |
|
| [[review-documentation]] | Periodically review and maintain documentation |
|
||||||
| [[review-services]] | Periodically review services for version freshness |
|
| [[review-services]] | Periodically review services for version freshness |
|
||||||
|
| [[agent-change-process]] | C0/C1/C2 change classification and Mikado method for agents |
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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
|
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
|
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
|
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
|
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
|
## Workflow Conventions
|
||||||
|
|
||||||
|
|
@ -84,7 +84,8 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail
|
||||||
|
|
||||||
| Task | When to Use |
|
| 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 |
|
| `provision-indri` | Deploy changes to [[indri]]-hosted services via Ansible |
|
||||||
| `services-check` | After deployments - verify all services are healthy |
|
| `services-check` | After deployments - verify all services are healthy |
|
||||||
| `pr-comments` | Check unresolved PR comments during review |
|
| `pr-comments` | Check unresolved PR comments during review |
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ You probably want quick access to operational details:
|
||||||
- [[plans]] captures migration and transition plans for future execution
|
- [[plans]] captures migration and transition plans for future execution
|
||||||
- [[reference]] has service URLs, commands, and config locations
|
- [[reference]] has service URLs, commands, and config locations
|
||||||
- [[ai-assistance-guide]] explains how to work effectively with Claude
|
- [[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
|
### For Claude/AI Agents
|
||||||
|
|
||||||
|
|
@ -77,10 +77,10 @@ Pre-commit hooks automatically validate that all wiki-links point to existing fi
|
||||||
|
|
||||||
## AI Context Priming
|
## 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
|
```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.
|
This outputs the AI assistance guide, reference index, how-to index, architecture overview, and tutorials index - providing Claude with essential context for BlumeOps operations.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docs"
|
||||||
# Key files for AI context priming, in order of importance
|
# Key files for AI context priming, in order of importance
|
||||||
FILES=(
|
FILES=(
|
||||||
"$DOCS_DIR/tutorials/ai-assistance-guide.md"
|
"$DOCS_DIR/tutorials/ai-assistance-guide.md"
|
||||||
|
"$DOCS_DIR/how-to/agent-change-process.md"
|
||||||
"$DOCS_DIR/index.md"
|
"$DOCS_DIR/index.md"
|
||||||
"$DOCS_DIR/reference/reference.md"
|
"$DOCS_DIR/reference/reference.md"
|
||||||
"$DOCS_DIR/how-to/how-to.md"
|
"$DOCS_DIR/how-to/how-to.md"
|
||||||
|
|
@ -22,7 +22,10 @@ from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||||
|
HOWTO_DIR = DOCS_DIR / "how-to"
|
||||||
REQUIRED_FIELDS = {"title", "tags", "modified"}
|
REQUIRED_FIELDS = {"title", "tags", "modified"}
|
||||||
|
# These fields are only permitted in docs/how-to/
|
||||||
|
HOWTO_ONLY_FIELDS = {"status", "requires"}
|
||||||
|
|
||||||
# Match YAML frontmatter block
|
# Match YAML frontmatter block
|
||||||
FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
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(f"Required fields: {', '.join(sorted(REQUIRED_FIELDS))}")
|
||||||
console.print()
|
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")):
|
for md_file in sorted(DOCS_DIR.rglob("*.md")):
|
||||||
if "changelog.d" in md_file.parts:
|
if "changelog.d" in md_file.parts:
|
||||||
|
|
@ -57,25 +61,50 @@ def main() -> int:
|
||||||
keys = extract_frontmatter_keys(md_file)
|
keys = extract_frontmatter_keys(md_file)
|
||||||
|
|
||||||
if keys is None:
|
if keys is None:
|
||||||
issues.append((rel_path, REQUIRED_FIELDS))
|
missing_issues.append((rel_path, REQUIRED_FIELDS))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
missing = REQUIRED_FIELDS - keys
|
missing = REQUIRED_FIELDS - keys
|
||||||
if missing:
|
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("[bold red]Missing Required Frontmatter[/bold red]")
|
||||||
console.print()
|
console.print()
|
||||||
table = Table(show_header=True, header_style="bold")
|
table = Table(show_header=True, header_style="bold")
|
||||||
table.add_column("File")
|
table.add_column("File")
|
||||||
table.add_column("Missing Fields")
|
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)))
|
table.add_row(rel_path, ", ".join(sorted(missing)))
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
console.print()
|
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
|
return 1
|
||||||
|
|
||||||
console.print("[bold green]All docs have required frontmatter![/bold green]")
|
console.print("[bold green]All docs have required frontmatter![/bold green]")
|
||||||
|
|
|
||||||
273
mise-tasks/docs-mikado
Executable file
273
mise-tasks/docs-mikado
Executable 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue