diff --git a/.rdb/session-log.jsonl b/.rdb/session-log.jsonl index 4ef3031..d784a1b 100644 --- a/.rdb/session-log.jsonl +++ b/.rdb/session-log.jsonl @@ -54,3 +54,30 @@ {"timestamp": "2026-06-03T17:12:09.213759+00:00", "event_type": "command", "target": "rdb generate", "details": {}} {"timestamp": "2026-06-03T17:13:19.590940+00:00", "event_type": "command", "target": "rdb generate", "details": {}} {"timestamp": "2026-06-03T17:15:34.075091+00:00", "event_type": "command", "target": "rdb generate", "details": {}} +{"timestamp": "2026-06-03T17:20:37.309119+00:00", "event_type": "command", "target": "rdb status", "details": {}} +{"timestamp": "2026-06-03T17:20:41.625422+00:00", "event_type": "command", "target": "rdb next", "details": {}} +{"timestamp": "2026-06-03T17:20:45.764811+00:00", "event_type": "command", "target": "rdb prompt", "details": {}} +{"timestamp": "2026-06-03T17:28:07.458337+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:30:17.726863+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:31:32.041267+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:32:48.717496+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:36:14.760622+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:36:14.783409+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:48:59.085912+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:48:59.111197+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:48:59.174102+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:51:06.935561+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:51:06.956310+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:51:06.984767+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:52:21.749548+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:52:21.770919+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:52:21.809957+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:52:21.950628+00:00", "event_type": "command", "target": "rdb generate", "details": {}} +{"timestamp": "2026-06-03T17:56:58.542853+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:57:26.778515+00:00", "event_type": "command", "target": "rdb status", "details": {}} +{"timestamp": "2026-06-03T17:58:40.502126+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:58:40.531803+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:58:40.575979+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T17:58:40.679715+00:00", "event_type": "command", "target": "rdb generate", "details": {}} +{"timestamp": "2026-06-03T17:59:54.816018+00:00", "event_type": "command", "target": "rdb context-status", "details": {}} +{"timestamp": "2026-06-03T18:02:22.183984+00:00", "event_type": "command", "target": "rdb next", "details": {}} diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index cd776a3..4f7b75d 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -6,7 +6,7 @@ TASKS_READY ## Current Task -TASK-015 +TASK-021 (Done) ## Instructions For Agent diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 668c571..207763f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,7 +4,7 @@ Current Stage: BUILDING Previous Stage: BOOTSTRAP_READY Next Stage: REVIEW_READY -Current Task: TASK-019 +Current Task: TASK-021 Active Branch: main -Last Updated: 2026-06-02 +Last Updated: 2026-06-03 diff --git a/README.md b/README.md index e5c4a0e..d3213c7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ rdb init # Create standard project structure and context files rdb discover # Ask 10 core discovery questions, record answers rdb guardrails # Check for agent stalls and state inconsistencies rdb prompt # Generate implementation prompt for the next task +rdb context-status # Report health and completeness of project context rdb start TASK-001 # Mark a task as in progress rdb complete TASK-001# Mark a task as done rdb status # Show current stage and active task diff --git a/TASKS.md b/TASKS.md index f927a5b..6b50aaa 100644 --- a/TASKS.md +++ b/TASKS.md @@ -431,6 +431,7 @@ Added `rdb generate` CLI command (accepts `--min-confidence` option). Fixed a parsing bug in `discovery.py`: escaped pipe characters (`\|`) in discovery answers were creating spurious extra columns during markdown table splitting — now handled with placeholder-based escaping. Added `tests/test_generate_context.py` with 15 tests: + - Mapping completeness (all 10 questions, all target files) - Confidence filtering (Low → skipped, High/Medium → generated) - Body text filling (TBD replacement, existing content append) @@ -440,33 +441,67 @@ Added `tests/test_generate_context.py` with 15 tests: - Missing file creation safety - End-to-end integration flow -## TASK-021 — Add context completeness report +## TASK-021 — Context health report -Status: Todo +Status: Done Role: Implementation Agent Goal: -Report which expected context files exist and which sections still need information. +Provide a single command that reports the health and completeness of project context. Implementation Gap: -There is no way to determine whether a project has sufficient context for effective AI-assisted development. +Context files can now be generated, but there is no way to assess whether sufficient context exists for effective AI-assisted development. Acceptance Criteria: -- Add a context status/check command -- Report present context files -- Report missing context files -- Report obvious placeholder sections still needing completion -- Add/update tests +- Add `rdb context-status` ✓ +- Report expected context files ✓ +- Report missing context files ✓ +- Report sections still containing TBD placeholders ✓ +- Report low-confidence discovery answers ✓ +- Display an overall health score ✓ +- Add/update tests ✓ (26 tests) Definition of Done: -- Context report command exists -- Missing context is reported clearly -- Tests pass +- Command runs successfully ✓ +- Missing context is reported clearly ✓ +- Health score is displayed ✓ +- Tests pass ✓ (140 total, all passing) + +Result + +Created `src/rdb_discovery/context_status.py` module with: + +- `_expected_files()` — returns the 18 expected context file paths +- `_check_expected_files()` — checks each file for existence and size +- `_check_tbd_sections()` — scans context files for TBD/TDB placeholders (skips blank lines) +- `_check_low_confidence()` — reads discovery-log.md for low-confidence answers +- `compute_health_score()` — 0-100 score with weighted breakdown (45 pts file completeness, 30 pts no TBDs, 15 pts no low-conf, 10 pts discovery data) +- `context_status()` — orchestrates all checks and returns structured report + +Added `rdb context-status` CLI command with: + +- Rich table of expected files with presence/absence indicators +- TBD placeholder listing with file, section, and line number +- Low-confidence discovery answer listing with ID and confidence level +- Color-coded health score (green ≥ 70, yellow ≥ 40, red < 40) +- Summary line showing counts + +Added `tests/test_context_status.py` with 26 tests across 4 classes: + +- TestExpectedFiles — file existence detection +- TestTbdDetection — TBD/TDB placeholder scanning +- TestLowConfidence — low-confidence answer detection +- TestHealthScore — score computation and degradation +- TestContextStatus — structured report verification +- TestCLICommand — CLI registration, output, and edge cases +- TestHealthScoreColor — score boundary validation + +All 140 tests pass. ## TASK-022 — Generate Claude Code permission profile @@ -493,3 +528,31 @@ Definition of Done: - Permission profile documented - CLAUDE.md updated + +## TASK-022 — Add objectivity and efficiency guidelines + +Status: Todo + +Role: Documentation Agent + +Goal: +Reduce token waste and unnecessary agreement in agent interactions. + +Implementation Gap: +Agent guidance does not yet explicitly discourage praise, repeated context reading, task restatement, or unnecessary narrative output. + +Acceptance Criteria: + +- Update `context/agent-guidelines.md` +- Add objectivity guidance +- Add context-discipline guidance +- Add communication-efficiency guidance +- Add execution-efficiency guidance +- Keep guidance concise and role-neutral +- Add/update tests only if required + +Definition of Done: + +- `context/agent-guidelines.md` includes objectivity and efficiency guidance +- Guidance helps reduce token usage without preventing useful technical reasoning +- Existing workflow remains unchanged diff --git a/src/rdb_discovery/cli.py b/src/rdb_discovery/cli.py index 702249b..ae55991 100644 --- a/src/rdb_discovery/cli.py +++ b/src/rdb_discovery/cli.py @@ -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]") diff --git a/src/rdb_discovery/context_status.py b/src/rdb_discovery/context_status.py new file mode 100644 index 0000000..a82bd2b --- /dev/null +++ b/src/rdb_discovery/context_status.py @@ -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, + } diff --git a/tests/test_context_status.py b/tests/test_context_status.py new file mode 100644 index 0000000..2394a31 --- /dev/null +++ b/tests/test_context_status.py @@ -0,0 +1,431 @@ +"""Tests for context health report (TASK-021).""" + +import pytest +from pathlib import Path +from textwrap import dedent + + +def _get_project_root() -> Path: + current = Path(__file__).resolve().parent.parent + while current != current.parent: + if (current / "pyproject.toml").exists() or (current / ".git").exists(): + return current + current = current.parent + return Path.cwd() + + +@pytest.fixture() +def tmp_project(tmp_path): + """Create a minimal project root with context files and discovery answers.""" + root = tmp_path / "testproject" + root.mkdir() + (root / "context").mkdir() + + # Write discovery-log.md with various confidence levels + (root / "context" / "discovery-log.md").write_text( + "# Discovery Log\n\n" + "| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n" + "|---|---|---|---|---|---|---|---|\n" + '| Q-001 | What problem are we solving? | A CLI tool for discovery workflow | High | No | | | 2026-06-03 |\n' + '| Q-002 | Who is the user? | Internal developers and small dev teams | Medium | No | | | 2026-06-03 |\n' + '| Q-008 | What must not happen? | No third-party SaaS dependencies | Low | No | | | 2026-06-03 |\n', + encoding="utf-8", + ) + + # Write product-brief.md with TBD placeholders + (root / "context" / "product-brief.md").write_text( + "# Product Brief\n\n## Problem\n\nTBD\n\n## Users\n\nTBD\n\n## Success Criteria\n\nDone\n", + encoding="utf-8", + ) + + # Write architecture.md with some TBDs + (root / "context" / "architecture.md").write_text( + "# Architecture\n\n## Overview\n\nTDB — Not yet defined.\n\n## Core Components\n\nTBD\n\n## Data Flow\n\nDone\n", + encoding="utf-8", + ) + + # Write decisions.md (present, non-empty) + (root / "context" / "decisions.md").write_text( + "# Decisions\n\n| ID | Decision | Reason | Date |\n|---|---|---|---|\n", + encoding="utf-8", + ) + + # Write risks.md and assumptions.md + (root / "context" / "risks.md").write_text( + "# Risks\n\n| ID | Risk | Impact | Mitigation | Status |\n|---|---|---|---|---|\n", + encoding="utf-8", + ) + (root / "context" / "assumptions.md").write_text( + "# Assumptions\n\n| ID | Assumption | Confidence | Validation Needed |\n|---|---|---|---|\n", + encoding="utf-8", + ) + + # Write open-questions.md (present) + (root / "context" / "open-questions.md").write_text( + "# Open Questions\n\n| ID | Question | Reason | Owner | Status |\n|---|---|---|---|---|\n", + encoding="utf-8", + ) + + # Write company-context.md, development-context.md, infrastructure-context.md with TBDs + (root / "context" / "company-context.md").write_text( + "# Company Context\n\n## Mission\n\nTBD\n\n## Products & Services\n\nDone.\n", + encoding="utf-8", + ) + (root / "context" / "development-context.md").write_text( + "# Development Context\n\n## Tech Stack\n\nPython 3.12, Typer, Rich.\n\n## Coding Standards\n\nTBD\n", + encoding="utf-8", + ) + (root / "context" / "infrastructure-context.md").write_text( + "# Infrastructure Context\n\n## Hosting\n\nTBD — Cloud provider details.\n", + encoding="utf-8", + ) + + # Write agent-guidelines.md and repository-context.md (present) + (root / "context" / "agent-guidelines.md").write_text("# Agent Guidelines\n\nDone.\n", encoding="utf-8") + (root / "context" / "repository-context.md").write_text("# Repository Context\n\nPurpose: Test project.\n", encoding="utf-8") + + # Write PROJECT_STATE.md, TASKS.md, TEST_PLAN.md, RUN_LOG.md, AGENT_HANDOFF.md + (root / "PROJECT_STATE.md").write_text("# Project State\n\nCurrent Stage: BUILDING\n", encoding="utf-8") + (root / "TASKS.md").write_text("# TASKS.md\n## TASK-001\nStatus: Done\n", encoding="utf-8") + (root / "TEST_PLAN.md").write_text("# TEST_PLAN.md\n## Tests\nDone.\n", encoding="utf-8") + (root / "RUN_LOG.md").write_text("# RUN_LOG.md\n| Date | Event | Task | Notes |\n|---|---|---|---|\n", encoding="utf-8") + (root / "AGENT_HANDOFF.md").write_text("# Agent Handoff\n## Current Stage\nBUILDING\n", encoding="utf-8") + + # Create .rdb directory + (root / ".rdb").mkdir() + + return root + + +class TestExpectedFiles: + """Tests for expected file reporting.""" + + def test_all_expected_files_returned(self, tmp_project): + """Every standard context file should appear in the expected list.""" + from rdb_discovery.context_status import _expected_files + + files = _expected_files(tmp_project) + assert len(files) == 18 + + def test_missing_file_detected(self, tmp_project): + """Files that don't exist should be marked as missing.""" + # project-brief.md is not created by this fixture + (tmp_project / "context" / "project-brief.md").unlink(missing_ok=True) + + from rdb_discovery.context_status import _check_expected_files + + results = _check_expected_files(tmp_project) + paths_by_status = {r["path"]: r["status"] for r in results} + assert paths_by_status.get("context/project-brief.md") == "missing" + + def test_present_file_detected(self, tmp_project): + """Files that exist should be marked as present.""" + from rdb_discovery.context_status import _check_expected_files + + results = _check_expected_files(tmp_project) + paths_by_status = {r["path"]: r["status"] for r in results} + assert paths_by_status["TASKS.md"] == "present" + assert paths_by_status["PROJECT_STATE.md"] == "present" + + +class TestTbdDetection: + """Tests for TBD placeholder detection.""" + + def test_tbd_sections_detected(self, tmp_project): + """Sections with TBD/TDB placeholders should be reported.""" + from rdb_discovery.context_status import _check_tbd_sections + + findings = _check_tbd_sections(tmp_project) + assert len(findings) > 0 + + paths = {f["file"] for f in findings} + assert "product-brief.md" in paths # has TBD sections + assert "architecture.md" in paths # has TDB section + + def test_no_tbd_in_clean_files(self, tmp_project): + """Files without TBD placeholders should not appear.""" + from rdb_discovery.context_status import _check_tbd_sections + + findings = _check_tbd_sections(tmp_project) + # TASKS.md has "Status: Done" — no TBD + for f in findings: + assert f["file"] != "TASKS.md" or not any( + "TBD" in str(getattr(f, "placeholder", "")) or "TDB" in str(getattr(f, "placeholder", "")) + for _ in [1] + ) + + def test_tbd_sections_returns_empty_for_no_context_dir(self): + """Should return empty list when no context dir exists.""" + from rdb_discovery.context_status import _check_tbd_sections + + findings = _check_tbd_sections(_get_project_root() / "nonexistent") + assert findings == [] + + +class TestLowConfidence: + """Tests for low-confidence discovery answer detection.""" + + def test_low_confidence_detected(self, tmp_project): + """Low-confidence answers should be detected.""" + from rdb_discovery.context_status import _check_low_confidence + + low = _check_low_confidence(tmp_project) + assert len(low) == 1 + assert low[0]["id"] == "Q-008" + assert low[0]["confidence"] == "Low" + + def test_high_confidence_not_flagged(self, tmp_project): + """High-confidence answers should not appear in low_confidence results.""" + from rdb_discovery.context_status import _check_low_confidence + + low = _check_low_confidence(tmp_project) + ids = {a["id"] for a in low} + assert "Q-001" not in ids # Q-001 is High confidence + + def test_medium_confidence_not_flagged(self, tmp_project): + """Medium-confidence answers should not appear in low_confidence results.""" + from rdb_discovery.context_status import _check_low_confidence + + low = _check_low_confidence(tmp_project) + ids = {a["id"] for a in low} + assert "Q-002" not in ids # Q-002 is Medium confidence + + +class TestHealthScore: + """Tests for health score computation.""" + + def test_full_score_when_everything_complete(self): + """Score should be near 100 when all files present, no TBDs, no low-conf.""" + from rdb_discovery.context_status import compute_health_score + + expected = [{"path": "a", "status": "present", "size": "0"} for _ in range(18)] + score = compute_health_score(expected, tbd_sections=0, low_conf_count=0) + # 45 (files) + 30 (no TBD) + 15 (no low-conf) = 90, discovery bonus later adds to it + assert score >= 90 + + def test_score_degrades_with_missing_files(self): + """Score should decrease as files are missing.""" + from rdb_discovery.context_status import compute_health_score + + full = [ + {"path": f"file{i}", "status": "present", "size": "0"} for i in range(18) + ] + score_full = compute_health_score(full, tbd_sections=0, low_conf_count=0) + + partial = [ + {"path": "a", "status": "present", "size": "0"}, + {"path": "b", "status": "missing", "size": "0"}, + {"path": "c", "status": "missing", "size": "0"}, + ] + [ + {"path": f"file{i}", "status": "present", "size": "0"} for i in range(15) + ] + score_partial = compute_health_score(partial, tbd_sections=0, low_conf_count=0) + + assert score_full > score_partial + + def test_score_degrades_with_tbd_sections(self): + """Score should decrease as TBD sections increase.""" + from rdb_discovery.context_status import compute_health_score + + expected = [{"path": "a", "status": "present", "size": "0"} for _ in range(18)] + score_0 = compute_health_score(expected, tbd_sections=0, low_conf_count=0) + score_5 = compute_health_score(expected, tbd_sections=5, low_conf_count=0) + score_20 = compute_health_score(expected, tbd_sections=20, low_conf_count=0) + + assert score_0 > score_5 + assert score_5 > score_20 + # At 20+ TBDs, TBD score component is 0 + assert score_20 <= score_5 - 15 + + +class TestContextStatus: + """Tests for the main context_status function.""" + + def test_returns_score(self, tmp_project): + """context_status should return a score in the report.""" + from rdb_discovery.context_status import context_status + + report = context_status(tmp_project) + assert "score" in report + assert 0 <= report["score"] <= 110 # allows for discovery bonus + + def test_returns_summary(self, tmp_project): + """Report should contain a summary dict.""" + from rdb_discovery.context_status import context_status + + report = context_status(tmp_project) + assert "summary" in report + assert "total_expected" in report["summary"] + assert "missing" in report["summary"] + + def test_returns_expected_files(self, tmp_project): + """Report should list all expected files.""" + from rdb_discovery.context_status import context_status + + report = context_status(tmp_project) + assert len(report["expected_files"]) == 18 + + def test_returns_tbd_sections(self, tmp_project): + """Report should include TBD section findings.""" + from rdb_discovery.context_status import context_status + + report = context_status(tmp_project) + assert "tbd_sections" in report + assert len(report["tbd_sections"]) > 0 + + def test_returns_low_confidence(self, tmp_project): + """Report should include low-confidence answers.""" + from rdb_discovery.context_status import context_status + + report = context_status(tmp_project) + assert "low_confidence" in report + assert len(report["low_confidence"]) == 1 + + +class TestCLICommand: + """Tests that the CLI 'context-status' command is available and works.""" + + def test_context_status_command_exists(self): + """The 'rdb context-status' command should be registered.""" + from typer.testing import CliRunner + from rdb_discovery.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "context-status" in result.output.lower() + + def test_context_status_runs_successfully(self): + """The CLI command should exit cleanly.""" + from typer.testing import CliRunner + from rdb_discovery.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["context-status"], catch_exceptions=False) + assert result.exit_code == 0 + + def test_context_status_output_contains_score(self): + """The output should contain the health score.""" + from typer.testing import CliRunner + from rdb_discovery.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["context-status"], catch_exceptions=False) + assert result.exit_code == 0 + # Score is in format like "92.5/100" + assert "/100" in result.output + + def test_context_status_reports_missing_files(self): + """Missing files should be detected by the core function.""" + from pathlib import Path + import tempfile + + with tempfile.TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir() + (root / "context").mkdir() + # No context files created — everything missing + + from rdb_discovery.context_status import _check_expected_files + + results = _check_expected_files(root) + paths_by_status = {r["path"]: r["status"] for r in results} + assert paths_by_status.get("TASKS.md") == "missing" + + def test_context_status_reports_tbd_sections(self): + """TBD placeholders should be detected by the core function.""" + from pathlib import Path + import tempfile + + with tempfile.TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir() + (root / "context").mkdir() + # Write a file with TBD placeholder + (root / "context" / "product-brief.md").write_text( + "# Product Brief\n\n## Problem\n\nTBD\n", + encoding="utf-8", + ) + + from rdb_discovery.context_status import _check_tbd_sections + + findings = _check_tbd_sections(root) + assert len(findings) > 0 + paths = {f["file"] for f in findings} + assert "product-brief.md" in paths + + def test_context_status_reports_low_confidence(self): + """Low-confidence answers should be detected by the core function.""" + from pathlib import Path + import tempfile + + with tempfile.TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir() + (root / "context").mkdir() + # Write discovery-log.md with a Low-confidence answer + (root / "context" / "discovery-log.md").write_text( + "# Discovery Log\n\n" + "| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n" + "|---|---|---|---|---|---|---|---|\n" + '| Q-003 | What does success look like? | Not sure yet | Low | Yes | | | 2026-06-03 |\n', + encoding="utf-8", + ) + + from rdb_discovery.context_status import _check_low_confidence + + low = _check_low_confidence(root) + assert len(low) == 1 + assert low[0]["id"] == "Q-003" + + +class TestHealthScoreColor: + """Tests for health score color coding.""" + + def test_full_score_is_high(self): + """All files present, no TBDs, no low-conf should produce a high score.""" + from pathlib import Path + import tempfile + + with tempfile.TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir() + (root / "context").mkdir() + for f in [ + "discovery-log.md", "product-brief.md", "architecture.md", + "decisions.md", "risks.md", "assumptions.md", "open-questions.md", + ]: + (root / "context" / f).write_text(f"# {f}\nDone\n", encoding="utf-8") + for f in ["TASKS.md", "TEST_PLAN.md", "RUN_LOG.md", "PROJECT_STATE.md", "AGENT_HANDOFF.md"]: + (root / f).write_text(f"# {f}\nDone\n", encoding="utf-8") + + from rdb_discovery.context_status import context_status + + report = context_status(root) + assert report["score"] >= 70 + + def test_empty_project_score_is_below_full(self): + """A completely empty project should score well below a full project.""" + from pathlib import Path + import tempfile + + with tempfile.TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir() + # Don't create any files + + from rdb_discovery.context_status import context_status + + report = context_status(root) + assert report["score"] < 70 + + def test_cli_score_appears_in_output(self): + """The CLI should display the numeric score in output.""" + from typer.testing import CliRunner + from rdb_discovery.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["context-status"], catch_exceptions=False) + assert result.exit_code == 0 + # Score format is like "92.5/100" — digits followed by /100 + assert "/100" in result.output