Add branch: frontmatter support and --resume to docs-mikado
- Parse branch: field from goal card frontmatter
- Goal cards without branch: shown as "not started" (planned but no branch)
- Add Branch column to chain listing table
- New --resume flag for cold-start session pickup:
- Detects current mikado/* branch and matches to active chain
- Shows branch position (planning/mid-cycle/between-cycles) by parsing
C2() commit convention from git log
- Lists ready leaf nodes for next work cycle
- With explicit chain name, validates branch consistency
- On main, lists all chains with branch status and ready leaves
- Add find_ready_leaves() helper for identifying actionable leaf nodes
- Add C2_COMMIT_RE for parsing C2(<chain>): <verb> <desc> convention
- Show branch: in walk_chain card detail output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ff482c2b96
commit
2864d6efe4
1 changed files with 305 additions and 0 deletions
|
|
@ -6,6 +6,7 @@
|
|||
#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"
|
||||
#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``
|
||||
|
|
@ -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 <chain> 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 <card> 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]")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue