feat(task-020): generate context from discovery answers

This commit is contained in:
2026-06-03 18:19:25 +01:00
parent 250cf9316a
commit 5bad6ad0f8
6 changed files with 646 additions and 13 deletions
+22
View File
@@ -14,6 +14,7 @@ from .tasks import generate_agent_prompt, get_next_task, update_task_status
from .templates import CONTEXT_FILES, write_file_if_missing
from .guardrails import run_all_guardrails, format_report
from .telemetry import record_event
from .generate_context import generate_context_files
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
console = Console()
@@ -209,3 +210,24 @@ def guardrails() -> None:
results = run_all_guardrails(root)
report = format_report(results)
console.print(report)
@app.command()
def generate(min_confidence: str = typer.Option("Medium", help="Minimum confidence to process (High or Medium).")) -> None:
"""Generate context files from discovery answers using the mapping document."""
root = root_path()
record_event(root, "command", "rdb generate")
result = generate_context_files(root, min_confidence)
if result["generated"]:
console.print("[bold green]Generated context content:[/bold green]")
for f in result["generated"]:
console.print(f"- {f}")
else:
console.print("[yellow]No context files generated.[/yellow]")
if result["skipped"]:
console.print("\n[dim]Skipped:[/dim]")
for s in result["skipped"]:
console.print(f" - {s}")
+4 -1
View File
@@ -58,7 +58,10 @@ def read_discovery_answers(root: Path) -> list[dict]:
if not line.startswith("| Q-"):
continue
cells = [c.strip() for c in line.split("|")[1:-1]]
# Replace escaped pipes with a placeholder before splitting,
# so they don't create extra columns. Restore after splitting.
safe_line = line.replace("\\|", "\x00PIPE\x00")
cells = [c.strip().replace("\x00PIPE\x00", "|") for c in safe_line.split("|")[1:-1]]
if len(cells) < 5:
continue
+263
View File
@@ -0,0 +1,263 @@
"""Generate context files from discovery answers.
Uses the mapping defined in context/discovery-context-mapping.md to transform
discovery-log.md entries into the appropriate context files.
Only processes High/Medium confidence answers. Low-confidence answers are
skipped and must be handled by `rdb ask-more` first.
Preserves existing content in context files - never overwrites.
"""
from __future__ import annotations
from pathlib import Path
from .discovery import read_discovery_answers
# Mapping from question ID to list of (file_path, section_name, format_type)
# format_type: "body" = fill under heading with body text
# "table" = append row to table-based section
# "append" = append as a new section at end of file
CONTEXT_MAP = {
"Q-001": [ # What problem are we solving?
("context/product-brief.md", "## Problem", "body"),
("context/project-brief.md", "## Problem Statement", "body"),
],
"Q-002": [ # Who is the user?
("context/product-brief.md", "## Users", "body"),
("context/development-context.md", "## IDEs and Editors", "body"),
],
"Q-003": [ # What does success look like?
("context/product-brief.md", "## Success Criteria", "body"),
("context/project-brief.md", "## Success Metrics", "body"),
],
"Q-004": [ # What is the minimum useful version?
("context/product-brief.md", "## Minimum Useful Version", "body"),
("context/project-brief.md", "## Key Features (MVP)", "body"),
],
"Q-005": [ # What data do we need?
("context/architecture.md", "## Data Flow", "body"),
("context/development-context.md", "## Dependencies", "body"),
],
"Q-006": [ # What systems must it connect to?
("context/architecture.md", "## External Integrations", "body"),
("context/infrastructure-context.md", None, "append"),
],
"Q-007": [ # What are the risks?
("context/risks.md", "# Risks", "table"),
],
"Q-008": [ # What must not happen?
("context/assumptions.md", "# Assumptions", "table"),
],
"Q-009": [ # How will we test it?
("context/development-context.md", "## Build & Test", "body"),
("context/project-brief.md", "## Timeline & Milestones", "body"),
],
"Q-010": [ # How will it be deployed?
("context/architecture.md", "## Deployment Architecture", "body"),
("context/infrastructure-context.md", None, "append"),
],
}
def generate_context_files(
root: Path, min_confidence: str = "Medium"
) -> dict[str, list[str]]:
"""Generate context files from discovery answers.
Args:
root: Project root path.
min_confidence: Minimum confidence to process (High, Medium).
Returns:
Dict with keys: 'generated', 'skipped' each a list of strings.
"""
valid_confidences = {"High", "Medium"}
answers = read_discovery_answers(root)
if not answers:
return {"generated": [], "skipped": ["No discovery answers found in discovery-log.md"]}
filtered = [a for a in answers if a["confidence"] in valid_confidences]
skipped = [a for a in answers if a["confidence"] not in valid_confidences]
generated_files: set[str] = set()
for answer in filtered:
qid = answer["id"]
rules = CONTEXT_MAP.get(qid)
if not rules:
skipped.append(f"{qid} (no mapping)")
continue
for file_path, section_name, fmt in rules:
_write_section(
root=root,
file_path=file_path,
section_name=section_name,
format_type=fmt,
answer=answer,
rules=rules,
)
generated_files.add(file_path)
skipped_ids = [a["id"] for a in skipped]
if skipped_ids:
skipped = ["Low-confidence or flagged answers (skipped): " + ", ".join(skipped_ids)]
return {"generated": sorted(generated_files), "skipped": skipped}
def _write_section(root, file_path, section_name, format_type, answer, rules):
"""Dispatch to the appropriate write strategy."""
target = root / file_path
target.parent.mkdir(parents=True, exist_ok=True)
if format_type == "table":
_write_table_row(target, section_name, answer)
elif format_type == "append":
_append_new_section(target, answer, rules)
else:
# body text type
existing = target.read_text(encoding="utf-8") if target.exists() else ""
lines = existing.splitlines() if existing else []
section_idx = _find_section(lines, section_name) if section_name else -1
if section_idx >= 0:
_fill_or_append_body(lines, target, section_idx, answer)
else:
_create_new_section(target, section_name, answer, format_type)
def _find_section(lines, section_header):
"""Find the index of a section header line in the markdown."""
for i, line in enumerate(lines):
if line.strip() == section_header:
return i
return -1
def _fill_or_append_body(lines, target, section_idx, answer):
"""Fill or append body text under an existing section header.
Preserves the rest of the document - never drops subsequent sections.
"""
start = section_idx + 1
# Find another section header to know the boundary
end = len(lines)
for i in range(start, len(lines)):
if lines[i].startswith("## "):
end = i
break
content_lines = [l.strip() for l in lines[start:end] if l.strip()]
# Check if section is empty or only has TBD placeholder
is_tbd = (
len(content_lines) == 1 and content_lines[0].lower().startswith("tbd")
) or len(content_lines) == 0
answer_text = _format_answer_text(answer)
if is_tbd:
# Replace TBD with discovery answer, keep rest of document
new_lines = (
lines[:section_idx + 1]
+ [answer_text, ""]
+ lines[end:]
)
else:
# Append under existing content, after the last non-empty line in this section
insert_at = start
for i in range(start, end):
if lines[i].strip():
insert_at = i + 1
new_lines = (
lines[:insert_at]
+ [answer_text, ""]
+ lines[insert_at:]
)
target.write_text("\n".join(new_lines), encoding="utf-8")
def _write_table_row(target, section_header, answer):
"""Append a row to a table-based context file."""
existing = target.read_text(encoding="utf-8") if target.exists() else ""
q_num = answer["id"].split("-")[1]
all_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?",
]
question_text = all_questions[int(q_num) - 1] if int(q_num) <= len(all_questions) else answer["question"]
fname = target.name
if fname == "risks.md":
risk_id = f"RISK-{q_num}"
clean = answer["answer"].replace("\\|", "|").strip()
row = f"| {risk_id} | {question_text}: {clean} | Medium | Monitor and review | Open |\n"
elif fname == "assumptions.md":
assump_id = f"ASSUMPTION-{q_num}"
clean = answer["answer"].replace("\\|", "|").strip()
row = f"| {assump_id} | {question_text}: {clean} | Medium | Yes |\n"
else:
row = f"| {answer['id']} | {question_text} | {answer['answer']} | Open |\n"
target.write_text(existing + "\n" + row, encoding="utf-8")
def _append_new_section(target, answer, rules):
"""Append a new section to the end of an existing file."""
existing = target.read_text(encoding="utf-8") if target.exists() else ""
display_name = None
for _, sec, _ in rules:
if sec and sec.startswith("## "):
display_name = sec
break
if not display_name:
q_num = answer["id"].split("-")[1]
display_name = f"## Q-{q_num} Discovery Answer"
section_text = (
f"\n{display_name}\n\n"
f"**Discovery Question:** {answer['question']}\n\n"
f"**Answer:** {answer['answer'].strip()}\n\n"
f"**Confidence:** {answer['confidence']}\n"
)
target.write_text(existing + section_text, encoding="utf-8")
def _create_new_section(target, header, answer, fmt):
"""Create a new section in a context file when it doesn't exist yet."""
existing = target.read_text(encoding="utf-8") if target.exists() else ""
if not header:
q_num = answer["id"].split("-")[1]
header = f"## Q-{q_num} Discovery Answer"
section_text = (
f"\n{header}\n\n"
f"**Discovery Question:** {answer['question']}\n\n"
f"**Answer:** {answer['answer'].strip()}\n\n"
f"**Confidence:** {answer['confidence']}\n"
)
target.write_text(existing + section_text, encoding="utf-8")
def _format_answer_text(answer):
"""Format a discovery answer into readable body text."""
return answer["answer"].replace("\\|", "|").strip()