Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions server/services/spec_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
})

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions test_spec_conversation_summary.py
Original file line number Diff line number Diff line change
@@ -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()