codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1118 @@
1
+ """PRD Discovery Session management for CodeFRAME v2.
2
+
3
+ This module provides AI-native Socratic discovery for PRD generation.
4
+ The AI drives the entire discovery process - generating context-sensitive
5
+ questions, validating answer adequacy, and determining when sufficient
6
+ information has been gathered.
7
+
8
+ This module is headless - no FastAPI or HTTP dependencies.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import uuid
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from enum import Enum
18
+ from typing import Any, Optional
19
+
20
+ from codeframe.adapters.llm.anthropic import AnthropicProvider
21
+ from codeframe.adapters.llm.base import Purpose
22
+ from codeframe.core import blockers, prd
23
+ from codeframe.core.workspace import Workspace, get_db_connection
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _utc_now() -> datetime:
29
+ """Get current UTC time as timezone-aware datetime."""
30
+ return datetime.now(timezone.utc)
31
+
32
+
33
+ class DiscoveryError(Exception):
34
+ """Base exception for discovery errors."""
35
+
36
+ pass
37
+
38
+
39
+ class NoApiKeyError(DiscoveryError):
40
+ """Raised when no API key is available for AI-driven discovery."""
41
+
42
+ pass
43
+
44
+
45
+ class ValidationError(DiscoveryError):
46
+ """Raised when answer validation fails."""
47
+
48
+ pass
49
+
50
+
51
+ class IncompleteSessionError(DiscoveryError):
52
+ """Raised when trying to generate PRD from incomplete session."""
53
+
54
+ pass
55
+
56
+
57
+ class SessionState(str, Enum):
58
+ """State of a discovery session."""
59
+
60
+ IDLE = "idle"
61
+ DISCOVERING = "discovering"
62
+ PAUSED = "paused"
63
+ COMPLETED = "completed"
64
+
65
+
66
+ # Categories that should be covered for a complete PRD
67
+ DISCOVERY_CATEGORIES = [
68
+ "problem", # What problem does this solve?
69
+ "users", # Who are the target users?
70
+ "features", # What are the core capabilities?
71
+ "constraints", # What limitations exist?
72
+ "tech_stack", # What technologies are preferred?
73
+ ]
74
+
75
+ # System prompt for the discovery AI
76
+ DISCOVERY_SYSTEM_PROMPT = """You are an expert product manager conducting Socratic discovery to gather requirements for a software project. Your goal is to ask thoughtful, context-sensitive questions that help clarify the project vision.
77
+
78
+ ## Your Role
79
+ - Ask ONE clear, specific question at a time
80
+ - Build on previous answers to go deeper
81
+ - Be conversational but professional
82
+ - Focus on understanding the "why" behind requirements
83
+
84
+ ## Categories to Cover
85
+ You need to gather enough information in these areas:
86
+ 1. **Problem**: What problem does this solve? Why does it matter? Who feels the pain?
87
+ 2. **Users**: Who are the target users? What are their roles and needs?
88
+ 3. **Features**: What are the core capabilities? What's the MVP vs nice-to-have?
89
+ 4. **Constraints**: Technical limitations? Timeline? Budget? Compliance requirements?
90
+ 5. **Tech Stack**: Preferred technologies? Existing systems to integrate with?
91
+
92
+ ## Scoring Guidelines
93
+ For each category, mentally score completeness from 0-100:
94
+ - 0-30: Not mentioned or too vague
95
+ - 30-60: Basic understanding but missing details
96
+ - 60-80: Good understanding, minor gaps
97
+ - 80-100: Clear, specific, actionable
98
+
99
+ ## When to Stop
100
+ Stop asking questions when:
101
+ - All categories score 60+ AND total average is 70+
102
+ - OR user has provided enough for a focused MVP PRD
103
+ - OR diminishing returns (user is repeating themselves)
104
+
105
+ Apply a "question tax" - only ask if the answer will meaningfully improve the PRD.
106
+ Don't ask about edge cases, future phases, or nice-to-haves unless core is unclear."""
107
+
108
+ QUESTION_GENERATION_PROMPT = """Based on the conversation so far, generate the next discovery question.
109
+
110
+ ## Previous Q&A
111
+ {qa_history}
112
+
113
+ ## Current Coverage Assessment
114
+ {coverage_assessment}
115
+
116
+ ## Instructions
117
+ 1. Identify the biggest gap in understanding
118
+ 2. Generate ONE focused question that addresses that gap
119
+ 3. Make it specific to what the user has already shared
120
+ 4. Don't repeat questions already asked
121
+
122
+ If you believe we have enough information (all categories adequately covered), respond with:
123
+ DISCOVERY_COMPLETE
124
+
125
+ Otherwise, respond with just the question text, nothing else."""
126
+
127
+ ANSWER_VALIDATION_PROMPT = """Evaluate whether this answer adequately addresses the question.
128
+
129
+ Question: {question}
130
+ Answer: {answer}
131
+
132
+ ## Evaluation Criteria
133
+ 1. **Relevance**: Does it actually answer what was asked?
134
+ 2. **Specificity**: Is it concrete enough to act on?
135
+ 3. **Completeness**: Does it fully address the question or leave major gaps?
136
+
137
+ ## Response Format
138
+ Respond with a JSON object:
139
+ {{
140
+ "adequate": true/false,
141
+ "reason": "brief explanation",
142
+ "follow_up": "optional follow-up question if answer is inadequate but not rejectable"
143
+ }}
144
+
145
+ Be lenient - accept answers that provide useful information even if not perfect.
146
+ Only mark inadequate if the answer is truly unhelpful (off-topic, too vague to use, or dismissive)."""
147
+
148
+ COVERAGE_ASSESSMENT_PROMPT = """Assess the current coverage of discovery categories based on the conversation.
149
+
150
+ ## Conversation History
151
+ {qa_history}
152
+
153
+ ## Categories to Assess
154
+ 1. problem - Understanding of the problem being solved
155
+ 2. users - Knowledge of target users and their needs
156
+ 3. features - Clarity on core capabilities needed
157
+ 4. constraints - Awareness of limitations and requirements
158
+ 5. tech_stack - Understanding of technology preferences
159
+
160
+ ## Response Format
161
+ Respond with a JSON object:
162
+ {{
163
+ "scores": {{
164
+ "problem": 0-100,
165
+ "users": 0-100,
166
+ "features": 0-100,
167
+ "constraints": 0-100,
168
+ "tech_stack": 0-100
169
+ }},
170
+ "average": 0-100,
171
+ "weakest_category": "category_name",
172
+ "ready_for_prd": true/false,
173
+ "reasoning": "brief explanation of readiness assessment"
174
+ }}"""
175
+
176
+ PRD_GENERATION_PROMPT = """Generate a Product Requirements Document based on the discovery conversation.
177
+
178
+ ## Discovery Conversation
179
+ {qa_history}
180
+
181
+ ## PRD Format
182
+ Generate a markdown PRD with these sections:
183
+
184
+ # [Project Title - infer from conversation]
185
+
186
+ ## Overview
187
+ A clear problem statement and vision (2-3 sentences).
188
+
189
+ ## Target Users
190
+ Who will use this and what are their key needs.
191
+
192
+ ## Core Features
193
+ Numbered list of MVP features with brief descriptions.
194
+
195
+ ## Technical Requirements
196
+ Technology stack, integrations, and technical constraints.
197
+
198
+ ## Constraints & Considerations
199
+ Timeline, budget, compliance, or other limitations.
200
+
201
+ ## Success Criteria
202
+ How we'll know if this project succeeds (measurable where possible).
203
+
204
+ ## Out of Scope (MVP)
205
+ What we're explicitly NOT building in the first version.
206
+
207
+ ---
208
+
209
+ Keep it concise but complete. Focus on actionable requirements.
210
+ This PRD should be sufficient to generate development tasks."""
211
+
212
+
213
+ @dataclass
214
+ class PrdDiscoverySession:
215
+ """Manages an AI-driven PRD discovery session.
216
+
217
+ The AI drives the entire discovery process:
218
+ - Generates context-sensitive questions based on conversation history
219
+ - Validates answer adequacy (not just length/pattern)
220
+ - Determines when sufficient information has been gathered
221
+ - Generates the final PRD
222
+
223
+ Attributes:
224
+ workspace: The workspace this session belongs to
225
+ api_key: API key for LLM provider (required)
226
+ session_id: Unique session identifier
227
+ state: Current session state
228
+ """
229
+
230
+ workspace: Workspace
231
+ api_key: Optional[str] = None
232
+ session_id: Optional[str] = field(default=None, init=False)
233
+ state: SessionState = field(default=SessionState.IDLE, init=False)
234
+ _llm_provider: Any = field(default=None, init=False)
235
+ _qa_history: list[dict[str, str]] = field(default_factory=list, init=False)
236
+ _current_question: Optional[str] = field(default=None, init=False)
237
+ _coverage: Optional[dict[str, Any]] = field(default=None, init=False)
238
+ _blocker_id: Optional[str] = field(default=None, init=False)
239
+ _is_complete: bool = field(default=False, init=False)
240
+
241
+ def __post_init__(self):
242
+ """Initialize the LLM provider."""
243
+ key = self.api_key or os.getenv("ANTHROPIC_API_KEY")
244
+ if not key:
245
+ raise NoApiKeyError(
246
+ "ANTHROPIC_API_KEY is required for AI-driven discovery. "
247
+ "Set the environment variable or pass api_key parameter."
248
+ )
249
+
250
+ self._llm_provider = AnthropicProvider(api_key=key)
251
+
252
+ @property
253
+ def answered_count(self) -> int:
254
+ """Number of questions answered."""
255
+ return len(self._qa_history)
256
+
257
+ def start_discovery(self) -> None:
258
+ """Start a new discovery session.
259
+
260
+ Creates a session record and generates the first question.
261
+ """
262
+ self.session_id = str(uuid.uuid4())
263
+ self.state = SessionState.DISCOVERING
264
+ self._qa_history = []
265
+ self._is_complete = False
266
+
267
+ # Ensure schema exists and save initial session
268
+ _ensure_discovery_schema(self.workspace)
269
+ self._save_session()
270
+
271
+ # Generate first question and persist it
272
+ self._current_question = self._generate_opening_question()
273
+ self._save_session()
274
+
275
+ logger.info(f"Started discovery session {self.session_id}")
276
+
277
+ def _generate_opening_question(self) -> str:
278
+ """Generate the opening question for discovery."""
279
+
280
+ prompt = """You're starting a new product discovery session.
281
+ Generate an opening question that invites the user to describe their project idea.
282
+ Be warm and encouraging. Just output the question, nothing else."""
283
+
284
+ response = self._llm_provider.complete(
285
+ messages=[{"role": "user", "content": prompt}],
286
+ purpose=Purpose.GENERATION,
287
+ system=DISCOVERY_SYSTEM_PROMPT,
288
+ max_tokens=200,
289
+ temperature=0.7,
290
+ )
291
+ return response.content.strip()
292
+
293
+ def load_session(self, session_id: str) -> None:
294
+ """Load an existing session from database.
295
+
296
+ Args:
297
+ session_id: Session ID to load
298
+
299
+ Raises:
300
+ ValueError: If session not found
301
+ """
302
+ conn = get_db_connection(self.workspace)
303
+ cursor = conn.cursor()
304
+
305
+ cursor.execute(
306
+ """
307
+ SELECT id, state, qa_history, current_question, coverage, blocker_id, is_complete
308
+ FROM discovery_sessions
309
+ WHERE id = ? AND workspace_id = ?
310
+ """,
311
+ (session_id, self.workspace.id),
312
+ )
313
+ row = cursor.fetchone()
314
+ conn.close()
315
+
316
+ if not row:
317
+ raise ValueError(f"Session not found: {session_id}")
318
+
319
+ self.session_id = row[0]
320
+ self.state = SessionState(row[1])
321
+ self._qa_history = json.loads(row[2]) if row[2] else []
322
+ self._current_question = row[3]
323
+ self._coverage = json.loads(row[4]) if row[4] else None
324
+ self._blocker_id = row[5]
325
+ self._is_complete = bool(row[6]) if row[6] is not None else False
326
+
327
+ logger.info(f"Loaded session {session_id} with {len(self._qa_history)} Q&A pairs")
328
+
329
+ def get_current_question(self) -> Optional[dict[str, Any]]:
330
+ """Get the current question to display.
331
+
332
+ Returns:
333
+ Question dict with 'text' and 'question_number', or None if complete
334
+ """
335
+ if self._is_complete:
336
+ return None
337
+
338
+ if self._current_question:
339
+ return {
340
+ "text": self._current_question,
341
+ "question_number": len(self._qa_history) + 1,
342
+ }
343
+
344
+ return None
345
+
346
+ def submit_answer(self, answer_text: str) -> dict[str, Any]:
347
+ """Submit an answer and get validation result.
348
+
349
+ The AI validates the answer for adequacy. If adequate, generates
350
+ the next question (or marks discovery complete).
351
+
352
+ Args:
353
+ answer_text: User's answer
354
+
355
+ Returns:
356
+ Dict with 'accepted', 'feedback', and optionally 'follow_up'
357
+
358
+ Raises:
359
+ ValidationError: If answer is empty
360
+ """
361
+ answer_text = answer_text.strip()
362
+
363
+ if not answer_text:
364
+ raise ValidationError("Please provide an answer.")
365
+
366
+ # Validate with AI
367
+ validation = self._validate_answer(self._current_question, answer_text)
368
+
369
+ if not validation["adequate"]:
370
+ # Answer not adequate - provide feedback
371
+ if validation.get("follow_up"):
372
+ # Replace current question with follow-up
373
+ self._current_question = validation["follow_up"]
374
+ self._save_session()
375
+ return {
376
+ "accepted": False,
377
+ "feedback": validation["reason"],
378
+ "follow_up": validation["follow_up"],
379
+ }
380
+ else:
381
+ return {
382
+ "accepted": False,
383
+ "feedback": validation["reason"],
384
+ }
385
+
386
+ # Answer accepted - store it
387
+ self._qa_history.append({
388
+ "question": self._current_question,
389
+ "answer": answer_text,
390
+ "timestamp": _utc_now().isoformat(),
391
+ })
392
+
393
+ # Check coverage and generate next question
394
+ self._update_coverage()
395
+ next_question = self._generate_next_question()
396
+
397
+ # Complete when AI signals done OR coverage assessment says ready
398
+ if next_question == "DISCOVERY_COMPLETE" or self._coverage_is_sufficient():
399
+ self._is_complete = True
400
+ self._current_question = None
401
+ else:
402
+ self._current_question = next_question
403
+
404
+ self._save_session()
405
+
406
+ return {
407
+ "accepted": True,
408
+ "feedback": "Answer recorded.",
409
+ "coverage": self._coverage,
410
+ }
411
+
412
+ def _validate_answer(self, question: str, answer: str) -> dict[str, Any]:
413
+ """Use AI to validate if answer adequately addresses the question."""
414
+
415
+ prompt = ANSWER_VALIDATION_PROMPT.format(
416
+ question=question,
417
+ answer=answer,
418
+ )
419
+
420
+ response = self._llm_provider.complete(
421
+ messages=[{"role": "user", "content": prompt}],
422
+ purpose=Purpose.GENERATION,
423
+ max_tokens=300,
424
+ temperature=0.3,
425
+ )
426
+
427
+ try:
428
+ # Parse JSON response
429
+ content = response.content.strip()
430
+ # Handle markdown code blocks
431
+ if content.startswith("```"):
432
+ content = content.split("```")[1]
433
+ if content.startswith("json"):
434
+ content = content[4:]
435
+ return json.loads(content)
436
+ except json.JSONDecodeError:
437
+ # If AI doesn't return valid JSON, be lenient and accept
438
+ logger.warning(f"Could not parse validation response: {response.content}")
439
+ return {"adequate": True, "reason": "Accepted"}
440
+
441
+ def _update_coverage(self) -> None:
442
+ """Update the coverage assessment based on conversation history."""
443
+
444
+ qa_history = self._format_qa_history()
445
+
446
+ prompt = COVERAGE_ASSESSMENT_PROMPT.format(qa_history=qa_history)
447
+
448
+ response = self._llm_provider.complete(
449
+ messages=[{"role": "user", "content": prompt}],
450
+ purpose=Purpose.GENERATION,
451
+ max_tokens=500,
452
+ temperature=0.3,
453
+ )
454
+
455
+ try:
456
+ content = response.content.strip()
457
+ if content.startswith("```"):
458
+ content = content.split("```")[1]
459
+ if content.startswith("json"):
460
+ content = content[4:]
461
+ self._coverage = json.loads(content)
462
+ except json.JSONDecodeError:
463
+ logger.warning(f"Could not parse coverage response: {response.content}")
464
+ self._coverage = None
465
+
466
+ def _coverage_is_sufficient(self) -> bool:
467
+ """Check if coverage is sufficient to generate PRD.
468
+
469
+ Relies on AI assessment - if a comprehensive answer covers all
470
+ categories (e.g., pasting a full PRD), discovery can complete quickly.
471
+ """
472
+ if not self._coverage:
473
+ return False
474
+ return self._coverage.get("ready_for_prd", False)
475
+
476
+ def _generate_next_question(self) -> str:
477
+ """Generate the next discovery question based on context."""
478
+
479
+ qa_history = self._format_qa_history()
480
+ coverage_str = json.dumps(self._coverage, indent=2) if self._coverage else "Not yet assessed"
481
+
482
+ prompt = QUESTION_GENERATION_PROMPT.format(
483
+ qa_history=qa_history,
484
+ coverage_assessment=coverage_str,
485
+ )
486
+
487
+ response = self._llm_provider.complete(
488
+ messages=[{"role": "user", "content": prompt}],
489
+ purpose=Purpose.GENERATION,
490
+ system=DISCOVERY_SYSTEM_PROMPT,
491
+ max_tokens=300,
492
+ temperature=0.7,
493
+ )
494
+
495
+ return response.content.strip()
496
+
497
+ def _format_qa_history(self) -> str:
498
+ """Format Q&A history for prompts."""
499
+ if not self._qa_history:
500
+ return "(No questions answered yet)"
501
+
502
+ lines = []
503
+ for i, qa in enumerate(self._qa_history, 1):
504
+ lines.append(f"Q{i}: {qa['question']}")
505
+ lines.append(f"A{i}: {qa['answer']}")
506
+ lines.append("")
507
+ return "\n".join(lines)
508
+
509
+ def is_complete(self) -> bool:
510
+ """Check if discovery is complete.
511
+
512
+ Returns:
513
+ True if AI has determined sufficient information gathered
514
+ """
515
+ return self._is_complete
516
+
517
+ def get_progress(self) -> dict[str, Any]:
518
+ """Get discovery progress statistics.
519
+
520
+ Returns:
521
+ Dict with coverage scores and completion status
522
+ """
523
+ return {
524
+ "answered": len(self._qa_history),
525
+ "coverage": self._coverage or {},
526
+ "is_complete": self._is_complete,
527
+ "percentage": self._coverage.get("average", 0) if self._coverage else 0,
528
+ }
529
+
530
+ def pause_discovery(self, reason: str) -> str:
531
+ """Pause the discovery session.
532
+
533
+ Creates a blocker for later resume.
534
+
535
+ Args:
536
+ reason: Reason for pausing
537
+
538
+ Returns:
539
+ Blocker ID for resume
540
+ """
541
+ self.state = SessionState.PAUSED
542
+
543
+ question = (
544
+ f"Discovery session paused: {reason}\n"
545
+ f"Session ID: {self.session_id}\n"
546
+ f"Progress: {len(self._qa_history)} questions answered"
547
+ )
548
+
549
+ blocker = blockers.create(
550
+ self.workspace, question=question, task_id=None, created_by="system"
551
+ )
552
+ self._blocker_id = blocker.id
553
+ self._save_session()
554
+
555
+ logger.info(f"Session paused with blocker {blocker.id}")
556
+ return blocker.id
557
+
558
+ def resume_discovery(self, blocker_id: str) -> None:
559
+ """Resume discovery from a blocker.
560
+
561
+ Args:
562
+ blocker_id: Blocker ID to resume from
563
+
564
+ Raises:
565
+ ValueError: If blocker not found or not a discovery blocker
566
+ """
567
+ blocker = blockers.get(self.workspace, blocker_id)
568
+ if not blocker:
569
+ raise ValueError(f"Blocker not found: {blocker_id}")
570
+
571
+ if "discovery" not in blocker.question.lower():
572
+ raise ValueError("Blocker is not a discovery session blocker")
573
+
574
+ # Extract session ID from blocker question
575
+ lines = blocker.question.split("\n")
576
+ session_id = None
577
+ for line in lines:
578
+ if "Session ID:" in line:
579
+ session_id = line.split("Session ID:")[-1].strip()
580
+ break
581
+
582
+ if not session_id:
583
+ raise ValueError("Could not find session ID in blocker")
584
+
585
+ # Load the session
586
+ self.load_session(session_id)
587
+ self.state = SessionState.DISCOVERING
588
+ self._save_session()
589
+
590
+ logger.info(f"Resumed session {session_id} from blocker {blocker_id}")
591
+
592
+ def generate_prd(self, template_id: Optional[str] = None) -> prd.PrdRecord:
593
+ """Generate PRD from discovery conversation.
594
+
595
+ Uses AI to synthesize the conversation into a structured PRD.
596
+
597
+ Args:
598
+ template_id: Optional PRD template ID to use for formatting.
599
+ If not provided or not found, uses the default built-in
600
+ prompt format (recorded as "default" in metadata).
601
+
602
+ Returns:
603
+ Created PrdRecord
604
+
605
+ Raises:
606
+ IncompleteSessionError: If discovery not complete
607
+ """
608
+ if not self._is_complete and not self._coverage_is_sufficient():
609
+ raise IncompleteSessionError(
610
+ "Cannot generate PRD: discovery not complete. "
611
+ f"Current coverage: {self._coverage}"
612
+ )
613
+
614
+ qa_history = self._format_qa_history()
615
+
616
+ # Build prompt based on template and get the resolved template ID
617
+ prompt, resolved_template_id = self._build_prd_prompt(qa_history, template_id)
618
+
619
+ response = self._llm_provider.complete(
620
+ messages=[{"role": "user", "content": prompt}],
621
+ purpose=Purpose.PLANNING, # Use stronger model for PRD generation
622
+ max_tokens=4000,
623
+ temperature=0.5,
624
+ )
625
+
626
+ content = response.content.strip()
627
+
628
+ # Extract title from PRD content
629
+ title = self._extract_title_from_prd(content)
630
+
631
+ # Store PRD with both requested and resolved template IDs in metadata
632
+ metadata: dict[str, Any] = {
633
+ "source": "ai_discovery",
634
+ "session_id": self.session_id,
635
+ "questions_asked": len(self._qa_history),
636
+ "coverage": self._coverage,
637
+ "generated_at": _utc_now().isoformat(),
638
+ "template_id": resolved_template_id,
639
+ }
640
+ # Track if a different template was requested but not found
641
+ if template_id and template_id != resolved_template_id:
642
+ metadata["requested_template_id"] = template_id
643
+
644
+ record = prd.store(
645
+ self.workspace,
646
+ content=content,
647
+ title=title,
648
+ metadata=metadata,
649
+ )
650
+
651
+ # Update session state
652
+ self.state = SessionState.COMPLETED
653
+ self._save_session()
654
+
655
+ logger.info(f"Generated PRD {record.id} from session {self.session_id} using template '{resolved_template_id}'")
656
+ return record
657
+
658
+ def _build_prd_prompt(
659
+ self, qa_history: str, template_id: Optional[str] = None
660
+ ) -> tuple[str, str]:
661
+ """Build PRD generation prompt based on template.
662
+
663
+ Args:
664
+ qa_history: Formatted Q&A history string
665
+ template_id: Template ID to use (defaults to None, which uses default prompt)
666
+
667
+ Returns:
668
+ Tuple of (prompt string, resolved template ID)
669
+ The resolved template ID is "default" if no template was used,
670
+ or the actual template ID that was successfully loaded.
671
+ """
672
+ from codeframe.planning.prd_templates import PrdTemplateManager
673
+ from pathlib import Path
674
+
675
+ # Use default prompt if no template specified
676
+ if not template_id:
677
+ return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
678
+
679
+ # Pass workspace path to include project templates
680
+ workspace_path = Path(self.workspace.repo_path) if self.workspace.repo_path else None
681
+ manager = PrdTemplateManager(workspace_path=workspace_path)
682
+ template = manager.get_template(template_id)
683
+
684
+ if template is None:
685
+ logger.warning(f"Template '{template_id}' not found, falling back to default prompt")
686
+ return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
687
+
688
+ # Build dynamic prompt from template sections
689
+ sections_spec = []
690
+ for section in template.sections:
691
+ required_note = " (required)" if section.required else " (optional)"
692
+ sections_spec.append(f"## {section.title}{required_note}\n{section.source} - related content")
693
+
694
+ sections_text = "\n\n".join(sections_spec)
695
+
696
+ prompt = f"""Generate a Product Requirements Document based on the discovery conversation.
697
+
698
+ ## Discovery Conversation
699
+ {qa_history}
700
+
701
+ ## Template: {template.name}
702
+ {template.description}
703
+
704
+ ## Sections
705
+ Generate a markdown PRD with these sections in order:
706
+
707
+ # [Project Title - infer from conversation]
708
+
709
+ {sections_text}
710
+
711
+ ---
712
+
713
+ Keep it concise but complete. Focus on actionable requirements.
714
+ Follow the template structure exactly. This PRD should be sufficient to generate development tasks."""
715
+
716
+ return (prompt, template_id)
717
+
718
+ def _extract_title_from_prd(self, content: str) -> str:
719
+ """Extract project title from generated PRD content."""
720
+ import re
721
+
722
+ # Try to find H1 heading
723
+ match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
724
+ if match:
725
+ title = match.group(1).strip()
726
+ # Remove any trailing punctuation or brackets
727
+ title = re.sub(r"[\[\]()]", "", title).strip()
728
+ return title[:100] # Limit length
729
+
730
+ return "Untitled Project"
731
+
732
+ def _save_session(self) -> None:
733
+ """Save session state to database."""
734
+ conn = get_db_connection(self.workspace)
735
+ cursor = conn.cursor()
736
+
737
+ now = _utc_now().isoformat()
738
+ qa_history_json = json.dumps(self._qa_history)
739
+ coverage_json = json.dumps(self._coverage) if self._coverage else None
740
+
741
+ cursor.execute(
742
+ """
743
+ INSERT OR REPLACE INTO discovery_sessions
744
+ (id, workspace_id, state, qa_history, current_question,
745
+ coverage, blocker_id, is_complete, created_at, updated_at)
746
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?,
747
+ COALESCE((SELECT created_at FROM discovery_sessions WHERE id = ?), ?),
748
+ ?)
749
+ """,
750
+ (
751
+ self.session_id,
752
+ self.workspace.id,
753
+ self.state.value,
754
+ qa_history_json,
755
+ self._current_question,
756
+ coverage_json,
757
+ self._blocker_id,
758
+ 1 if self._is_complete else 0,
759
+ self.session_id,
760
+ now,
761
+ now,
762
+ ),
763
+ )
764
+ conn.commit()
765
+ conn.close()
766
+
767
+
768
+ def _ensure_discovery_schema(workspace: Workspace) -> None:
769
+ """Ensure discovery_sessions table exists with updated schema.
770
+
771
+ Args:
772
+ workspace: Workspace to update
773
+ """
774
+ conn = get_db_connection(workspace)
775
+ cursor = conn.cursor()
776
+
777
+ cursor.execute("""
778
+ CREATE TABLE IF NOT EXISTS discovery_sessions (
779
+ id TEXT PRIMARY KEY,
780
+ workspace_id TEXT NOT NULL,
781
+ state TEXT NOT NULL DEFAULT 'idle',
782
+ qa_history TEXT DEFAULT '[]',
783
+ current_question TEXT,
784
+ coverage TEXT,
785
+ blocker_id TEXT,
786
+ is_complete INTEGER DEFAULT 0,
787
+ created_at TEXT NOT NULL,
788
+ updated_at TEXT NOT NULL,
789
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
790
+ FOREIGN KEY (blocker_id) REFERENCES blockers(id),
791
+ CHECK (state IN ('idle', 'discovering', 'paused', 'completed'))
792
+ )
793
+ """)
794
+
795
+ cursor.execute(
796
+ "CREATE INDEX IF NOT EXISTS idx_discovery_sessions_workspace "
797
+ "ON discovery_sessions(workspace_id)"
798
+ )
799
+ cursor.execute(
800
+ "CREATE INDEX IF NOT EXISTS idx_discovery_sessions_state "
801
+ "ON discovery_sessions(state)"
802
+ )
803
+
804
+ conn.commit()
805
+ conn.close()
806
+
807
+
808
+ def get_active_session(workspace: Workspace) -> Optional[PrdDiscoverySession]:
809
+ """Get the most recent active (non-completed) discovery session.
810
+
811
+ Args:
812
+ workspace: Workspace to query
813
+
814
+ Returns:
815
+ PrdDiscoverySession if found, None if no active session exists
816
+
817
+ Raises:
818
+ NoApiKeyError: If session exists but ANTHROPIC_API_KEY is not set
819
+ """
820
+ _ensure_discovery_schema(workspace)
821
+
822
+ conn = get_db_connection(workspace)
823
+ cursor = conn.cursor()
824
+
825
+ cursor.execute(
826
+ """
827
+ SELECT id FROM discovery_sessions
828
+ WHERE workspace_id = ? AND state != 'completed'
829
+ ORDER BY updated_at DESC
830
+ LIMIT 1
831
+ """,
832
+ (workspace.id,),
833
+ )
834
+ row = cursor.fetchone()
835
+ conn.close()
836
+
837
+ if not row:
838
+ return None
839
+
840
+ # Need API key to load session
841
+ api_key = os.getenv("ANTHROPIC_API_KEY")
842
+ if not api_key:
843
+ logger.warning(
844
+ "Cannot load discovery session %s: ANTHROPIC_API_KEY environment "
845
+ "variable is not set. Set the API key to resume this session.",
846
+ row[0],
847
+ )
848
+ raise NoApiKeyError(
849
+ "ANTHROPIC_API_KEY is required to load discovery session. "
850
+ "Set the environment variable to resume."
851
+ )
852
+
853
+ session = PrdDiscoverySession(workspace, api_key=api_key)
854
+ session.load_session(row[0])
855
+ return session
856
+
857
+
858
+ # ============================================================================
859
+ # Module-Level Convenience Functions (for Route Delegation)
860
+ # ============================================================================
861
+
862
+
863
+ def start_discovery_session(
864
+ workspace: Workspace,
865
+ api_key: Optional[str] = None,
866
+ ) -> PrdDiscoverySession:
867
+ """Start a new PRD discovery session.
868
+
869
+ This is a convenience function for routes to delegate to.
870
+ Creates a new PrdDiscoverySession, starts discovery, and returns it.
871
+
872
+ Args:
873
+ workspace: Target workspace
874
+ api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
875
+
876
+ Returns:
877
+ PrdDiscoverySession with first question ready
878
+
879
+ Raises:
880
+ NoApiKeyError: If no API key available
881
+
882
+ Example:
883
+ session = start_discovery_session(workspace)
884
+ question = session.get_current_question()
885
+ """
886
+ session = PrdDiscoverySession(workspace, api_key=api_key)
887
+ session.start_discovery()
888
+ return session
889
+
890
+
891
+ def get_session(
892
+ workspace: Workspace,
893
+ session_id: str,
894
+ api_key: Optional[str] = None,
895
+ ) -> PrdDiscoverySession:
896
+ """Load an existing discovery session by ID.
897
+
898
+ Args:
899
+ workspace: Target workspace
900
+ session_id: Session ID to load
901
+ api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
902
+
903
+ Returns:
904
+ Loaded PrdDiscoverySession
905
+
906
+ Raises:
907
+ ValueError: If session not found
908
+ NoApiKeyError: If no API key available
909
+ """
910
+ session = PrdDiscoverySession(workspace, api_key=api_key)
911
+ session.load_session(session_id)
912
+ return session
913
+
914
+
915
+ def process_discovery_answer(
916
+ workspace: Workspace,
917
+ session_id: str,
918
+ answer: str,
919
+ api_key: Optional[str] = None,
920
+ ) -> dict[str, Any]:
921
+ """Process an answer for a discovery session.
922
+
923
+ This is a convenience function for routes to delegate to.
924
+ Loads the session, submits the answer, and returns the result.
925
+
926
+ Args:
927
+ workspace: Target workspace
928
+ session_id: Session ID
929
+ answer: Answer text
930
+ api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
931
+
932
+ Returns:
933
+ Dict with keys:
934
+ - accepted: bool - whether answer was accepted
935
+ - feedback: str - feedback message
936
+ - follow_up: Optional[str] - follow-up question if answer inadequate
937
+ - coverage: Optional[dict] - coverage scores after accepted answer
938
+ - is_complete: bool - whether discovery is now complete
939
+ - next_question: Optional[dict] - next question if not complete
940
+
941
+ Raises:
942
+ ValueError: If session not found
943
+ ValidationError: If answer is empty
944
+ NoApiKeyError: If no API key available
945
+
946
+ Example:
947
+ result = process_discovery_answer(workspace, session_id, "My answer")
948
+ if result["is_complete"]:
949
+ prd = generate_prd_from_discovery(workspace, session_id)
950
+ """
951
+ session = get_session(workspace, session_id, api_key=api_key)
952
+ result = session.submit_answer(answer)
953
+
954
+ # Add convenience fields
955
+ result["is_complete"] = session.is_complete()
956
+ if not session.is_complete():
957
+ result["next_question"] = session.get_current_question()
958
+ else:
959
+ result["next_question"] = None
960
+
961
+ return result
962
+
963
+
964
+ def generate_prd_from_discovery(
965
+ workspace: Workspace,
966
+ session_id: str,
967
+ template_id: Optional[str] = None,
968
+ api_key: Optional[str] = None,
969
+ ) -> "prd.PrdRecord":
970
+ """Generate a PRD from a completed discovery session.
971
+
972
+ This is a convenience function for routes to delegate to.
973
+ Loads the session, validates it's complete, and generates the PRD.
974
+
975
+ Args:
976
+ workspace: Target workspace
977
+ session_id: Session ID (must be complete)
978
+ template_id: Optional PRD template to use
979
+ api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
980
+
981
+ Returns:
982
+ Created PrdRecord
983
+
984
+ Raises:
985
+ ValueError: If session not found
986
+ IncompleteSessionError: If session not complete
987
+ NoApiKeyError: If no API key available
988
+
989
+ Example:
990
+ prd = generate_prd_from_discovery(workspace, session_id)
991
+ print(f"Created PRD: {prd.title}")
992
+ """
993
+ session = get_session(workspace, session_id, api_key=api_key)
994
+ return session.generate_prd(template_id=template_id)
995
+
996
+
997
+ def get_discovery_status(
998
+ workspace: Workspace,
999
+ session_id: Optional[str] = None,
1000
+ api_key: Optional[str] = None,
1001
+ ) -> dict[str, Any]:
1002
+ """Get discovery status for a workspace.
1003
+
1004
+ If session_id is provided, returns status for that specific session.
1005
+ Otherwise, returns status for the most recent active session, or
1006
+ an idle status if no active session exists.
1007
+
1008
+ Args:
1009
+ workspace: Target workspace
1010
+ session_id: Optional specific session ID
1011
+ api_key: Optional API key (only needed if session exists)
1012
+
1013
+ Returns:
1014
+ Dict with keys:
1015
+ - state: str - 'idle', 'discovering', 'paused', or 'completed'
1016
+ - session_id: Optional[str] - session ID if active
1017
+ - progress: dict - progress statistics (if session exists)
1018
+ - current_question: Optional[dict] - current question (if discovering)
1019
+
1020
+ Example:
1021
+ status = get_discovery_status(workspace)
1022
+ if status["state"] == "idle":
1023
+ session = start_discovery_session(workspace)
1024
+ """
1025
+ if session_id:
1026
+ try:
1027
+ session = get_session(workspace, session_id, api_key=api_key)
1028
+ except (ValueError, NoApiKeyError):
1029
+ return {
1030
+ "state": "idle",
1031
+ "session_id": None,
1032
+ "progress": {},
1033
+ "current_question": None,
1034
+ }
1035
+ else:
1036
+ try:
1037
+ session = get_active_session(workspace)
1038
+ except NoApiKeyError:
1039
+ # Session exists but can't load without API key
1040
+ return {
1041
+ "state": "unknown",
1042
+ "session_id": None,
1043
+ "progress": {},
1044
+ "current_question": None,
1045
+ "error": "ANTHROPIC_API_KEY required to load session",
1046
+ }
1047
+
1048
+ if session is None:
1049
+ return {
1050
+ "state": "idle",
1051
+ "session_id": None,
1052
+ "progress": {},
1053
+ "current_question": None,
1054
+ }
1055
+
1056
+ return {
1057
+ "state": session.state.value,
1058
+ "session_id": session.session_id,
1059
+ "progress": session.get_progress(),
1060
+ "current_question": session.get_current_question(),
1061
+ }
1062
+
1063
+
1064
+ def reset_discovery(
1065
+ workspace: Workspace,
1066
+ session_id: Optional[str] = None,
1067
+ ) -> bool:
1068
+ """Reset discovery for a workspace.
1069
+
1070
+ If session_id is provided, resets that specific session.
1071
+ Otherwise, resets the most recent active session.
1072
+
1073
+ This does NOT delete PRDs or tasks - use separate functions for that.
1074
+
1075
+ Args:
1076
+ workspace: Target workspace
1077
+ session_id: Optional specific session ID to reset
1078
+
1079
+ Returns:
1080
+ True if a session was reset, False if no session found
1081
+
1082
+ Note:
1083
+ This marks the session as completed/abandoned. To start fresh,
1084
+ call start_discovery_session() after reset.
1085
+ """
1086
+ _ensure_discovery_schema(workspace)
1087
+
1088
+ conn = get_db_connection(workspace)
1089
+ cursor = conn.cursor()
1090
+
1091
+ if session_id:
1092
+ cursor.execute(
1093
+ """
1094
+ UPDATE discovery_sessions
1095
+ SET state = 'completed', updated_at = ?
1096
+ WHERE id = ? AND workspace_id = ?
1097
+ """,
1098
+ (_utc_now().isoformat(), session_id, workspace.id),
1099
+ )
1100
+ else:
1101
+ # Reset most recent non-completed session
1102
+ cursor.execute(
1103
+ """
1104
+ UPDATE discovery_sessions
1105
+ SET state = 'completed', updated_at = ?
1106
+ WHERE workspace_id = ? AND state != 'completed'
1107
+ """,
1108
+ (_utc_now().isoformat(), workspace.id),
1109
+ )
1110
+
1111
+ updated = cursor.rowcount > 0
1112
+ conn.commit()
1113
+ conn.close()
1114
+
1115
+ if updated:
1116
+ logger.info(f"Reset discovery session for workspace {workspace.id}")
1117
+
1118
+ return updated