feat(task-021): add context health reporting

This commit is contained in:
2026-06-03 19:05:04 +01:00
parent 5bad6ad0f8
commit a0b28e7d8d
8 changed files with 749 additions and 15 deletions
+62
View File
@@ -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]")
+150
View File
@@ -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,
}