chore: bootstrap rdb-discovery
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
typer>=0.12.0
|
||||
rich>=13.0.0
|
||||
|
||||
[dev]
|
||||
pytest>=8.0.0
|
||||
@@ -0,0 +1 @@
|
||||
rdb_discovery
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user