diff --git a/CLAUDE.md b/CLAUDE.md index 6f59910be..c8c5831e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,6 +282,7 @@ Keyboard shortcuts (press `?` for help): Projects can be stored in any directory (registered in `~/.autoforge/registry.db`). Each project contains: - `.autoforge/prompts/app_spec.txt` - Application specification (XML format) +- `.autoforge/prompts/spec_input_summary.md` - Markdown transcript of the spec creation chat (questions, options, answers), written by `spec_chat_session.py` after every exchange - `.autoforge/prompts/initializer_prompt.md` - First session prompt - `.autoforge/prompts/coding_prompt.md` - Continuation session prompt - `.autoforge/features.db` - SQLite database with feature test cases diff --git a/README.md b/README.md index 0ad71c941..d3095b756 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ generations/my_project/ ├── features.db # SQLite database (feature test cases) ├── prompts/ │ ├── app_spec.txt # Your app specification +│ ├── spec_input_summary.md # Transcript of the spec creation conversation (Web UI) │ ├── initializer_prompt.md # First session prompt │ └── coding_prompt.md # Continuation session prompt ├── init.sh # Environment setup script diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 0788b13ac..2d97f13b1 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -34,6 +34,64 @@ logger = logging.getLogger(__name__) +# Human-readable transcript of the spec creation conversation, written next to +# app_spec.txt so users can refer back to how the spec was produced. +SPEC_CONVERSATION_FILENAME = "spec_input_summary.md" + + +def render_spec_conversation_markdown( + project_name: str, + messages: list[dict], + started_at: datetime, + complete: bool, +) -> str: + """ + Render the spec creation conversation as Markdown. + + Consecutive assistant text chunks (streamed as separate blocks) are merged + into a single turn so each question/answer exchange reads naturally. + """ + turns: list[dict] = [] + for msg in messages: + role = msg.get("role") + content = str(msg.get("content", "")) + if role == "assistant" and turns and turns[-1]["role"] == "assistant": + turns[-1]["content"] += content + continue + turns.append({**msg, "content": content}) + + status = "Complete" if complete else "In progress" + lines = [ + f"# Spec Creation Conversation: {project_name}", + "", + "This file records the interactive conversation used to create this project's", + "specification (`app_spec.txt`): the initial prompt, the questions and suggested", + "options from Claude, and your answers. It is generated automatically by AutoForge.", + "", + f"- **Started:** {started_at.isoformat(timespec='seconds')}", + f"- **Status:** {status}", + "", + ] + + user_turns = [t for t in turns if t.get("role") == "user"] + if user_turns: + lines += ["## Initial Prompt", "", user_turns[0]["content"].strip() or "_(empty)_", ""] + + lines += ["## Conversation", ""] + for turn in turns: + heading = "Claude" if turn.get("role") == "assistant" else "You" + timestamp = turn.get("timestamp") + lines.append(f"### {heading}" + (f" ({timestamp})" if timestamp else "")) + lines.append("") + attachments = turn.get("attachments") or [] + if attachments: + lines.append("_Attachments: " + ", ".join(f"`{name}`" for name in attachments) + "_") + lines.append("") + lines.append(turn["content"].strip() or "_(no text)_") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + class SpecChatSession: """ @@ -64,6 +122,7 @@ def __init__(self, project_name: str, project_dir: Path): self.created_at = datetime.now() self._conversation_id: Optional[str] = None self._client_entered: bool = False # Track if context manager is active + self._summary_path: Optional[Path] = None # Set in start() once prompts dir is known async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -109,6 +168,7 @@ async def start(self) -> AsyncGenerator[dict, None]: if app_spec_path.exists(): app_spec_path.unlink() logger.info("Deleted scaffolded app_spec.txt for fresh spec creation") + self._summary_path = prompts_dir / SPEC_CONVERSATION_FILENAME # Create security settings file (like client.py does) # This grants permissions for file operations in the project directory @@ -202,6 +262,8 @@ async def start(self) -> AsyncGenerator[dict, None]: "type": "error", "content": f"Failed to start conversation: {str(e)}" } + finally: + self._save_conversation_summary() async def send_message( self, @@ -234,6 +296,7 @@ async def send_message( "role": "user", "content": user_message, "has_attachments": bool(attachments), + "attachments": [a.filename for a in attachments] if attachments else [], "timestamp": datetime.now().isoformat() }) @@ -248,6 +311,9 @@ async def send_message( "type": "error", "content": f"Error: {str(e)}" } + finally: + # Persist after every exchange so partial conversations survive too + self._save_conversation_summary() async def _query_claude( self, @@ -430,6 +496,21 @@ async def _query_claude( return raise + def _save_conversation_summary(self) -> None: + """Persist the conversation so far to spec_input_summary.md (best effort).""" + if self._summary_path is None: + return + try: + self._summary_path.parent.mkdir(parents=True, exist_ok=True) + self._summary_path.write_text( + render_spec_conversation_markdown( + self.project_name, self.messages, self.created_at, self.complete + ), + encoding="utf-8", + ) + except OSError as e: + logger.warning(f"Failed to write spec conversation summary: {e}") + def is_complete(self) -> bool: """Check if spec creation is complete.""" return self.complete diff --git a/test_spec_conversation_summary.py b/test_spec_conversation_summary.py new file mode 100644 index 000000000..46a9bf808 --- /dev/null +++ b/test_spec_conversation_summary.py @@ -0,0 +1,74 @@ +""" +Unit tests for the spec creation conversation transcript (spec_input_summary.md). +""" + +import tempfile +import unittest +from datetime import datetime +from pathlib import Path + +from server.services.spec_chat_session import ( + SPEC_CONVERSATION_FILENAME, + SpecChatSession, + render_spec_conversation_markdown, +) + +STARTED = datetime(2026, 1, 2, 3, 4, 5) + + +class TestRenderSpecConversationMarkdown(unittest.TestCase): + def test_merges_streamed_assistant_chunks(self): + messages = [ + {"role": "assistant", "content": "Hi! ", "timestamp": "t1"}, + {"role": "assistant", "content": "What are you building?", "timestamp": "t2"}, + {"role": "user", "content": "A todo app", "timestamp": "t3"}, + {"role": "assistant", "content": "Pick one:\n1. Quick\n2. Detailed", "timestamp": "t4"}, + {"role": "user", "content": "Quick", "timestamp": "t5"}, + ] + md = render_spec_conversation_markdown("todo", messages, STARTED, complete=False) + + assert md.startswith("# Spec Creation Conversation: todo\n") + assert "**Status:** In progress" in md + assert "**Started:** 2026-01-02T03:04:05" in md + assert "Hi! What are you building?" in md + assert md.count("### Claude") == 2 + assert md.count("### You") == 2 + assert "1. Quick\n2. Detailed" in md + # Initial prompt is the user's first message + initial = md.split("## Initial Prompt")[1].split("## Conversation")[0] + assert initial.strip() == "A todo app" + + def test_complete_status_and_attachments(self): + messages = [ + {"role": "user", "content": "See mockup", "attachments": ["mock.png"], "timestamp": "t1"}, + ] + md = render_spec_conversation_markdown("p", messages, STARTED, complete=True) + assert "**Status:** Complete" in md + assert "_Attachments: `mock.png`_" in md + + def test_no_user_messages_omits_initial_prompt(self): + messages = [{"role": "assistant", "content": "Hello", "timestamp": "t1"}] + md = render_spec_conversation_markdown("p", messages, STARTED, complete=False) + assert "## Initial Prompt" not in md + assert "Hello" in md + + +class TestSaveConversationSummary(unittest.TestCase): + def test_writes_file_to_summary_path(self): + with tempfile.TemporaryDirectory() as tmp: + project_dir = Path(tmp) + session = SpecChatSession("p", project_dir) + # Not started yet: nothing is written + session._save_conversation_summary() + assert not any(project_dir.rglob(SPEC_CONVERSATION_FILENAME)) + + session._summary_path = project_dir / ".autoforge" / "prompts" / SPEC_CONVERSATION_FILENAME + session.messages.append({"role": "user", "content": "Build a blog"}) + session._save_conversation_summary() + + content = session._summary_path.read_text(encoding="utf-8") + assert "Build a blog" in content + + +if __name__ == "__main__": + unittest.main()