Compare commits
13
Commits
v0.1.0
..
13f5e10f6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f5e10f6f | ||
|
|
6eef97582d | ||
|
|
2fcc822394 | ||
|
|
eb30450f34 | ||
|
|
8222660a3c | ||
|
|
8b96d2c758 | ||
|
|
7a1d36c03c | ||
|
|
318b9471ae | ||
|
|
aee13b045e | ||
|
|
21611b7c4e | ||
|
|
9536f5d0ff | ||
|
|
5456947ac9 | ||
|
|
39728667a7 |
@@ -0,0 +1,17 @@
|
||||
{"timestamp": "2026-06-02T14:38:58.532898+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T14:43:05.281782+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T14:47:33.841698+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T14:49:48.692247+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T14:49:51.767548+00:00", "event_type": "command", "target": "rdb next", "details": {}}
|
||||
{"timestamp": "2026-06-02T14:49:58.009037+00:00", "event_type": "command", "target": "rdb prompt", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:21:45.848114+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:40:37.716111+00:00", "event_type": "command", "target": "rdb guardrails", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:42:28.735379+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:44:50.746110+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:44:50.931122+00:00", "event_type": "command", "target": "rdb next", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:58:40.572784+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:58:44.609967+00:00", "event_type": "command", "target": "rdb next", "details": {}}
|
||||
{"timestamp": "2026-06-02T15:58:49.677959+00:00", "event_type": "command", "target": "rdb prompt", "details": {}}
|
||||
{"timestamp": "2026-06-02T16:01:01.182387+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-02T16:05:14.118862+00:00", "event_type": "command", "target": "rdb complete", "details": {"task_id": "TASK-015"}}
|
||||
{"timestamp": "2026-06-02T16:05:53.807097+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
+2
-2
@@ -2,11 +2,11 @@
|
||||
|
||||
## Current Stage
|
||||
|
||||
BUILDING
|
||||
TASKS_READY
|
||||
|
||||
## Current Task
|
||||
|
||||
TASK-002 — Run initial project discovery
|
||||
TASK-015
|
||||
|
||||
## Instructions For Agent
|
||||
|
||||
|
||||
@@ -18,3 +18,34 @@ Rules:
|
||||
- Stop.
|
||||
|
||||
Do not start the next task automatically.
|
||||
|
||||
## Test Commands
|
||||
|
||||
Use the existing virtual environment.
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Do not search the filesystem for pytest.
|
||||
Do not create a new virtual environment unless explicitly asked.
|
||||
|
||||
If pytest is unavailable, run:
|
||||
|
||||
```bash
|
||||
pip install -e '.[dev]'
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
## Validation Commands
|
||||
|
||||
For normal task validation, run:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
rdb status
|
||||
rdb next
|
||||
```
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Current Stage: BUILDING
|
||||
Previous Stage: BOOTSTRAP_READY
|
||||
Next Stage: REVIEW_READY
|
||||
|
||||
Current Task: TASK-004
|
||||
Current Task: TASK-015
|
||||
Active Branch: main
|
||||
|
||||
Last Updated: 2026-06-02
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# rdb-discovery
|
||||
|
||||
A small CLI-first tool for repeatable software project discovery and delivery.
|
||||
A small CLI-first tool that helps you generate and maintain useful project context files for AI-assisted development.
|
||||
|
||||
It creates markdown files that help humans and AI coding agents understand:
|
||||
## Purpose
|
||||
|
||||
rdb-discovery exists to make it easier for humans and AI coding agents to understand:
|
||||
|
||||
- what is being built
|
||||
- why it exists
|
||||
@@ -10,6 +12,20 @@ It creates markdown files that help humans and AI coding agents understand:
|
||||
- what task should be done next
|
||||
- what stage the project is currently in
|
||||
|
||||
The tool creates and manages a set of markdown files — your project's source-of-truth context.
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. **Ask discovery questions** — `rdb discover` captures answers about the product, architecture, risks, and open questions.
|
||||
2. **Capture answers** — Structured responses are stored in `context/discovery-log.md` alongside confidence scores and follow-ups.
|
||||
3. **Generate context files** — `rdb init` creates standardised project files (`product-brief.md`, `architecture.md`, `decisions.md`, etc.).
|
||||
4. **Support agent implementation** — `rdb prompt` produces ready-to-paste implementation prompts for Claude Code or Cline, and `rdb start` / `rdb complete` manage task lifecycles.
|
||||
|
||||
## Support features (not the product)
|
||||
|
||||
- **Telemetry** records structured agent activity events in `.rdb/session-log.jsonl`. It enables future analysis but is not the main offering.
|
||||
- **Guardrails** detect agent stalls, repeated reads, and inconsistent state to keep projects on track. They support the workflow but are secondary.
|
||||
|
||||
## Install for local development
|
||||
|
||||
```bash
|
||||
@@ -21,23 +37,24 @@ pip install -e '.[dev]'
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
rdb init
|
||||
rdb discover
|
||||
rdb status
|
||||
rdb next
|
||||
rdb start TASK-001
|
||||
rdb complete TASK-001
|
||||
rdb handoff
|
||||
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 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
|
||||
rdb next # Print the next task to work on
|
||||
rdb handoff # Generate agent handoff notes
|
||||
```
|
||||
|
||||
## Bootstrap workflow
|
||||
|
||||
1. Run `rdb init`
|
||||
2. Run `rdb discover`
|
||||
3. Run `rdb status`
|
||||
4. Run `rdb next`
|
||||
5. Give the next task to Claude Code or Cline
|
||||
6. Commit after each completed task
|
||||
3. Review `rdb status` and `rdb next`
|
||||
4. Give the next prompt to Claude Code or Cline via `rdb prompt`
|
||||
5. Commit after each completed task
|
||||
|
||||
## Project structure
|
||||
|
||||
|
||||
@@ -8,3 +8,8 @@ TASK-001 marked Done — rdb init creates all project control files.
|
||||
2026-06-02
|
||||
Added TASK-002 for agent prompt generation.
|
||||
TASK-002 marked Done — agent prompt generation complete.
|
||||
| 2026-06-02T10:01:31 | Task completed | TASK-004 | 28 tests passed |
|
||||
| 2026-06-02T10:02:18 | Task completed | TASK-004 | rdb ask-more implemented and tested |
|
||||
| 2026-06-02T12:51:20 | Task completed | TASK-007 | enhanced prompts |
|
||||
| 2026-06-02T13:45:00 | Task completed | TASK-012 | telemetry foundation — writer, JSONL storage, event reader, 12 tests passed |
|
||||
| 2026-06-02T17:05:41 | Task completed | TASK-015 | updated README.md |
|
||||
|
||||
@@ -63,7 +63,7 @@ Acceptance Criteria:
|
||||
|
||||
## TASK-004 — Implement ask-more command
|
||||
|
||||
Status: Todo
|
||||
Status: Done
|
||||
|
||||
Goal: Add a command that finds weak answers and asks deeper follow-up questions.
|
||||
|
||||
@@ -72,3 +72,249 @@ Acceptance Criteria:
|
||||
- command reads discovery-log.md
|
||||
- low-confidence answers are detected
|
||||
- follow-up answers are appended to discovery-log.md
|
||||
|
||||
## TASK-005 — Task lifecycle commands
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Allow tasks to be managed from the CLI rather than manually editing TASKS.md.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- `rdb start TASK-ID`
|
||||
- Marks task In Progress
|
||||
|
||||
- `rdb complete TASK-ID`
|
||||
- Marks task Done
|
||||
|
||||
- Updates PROJECT_STATE.md
|
||||
- Updates AGENT_HANDOFF.md
|
||||
- Updates RUN_LOG.md
|
||||
- Preserves task formatting
|
||||
- Add/update tests
|
||||
- Run python -m pytest
|
||||
|
||||
## TASK-006 — Agent execution guardrails
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Detect agent stalls, repeated reads, long reasoning loops, and non-progressing execution.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- `rdb guardrails` command exists ✓
|
||||
- Detects repeated file reads via AGENT_HANDOFF.md analysis ✓
|
||||
- Detects repeated command entries in RUN_LOG.md ✓
|
||||
- Flags project files not modified in >48 hours ✓
|
||||
- Flags missing test run records in RUN_LOG.md ✓
|
||||
- Checks TASKS.md ↔ RUN_LOG.md consistency ✓
|
||||
- Produces clear human-readable report with overall status ✓
|
||||
- All existing tests still pass (33 → 51) ✓
|
||||
- Guardrail-specific tests added (18 new tests) ✓
|
||||
|
||||
## TASK-007 — Improve generated agent prompts
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Make `rdb prompt` produce smaller, more direct prompts for Claude Code/local LLM agents.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Prompt includes exact known implementation gap when available
|
||||
- Prompt includes existing test command from CLAUDE.md
|
||||
- Prompt tells agent not to repeatedly reread unchanged files
|
||||
- Prompt tells agent to inspect first, then edit
|
||||
- Prompt limits scope to one small implementation step
|
||||
- Add/update tests
|
||||
|
||||
## TASK-012 — Telemetry foundation
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Create a minimal telemetry system that can record structured agent activity for future guardrail and analysis features.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
The project currently infers agent behaviour from documentation, task files, and run logs.
|
||||
|
||||
There is no structured event log showing what actions an agent actually performed during a session.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Create a telemetry module
|
||||
- Create `.rdb/session-log.jsonl` automatically when recording an event
|
||||
- Support recording events in JSONL format
|
||||
- Each event contains:
|
||||
- timestamp
|
||||
- event_type
|
||||
- target
|
||||
- details
|
||||
- Provide a simple public function for writing telemetry events
|
||||
- Add/update tests
|
||||
|
||||
Example Event:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-06-02T12:00:00Z",
|
||||
"event_type": "command",
|
||||
"target": "rdb prompt",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
Constraints:
|
||||
|
||||
- Do not integrate telemetry into existing commands yet
|
||||
- Do not modify guardrails yet
|
||||
- Do not implement dashboards or reporting
|
||||
- Build the smallest useful telemetry foundation only
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Telemetry writer exists
|
||||
- JSONL file is created correctly
|
||||
- Events append correctly
|
||||
- Tests pass
|
||||
- Documentation updated if required
|
||||
|
||||
## TASK-013 — Record CLI command execution
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Record rdb CLI command execution using the telemetry system.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
Telemetry storage exists but no command activity is recorded.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Record command execution events
|
||||
- Include command name
|
||||
- Include timestamp
|
||||
- Add/update tests
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- All 10 CLI commands record a telemetry event on invocation
|
||||
- Events have event_type "command" and target "rdb <cmd_name>"
|
||||
- Timestamps are present in UTC ISO format
|
||||
- New integration tests added to test_telemetry.py (4 new tests)
|
||||
- All 75 tests pass
|
||||
|
||||
## TASK-014 — Integrate telemetry with guardrails
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Use structured telemetry data in guardrail analysis.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
Guardrails currently rely on heuristics and markdown files rather than actual activity records.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Read telemetry events ✓
|
||||
- Detect repeated commands ✓
|
||||
- Detect repeated reads when available ✓
|
||||
- Fall back gracefully when telemetry is absent ✓
|
||||
- Add/update tests ✓
|
||||
|
||||
Result:
|
||||
|
||||
Two new guardrail checks added:
|
||||
|
||||
- `check_repeated_commands_telemetry` — uses session-log.jsonl to detect repeated CLI commands (>3x)
|
||||
- `check_repeated_reads_telemetry` — uses session-log.jsonl to detect repeated file reads (>3x)
|
||||
Both integrate into `run_all_guardrails` alongside existing heuristic checks.
|
||||
When telemetry data is absent, both return `"ok"` with an informative fallback message instead of failing.
|
||||
|
||||
## TASK-015 — Re-centre project purpose
|
||||
|
||||
Status: Done
|
||||
|
||||
Goal:
|
||||
Clarify that rdb-discovery exists to help generate and maintain useful project context files for AI-assisted development.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
The project now has task, prompt, guardrail, and telemetry features, but the core product purpose needs to be made explicit again.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Update README.md with a clear project purpose
|
||||
- Explain the core workflow:
|
||||
- ask discovery questions
|
||||
- capture answers
|
||||
- generate context files
|
||||
- support agent implementation
|
||||
- Clarify that telemetry and guardrails support the workflow but are not the main product
|
||||
- Add/update tests only if required
|
||||
|
||||
## TASK-016 — Define standard context file templates
|
||||
|
||||
Status: Todo
|
||||
|
||||
Goal:
|
||||
Define the standard context files that rdb-discovery should help generate.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
There is not yet a clear built-in definition of the context files the tool should produce.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Define templates for:
|
||||
- company-context.md
|
||||
- development-context.md
|
||||
- infrastructure-context.md
|
||||
- agent-guidelines.md
|
||||
- project-brief.md
|
||||
- architecture.md
|
||||
- Templates include headings and placeholder guidance
|
||||
- Keep templates simple markdown
|
||||
- Add/update tests
|
||||
|
||||
## TASK-017 — Generate context files from discovery answers
|
||||
|
||||
Status: Todo
|
||||
|
||||
Goal:
|
||||
Use captured discovery answers to generate initial context files.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
Discovery questions can collect information, but the answers are not yet transformed into structured project context documents.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Add command or function to generate context files from discovery data
|
||||
- Create missing context files safely
|
||||
- Do not overwrite existing files without explicit behaviour
|
||||
- Add/update tests
|
||||
|
||||
## TASK-018 — Add context completeness report
|
||||
|
||||
Status: Todo
|
||||
|
||||
Goal:
|
||||
Report which expected context files exist and which sections still need information.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
There is no way to see whether a project has enough context for an AI agent to work effectively.
|
||||
|
||||
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
|
||||
|
||||
@@ -15,3 +15,17 @@ Expected result:
|
||||
|
||||
Tests pass
|
||||
CLI help displays
|
||||
|
||||
## Guardrails Validation
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rdb guardrails
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
A report listing at least 5 checks (repeated reads, repeated commands,
|
||||
recent file changes, test run record, RUN_LOG consistency) with an
|
||||
overall status summary.
|
||||
|
||||
@@ -7,11 +7,13 @@ import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from .discovery import append_discovery_answer, core_questions
|
||||
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
|
||||
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
|
||||
|
||||
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
|
||||
console = Console()
|
||||
@@ -34,6 +36,7 @@ def append_run_log(root: Path, event: str, task_id: str = "", notes: str = "") -
|
||||
def init() -> None:
|
||||
"""Create the initial markdown project structure."""
|
||||
root = root_path()
|
||||
record_event(root, "command", "rdb init")
|
||||
created: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
@@ -58,6 +61,7 @@ def init() -> None:
|
||||
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):
|
||||
@@ -71,10 +75,55 @@ def discover() -> None:
|
||||
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)
|
||||
@@ -94,7 +143,9 @@ def status() -> None:
|
||||
@app.command(name="next")
|
||||
def next_task() -> None:
|
||||
"""Show the next Todo task."""
|
||||
task = get_next_task(root_path())
|
||||
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)
|
||||
@@ -108,10 +159,13 @@ def next_task() -> None:
|
||||
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]")
|
||||
|
||||
|
||||
@@ -119,23 +173,39 @@ def start(task_id: str) -> None:
|
||||
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."""
|
||||
console.print(build_handoff(root_path()))
|
||||
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)
|
||||
|
||||
@@ -36,3 +36,71 @@ def append_discovery_answer(
|
||||
|
||||
def core_questions() -> list[str]:
|
||||
return CORE_QUESTIONS
|
||||
|
||||
|
||||
def read_discovery_answers(root: Path) -> list[dict]:
|
||||
"""Read discovery-log.md and return parsed rows as dicts.
|
||||
|
||||
Each dict has keys: id, question, answer, confidence, follow_up_needed.
|
||||
Rows with 'Low' confidence or follow_up_needed == 'Yes' are flagged
|
||||
via ``needs_followup`` and ``low_confidence`` boolean fields.
|
||||
"""
|
||||
log_path = root / "context" / "discovery-log.md"
|
||||
if not log_path.exists():
|
||||
return []
|
||||
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
answers: list[dict] = []
|
||||
for line in lines:
|
||||
# Skip header and non-data rows
|
||||
if not line.startswith("| Q-"):
|
||||
continue
|
||||
|
||||
cells = [c.strip() for c in line.split("|")[1:-1]]
|
||||
if len(cells) < 5:
|
||||
continue
|
||||
|
||||
answers.append({
|
||||
"id": cells[0],
|
||||
"question": cells[1],
|
||||
"answer": cells[2].replace("\\|", "|"),
|
||||
"confidence": cells[3],
|
||||
"follow_up_needed": cells[4],
|
||||
"needs_followup": cells[4] == "Yes",
|
||||
"low_confidence": cells[3] in ("Low", "low"),
|
||||
})
|
||||
|
||||
return answers
|
||||
|
||||
|
||||
def append_followup_answer(
|
||||
root: Path,
|
||||
question_id: str,
|
||||
follow_up_question: str,
|
||||
answer: str,
|
||||
confidence: str = "Medium",
|
||||
) -> None:
|
||||
"""Append a follow-up answer row to discovery-log.md.
|
||||
|
||||
The original ``question_id`` is stored in the 'Linked decision' column.
|
||||
"""
|
||||
log_path = root / "context" / "discovery-log.md"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not log_path.exists():
|
||||
log_path.write_text(
|
||||
"# Discovery Log\n\n"
|
||||
"| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n"
|
||||
"|---|---|---|---|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
safe_answer = answer.replace("|", "\\|").replace("\n", " ").strip()
|
||||
line = (
|
||||
f"| {question_id} | {follow_up_question} | {safe_answer} | {confidence} | "
|
||||
f"Yes | | | {date.today().isoformat()} |\n"
|
||||
)
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line)
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .telemetry import read_events
|
||||
|
||||
|
||||
def _read_safe(root: Path, relative: str) -> str | None:
|
||||
"""Read a file if it exists, otherwise return None."""
|
||||
path = root / relative
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
|
||||
|
||||
def _last_modified(root: Path, relative: str) -> datetime | None:
|
||||
"""Return the last-modified time of a file, or None."""
|
||||
path = root / relative
|
||||
if path.exists():
|
||||
ts = path.stat().st_mtime
|
||||
return datetime.fromtimestamp(ts)
|
||||
return None
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual guardrail checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_repeated_reads(root: Path) -> dict:
|
||||
"""Detect files that appear to be read repeatedly in AGENT_HANDOFF.md or project context.
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
- status: 'ok', 'warning', 'error'
|
||||
- details: human-readable explanation
|
||||
"""
|
||||
handoff_text = _read_safe(root, "AGENT_HANDOFF.md")
|
||||
prompt_log = _read_safe(root, ".rdb/prompt-history.md") # optional
|
||||
|
||||
text_parts = []
|
||||
if handoff_text is not None:
|
||||
text_parts.append(handoff_text)
|
||||
if prompt_log is not None:
|
||||
text_parts.append(prompt_log)
|
||||
|
||||
if not text_parts:
|
||||
return {"status": "ok", "details": "No agent history files to analyse."}
|
||||
|
||||
combined = "\n".join(text_parts)
|
||||
file_mentions = re.findall(r"[A-Za-z0-9_/.-]+\.md", combined)
|
||||
counts = Counter(file_mentions)
|
||||
|
||||
repeated = {path: count for path, count in counts.items() if count > 2}
|
||||
|
||||
if not repeated:
|
||||
return {"status": "ok", "details": "No files appear to be read excessively."}
|
||||
|
||||
top_files = ", ".join(f"{p} ({c}x)" for p, c in list(repeated.items())[:5])
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": f"Files mentioned more than twice: {top_files}",
|
||||
}
|
||||
|
||||
|
||||
def check_repeated_commands(root: Path) -> dict:
|
||||
"""Detect repeated command entries in RUN_LOG.md.
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
"""
|
||||
run_log = _read_safe(root, "RUN_LOG.md")
|
||||
if not run_log:
|
||||
return {"status": "ok", "details": "No RUN_LOG.md found."}
|
||||
|
||||
lines = [l.strip() for l in run_log.splitlines() if l.startswith("|")]
|
||||
events = []
|
||||
for line in lines:
|
||||
parts = [p.strip() for p in line.split("|")][1:-1]
|
||||
if len(parts) >= 2:
|
||||
events.append(f"{parts[0]}|{parts[1]}")
|
||||
|
||||
counts = Counter(events)
|
||||
repeated = {k: v for k, v in counts.items() if v > 2}
|
||||
|
||||
if not repeated:
|
||||
return {"status": "ok", "details": "No significantly repeated commands detected."}
|
||||
|
||||
items = ", ".join(f"'{k}' ({v}x)" for k, v in list(repeated.items())[:5])
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": f"Repeated entries: {items}",
|
||||
}
|
||||
|
||||
|
||||
def check_repeated_commands_telemetry(root: Path) -> dict:
|
||||
"""Detect repeated commands from telemetry events.
|
||||
|
||||
Reads structured session-log.jsonl to find command targets
|
||||
that have been issued more than 3 times. Falls back gracefully
|
||||
when the telemetry log is absent.
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
"""
|
||||
events = read_events(root)
|
||||
if not events:
|
||||
return {"status": "ok", "details": "No telemetry data available."}
|
||||
|
||||
command_events = [e for e in events if e.event_type == "command"]
|
||||
if not command_events:
|
||||
return {"status": "ok", "details": "No command events found in telemetry."}
|
||||
|
||||
counts = Counter(e.target for e in command_events)
|
||||
repeated = {target: count for target, count in counts.items() if count > 3}
|
||||
|
||||
if not repeated:
|
||||
return {"status": "ok", "details": "No significantly repeated commands detected in telemetry."}
|
||||
|
||||
items = ", ".join(f"{t} ({c}x)" for t, c in list(repeated.items())[:5])
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": f"Repeated commands: {items}",
|
||||
}
|
||||
|
||||
|
||||
def check_repeated_reads_telemetry(root: Path) -> dict:
|
||||
"""Detect repeated file reads from telemetry events.
|
||||
|
||||
Reads structured session-log.jsonl to find read/read_file target events
|
||||
that have been issued more than 3 times. Falls back gracefully
|
||||
when the telemetry log is absent.
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
"""
|
||||
events = read_events(root)
|
||||
if not events:
|
||||
return {"status": "ok", "details": "No telemetry data available."}
|
||||
|
||||
read_events_list = [e for e in events if e.event_type in ("read", "read_file")]
|
||||
if not read_events_list:
|
||||
return {"status": "ok", "details": "No file read events found in telemetry."}
|
||||
|
||||
counts = Counter(e.target for e in read_events_list)
|
||||
repeated = {target: count for target, count in counts.items() if count > 3}
|
||||
|
||||
if not repeated:
|
||||
return {"status": "ok", "details": "No significantly repeated file reads detected in telemetry."}
|
||||
|
||||
items = ", ".join(f"{t} ({c}x)" for t, c in list(repeated.items())[:5])
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": f"Repeated file reads: {items}",
|
||||
}
|
||||
|
||||
|
||||
def check_no_recent_file_changes(root: Path) -> dict:
|
||||
"""Flag if project files have not been modified in a long time (> 48 h).
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
"""
|
||||
monitored = [
|
||||
"TASKS.md",
|
||||
"PROJECT_STATE.md",
|
||||
"AGENT_HANDOFF.md",
|
||||
"context/product-brief.md",
|
||||
"context/architecture.md",
|
||||
]
|
||||
now = _now()
|
||||
stale: list[str] = []
|
||||
|
||||
for rel in monitored:
|
||||
ts = _last_modified(root, rel)
|
||||
if ts is None:
|
||||
continue
|
||||
age = now - ts
|
||||
if age > timedelta(hours=48):
|
||||
stale.append(rel)
|
||||
|
||||
if not stale:
|
||||
return {"status": "ok", "details": "Project files have recent updates."}
|
||||
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": f"No changes in the last 48 hours: {', '.join(stale)}",
|
||||
}
|
||||
|
||||
|
||||
def check_no_test_run_recorded(root: Path) -> dict:
|
||||
"""Flag if RUN_LOG.md does not mention tests or pytest.
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
"""
|
||||
run_log = _read_safe(root, "RUN_LOG.md")
|
||||
if not run_log:
|
||||
return {"status": "ok", "details": "No RUN_LOG.md found — can't check."}
|
||||
|
||||
lower = run_log.lower()
|
||||
keywords = ["pytest", "test passed", "tests passed", "all tests"]
|
||||
if any(kw in lower for kw in keywords):
|
||||
return {"status": "ok", "details": "Test activity recorded."}
|
||||
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": "No test execution recorded in RUN_LOG.md.",
|
||||
}
|
||||
|
||||
|
||||
def check_run_log_updated(root: Path) -> dict:
|
||||
"""Check whether TASKS.md status changes are reflected in RUN_LOG.md.
|
||||
|
||||
Returns a dict with keys: status, details.
|
||||
"""
|
||||
tasks_text = _read_safe(root, "TASKS.md")
|
||||
run_log = _read_safe(root, "RUN_LOG.md")
|
||||
|
||||
if not tasks_text or not run_log:
|
||||
return {"status": "ok", "details": "Cannot compare — missing files."}
|
||||
|
||||
log_lines = [l.strip() for l in run_log.splitlines() if l.startswith("|")]
|
||||
if not log_lines:
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": "RUN_LOG.md exists but contains no log entries.",
|
||||
}
|
||||
|
||||
# Check done tasks have corresponding RUN_LOG entries
|
||||
done_tasks = re.findall(r"(TASK-\d+).*?Status:\s*Done", tasks_text, re.DOTALL)
|
||||
for task_id in done_tasks:
|
||||
found = any(task_id in line for line in log_lines)
|
||||
if not found:
|
||||
return {
|
||||
"status": "warning",
|
||||
"details": f"Task {task_id} marked Done but no corresponding RUN_LOG entry found.",
|
||||
}
|
||||
|
||||
return {"status": "ok", "details": "TASKS.md and RUN_LOG.md appear consistent."}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_all_guardrails(root: Path) -> dict:
|
||||
"""Run all guardrail checks and return combined results."""
|
||||
checks = {
|
||||
"Repeated reads (heuristics)": check_repeated_reads(root),
|
||||
"Repeated commands (RUN_LOG)": check_repeated_commands(root),
|
||||
"Repeated commands (telemetry)": check_repeated_commands_telemetry(root),
|
||||
"Repeated file reads (telemetry)": check_repeated_reads_telemetry(root),
|
||||
"No recent file changes": check_no_recent_file_changes(root),
|
||||
"No test run recorded": check_no_test_run_recorded(root),
|
||||
"Run log updated": check_run_log_updated(root),
|
||||
}
|
||||
|
||||
statuses = [v["status"] for v in checks.values()]
|
||||
if "error" in statuses:
|
||||
overall = "action required"
|
||||
elif "warning" in statuses:
|
||||
overall = "review recommended"
|
||||
else:
|
||||
overall = "all clear"
|
||||
|
||||
return {"checks": checks, "overall_status": overall}
|
||||
|
||||
|
||||
def format_report(results: dict) -> str:
|
||||
"""Format guardrail results as a human-readable report."""
|
||||
lines = ["# Guardrail Report", ""]
|
||||
|
||||
for name, result in results["checks"].items():
|
||||
icon_map = {"ok": "[green]✓[/green]", "warning": "[yellow]⚠[/yellow]", "error": "[red]✗[/red]"}
|
||||
icon = icon_map.get(result["status"], "?")
|
||||
lines.append(f"- {icon} **{name}:** {result['details']}")
|
||||
|
||||
status_icon_map = {
|
||||
"all clear": "[green]✓[/green]",
|
||||
"review recommended": "[yellow]⚠[/yellow]",
|
||||
"action required": "[red]✗[/red]",
|
||||
}
|
||||
icon = status_icon_map.get(results["overall_status"], "?")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"**Overall status:** {icon} {results['overall_status']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .tasks import read_tasks
|
||||
|
||||
@@ -23,3 +25,38 @@ def task_counts(root: Path) -> dict[str, int]:
|
||||
for task in read_tasks(root):
|
||||
counts[task.status] = counts.get(task.status, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
_CURRENT_TASK_RE = re.compile(r"^(Current Task): .+$", re.MULTILINE)
|
||||
_UPDATED_RE = re.compile(r"^(Last Updated): .+$", re.MULTILINE)
|
||||
|
||||
|
||||
def update_project_state(root: Path, task_id: str = "None") -> bool:
|
||||
"""Update PROJECT_STATE.md current task and timestamp."""
|
||||
path = root / "PROJECT_STATE.md"
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = _CURRENT_TASK_RE.sub(f"Current Task: {task_id}", text, count=1)
|
||||
text = _UPDATED_RE.sub(f"Last Updated: {date.today().isoformat()}", text, count=1)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
_STAGE_RE = re.compile(r"(## Current Stage\n\n)[\s\S]+?(?=\n)", re.MULTILINE | re.DOTALL)
|
||||
_TASK_RE = re.compile(r"(## Current Task\n\n)[\s\S]+?(?=\n)", re.MULTILINE | re.DOTALL)
|
||||
|
||||
|
||||
def update_agent_handoff(root: Path, task_id: str = "None") -> bool:
|
||||
"""Update AGENT_HANDOFF.md current stage and task."""
|
||||
path = root / "AGENT_HANDOFF.md"
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
stage = project_stage(root)
|
||||
text = _STAGE_RE.sub(f"## Current Stage\n\n{stage}", text, count=1)
|
||||
text = _TASK_RE.sub(f"## Current Task\n\n{task_id}", text, count=1)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return True
|
||||
|
||||
@@ -10,6 +10,7 @@ STATUS_RE = re.compile(r"^Status:\s*(.+)$", re.MULTILINE)
|
||||
|
||||
GOAL_RE = re.compile(r"^Goal:\s*(.+)", re.MULTILINE)
|
||||
AC_RE = re.compile(r"^- (.+)$", re.MULTILINE)
|
||||
GAP_RE = re.compile(r"^Implementation Gap:\s*(.+)", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,6 +29,12 @@ class Task:
|
||||
def ac_lines(self) -> list[str]:
|
||||
return AC_RE.findall(self.body)
|
||||
|
||||
def implementation_gap(self, root: Path) -> str | None:
|
||||
match = GAP_RE.search(self.body)
|
||||
if match and match.group(1).strip():
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def read_tasks(root: Path) -> list[Task]:
|
||||
tasks_path = root / "TASKS.md"
|
||||
@@ -92,6 +99,12 @@ CONTEXT_FILES_TO_READ = [
|
||||
"context/agent-guidelines.md",
|
||||
]
|
||||
|
||||
TEST_CMD_SECTION_RE = re.compile(
|
||||
r"^##\s*Test Commands\s*\n([\s\S]*?)(?=^##|\Z)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
BASH_BLOCK_RE = re.compile(r"```bash\n(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
def _read_file_safe(root: Path, relative: str) -> str:
|
||||
path = root / relative
|
||||
@@ -100,6 +113,55 @@ def _read_file_safe(root: Path, relative: str) -> str:
|
||||
return f"# {relative} — not found"
|
||||
|
||||
|
||||
def _extract_test_commands_from_claude(root: Path) -> list[str]:
|
||||
"""Extract bash test commands from CLAUDE.md Test Commands section."""
|
||||
|
||||
def _looks_like_command(line: str) -> bool:
|
||||
"""Heuristic: keep lines that look like shell commands."""
|
||||
if not line.strip():
|
||||
return False
|
||||
lower = line.lower()
|
||||
# Skip markdown, prose, headings
|
||||
if any(lower.startswith(p) for p in ("# ", "- ", "if ", "do not", "for ", "use ")) or lower in (
|
||||
"validation commands",
|
||||
):
|
||||
return False
|
||||
# Skip lines that look like sentences (contain spaces followed by lowercase words)
|
||||
# but keep things like `source .venv/bin/activate` and `pip install ...`
|
||||
parts = line.split()
|
||||
if len(parts) <= 1:
|
||||
return True
|
||||
# If the first word is a known shell builtin / command prefix, accept it
|
||||
known_prefixes = ("source", "cd", "ls", "cp", "mv", "rm", "mkdir", "echo", "grep",
|
||||
"git", "pip", "python", "pytest", "rdb", "cat", "head", "tail",
|
||||
"sed", "awk", "find", "install")
|
||||
return parts[0].lower() in known_prefixes
|
||||
|
||||
claude_path = root / "CLAUDE.md"
|
||||
if not claude_path.exists():
|
||||
return []
|
||||
|
||||
text = claude_path.read_text(encoding="utf-8")
|
||||
section_match = TEST_CMD_SECTION_RE.search(text)
|
||||
if not section_match:
|
||||
return []
|
||||
|
||||
section_text = section_match.group(1)
|
||||
blocks = BASH_BLOCK_RE.findall(section_text)
|
||||
seen: set[str] = set()
|
||||
commands: list[str] = []
|
||||
for block in blocks:
|
||||
for line in block.strip().splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if _looks_like_command(stripped):
|
||||
if stripped not in seen:
|
||||
seen.add(stripped)
|
||||
commands.append(stripped)
|
||||
return commands
|
||||
|
||||
|
||||
def generate_agent_prompt(root: Path) -> str:
|
||||
task = get_next_task(root)
|
||||
if not task:
|
||||
@@ -128,9 +190,20 @@ def generate_agent_prompt(root: Path) -> str:
|
||||
f"Status: {task.status}",
|
||||
"",
|
||||
f"Goal:\n{task.goal(root)}",
|
||||
]
|
||||
|
||||
gap = task.implementation_gap(root)
|
||||
if gap:
|
||||
sections.extend([
|
||||
"",
|
||||
"Implementation Gap:",
|
||||
gap,
|
||||
])
|
||||
|
||||
sections.extend([
|
||||
"",
|
||||
"Acceptance Criteria:",
|
||||
]
|
||||
])
|
||||
|
||||
for line in task.ac_lines():
|
||||
sections.append(f"- {line}")
|
||||
@@ -141,8 +214,25 @@ def generate_agent_prompt(root: Path) -> str:
|
||||
"",
|
||||
"# Constraints",
|
||||
"",
|
||||
"- Implement ONE task only. Do not combine with other tasks.",
|
||||
"- Inspect the codebase first, then edit.",
|
||||
"- Do not repeatedly reread unchanged files.",
|
||||
"- Limit your work to ONE small implementation step only. Do not combine tasks or features.",
|
||||
"- Make the smallest useful change possible.",
|
||||
])
|
||||
|
||||
test_cmds = _extract_test_commands_from_claude(root)
|
||||
if test_cmds:
|
||||
sections.extend([
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"# Test Commands (from CLAUDE.md)",
|
||||
"",
|
||||
"Use these existing test commands:",
|
||||
("```bash\n" + "\n".join(test_cmds) + "\n```\n"),
|
||||
])
|
||||
|
||||
sections.extend([
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelemetryEvent:
|
||||
"""A single structured telemetry event."""
|
||||
timestamp: str
|
||||
event_type: str
|
||||
target: str
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _session_log_path(root: Path) -> Path:
|
||||
"""Return the path to the session-log.jsonl file under .rdb/."""
|
||||
rdb_dir = root / ".rdb"
|
||||
rdb_dir.mkdir(parents=True, exist_ok=True)
|
||||
return rdb_dir / "session-log.jsonl"
|
||||
|
||||
|
||||
def record_event(root: Path, event_type: str, target: str, details: dict | None = None) -> TelemetryEvent:
|
||||
"""Record a telemetry event to the session log.
|
||||
|
||||
Creates the .rdb/session-log.jsonl file if it does not exist.
|
||||
Each call appends one JSON line to the file.
|
||||
|
||||
Args:
|
||||
root: Project root directory.
|
||||
event_type: Category of event, e.g. 'command', 'task', 'guardrail'.
|
||||
target: The entity the event relates to, e.g. 'rdb prompt'.
|
||||
details: Optional extra key-value pairs for context.
|
||||
|
||||
Returns:
|
||||
The TelemetryEvent that was recorded.
|
||||
"""
|
||||
event = TelemetryEvent(
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
event_type=event_type,
|
||||
target=target,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
log_path = _session_log_path(root)
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event.to_dict()) + "\n")
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def read_events(root: Path) -> list[TelemetryEvent]:
|
||||
"""Read all events from the session log.
|
||||
|
||||
Returns an empty list if the log does not yet exist or is empty.
|
||||
Malformed lines are silently skipped.
|
||||
"""
|
||||
log_path = _session_log_path(root)
|
||||
if not log_path.exists():
|
||||
return []
|
||||
|
||||
events: list[TelemetryEvent] = []
|
||||
for line in log_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
events.append(TelemetryEvent(**data))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return events
|
||||
@@ -105,6 +105,96 @@ def test_append_preserves_existing_history(tmp_path: Path) -> None:
|
||||
assert len(data_rows) == 2
|
||||
|
||||
|
||||
def test_read_discovery_answers_returns_dicts(tmp_path: Path) -> None:
|
||||
discovery_mod.append_discovery_answer(
|
||||
tmp_path,
|
||||
question_id="Q-001",
|
||||
question="What is this?",
|
||||
answer="A thing",
|
||||
confidence="High",
|
||||
follow_up_needed="No",
|
||||
)
|
||||
|
||||
answers = discovery_mod.read_discovery_answers(tmp_path)
|
||||
assert len(answers) == 1
|
||||
assert answers[0]["id"] == "Q-001"
|
||||
assert answers[0]["question"] == "What is this?"
|
||||
assert answers[0]["answer"] == "A thing"
|
||||
assert answers[0]["confidence"] == "High"
|
||||
assert answers[0]["follow_up_needed"] == "No"
|
||||
assert answers[0]["needs_followup"] is False
|
||||
assert answers[0]["low_confidence"] is False
|
||||
|
||||
|
||||
def test_read_discovery_answers_returns_empty_when_no_log(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "context" / "discovery-log.md"
|
||||
assert not log_path.exists()
|
||||
|
||||
answers = discovery_mod.read_discovery_answers(tmp_path)
|
||||
assert answers == []
|
||||
|
||||
|
||||
def test_read_discovery_answers_detects_low_confidence(tmp_path: Path) -> None:
|
||||
discovery_mod.append_discovery_answer(
|
||||
tmp_path,
|
||||
question_id="Q-002",
|
||||
question="How much?",
|
||||
answer="About half",
|
||||
confidence="Low",
|
||||
follow_up_needed="No",
|
||||
)
|
||||
|
||||
answers = discovery_mod.read_discovery_answers(tmp_path)
|
||||
low_conf_rows = [a for a in answers if a["low_confidence"]]
|
||||
assert len(low_conf_rows) == 1
|
||||
assert low_conf_rows[0]["id"] == "Q-002"
|
||||
|
||||
|
||||
def test_read_discovery_answers_detects_followup_flag(tmp_path: Path) -> None:
|
||||
discovery_mod.append_discovery_answer(
|
||||
tmp_path,
|
||||
question_id="Q-003",
|
||||
question="What next?",
|
||||
answer="TBD",
|
||||
confidence="High",
|
||||
follow_up_needed="Yes",
|
||||
)
|
||||
|
||||
answers = discovery_mod.read_discovery_answers(tmp_path)
|
||||
fu_rows = [a for a in answers if a["needs_followup"]]
|
||||
assert len(fu_rows) == 1
|
||||
assert fu_rows[0]["id"] == "Q-003"
|
||||
|
||||
|
||||
def test_append_followup_appends_row(tmp_path: Path) -> None:
|
||||
# First create the log with an existing entry
|
||||
discovery_mod.append_discovery_answer(
|
||||
tmp_path,
|
||||
question_id="Q-010",
|
||||
question="Original?",
|
||||
answer="Original answer",
|
||||
confidence="High",
|
||||
follow_up_needed="No",
|
||||
)
|
||||
|
||||
original_count = len(discovery_mod.read_discovery_answers(tmp_path))
|
||||
|
||||
discovery_mod.append_followup_answer(
|
||||
tmp_path,
|
||||
question_id="Q-010",
|
||||
follow_up_question="Can you elaborate?",
|
||||
answer="Yes, it's bigger than expected.",
|
||||
confidence="Medium",
|
||||
)
|
||||
|
||||
answers = discovery_mod.read_discovery_answers(tmp_path)
|
||||
assert len(answers) == original_count + 1
|
||||
# The new row should have the original Q-010 id and follow-up data
|
||||
new_row = [a for a in answers if a["question"] == "Can you elaborate?"][0]
|
||||
assert new_row["follow_up_needed"] == "Yes"
|
||||
assert new_row["confidence"] == "Medium"
|
||||
|
||||
|
||||
def test_header_is_written_on_create(tmp_path: Path) -> None:
|
||||
discovery_mod.append_discovery_answer(
|
||||
tmp_path,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from rdb_discovery.guardrails import (
|
||||
check_repeated_reads,
|
||||
check_repeated_commands,
|
||||
check_repeated_commands_telemetry,
|
||||
check_repeated_reads_telemetry,
|
||||
check_no_recent_file_changes,
|
||||
check_no_test_run_recorded,
|
||||
check_run_log_updated,
|
||||
run_all_guardrails,
|
||||
format_report,
|
||||
)
|
||||
|
||||
|
||||
# -- check_repeated_reads --
|
||||
|
||||
def test_check_repeated_reads_ok(tmp_path: Path) -> None:
|
||||
"""No agent history files → status ok."""
|
||||
result = check_repeated_reads(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
def test_check_repeated_reads_warning(tmp_path: Path) -> None:
|
||||
"""AGENT_HANDOFF.md mentions the same file >2 times."""
|
||||
ah = tmp_path / "AGENT_HANDOFF.md"
|
||||
ah.write_text(
|
||||
"# Agent Handoff\n\n"
|
||||
"- README.md\n- README.md\n- README.md\n"
|
||||
"- TASKS.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = check_repeated_reads(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
assert "README.md (3x)" in result["details"]
|
||||
|
||||
|
||||
def test_check_repeated_reads_no_over_threshold(tmp_path: Path) -> None:
|
||||
"""Files mentioned <=2 times should not trigger warning."""
|
||||
ah = tmp_path / "AGENT_HANDOFF.md"
|
||||
ah.write_text(
|
||||
"# Agent Handoff\n\n"
|
||||
"- README.md\n- README.md\n"
|
||||
"- TASKS.md\n- TASKS.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = check_repeated_reads(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# -- check_repeated_commands --
|
||||
|
||||
def test_check_repeated_commands_ok_no_log(tmp_path: Path) -> None:
|
||||
result = check_repeated_commands(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
def test_check_repeated_commands_ok(tmp_path: Path) -> None:
|
||||
rl = tmp_path / "RUN_LOG.md"
|
||||
rl.write_text(
|
||||
"| date | event1 |\n"
|
||||
"| date | event2 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = check_repeated_commands(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
def test_check_repeated_commands_warning(tmp_path: Path) -> None:
|
||||
rl = tmp_path / "RUN_LOG.md"
|
||||
line = "| date | Task completed |\n"
|
||||
rl.write_text(
|
||||
"# RUN_LOG\n\n| Date | Event | Task | Notes |\n" + line * 5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = check_repeated_commands(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
|
||||
|
||||
# -- check_repeated_commands_telemetry --
|
||||
|
||||
def test_check_repeated_commands_telemetry_no_events(tmp_path: Path) -> None:
|
||||
"""No telemetry events → status ok with fallback message."""
|
||||
result = check_repeated_commands_telemetry(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
assert "No telemetry data available" in result["details"]
|
||||
|
||||
|
||||
def test_check_repeated_commands_telemetry_warning(tmp_path: Path) -> None:
|
||||
"""Same command repeated >3 times in telemetry → warning."""
|
||||
from rdb_discovery.telemetry import record_event
|
||||
|
||||
for _ in range(5):
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
result = check_repeated_commands_telemetry(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
assert "rdb prompt" in result["details"]
|
||||
|
||||
|
||||
def test_check_repeated_commands_telemetry_no_over_threshold(tmp_path: Path) -> None:
|
||||
"""Same command repeated <=3 times → ok."""
|
||||
from rdb_discovery.telemetry import record_event
|
||||
|
||||
for _ in range(3):
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
result = check_repeated_commands_telemetry(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# -- check_repeated_reads_telemetry --
|
||||
|
||||
def test_check_repeated_reads_telemetry_no_events(tmp_path: Path) -> None:
|
||||
"""No telemetry events → status ok with fallback message."""
|
||||
result = check_repeated_reads_telemetry(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
assert "No telemetry data available" in result["details"]
|
||||
|
||||
|
||||
def test_check_repeated_reads_telemetry_no_read_events(tmp_path: Path) -> None:
|
||||
"""Only command events, no read events → ok with message."""
|
||||
from rdb_discovery.telemetry import record_event
|
||||
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
result = check_repeated_reads_telemetry(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
assert "No file read events found" in result["details"]
|
||||
|
||||
|
||||
def test_check_repeated_reads_telemetry_warning(tmp_path: Path) -> None:
|
||||
"""Same file read >3 times in telemetry → warning."""
|
||||
from rdb_discovery.telemetry import record_event
|
||||
|
||||
for _ in range(5):
|
||||
record_event(tmp_path, "read", "README.md")
|
||||
result = check_repeated_reads_telemetry(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
assert "README.md" in result["details"]
|
||||
|
||||
|
||||
def test_check_repeated_reads_telemetry_no_over_threshold(tmp_path: Path) -> None:
|
||||
"""Same file read <=3 times → ok."""
|
||||
from rdb_discovery.telemetry import record_event
|
||||
|
||||
for _ in range(3):
|
||||
record_event(tmp_path, "read", "TASKS.md")
|
||||
result = check_repeated_reads_telemetry(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# -- check_no_recent_file_changes --
|
||||
|
||||
def test_check_no_recent_file_changes_ok(tmp_path: Path) -> None:
|
||||
"""Files modified recently → ok."""
|
||||
for rel in ["TASKS.md", "PROJECT_STATE.md"]:
|
||||
(tmp_path / rel).write_text("x", encoding="utf-8")
|
||||
|
||||
# Patch _last_modified to return a recent time
|
||||
import rdb_discovery.guardrails as mod
|
||||
original_now = mod._now
|
||||
|
||||
def fake_now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
mod._now = fake_now
|
||||
try:
|
||||
result = check_no_recent_file_changes(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
finally:
|
||||
mod._now = original_now
|
||||
|
||||
|
||||
def test_check_no_recent_file_changes_warning(tmp_path: Path) -> None:
|
||||
"""Files modified >48h ago → warning."""
|
||||
(tmp_path / "TASKS.md").write_text("x", encoding="utf-8")
|
||||
|
||||
import rdb_discovery.guardrails as mod
|
||||
|
||||
old_ts = datetime.now() - timedelta(hours=72)
|
||||
# Set a fake mtime via os.utime (avoids pathlib.touch times= on macOS)
|
||||
ts = old_ts.timestamp()
|
||||
(tmp_path / "TASKS.md").touch()
|
||||
os.utime(str(tmp_path / "TASKS.md"), (ts, ts))
|
||||
|
||||
result = check_no_recent_file_changes(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
|
||||
|
||||
# -- check_no_test_run_recorded --
|
||||
|
||||
def test_check_no_test_run_recorded_ok(tmp_path: Path) -> None:
|
||||
rl = tmp_path / "RUN_LOG.md"
|
||||
rl.write_text("# RUN\n\n| date | pytest passed |\n", encoding="utf-8")
|
||||
result = check_no_test_run_recorded(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
def test_check_no_test_run_recorded_warning(tmp_path: Path) -> None:
|
||||
rl = tmp_path / "RUN_LOG.md"
|
||||
rl.write_text("# RUN\n\n| date | task done |\n", encoding="utf-8")
|
||||
result = check_no_test_run_recorded(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
|
||||
|
||||
def test_check_no_test_run_recorded_no_log(tmp_path: Path) -> None:
|
||||
result = check_no_test_run_recorded(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# -- check_run_log_updated --
|
||||
|
||||
def test_check_run_log_updated_ok(tmp_path: Path) -> None:
|
||||
tasks = tmp_path / "TASKS.md"
|
||||
tasks.write_text(
|
||||
"## TASK-001 - First\nStatus: Done\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rl = tmp_path / "RUN_LOG.md"
|
||||
rl.write_text(
|
||||
"# RUN\n\n| date | Task completed | TASK-001 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = check_run_log_updated(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
def test_check_run_log_updated_warning(tmp_path: Path) -> None:
|
||||
tasks = tmp_path / "TASKS.md"
|
||||
tasks.write_text(
|
||||
"## TASK-001 - First\nStatus: Done\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rl = tmp_path / "RUN_LOG.md"
|
||||
rl.write_text("# RUN\n\n| date | something else |\n", encoding="utf-8")
|
||||
result = check_run_log_updated(tmp_path)
|
||||
assert result["status"] == "warning"
|
||||
|
||||
|
||||
def test_check_run_log_updated_no_files(tmp_path: Path) -> None:
|
||||
result = check_run_log_updated(tmp_path)
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# -- run_all_guardrails --
|
||||
|
||||
def test_run_all_guardrails_returns_keys() -> None:
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
# Create minimal files so checks don't return 'ok' for missing file
|
||||
(root / "RUN_LOG.md").write_text("# RUN\n", encoding="utf-8")
|
||||
result = run_all_guardrails(root)
|
||||
assert "checks" in result
|
||||
assert "overall_status" in result
|
||||
|
||||
|
||||
def test_run_all_guardrails_overall_all_clear(tmp_path: Path) -> None:
|
||||
"""All checks pass → overall 'all clear'."""
|
||||
# Create files with recent mtimes and ok content
|
||||
(tmp_path / "TASKS.md").write_text("# TASKS\n", encoding="utf-8")
|
||||
(tmp_path / "RUN_LOG.md").write_text("# RUN\n| date | pytest passed |\n", encoding="utf-8")
|
||||
|
||||
import rdb_discovery.guardrails as mod
|
||||
|
||||
old_now = mod._now
|
||||
now = datetime.now()
|
||||
|
||||
class FakePath:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self._path.exists()
|
||||
|
||||
def stat(self):
|
||||
class StatResult:
|
||||
st_mtime = now.timestamp()
|
||||
return StatResult()
|
||||
|
||||
orig_last_modified = mod._last_modified
|
||||
|
||||
def fake_last_modified(root: Path, rel: str) -> datetime | None:
|
||||
p = root / rel
|
||||
if p.exists():
|
||||
return now
|
||||
return None
|
||||
|
||||
mod._now = lambda: now
|
||||
mod._last_modified = fake_last_modified
|
||||
|
||||
try:
|
||||
result = run_all_guardrails(tmp_path)
|
||||
# Should be clear or have a non-error overall status (guardrails may flag other things)
|
||||
assert result["overall_status"] in ("all clear", "review recommended")
|
||||
finally:
|
||||
mod._now = old_now
|
||||
mod._last_modified = orig_last_modified
|
||||
|
||||
|
||||
# -- format_report --
|
||||
|
||||
def test_format_report_includes_overall() -> None:
|
||||
results = {
|
||||
"checks": {"Test check": {"status": "ok", "details": "fine"}},
|
||||
"overall_status": "all clear",
|
||||
}
|
||||
report = format_report(results)
|
||||
assert "# Guardrail Report" in report
|
||||
assert "all clear" in report
|
||||
|
||||
|
||||
def test_format_report_with_warning() -> None:
|
||||
results = {
|
||||
"checks": {"Test check": {"status": "warning", "details": "watch out"}},
|
||||
"overall_status": "review recommended",
|
||||
}
|
||||
report = format_report(results)
|
||||
assert "review recommended" in report
|
||||
+133
-3
@@ -2,7 +2,25 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rdb_discovery.tasks import generate_agent_prompt, get_next_task
|
||||
from rdb_discovery.tasks import (
|
||||
_extract_test_commands_from_claude,
|
||||
generate_agent_prompt,
|
||||
get_next_task,
|
||||
)
|
||||
|
||||
|
||||
def _write_claude_with_test_cmds(tmp_path: Path) -> None:
|
||||
"""Write a CLAUDE.md with Test Commands section."""
|
||||
(tmp_path / "CLAUDE.md").write_text(
|
||||
"# Claude Code Instructions\n\n"
|
||||
"## Test Commands\n\n"
|
||||
"Use the existing virtual environment.\n\n"
|
||||
"```bash\n"
|
||||
"source .venv/bin/activate\n"
|
||||
"python -m pytest\n"
|
||||
"```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_task_info(tmp_path: Path) -> None:
|
||||
@@ -67,7 +85,7 @@ def test_generate_agent_prompt_includes_read_instructions(tmp_path: Path) -> Non
|
||||
assert "context/agent-guidelines.md" in prompt
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_one_task_constraint(tmp_path: Path) -> None:
|
||||
def test_generate_agent_prompt_includes_one_step_constraint(tmp_path: Path) -> None:
|
||||
(tmp_path / "TASKS.md").write_text(
|
||||
"# TASKS\n\n"
|
||||
"## TASK-001 — First\nStatus: Done\n\n"
|
||||
@@ -79,7 +97,7 @@ def test_generate_agent_prompt_includes_one_task_constraint(tmp_path: Path) -> N
|
||||
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert "ONE task only" in prompt or "one task only" in prompt
|
||||
assert "ONE small implementation step only" in prompt
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_validation(tmp_path: Path) -> None:
|
||||
@@ -129,3 +147,115 @@ def test_generate_agent_prompt_no_tasks_file(tmp_path: Path) -> None:
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert prompt == "No Todo task found."
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_implementation_gap(tmp_path: Path) -> None:
|
||||
(tmp_path / "TASKS.md").write_text(
|
||||
"# TASKS\n\n"
|
||||
"## TASK-001 — First\nStatus: Done\n\n"
|
||||
"Goal: x.\n\n"
|
||||
"## TASK-002 — Second\nStatus: Todo\n\n"
|
||||
"Goal: y.\n"
|
||||
"Implementation Gap:\n"
|
||||
"Missing validation handler in cli.py\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert "Implementation Gap:" in prompt
|
||||
assert "Missing validation handler in cli.py" in prompt
|
||||
|
||||
|
||||
def test_generate_agent_prompt_omits_gap_when_missing(tmp_path: Path) -> None:
|
||||
(tmp_path / "TASKS.md").write_text(
|
||||
"# TASKS\n\n"
|
||||
"## TASK-001 — First\nStatus: Done\n\n"
|
||||
"Goal: x.\n\n"
|
||||
"## TASK-002 — Second\nStatus: Todo\n\n"
|
||||
"Goal: y.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert "Implementation Gap:" not in prompt
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_test_commands_from_claude(tmp_path: Path) -> None:
|
||||
(tmp_path / "TASKS.md").write_text(
|
||||
"# TASKS\n\n"
|
||||
"## TASK-001 — First\nStatus: Done\n\n"
|
||||
"## TASK-002 — Second\nStatus: Todo\n\n"
|
||||
"Goal: y.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_claude_with_test_cmds(tmp_path)
|
||||
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert "Test Commands (from CLAUDE.md)" in prompt
|
||||
assert "source .venv/bin/activate" in prompt
|
||||
assert "python -m pytest" in prompt
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_no_reread_constraint(tmp_path: Path) -> None:
|
||||
(tmp_path / "TASKS.md").write_text(
|
||||
"# TASKS\n\n"
|
||||
"## TASK-001 — First\nStatus: Done\n\n"
|
||||
"## TASK-002 — Second\nStatus: Todo\n\n"
|
||||
"Goal: y.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert "reread" in prompt.lower() or "read" in prompt.lower()
|
||||
|
||||
|
||||
def test_generate_agent_prompt_includes_inspect_first_constraint(tmp_path: Path) -> None:
|
||||
(tmp_path / "TASKS.md").write_text(
|
||||
"# TASKS\n\n"
|
||||
"## TASK-001 — First\nStatus: Done\n\n"
|
||||
"## TASK-002 — Second\nStatus: Todo\n\n"
|
||||
"Goal: y.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
prompt = generate_agent_prompt(tmp_path)
|
||||
|
||||
assert "Inspect" in prompt or "inspect" in prompt
|
||||
|
||||
|
||||
def test_extract_test_commands_no_claude_file(tmp_path: Path) -> None:
|
||||
commands = _extract_test_commands_from_claude(tmp_path)
|
||||
assert commands == []
|
||||
|
||||
|
||||
def test_extract_test_commands_from_claude(tmp_path: Path) -> None:
|
||||
(tmp_path / "CLAUDE.md").write_text(
|
||||
"# Instructions\n\n"
|
||||
"## Test Commands\n\n"
|
||||
"Run the following:\n\n"
|
||||
"```bash\n"
|
||||
"source .venv/bin/activate\n"
|
||||
"python -m pytest\n"
|
||||
"```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
commands = _extract_test_commands_from_claude(tmp_path)
|
||||
|
||||
assert any(".venv/bin/activate" in cmd for cmd in commands)
|
||||
assert "python -m pytest" in commands
|
||||
|
||||
|
||||
def test_extract_test_commands_no_section(tmp_path: Path) -> None:
|
||||
(tmp_path / "CLAUDE.md").write_text(
|
||||
"# Instructions\n\n"
|
||||
"No test section here.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
commands = _extract_test_commands_from_claude(tmp_path)
|
||||
assert commands == []
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
|
||||
from rdb_discovery.status import (
|
||||
update_project_state,
|
||||
update_agent_handoff,
|
||||
)
|
||||
|
||||
|
||||
def test_update_project_state_updates_task_id(tmp_path: Path) -> None:
|
||||
ps = tmp_path / "PROJECT_STATE.md"
|
||||
ps.write_text(
|
||||
"# Project State\n\nCurrent Stage: DISCOVERY\nPrevious Stage: NONE\nNext Stage: BOOTSTRAP_READY\n\nCurrent Task: None\nActive Branch: main\n\nLast Updated: 2026-01-01\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert update_project_state(tmp_path, "TASK-005") is True
|
||||
|
||||
content = ps.read_text(encoding="utf-8")
|
||||
assert "Current Task: TASK-005" in content
|
||||
|
||||
|
||||
def test_update_project_state_updates_timestamp(tmp_path: Path) -> None:
|
||||
ps = tmp_path / "PROJECT_STATE.md"
|
||||
ps.write_text(
|
||||
"# Project State\n\nCurrent Stage: DISCOVERY\nPrevious Stage: NONE\nNext Stage: BOOTSTRAP_READY\n\nCurrent Task: None\nActive Branch: main\n\nLast Updated: 2026-01-01\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert update_project_state(tmp_path, "TASK-999") is True
|
||||
|
||||
content = ps.read_text(encoding="utf-8")
|
||||
from datetime import date
|
||||
expected_date = date.today().isoformat()
|
||||
assert f"Last Updated: {expected_date}" in content
|
||||
|
||||
|
||||
def test_update_project_state_no_file_returns_false(tmp_path: Path) -> None:
|
||||
assert update_project_state(tmp_path, "TASK-001") is False
|
||||
|
||||
|
||||
def test_update_agent_handoff_updates_task_and_stage(tmp_path: Path) -> None:
|
||||
ah = tmp_path / "AGENT_HANDOFF.md"
|
||||
ah.write_text(
|
||||
"# Agent Handoff\n\n## Current Stage\n\nDISCOVERY\n\n## Current Task\n\nNone\n\n## Instructions For Agent\n\n- Complete one task only\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert update_agent_handoff(tmp_path, "TASK-005") is True
|
||||
|
||||
content = ah.read_text(encoding="utf-8")
|
||||
assert "Current Task" in content
|
||||
assert "TASK-005" in content
|
||||
|
||||
|
||||
def test_update_agent_handoff_no_file_returns_false(tmp_path: Path) -> None:
|
||||
assert update_agent_handoff(tmp_path, "TASK-001") is False
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rdb_discovery.telemetry import TelemetryEvent, record_event, read_events
|
||||
|
||||
|
||||
# -- TelemetryEvent --
|
||||
|
||||
def test_telemetry_event_to_dict() -> None:
|
||||
event = TelemetryEvent(
|
||||
timestamp="2026-06-02T12:00:00+00:00",
|
||||
event_type="command",
|
||||
target="rdb prompt",
|
||||
details={"flag": True},
|
||||
)
|
||||
d = event.to_dict()
|
||||
assert d["event_type"] == "command"
|
||||
assert d["target"] == "rdb prompt"
|
||||
assert d["details"]["flag"] is True
|
||||
|
||||
|
||||
def test_telemetry_event_defaults_to_empty_details() -> None:
|
||||
event = TelemetryEvent(
|
||||
timestamp="2026-06-02T12:00:00+00:00",
|
||||
event_type="command",
|
||||
target="rdb prompt",
|
||||
)
|
||||
assert event.details == {}
|
||||
|
||||
|
||||
def test_telemetry_event_required_fields() -> None:
|
||||
event = TelemetryEvent(
|
||||
timestamp="2026-06-02T12:00:00+00:00",
|
||||
event_type="command",
|
||||
target="rdb prompt",
|
||||
)
|
||||
assert event.timestamp == "2026-06-02T12:00:00+00:00"
|
||||
assert event.event_type == "command"
|
||||
assert event.target == "rdb prompt"
|
||||
|
||||
|
||||
# -- record_event (file creation) --
|
||||
|
||||
def test_record_event_creates_session_log(tmp_path: Path) -> None:
|
||||
log = tmp_path / ".rdb" / "session-log.jsonl"
|
||||
assert not log.exists()
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
assert log.exists()
|
||||
|
||||
|
||||
# -- record_event (appending) --
|
||||
|
||||
def test_record_event_appends_jsonl_line(tmp_path: Path) -> None:
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
record_event(tmp_path, "task", "TASK-012")
|
||||
lines = log_lines(tmp_path)
|
||||
assert len(lines) == 2
|
||||
|
||||
|
||||
def test_record_event_writes_valid_json_per_line(tmp_path: Path) -> None:
|
||||
record_event(tmp_path, "command", "rdb prompt", {"arg": "value"})
|
||||
record_event(tmp_path, "task", "TASK-012")
|
||||
for line in log_lines(tmp_path):
|
||||
data = json.loads(line)
|
||||
assert data["event_type"] in ("command", "task")
|
||||
|
||||
|
||||
def test_record_event_has_required_keys(tmp_path: Path) -> None:
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
event = TelemetryEvent(**json.loads(log_lines(tmp_path)[0]))
|
||||
assert hasattr(event, "timestamp")
|
||||
assert hasattr(event, "event_type")
|
||||
assert hasattr(event, "target")
|
||||
assert hasattr(event, "details")
|
||||
|
||||
|
||||
def test_record_event_details_default_empty_dict(tmp_path: Path) -> None:
|
||||
record_event(tmp_path, "command", "rdb prompt")
|
||||
event = TelemetryEvent(**json.loads(log_lines(tmp_path)[0]))
|
||||
assert event.details == {}
|
||||
|
||||
|
||||
# -- read_events (no file) --
|
||||
|
||||
def test_read_events_returns_empty_when_no_log(tmp_path: Path) -> None:
|
||||
events = read_events(tmp_path)
|
||||
assert events == []
|
||||
|
||||
|
||||
# -- read_events (round-trip) --
|
||||
|
||||
def test_read_events_returns_recorded_events(tmp_path: Path) -> None:
|
||||
record_event(tmp_path, "command", "rdb prompt", {"count": 42})
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "command"
|
||||
assert events[0].target == "rdb prompt"
|
||||
assert events[0].details["count"] == 42
|
||||
|
||||
|
||||
def test_read_events_returns_multiple_events(tmp_path: Path) -> None:
|
||||
record_event(tmp_path, "task", "TASK-001")
|
||||
record_event(tmp_path, "task", "TASK-002")
|
||||
record_event(tmp_path, "command", "rdb status")
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == 3
|
||||
|
||||
|
||||
# -- read_events (malformed lines) --
|
||||
|
||||
def test_read_events_skips_malformed_lines(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / ".rdb" / "session-log.jsonl"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path.write_text(
|
||||
'{"timestamp":"2026-01-01T00:00:00+00:00","event_type":"ok","target":"x","details":{}}\n'
|
||||
'this is not json\n'
|
||||
'{"timestamp":"2026-01-01T00:00:00+00:00","event_type":"also_ok","target":"y","details":{}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == 2
|
||||
assert events[0].event_type == "ok"
|
||||
assert events[1].event_type == "also_ok"
|
||||
|
||||
|
||||
# -- CLI command events --
|
||||
|
||||
COMMANDS = [
|
||||
"rdb init",
|
||||
"rdb discover",
|
||||
"rdb ask_more",
|
||||
"rdb status",
|
||||
"rdb next",
|
||||
"rdb start",
|
||||
"rdb complete",
|
||||
"rdb handoff",
|
||||
"rdb prompt",
|
||||
"rdb guardrails",
|
||||
]
|
||||
|
||||
|
||||
def test_record_event_creates_command_events(tmp_path: Path) -> None:
|
||||
"""Each CLI command records an event with event_type 'command' and target 'rdb <name>'."""
|
||||
for target in COMMANDS:
|
||||
record_event(tmp_path, "command", target)
|
||||
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == len(COMMANDS)
|
||||
for event in events:
|
||||
assert event.event_type == "command"
|
||||
|
||||
|
||||
def test_record_event_target_matches_command_name(tmp_path: Path) -> None:
|
||||
"""Event target matches 'rdb <cmd>' format."""
|
||||
for expected in COMMANDS:
|
||||
record_event(tmp_path, "command", expected)
|
||||
|
||||
events = read_events(tmp_path)
|
||||
targets = [e.target for e in events]
|
||||
assert set(targets) == set(COMMANDS)
|
||||
|
||||
|
||||
def test_record_command_event_has_timestamp(tmp_path: Path) -> None:
|
||||
"""Each command event has a non-empty timestamp."""
|
||||
record_event(tmp_path, "command", "rdb status")
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == 1
|
||||
assert events[0].timestamp
|
||||
assert "+" in events[0].timestamp # UTC offset
|
||||
|
||||
|
||||
def test_record_command_event_with_details(tmp_path: Path) -> None:
|
||||
"""Commands like start/complete can pass details dict."""
|
||||
record_event(tmp_path, "command", "rdb start", {"task_id": "TASK-013"})
|
||||
events = read_events(tmp_path)
|
||||
assert events[0].details["task_id"] == "TASK-013"
|
||||
|
||||
record_event(tmp_path, "command", "rdb complete", {"task_id": "TASK-007"})
|
||||
events = read_events(tmp_path)
|
||||
assert events[-1].details["task_id"] == "TASK-007"
|
||||
|
||||
|
||||
# -- Helpers --
|
||||
|
||||
def log_lines(root: Path) -> list[str]:
|
||||
return (root / ".rdb" / "session-log.jsonl").read_text(encoding="utf-8").strip().splitlines()
|
||||
Reference in New Issue
Block a user