Files
rdb-discovery/src/rdb_discovery/cli.py
T

234 lines
8.1 KiB
Python

from __future__ import annotations
from datetime import datetime
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from .discovery import append_discovery_answer, append_followup_answer, core_questions, read_discovery_answers
from .handoff import build_handoff
from .status import project_stage, task_counts, update_project_state, update_agent_handoff
from .tasks import generate_agent_prompt, get_next_task, update_task_status
from .templates import CONTEXT_FILES, write_file_if_missing
from .guardrails import run_all_guardrails, format_report
from .telemetry import record_event
from .generate_context import generate_context_files
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
console = Console()
def root_path() -> Path:
return Path.cwd()
def append_run_log(root: Path, event: str, task_id: str = "", notes: str = "") -> None:
path = root / "RUN_LOG.md"
if not path.exists():
path.write_text("# RUN_LOG.md\n\n| Date | Event | Task | Notes |\n|---|---|---|---|\n", encoding="utf-8")
line = f"| {datetime.now().isoformat(timespec='seconds')} | {event} | {task_id} | {notes} |\n"
with path.open("a", encoding="utf-8") as handle:
handle.write(line)
@app.command()
def init() -> None:
"""Create the initial markdown project structure."""
root = root_path()
record_event(root, "command", "rdb init")
created: list[str] = []
skipped: list[str] = []
for relative_path, content in CONTEXT_FILES.items():
if write_file_if_missing(root, relative_path, content):
created.append(relative_path)
else:
skipped.append(relative_path)
console.print("[bold green]Initialised rdb-discovery files.[/bold green]")
if created:
console.print("\nCreated:")
for item in created:
console.print(f"- {item}")
if skipped:
console.print("\nSkipped existing files:")
for item in skipped:
console.print(f"- {item}")
@app.command()
def discover() -> None:
"""Ask the core discovery questions and append answers to the discovery ledger."""
root = root_path()
record_event(root, "command", "rdb discover")
write_file_if_missing(root, "context/discovery-log.md", CONTEXT_FILES["context/discovery-log.md"])
for index, question in enumerate(core_questions(), start=1):
qid = f"Q-{index:03d}"
answer = typer.prompt(question)
confidence = typer.prompt("Confidence?", default="Medium")
follow_up = typer.prompt("Follow-up needed?", default="No")
append_discovery_answer(root, qid, question, answer, confidence, follow_up)
append_run_log(root, "Discovery completed", notes="Core questions answered")
console.print("[bold green]Discovery complete.[/bold green]")
@app.command()
def ask_more() -> None:
"""Ask for additional details on Low-confidence or follow-up-needed answers."""
root = root_path()
record_event(root, "command", "rdb ask_more")
answers = read_discovery_answers(root)
if not answers:
console.print("[yellow]No discovery answers found. Run `discover` first.[/yellow]")
raise typer.Exit(code=0)
low_conf = [a for a in answers if a["low_confidence"]]
follow_ups = [a for a in answers if a["needs_followup"]]
flagged: dict[str, list[str]] = {} # question_id -> list of reasons
for a in low_conf:
flagged.setdefault(a["id"], []).append("Low confidence")
for a in follow_ups:
flagged.setdefault(a["id"], []).append("Follow-up needed")
if not flagged:
console.print("[green]No Low-confidence or Follow-up-needed answers found.[/green]")
raise typer.Exit(code=0)
count = 0
for qid, reasons in sorted(flagged.items()):
original = next(a for a in answers if a["id"] == qid)
console.print(f"\n[yellow]Question:[/yellow] {original['question']}")
console.print(f"[dim]Reasons: {', '.join(reasons)}[/dim]")
fu_question = typer.prompt(
f"Follow-up for {qid}",
default=f"Additional detail on: {original['question'][:40]}",
)
fu_answer = typer.prompt("New answer")
fu_confidence = typer.prompt("Confidence?", default="Medium")
append_followup_answer(root, qid, fu_question, fu_answer, fu_confidence)
count += 1
console.print(f"[green]Recorded follow-up for {qid}.[/green]")
console.print(f"\n[bold green]Ask-more complete: recorded {count} follow-up(s).[/bold green]")
@app.command()
def status() -> None:
"""Show current project stage and task counts."""
root = root_path()
record_event(root, "command", "rdb status")
console.print(f"[bold]Project stage:[/bold] {project_stage(root)}")
counts = task_counts(root)
table = Table(title="Task Counts")
table.add_column("Status")
table.add_column("Count")
if counts:
for status_name, count in counts.items():
table.add_row(status_name, str(count))
else:
table.add_row("None", "0")
console.print(table)
@app.command(name="next")
def next_task() -> None:
"""Show the next Todo task."""
root = root_path()
record_event(root, "command", "rdb next")
task = get_next_task(root)
if not task:
console.print("[yellow]No Todo task found.[/yellow]")
raise typer.Exit(code=0)
console.print(f"[bold]{task.task_id}[/bold] - {task.title}")
console.print(f"Status: {task.status}")
console.print(task.body)
@app.command()
def start(task_id: str) -> None:
"""Mark a task as In Progress."""
root = root_path()
record_event(root, "command", "rdb start", {"task_id": task_id})
if not update_task_status(root, task_id, "In Progress"):
console.print(f"[red]Task not found:[/red] {task_id}")
raise typer.Exit(code=1)
append_run_log(root, "Task started", task_id=task_id)
update_project_state(root, task_id)
update_agent_handoff(root, task_id)
console.print(f"[green]Started {task_id}.[/green]")
@app.command()
def complete(task_id: str) -> None:
"""Mark a task as Done and record validation notes."""
root = root_path()
record_event(root, "command", "rdb complete", {"task_id": task_id})
notes = typer.prompt("Validation notes", default="Not tested")
if not update_task_status(root, task_id, "Done"):
console.print(f"[red]Task not found:[/red] {task_id}")
raise typer.Exit(code=1)
append_run_log(root, "Task completed", task_id=task_id, notes=notes)
update_project_state(root, task_id)
update_agent_handoff(root, task_id)
console.print(f"[green]Completed {task_id}.[/green]")
@app.command()
def handoff() -> None:
"""Print an AI-agent handoff summary."""
root = root_path()
record_event(root, "command", "rdb handoff")
console.print(build_handoff(root))
@app.command()
def prompt() -> None:
"""Generate a ready-to-paste implementation prompt for the next Todo task."""
root = root_path()
record_event(root, "command", "rdb prompt")
result = generate_agent_prompt(root)
console.print(result)
@app.command()
def guardrails() -> None:
"""Review agent runs for signs of non-progress (stalls, repeats, loops)."""
root = root_path()
record_event(root, "command", "rdb guardrails")
results = run_all_guardrails(root)
report = format_report(results)
console.print(report)
@app.command()
def generate(min_confidence: str = typer.Option("Medium", help="Minimum confidence to process (High or Medium).")) -> None:
"""Generate context files from discovery answers using the mapping document."""
root = root_path()
record_event(root, "command", "rdb generate")
result = generate_context_files(root, min_confidence)
if result["generated"]:
console.print("[bold green]Generated context content:[/bold green]")
for f in result["generated"]:
console.print(f"- {f}")
else:
console.print("[yellow]No context files generated.[/yellow]")
if result["skipped"]:
console.print("\n[dim]Skipped:[/dim]")
for s in result["skipped"]:
console.print(f" - {s}")