feat(task-021): add context health reporting
This commit is contained in:
@@ -15,6 +15,7 @@ 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
|
||||
from .context_status import context_status as get_context_status, compute_health_score
|
||||
|
||||
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
|
||||
console = Console()
|
||||
@@ -231,3 +232,64 @@ def generate(min_confidence: str = typer.Option("Medium", help="Minimum confiden
|
||||
console.print("\n[dim]Skipped:[/dim]")
|
||||
for s in result["skipped"]:
|
||||
console.print(f" - {s}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def context_status() -> None:
|
||||
"""Report the health and completeness of project context."""
|
||||
root = root_path()
|
||||
record_event(root, "command", "rdb context-status")
|
||||
|
||||
report = get_context_status(root)
|
||||
score = report["score"]
|
||||
summary = report["summary"]
|
||||
|
||||
# Score bar
|
||||
if score >= 70:
|
||||
score_color = "green"
|
||||
elif score >= 40:
|
||||
score_color = "yellow"
|
||||
else:
|
||||
score_color = "red"
|
||||
|
||||
console.print(f"\n[bold]Context Health Report[/bold]")
|
||||
score_text = f"{score}/100"
|
||||
console.print(f"[bold][{score_color}]{score_text}[/{score_color}]")
|
||||
console.print("")
|
||||
|
||||
# File status table
|
||||
table = Table(title="Expected Files")
|
||||
table.add_column("File")
|
||||
table.add_column("Status")
|
||||
table.add_column("Size")
|
||||
for f in report["expected_files"]:
|
||||
color = "green" if f["status"] == "present" else "red"
|
||||
icon = "[green]✓[/green]" if f["status"] == "present" else "[red]✗[/red]"
|
||||
table.add_row(f["path"], f"[{color}]{icon}[/{color}]", f["size"])
|
||||
console.print(table)
|
||||
|
||||
# TBD placeholders
|
||||
if report["tbd_sections"]:
|
||||
console.print(f"\n[bold yellow]TBD Placeholders ({summary['tbd_sections']} still open):[/bold yellow]")
|
||||
for item in report["tbd_sections"]:
|
||||
console.print(f" [yellow]• {item['file']} — {item['section']} (line {item['line']})[/yellow]")
|
||||
else:
|
||||
console.print("[green]\nNo TBD placeholders found.[/green]")
|
||||
|
||||
# Low-confidence discovery answers
|
||||
if report["low_confidence"]:
|
||||
console.print(f"\n[bold yellow]Low-Confidence Discovery Answers ({summary['low_confidence_answers']}):[/bold yellow]")
|
||||
for item in report["low_confidence"]:
|
||||
console.print(f" [yellow]• {item['id']}: {item['question']} (confidence: {item['confidence']})[/yellow]")
|
||||
else:
|
||||
console.print("[green]\nNo low-confidence discovery answers.[/green]")
|
||||
|
||||
# Summary line
|
||||
missing = summary["missing"]
|
||||
if missing > 0:
|
||||
console.print(f"\n[dim]{summary['present']} of {summary['total_expected']} context files present. "
|
||||
f"{missing} missing, {summary['tbd_sections']} TBD sections, "
|
||||
f"{summary['low_confidence_answers']} low-confidence answers.[/dim]")
|
||||
else:
|
||||
console.print(f"\n[dim]{summary['present']} of {summary['total_expected']} context files present. "
|
||||
f"All expected files exist.{'' if summary['tbd_sections'] == 0 and summary['low_confidence_answers'] == 0 else ' Review TBD sections and low-confidence answers above.'}[/dim]")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Context health report — assess whether sufficient context exists for AI-assisted development."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _expected_files(root: Path) -> list[str]:
|
||||
"""Return the list of expected context files for this project."""
|
||||
return [
|
||||
"context/discovery-log.md",
|
||||
"context/product-brief.md",
|
||||
"context/architecture.md",
|
||||
"context/decisions.md",
|
||||
"context/risks.md",
|
||||
"context/assumptions.md",
|
||||
"context/open-questions.md",
|
||||
"context/repository-context.md",
|
||||
"context/company-context.md",
|
||||
"context/development-context.md",
|
||||
"context/infrastructure-context.md",
|
||||
"context/agent-guidelines.md",
|
||||
"context/project-brief.md",
|
||||
"TASKS.md",
|
||||
"TEST_PLAN.md",
|
||||
"RUN_LOG.md",
|
||||
"PROJECT_STATE.md",
|
||||
"AGENT_HANDOFF.md",
|
||||
]
|
||||
|
||||
|
||||
def _check_expected_files(root: Path) -> list[dict[str, str]]:
|
||||
"""Check which expected files exist and which are missing."""
|
||||
results: list[dict[str, str]] = []
|
||||
for path_str in _expected_files(root):
|
||||
full = root / path_str
|
||||
status = "missing" if not full.exists() else "present"
|
||||
size = full.stat().st_size if full.exists() else 0
|
||||
results.append({"path": path_str, "status": status, "size": str(size)})
|
||||
return results
|
||||
|
||||
|
||||
def _check_tbd_sections(root: Path) -> list[dict[str, Any]]:
|
||||
"""Find sections still containing TBD/TDB placeholders."""
|
||||
context_dir = root / "context"
|
||||
if not context_dir.exists():
|
||||
return []
|
||||
|
||||
findings: list[dict[str, Any]] = []
|
||||
for file_path in sorted(context_dir.iterdir()):
|
||||
if not file_path.is_file() or file_path.suffix != ".md":
|
||||
continue
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if re.match(r"^## .+$", stripped):
|
||||
# Look past blank lines to find the first non-empty content line
|
||||
content_line = None
|
||||
for j in range(i + 1, len(lines)):
|
||||
candidate = lines[j].strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if re.match(r"^## ", candidate) or candidate.startswith("# "):
|
||||
break
|
||||
content_line = candidate
|
||||
break
|
||||
if content_line and (content_line.startswith("TBD") or content_line.startswith("TDB")):
|
||||
placeholder_text = content_line.split("—")[0].split()[0] if content_line.split() else ""
|
||||
findings.append({
|
||||
"file": file_path.name,
|
||||
"section": stripped,
|
||||
"line": i + 2,
|
||||
"placeholder": placeholder_text,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _check_low_confidence(root: Path) -> list[dict[str, str]]:
|
||||
"""Find low-confidence discovery answers from discovery-log.md."""
|
||||
from .discovery import read_discovery_answers
|
||||
|
||||
answers = read_discovery_answers(root)
|
||||
low_conf = [a for a in answers if a["low_confidence"]]
|
||||
return [{"id": a["id"], "question": a["question"], "confidence": a["confidence"]} for a in low_conf]
|
||||
|
||||
|
||||
def compute_health_score(expected: list[dict[str, str]], tbd_sections: int, low_conf_count: int) -> float:
|
||||
"""Compute an overall health score (0-100).
|
||||
|
||||
Scoring breakdown:
|
||||
- 45 pts for file completeness (each file is equal share of 45)
|
||||
- 30 pts for no TBD placeholders in body sections (proportional decay from 30 down to 0 at 20+ TBDs)
|
||||
- 15 pts for no low-confidence answers (proportional decay from 15 down to 0 at 10+ low-conf)
|
||||
- 10 pts if discovery-log.md exists with data
|
||||
"""
|
||||
total_expected = len(expected)
|
||||
present_count = sum(1 for f in expected if f["status"] == "present")
|
||||
|
||||
# File completeness: up to 45 points
|
||||
file_score = (present_count / max(total_expected, 1)) * 45
|
||||
|
||||
# TBD penalty: start at 30, lose 1.5 per TBD (min 0)
|
||||
tbd_score = max(30 - (tbd_sections * 1.5), 0)
|
||||
|
||||
# Low-confidence penalty: start at 15, lose 1.5 per low-conf answer (min 0)
|
||||
lc_score = max(15 - (low_conf_count * 1.5), 0)
|
||||
|
||||
return round(min(file_score + tbd_score + lc_score, 100), 1)
|
||||
|
||||
|
||||
def context_status(root: Path) -> dict[str, Any]:
|
||||
"""Run all health checks and return a structured report."""
|
||||
expected = _check_expected_files(root)
|
||||
tbd_sections = _check_tbd_sections(root)
|
||||
low_confidence = _check_low_confidence(root)
|
||||
|
||||
missing_count = sum(1 for f in expected if f["status"] == "missing")
|
||||
present_count = len(expected) - missing_count
|
||||
total_expected = len(expected)
|
||||
|
||||
score = compute_health_score(expected, len(tbd_sections), len(low_confidence))
|
||||
|
||||
# discovery log bonus (up to 10 points)
|
||||
discovery_log = root / "context" / "discovery-log.md"
|
||||
discovery_score = 0
|
||||
if discovery_log.exists():
|
||||
content = discovery_log.read_text(encoding="utf-8")
|
||||
if any(line.startswith("| Q-") for line in content.splitlines()):
|
||||
discovery_score = 10
|
||||
else:
|
||||
discovery_score = 5
|
||||
|
||||
score += discovery_score
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": {
|
||||
"total_expected": total_expected,
|
||||
"present": present_count,
|
||||
"missing": missing_count,
|
||||
"tbd_sections": len(tbd_sections),
|
||||
"low_confidence_answers": len(low_confidence),
|
||||
},
|
||||
"expected_files": expected,
|
||||
"tbd_sections": tbd_sections,
|
||||
"low_confidence": low_confidence,
|
||||
}
|
||||
Reference in New Issue
Block a user