chore: bootstrap rdb-discovery

This commit is contained in:
2026-06-01 17:43:02 +01:00
commit dede6ecc60
45 changed files with 2101 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
Metadata-Version: 2.4
Name: rdb-discovery
Version: 0.1.0
Summary: Markdown-first discovery and delivery workflow CLI for software projects.
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: typer>=0.12.0
Requires-Dist: rich>=13.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
# rdb-discovery
A small CLI-first tool for repeatable software project discovery and delivery.
It creates markdown files that help humans and AI coding agents understand:
- what is being built
- why it exists
- what questions remain open
- what task should be done next
- what stage the project is currently in
## Install for local development
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
```
## Commands
```bash
rdb init
rdb discover
rdb status
rdb next
rdb start TASK-001
rdb complete TASK-001
rdb handoff
```
## 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
## Principle
Markdown files are the source of truth. No database is required.
+17
View File
@@ -0,0 +1,17 @@
README.md
pyproject.toml
src/rdb_discovery/__init__.py
src/rdb_discovery/cli.py
src/rdb_discovery/discovery.py
src/rdb_discovery/handoff.py
src/rdb_discovery/status.py
src/rdb_discovery/tasks.py
src/rdb_discovery/templates.py
src/rdb_discovery.egg-info/PKG-INFO
src/rdb_discovery.egg-info/SOURCES.txt
src/rdb_discovery.egg-info/dependency_links.txt
src/rdb_discovery.egg-info/entry_points.txt
src/rdb_discovery.egg-info/requires.txt
src/rdb_discovery.egg-info/top_level.txt
tests/test_tasks.py
tests/test_templates.py
@@ -0,0 +1 @@
@@ -0,0 +1,2 @@
[console_scripts]
rdb = rdb_discovery.cli:app
+5
View File
@@ -0,0 +1,5 @@
typer>=0.12.0
rich>=13.0.0
[dev]
pytest>=8.0.0
+1
View File
@@ -0,0 +1 @@
rdb_discovery
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from .discovery import append_discovery_answer, core_questions
from .handoff import build_handoff
from .status import project_stage, task_counts
from .tasks import get_next_task, update_task_status
from .templates import CONTEXT_FILES, write_file_if_missing
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
console = Console()
def root_path() -> Path:
return Path.cwd()
def append_run_log(root: Path, event: str, task_id: str = "", notes: str = "") -> None:
path = root / "RUN_LOG.md"
if not path.exists():
path.write_text("# RUN_LOG.md\n\n| Date | Event | Task | Notes |\n|---|---|---|---|\n", encoding="utf-8")
line = f"| {datetime.now().isoformat(timespec='seconds')} | {event} | {task_id} | {notes} |\n"
with path.open("a", encoding="utf-8") as handle:
handle.write(line)
@app.command()
def init() -> None:
"""Create the initial markdown project structure."""
root = root_path()
created: list[str] = []
skipped: list[str] = []
for relative_path, content in CONTEXT_FILES.items():
if write_file_if_missing(root, relative_path, content):
created.append(relative_path)
else:
skipped.append(relative_path)
console.print("[bold green]Initialised rdb-discovery files.[/bold green]")
if created:
console.print("\nCreated:")
for item in created:
console.print(f"- {item}")
if skipped:
console.print("\nSkipped existing files:")
for item in skipped:
console.print(f"- {item}")
@app.command()
def discover() -> None:
"""Ask the core discovery questions and append answers to the discovery ledger."""
root = root_path()
write_file_if_missing(root, "context/discovery-log.md", CONTEXT_FILES["context/discovery-log.md"])
for index, question in enumerate(core_questions(), start=1):
qid = f"Q-{index:03d}"
answer = typer.prompt(question)
confidence = typer.prompt("Confidence?", default="Medium")
follow_up = typer.prompt("Follow-up needed?", default="No")
append_discovery_answer(root, qid, question, answer, confidence, follow_up)
append_run_log(root, "Discovery completed", notes="Core questions answered")
console.print("[bold green]Discovery complete.[/bold green]")
@app.command()
def status() -> None:
"""Show current project stage and task counts."""
root = root_path()
console.print(f"[bold]Project stage:[/bold] {project_stage(root)}")
counts = task_counts(root)
table = Table(title="Task Counts")
table.add_column("Status")
table.add_column("Count")
if counts:
for status_name, count in counts.items():
table.add_row(status_name, str(count))
else:
table.add_row("None", "0")
console.print(table)
@app.command(name="next")
def next_task() -> None:
"""Show the next Todo task."""
task = get_next_task(root_path())
if not task:
console.print("[yellow]No Todo task found.[/yellow]")
raise typer.Exit(code=0)
console.print(f"[bold]{task.task_id}[/bold] - {task.title}")
console.print(f"Status: {task.status}")
console.print(task.body)
@app.command()
def start(task_id: str) -> None:
"""Mark a task as In Progress."""
root = root_path()
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)
console.print(f"[green]Started {task_id}.[/green]")
@app.command()
def complete(task_id: str) -> None:
"""Mark a task as Done and record validation notes."""
root = root_path()
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)
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()))
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from .templates import CORE_QUESTIONS
def append_discovery_answer(
root: Path,
question_id: str,
question: str,
answer: str,
confidence: str,
follow_up_needed: str,
) -> None:
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} | {question} | {safe_answer} | {confidence} | "
f"{follow_up_needed} | | | {date.today().isoformat()} |\n"
)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(line)
def core_questions() -> list[str]:
return CORE_QUESTIONS
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
from .status import project_stage, task_counts
from .tasks import get_next_task
def build_handoff(root: Path) -> str:
next_task = get_next_task(root)
counts = task_counts(root)
lines = [
"# AI Agent Handoff",
"",
f"Project stage: {project_stage(root)}",
"",
"## Task Counts",
]
if counts:
for status, count in counts.items():
lines.append(f"- {status}: {count}")
else:
lines.append("- No tasks found")
lines.extend(["", "## Next Task"])
if next_task:
lines.extend([
f"- ID: {next_task.task_id}",
f"- Title: {next_task.title}",
f"- Status: {next_task.status}",
])
else:
lines.append("No Todo task found.")
lines.extend([
"",
"## Agent Rules",
"- Implement one task only.",
"- Make the smallest useful change possible.",
"- Do not rewrite unrelated code.",
"- Run relevant tests.",
"- Update documentation if behaviour changes.",
"- Report files changed, validation, risks, and next recommended task.",
])
return "\n".join(lines) + "\n"
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
from .tasks import read_tasks
def project_stage(root: Path) -> str:
if not (root / "context" / "discovery-log.md").exists():
return "NOT_INITIALISED"
tasks = read_tasks(root)
if not tasks:
return "DISCOVERY"
if any(task.status.lower() == "in progress" for task in tasks):
return "BUILDING"
if any(task.status.lower() == "todo" for task in tasks):
return "TASKS_READY"
return "REVIEW_READY"
def task_counts(root: Path) -> dict[str, int]:
counts: dict[str, int] = {}
for task in read_tasks(root):
counts[task.status] = counts.get(task.status, 0) + 1
return counts
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
TASK_HEADING_RE = re.compile(r"^##\s+(TASK-\d+)\s+-\s+(.+)$", re.MULTILINE)
STATUS_RE = re.compile(r"^Status:\s*(.+)$", re.MULTILINE)
@dataclass
class Task:
task_id: str
title: str
status: str
body: str
def read_tasks(root: Path) -> list[Task]:
tasks_path = root / "TASKS.md"
if not tasks_path.exists():
return []
text = tasks_path.read_text(encoding="utf-8")
matches = list(TASK_HEADING_RE.finditer(text))
tasks: list[Task] = []
for index, match in enumerate(matches):
start = match.start()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
block = text[start:end]
status_match = STATUS_RE.search(block)
status = status_match.group(1).strip() if status_match else "Unknown"
tasks.append(Task(match.group(1), match.group(2).strip(), status, block))
return tasks
def get_next_task(root: Path) -> Task | None:
for task in read_tasks(root):
if task.status.lower() == "todo":
return task
return None
def update_task_status(root: Path, task_id: str, new_status: str) -> bool:
tasks_path = root / "TASKS.md"
if not tasks_path.exists():
return False
text = tasks_path.read_text(encoding="utf-8")
matches = list(TASK_HEADING_RE.finditer(text))
for index, match in enumerate(matches):
if match.group(1) != task_id:
continue
start = match.start()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
block = text[start:end]
if STATUS_RE.search(block):
new_block = STATUS_RE.sub(f"Status: {new_status}", block, count=1)
else:
lines = block.splitlines()
lines.insert(1, f"Status: {new_status}")
new_block = "\n".join(lines) + "\n"
tasks_path.write_text(text[:start] + new_block + text[end:], encoding="utf-8")
return True
return False
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
from pathlib import Path
CORE_QUESTIONS = [
"What problem are we solving?",
"Who is the user?",
"What does success look like?",
"What is the minimum useful version?",
"What data do we need?",
"What systems must it connect to?",
"What are the risks?",
"What must not happen?",
"How will we test it?",
"How will it be deployed?",
]
CONTEXT_FILES: dict[str, str] = {
"context/discovery-log.md": """# Discovery Log
| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |
|---|---|---|---|---|---|---|---|
""",
"context/product-brief.md": """# Product Brief
## Problem
TBD
## Users
TBD
## Success Criteria
TBD
## Minimum Useful Version
TBD
""",
"context/architecture.md": """# Architecture
## Overview
TBD
## Components
TBD
## Integrations
TBD
## Deployment
TBD
""",
"context/decisions.md": """# Decisions
| ID | Decision | Reason | Date |
|---|---|---|---|
""",
"context/risks.md": """# Risks
| ID | Risk | Impact | Mitigation | Status |
|---|---|---|---|---|
""",
"context/assumptions.md": """# Assumptions
| ID | Assumption | Confidence | Validation Needed |
|---|---|---|---|
""",
"context/open-questions.md": """# Open Questions
| ID | Question | Reason | Owner | Status |
|---|---|---|---|---|
""",
"TASKS.md": """# TASKS.md
## TASK-001 - Create Python CLI project skeleton
Status: Done
Goal: Create the initial package structure, CLI entry point, and markdown-first bootstrap.
Acceptance Criteria:
- `rdb --help` runs
- `rdb init` creates project files
- README exists
## TASK-002 - Run initial project discovery
Status: Todo
Goal: Run `rdb discover` and capture the core project answers.
Acceptance Criteria:
- all 10 core questions are answered
- discovery-log.md contains ledger entries
- low-confidence answers are marked for follow-up
## TASK-003 - Review generated context files
Status: Todo
Goal: Review the generated markdown files and fill in obvious gaps.
Acceptance Criteria:
- product-brief.md reviewed
- architecture.md reviewed
- open-questions.md updated
## TASK-004 - Implement ask-more command
Status: Todo
Goal: Add a command that finds weak answers and asks deeper follow-up questions.
Acceptance Criteria:
- command reads discovery-log.md
- low-confidence answers are detected
- follow-up answers are appended to discovery-log.md
""",
"TEST_PLAN.md": """# TEST_PLAN.md
## Manual Tests
| ID | Test | Expected Result | Status |
|---|---|---|---|
| TEST-001 | Run `rdb --help` | CLI help is displayed | Not run |
| TEST-002 | Run `rdb init` | Markdown files are created | Not run |
| TEST-003 | Run `rdb status` | Project status is displayed | Not run |
| TEST-004 | Run `rdb next` | Next Todo task is shown | Not run |
## Automated Tests
Run:
```bash
pytest
```
""",
"RUN_LOG.md": """# RUN_LOG.md
| Date | Event | Task | Notes |
|---|---|---|---|
""",
}
def write_file_if_missing(root: Path, relative_path: str, content: str) -> bool:
target = root / relative_path
if target.exists():
return False
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return True