Compare commits
9
Commits
v0.2.0
..
13f5e10f6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f5e10f6f | ||
|
|
6eef97582d | ||
|
|
2fcc822394 | ||
|
|
eb30450f34 | ||
|
|
8222660a3c | ||
|
|
8b96d2c758 | ||
|
|
7a1d36c03c | ||
|
|
318b9471ae | ||
|
|
aee13b045e |
@@ -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
|
## Current Stage
|
||||||
|
|
||||||
BUILDING
|
TASKS_READY
|
||||||
|
|
||||||
## Current Task
|
## Current Task
|
||||||
|
|
||||||
TASK-002 — Run initial project discovery
|
TASK-015
|
||||||
|
|
||||||
## Instructions For Agent
|
## Instructions For Agent
|
||||||
|
|
||||||
|
|||||||
@@ -28,18 +28,23 @@ From the repository root, run:
|
|||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
python -m pytest
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
Do not search the filesystem for pytest.
|
Do not search the filesystem for pytest.
|
||||||
Do not create a new virtual environment unless explicitly asked.
|
Do not create a new virtual environment unless explicitly asked.
|
||||||
|
|
||||||
If pytest is unavailable, run:
|
If pytest is unavailable, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
pip install -e '.[dev]'
|
pip install -e '.[dev]'
|
||||||
python -m pytest
|
python -m pytest
|
||||||
Validation Commands
|
```
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
For normal task validation, run:
|
For normal task validation, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
python -m pytest
|
python -m pytest
|
||||||
rdb status
|
rdb status
|
||||||
rdb next
|
rdb next
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ Current Stage: BUILDING
|
|||||||
Previous Stage: BOOTSTRAP_READY
|
Previous Stage: BOOTSTRAP_READY
|
||||||
Next Stage: REVIEW_READY
|
Next Stage: REVIEW_READY
|
||||||
|
|
||||||
Current Task: TASK-004
|
Current Task: TASK-015
|
||||||
Active Branch: main
|
Active Branch: main
|
||||||
|
|
||||||
Last Updated: 2026-06-02
|
Last Updated: 2026-06-02
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# rdb-discovery
|
# 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
|
- what is being built
|
||||||
- why it exists
|
- 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 task should be done next
|
||||||
- what stage the project is currently in
|
- 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
|
## Install for local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -21,23 +37,24 @@ pip install -e '.[dev]'
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rdb init
|
rdb init # Create standard project structure and context files
|
||||||
rdb discover
|
rdb discover # Ask 10 core discovery questions, record answers
|
||||||
rdb status
|
rdb guardrails # Check for agent stalls and state inconsistencies
|
||||||
rdb next
|
rdb prompt # Generate implementation prompt for the next task
|
||||||
rdb start TASK-001
|
rdb start TASK-001 # Mark a task as in progress
|
||||||
rdb complete TASK-001
|
rdb complete TASK-001# Mark a task as done
|
||||||
rdb handoff
|
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
|
## Bootstrap workflow
|
||||||
|
|
||||||
1. Run `rdb init`
|
1. Run `rdb init`
|
||||||
2. Run `rdb discover`
|
2. Run `rdb discover`
|
||||||
3. Run `rdb status`
|
3. Review `rdb status` and `rdb next`
|
||||||
4. Run `rdb next`
|
4. Give the next prompt to Claude Code or Cline via `rdb prompt`
|
||||||
5. Give the next task to Claude Code or Cline
|
5. Commit after each completed task
|
||||||
6. Commit after each completed task
|
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ Added TASK-002 for agent prompt generation.
|
|||||||
TASK-002 marked Done — agent prompt generation complete.
|
TASK-002 marked Done — agent prompt generation complete.
|
||||||
| 2026-06-02T10:01:31 | Task completed | TASK-004 | 28 tests passed |
|
| 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-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 |
|
||||||
|
|||||||
@@ -95,9 +95,28 @@ Acceptance Criteria:
|
|||||||
- Add/update tests
|
- Add/update tests
|
||||||
- Run python -m pytest
|
- 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
|
## TASK-007 — Improve generated agent prompts
|
||||||
|
|
||||||
Status: Todo
|
Status: Done
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
Make `rdb prompt` produce smaller, more direct prompts for Claude Code/local LLM agents.
|
Make `rdb prompt` produce smaller, more direct prompts for Claude Code/local LLM agents.
|
||||||
@@ -110,3 +129,192 @@ Acceptance Criteria:
|
|||||||
- Prompt tells agent to inspect first, then edit
|
- Prompt tells agent to inspect first, then edit
|
||||||
- Prompt limits scope to one small implementation step
|
- Prompt limits scope to one small implementation step
|
||||||
- Add/update tests
|
- 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
|
Tests pass
|
||||||
CLI help displays
|
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.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from .handoff import build_handoff
|
|||||||
from .status import project_stage, task_counts, update_project_state, update_agent_handoff
|
from .status import project_stage, task_counts, update_project_state, update_agent_handoff
|
||||||
from .tasks import generate_agent_prompt, get_next_task, update_task_status
|
from .tasks import generate_agent_prompt, get_next_task, update_task_status
|
||||||
from .templates import CONTEXT_FILES, write_file_if_missing
|
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.")
|
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -34,6 +36,7 @@ def append_run_log(root: Path, event: str, task_id: str = "", notes: str = "") -
|
|||||||
def init() -> None:
|
def init() -> None:
|
||||||
"""Create the initial markdown project structure."""
|
"""Create the initial markdown project structure."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb init")
|
||||||
created: list[str] = []
|
created: list[str] = []
|
||||||
skipped: list[str] = []
|
skipped: list[str] = []
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ def init() -> None:
|
|||||||
def discover() -> None:
|
def discover() -> None:
|
||||||
"""Ask the core discovery questions and append answers to the discovery ledger."""
|
"""Ask the core discovery questions and append answers to the discovery ledger."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb discover")
|
||||||
write_file_if_missing(root, "context/discovery-log.md", CONTEXT_FILES["context/discovery-log.md"])
|
write_file_if_missing(root, "context/discovery-log.md", CONTEXT_FILES["context/discovery-log.md"])
|
||||||
|
|
||||||
for index, question in enumerate(core_questions(), start=1):
|
for index, question in enumerate(core_questions(), start=1):
|
||||||
@@ -75,6 +79,7 @@ def discover() -> None:
|
|||||||
def ask_more() -> None:
|
def ask_more() -> None:
|
||||||
"""Ask for additional details on Low-confidence or follow-up-needed answers."""
|
"""Ask for additional details on Low-confidence or follow-up-needed answers."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb ask_more")
|
||||||
answers = read_discovery_answers(root)
|
answers = read_discovery_answers(root)
|
||||||
|
|
||||||
if not answers:
|
if not answers:
|
||||||
@@ -118,6 +123,7 @@ def ask_more() -> None:
|
|||||||
def status() -> None:
|
def status() -> None:
|
||||||
"""Show current project stage and task counts."""
|
"""Show current project stage and task counts."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb status")
|
||||||
console.print(f"[bold]Project stage:[/bold] {project_stage(root)}")
|
console.print(f"[bold]Project stage:[/bold] {project_stage(root)}")
|
||||||
|
|
||||||
counts = task_counts(root)
|
counts = task_counts(root)
|
||||||
@@ -137,7 +143,9 @@ def status() -> None:
|
|||||||
@app.command(name="next")
|
@app.command(name="next")
|
||||||
def next_task() -> None:
|
def next_task() -> None:
|
||||||
"""Show the next Todo task."""
|
"""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:
|
if not task:
|
||||||
console.print("[yellow]No Todo task found.[/yellow]")
|
console.print("[yellow]No Todo task found.[/yellow]")
|
||||||
raise typer.Exit(code=0)
|
raise typer.Exit(code=0)
|
||||||
@@ -151,6 +159,7 @@ def next_task() -> None:
|
|||||||
def start(task_id: str) -> None:
|
def start(task_id: str) -> None:
|
||||||
"""Mark a task as In Progress."""
|
"""Mark a task as In Progress."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb start", {"task_id": task_id})
|
||||||
if not update_task_status(root, task_id, "In Progress"):
|
if not update_task_status(root, task_id, "In Progress"):
|
||||||
console.print(f"[red]Task not found:[/red] {task_id}")
|
console.print(f"[red]Task not found:[/red] {task_id}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
@@ -164,6 +173,7 @@ def start(task_id: str) -> None:
|
|||||||
def complete(task_id: str) -> None:
|
def complete(task_id: str) -> None:
|
||||||
"""Mark a task as Done and record validation notes."""
|
"""Mark a task as Done and record validation notes."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb complete", {"task_id": task_id})
|
||||||
notes = typer.prompt("Validation notes", default="Not tested")
|
notes = typer.prompt("Validation notes", default="Not tested")
|
||||||
if not update_task_status(root, task_id, "Done"):
|
if not update_task_status(root, task_id, "Done"):
|
||||||
console.print(f"[red]Task not found:[/red] {task_id}")
|
console.print(f"[red]Task not found:[/red] {task_id}")
|
||||||
@@ -177,12 +187,25 @@ def complete(task_id: str) -> None:
|
|||||||
@app.command()
|
@app.command()
|
||||||
def handoff() -> None:
|
def handoff() -> None:
|
||||||
"""Print an AI-agent handoff summary."""
|
"""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()
|
@app.command()
|
||||||
def prompt() -> None:
|
def prompt() -> None:
|
||||||
"""Generate a ready-to-paste implementation prompt for the next Todo task."""
|
"""Generate a ready-to-paste implementation prompt for the next Todo task."""
|
||||||
root = root_path()
|
root = root_path()
|
||||||
|
record_event(root, "command", "rdb prompt")
|
||||||
result = generate_agent_prompt(root)
|
result = generate_agent_prompt(root)
|
||||||
console.print(result)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -10,6 +10,7 @@ STATUS_RE = re.compile(r"^Status:\s*(.+)$", re.MULTILINE)
|
|||||||
|
|
||||||
GOAL_RE = re.compile(r"^Goal:\s*(.+)", re.MULTILINE)
|
GOAL_RE = re.compile(r"^Goal:\s*(.+)", re.MULTILINE)
|
||||||
AC_RE = re.compile(r"^- (.+)$", re.MULTILINE)
|
AC_RE = re.compile(r"^- (.+)$", re.MULTILINE)
|
||||||
|
GAP_RE = re.compile(r"^Implementation Gap:\s*(.+)", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -28,6 +29,12 @@ class Task:
|
|||||||
def ac_lines(self) -> list[str]:
|
def ac_lines(self) -> list[str]:
|
||||||
return AC_RE.findall(self.body)
|
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]:
|
def read_tasks(root: Path) -> list[Task]:
|
||||||
tasks_path = root / "TASKS.md"
|
tasks_path = root / "TASKS.md"
|
||||||
@@ -92,6 +99,12 @@ CONTEXT_FILES_TO_READ = [
|
|||||||
"context/agent-guidelines.md",
|
"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:
|
def _read_file_safe(root: Path, relative: str) -> str:
|
||||||
path = root / relative
|
path = root / relative
|
||||||
@@ -100,6 +113,55 @@ def _read_file_safe(root: Path, relative: str) -> str:
|
|||||||
return f"# {relative} — not found"
|
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:
|
def generate_agent_prompt(root: Path) -> str:
|
||||||
task = get_next_task(root)
|
task = get_next_task(root)
|
||||||
if not task:
|
if not task:
|
||||||
@@ -128,9 +190,20 @@ def generate_agent_prompt(root: Path) -> str:
|
|||||||
f"Status: {task.status}",
|
f"Status: {task.status}",
|
||||||
"",
|
"",
|
||||||
f"Goal:\n{task.goal(root)}",
|
f"Goal:\n{task.goal(root)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
gap = task.implementation_gap(root)
|
||||||
|
if gap:
|
||||||
|
sections.extend([
|
||||||
|
"",
|
||||||
|
"Implementation Gap:",
|
||||||
|
gap,
|
||||||
|
])
|
||||||
|
|
||||||
|
sections.extend([
|
||||||
"",
|
"",
|
||||||
"Acceptance Criteria:",
|
"Acceptance Criteria:",
|
||||||
]
|
])
|
||||||
|
|
||||||
for line in task.ac_lines():
|
for line in task.ac_lines():
|
||||||
sections.append(f"- {line}")
|
sections.append(f"- {line}")
|
||||||
@@ -141,8 +214,25 @@ def generate_agent_prompt(root: Path) -> str:
|
|||||||
"",
|
"",
|
||||||
"# Constraints",
|
"# 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.",
|
"- 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
|
||||||
@@ -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
|
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:
|
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
|
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(
|
(tmp_path / "TASKS.md").write_text(
|
||||||
"# TASKS\n\n"
|
"# TASKS\n\n"
|
||||||
"## TASK-001 — First\nStatus: Done\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)
|
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:
|
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)
|
prompt = generate_agent_prompt(tmp_path)
|
||||||
|
|
||||||
assert prompt == "No Todo task found."
|
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,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