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,499 @@
1
+ """PRD stress test via recursive decomposition.
2
+
3
+ Recursively decomposes PRD goals using a tri-state classification
4
+ (atomic / composite / ambiguous) to surface requirements gaps and
5
+ generate a technical specification. This is a human-facing discovery
6
+ tool — not a task generator.
7
+
8
+ This module is headless — no FastAPI or HTTP dependencies.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import uuid
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import AsyncGenerator, Literal, Optional
18
+
19
+ from codeframe.adapters.llm.base import Purpose
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Data Models
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class Classification(str, Enum):
30
+ ATOMIC = "atomic"
31
+ COMPOSITE = "composite"
32
+ AMBIGUOUS = "ambiguous"
33
+
34
+
35
+ @dataclass
36
+ class DecompositionNode:
37
+ id: str
38
+ title: str
39
+ description: str
40
+ classification: Classification
41
+ children: list["DecompositionNode"]
42
+ lineage: list[str]
43
+ depth: int
44
+ complexity_hint: Optional[str] = None
45
+ ambiguity_id: Optional[str] = None
46
+
47
+
48
+ @dataclass
49
+ class Ambiguity:
50
+ id: str
51
+ label: str
52
+ source_node_title: str
53
+ questions: list[str]
54
+ recommendation: str
55
+ # "blocking" ambiguities must be answered before a PRD can be refined;
56
+ # "warning" ambiguities are advisory and skippable (issue #562).
57
+ severity: Literal["blocking", "warning"] = "blocking"
58
+ resolved_answer: Optional[str] = None
59
+
60
+
61
+ @dataclass
62
+ class StressTestResult:
63
+ prd_title: str
64
+ tree: list[DecompositionNode]
65
+ ambiguities: list[Ambiguity]
66
+ tech_spec_markdown: str
67
+ ambiguity_report: str
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Prompt Constants
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ GOAL_EXTRACTION_SYSTEM = (
76
+ "You are a requirements analyst. Given a Product Requirements Document, "
77
+ "extract the high-level deliverable goals — the major features or systems "
78
+ "that need to be built. Return ONLY a JSON array of short goal strings. "
79
+ "Example: [\"User Authentication\", \"Invoice Management\", \"PDF Export\"]"
80
+ )
81
+
82
+ CLASSIFY_AND_DECOMPOSE_SYSTEM = """\
83
+ You are a recursive requirements decomposer. Given a goal, its context (lineage \
84
+ of ancestor goals), and the original PRD, classify the goal into exactly one of:
85
+
86
+ - "atomic": Small enough to implement directly (1-2 days of work). The PRD \
87
+ provides enough detail.
88
+ - "composite": Clearly needs breakdown into sub-goals. The PRD provides \
89
+ enough detail to know what the pieces are.
90
+ - "ambiguous": You CANNOT confidently classify because the PRD is missing \
91
+ critical information. You must explain what's missing.
92
+
93
+ Return a JSON object with these fields:
94
+ {
95
+ "classification": "atomic" | "composite" | "ambiguous",
96
+ "children": [{"title": "...", "description": "..."}], // only if composite
97
+ "ambiguity_label": "SHORT LABEL", // only if ambiguous
98
+ "questions": ["question 1", "question 2"], // only if ambiguous
99
+ "recommendation": "what to add to the PRD", // only if ambiguous
100
+ "severity": "blocking" | "warning", // only if ambiguous
101
+ "complexity_hint": "Low" | "Low-Medium" | "Medium" | "High" // always
102
+ }
103
+
104
+ For "severity": use "blocking" when the missing information prevents \
105
+ implementation and must be answered; use "warning" for advisory gaps that \
106
+ have a reasonable default and can be skipped.
107
+
108
+ Return ONLY valid JSON. No markdown wrapping."""
109
+
110
+ AMBIGUITY_RESOLUTION_SYSTEM = (
111
+ "You are a PRD editor. Given the original PRD content and a set of resolved "
112
+ "ambiguities (question + answer pairs), update the PRD to incorporate the "
113
+ "new information. Preserve the original structure and tone. Return the "
114
+ "complete updated PRD content."
115
+ )
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Core Functions
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def extract_goals(prd_content: str, provider) -> list[str]:
124
+ """Extract high-level deliverable goals from a PRD."""
125
+ response = provider.complete(
126
+ messages=[{"role": "user", "content": prd_content}],
127
+ purpose=Purpose.PLANNING,
128
+ system=GOAL_EXTRACTION_SYSTEM,
129
+ max_tokens=1024,
130
+ temperature=0.0,
131
+ )
132
+ try:
133
+ goals = json.loads(response.content)
134
+ if isinstance(goals, list):
135
+ return [str(g) for g in goals]
136
+ logger.warning("Goal extraction returned non-list: %s", type(goals).__name__)
137
+ except (json.JSONDecodeError, TypeError) as exc:
138
+ logger.warning("Failed to parse goal extraction response: %s", exc)
139
+ return []
140
+
141
+
142
+ def classify_and_decompose(
143
+ title: str,
144
+ description: str,
145
+ lineage: list[str],
146
+ prd_content: str,
147
+ depth: int,
148
+ provider,
149
+ ) -> tuple[Classification, list[dict], Optional[Ambiguity], str]:
150
+ """Classify a goal node and optionally decompose or flag ambiguity."""
151
+ lineage_ctx = ""
152
+ if lineage:
153
+ lineage_ctx = "\n\nAncestor context:\n" + "\n".join(
154
+ f"- {desc}" for desc in lineage
155
+ )
156
+
157
+ user_msg = (
158
+ f"Goal: {title}\n"
159
+ f"Description: {description}\n"
160
+ f"Depth: {depth}{lineage_ctx}\n\n"
161
+ f"PRD:\n{prd_content}"
162
+ )
163
+
164
+ response = provider.complete(
165
+ messages=[{"role": "user", "content": user_msg}],
166
+ purpose=Purpose.PLANNING,
167
+ system=CLASSIFY_AND_DECOMPOSE_SYSTEM,
168
+ max_tokens=2048,
169
+ temperature=0.0,
170
+ )
171
+
172
+ try:
173
+ data = json.loads(response.content)
174
+ except (json.JSONDecodeError, TypeError) as exc:
175
+ logger.warning("Failed to parse classification for '%s': %s", title, exc)
176
+ return Classification.ATOMIC, [], None, "Low"
177
+
178
+ raw_cls = data.get("classification", "atomic").lower()
179
+ try:
180
+ cls = Classification(raw_cls)
181
+ except ValueError:
182
+ cls = Classification.ATOMIC
183
+
184
+ complexity = data.get("complexity_hint", "Low")
185
+ raw_children = data.get("children", []) if cls == Classification.COMPOSITE else []
186
+ # Validate children are dicts with expected keys
187
+ children = [
188
+ c for c in raw_children
189
+ if isinstance(c, dict) and ("title" in c or "description" in c)
190
+ ]
191
+
192
+ ambiguity = None
193
+ if cls == Classification.AMBIGUOUS:
194
+ raw_severity = str(data.get("severity", "blocking")).lower()
195
+ severity = raw_severity if raw_severity in ("blocking", "warning") else "blocking"
196
+ ambiguity = Ambiguity(
197
+ id=str(uuid.uuid4()),
198
+ label=data.get("ambiguity_label", "UNSPECIFIED"),
199
+ source_node_title=title,
200
+ questions=data.get("questions", []),
201
+ recommendation=data.get("recommendation", ""),
202
+ severity=severity,
203
+ )
204
+
205
+ return cls, children, ambiguity, complexity
206
+
207
+
208
+ def recursive_decompose(
209
+ title: str,
210
+ description: str,
211
+ lineage: list[str],
212
+ prd_content: str,
213
+ depth: int,
214
+ max_depth: int,
215
+ ambiguities: list[Ambiguity],
216
+ provider,
217
+ ) -> DecompositionNode:
218
+ """Recursively decompose a goal, collecting ambiguities along the way."""
219
+ # Force leaf at max depth
220
+ if depth >= max_depth:
221
+ return DecompositionNode(
222
+ id=str(uuid.uuid4()),
223
+ title=title,
224
+ description=description,
225
+ classification=Classification.ATOMIC,
226
+ children=[],
227
+ lineage=lineage,
228
+ depth=depth,
229
+ complexity_hint="Unknown",
230
+ )
231
+
232
+ cls, child_dicts, ambiguity, complexity = classify_and_decompose(
233
+ title, description, lineage, prd_content, depth, provider,
234
+ )
235
+
236
+ if ambiguity:
237
+ ambiguities.append(ambiguity)
238
+
239
+ children = []
240
+ if cls == Classification.COMPOSITE:
241
+ child_lineage = lineage + [title]
242
+ for child_dict in child_dicts:
243
+ child_node = recursive_decompose(
244
+ child_dict.get("title", "Untitled"),
245
+ child_dict.get("description", ""),
246
+ child_lineage,
247
+ prd_content,
248
+ depth + 1,
249
+ max_depth,
250
+ ambiguities,
251
+ provider,
252
+ )
253
+ children.append(child_node)
254
+
255
+ return DecompositionNode(
256
+ id=str(uuid.uuid4()),
257
+ title=title,
258
+ description=description,
259
+ classification=cls,
260
+ children=children,
261
+ lineage=lineage,
262
+ depth=depth,
263
+ complexity_hint=complexity,
264
+ ambiguity_id=ambiguity.id if ambiguity else None,
265
+ )
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Renderers
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ def render_tech_spec(
274
+ tree: list[DecompositionNode], ambiguities: list[Ambiguity]
275
+ ) -> str:
276
+ """Render the decomposition tree as a markdown technical specification."""
277
+ amb_map = {a.id: i + 1 for i, a in enumerate(ambiguities)}
278
+ lines = ["# Technical Specification\n"]
279
+
280
+ for node in tree:
281
+ _render_spec_node(node, lines, amb_map, header_level=2)
282
+
283
+ return "\n".join(lines)
284
+
285
+
286
+ def _render_spec_node(
287
+ node: DecompositionNode,
288
+ lines: list[str],
289
+ amb_map: dict[str, int],
290
+ header_level: int,
291
+ ) -> None:
292
+ """Recursively render a node into the tech spec."""
293
+ prefix = "#" * min(header_level, 6)
294
+ lines.append(f"{prefix} {node.title}")
295
+
296
+ if node.classification == Classification.AMBIGUOUS:
297
+ amb_num = amb_map.get(node.ambiguity_id, "?") if node.ambiguity_id else "?"
298
+ lines.append(f"**[NEEDS CLARIFICATION — see ambiguity #{amb_num}]**\n")
299
+ elif node.classification == Classification.ATOMIC:
300
+ lines.append(f"- {node.description}")
301
+ if node.complexity_hint:
302
+ lines.append(f"- Estimated complexity: {node.complexity_hint}")
303
+ lines.append("")
304
+ else:
305
+ lines.append("")
306
+
307
+ for child in node.children:
308
+ _render_spec_node(child, lines, amb_map, header_level + 1)
309
+
310
+
311
+ def render_ambiguity_report(ambiguities: list[Ambiguity]) -> str:
312
+ """Render the ambiguity list as a human-readable report."""
313
+ if not ambiguities:
314
+ return "No ambiguities found — PRD is well-specified for decomposition."
315
+
316
+ lines = [
317
+ f"PRD Stress Test — {len(ambiguities)} ambiguities found:\n",
318
+ ]
319
+
320
+ for i, amb in enumerate(ambiguities, 1):
321
+ lines.append(
322
+ f"{i}. {amb.label} (from decomposing \"{amb.source_node_title}\")"
323
+ )
324
+ lines.append(" The PRD doesn't specify:")
325
+ for q in amb.questions:
326
+ lines.append(f" - {q}")
327
+ lines.append(f" → Recommendation: {amb.recommendation}")
328
+ if amb.resolved_answer:
329
+ lines.append(f" ✓ Resolved: {amb.resolved_answer}")
330
+ lines.append("")
331
+
332
+ return "\n".join(lines)
333
+
334
+
335
+ def ambiguity_to_dict(amb: Ambiguity) -> dict[str, object]:
336
+ """Serialize an :class:`Ambiguity` for SSE / JSON transport (issue #562).
337
+
338
+ Carries the structured fields the web results view needs to render an
339
+ answerable card: question text, severity, and the recommendation.
340
+ """
341
+ return {
342
+ "id": amb.id,
343
+ "label": amb.label,
344
+ "source_node_title": amb.source_node_title,
345
+ "questions": list(amb.questions),
346
+ "recommendation": amb.recommendation,
347
+ "severity": amb.severity,
348
+ "resolved_answer": amb.resolved_answer,
349
+ }
350
+
351
+
352
+ def resolve_ambiguities_into_prd(
353
+ prd_content: str,
354
+ ambiguities: list[Ambiguity],
355
+ provider,
356
+ ) -> str:
357
+ """Use LLM to update PRD content with resolved ambiguity answers."""
358
+ resolved = [a for a in ambiguities if a.resolved_answer]
359
+ if not resolved:
360
+ return prd_content
361
+
362
+ resolution_text = "\n".join(
363
+ f"- {a.label}: {', '.join(a.questions)} → Answer: {a.resolved_answer}"
364
+ for a in resolved
365
+ )
366
+
367
+ response = provider.complete(
368
+ messages=[{
369
+ "role": "user",
370
+ "content": (
371
+ f"Original PRD:\n{prd_content}\n\n"
372
+ f"Resolved ambiguities:\n{resolution_text}\n\n"
373
+ "Update the PRD to incorporate these answers."
374
+ ),
375
+ }],
376
+ purpose=Purpose.PLANNING,
377
+ system=AMBIGUITY_RESOLUTION_SYSTEM,
378
+ max_tokens=8192,
379
+ temperature=0.0,
380
+ )
381
+ updated = response.content.strip()
382
+ if not updated or len(updated) < len(prd_content) // 2:
383
+ logger.warning(
384
+ "PRD rewrite looks truncated (%d chars vs original %d), returning original",
385
+ len(updated), len(prd_content),
386
+ )
387
+ return prd_content
388
+ return updated
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # Top-level Orchestrator
393
+ # ---------------------------------------------------------------------------
394
+
395
+
396
+ def stress_test_prd(
397
+ prd_content: str, provider, max_depth: int = 3
398
+ ) -> StressTestResult:
399
+ """Run the full PRD stress test: extract goals → recursive decompose → render."""
400
+ goals = extract_goals(prd_content, provider)
401
+
402
+ tree: list[DecompositionNode] = []
403
+ ambiguities: list[Ambiguity] = []
404
+
405
+ for goal in goals:
406
+ node = recursive_decompose(
407
+ title=goal,
408
+ description=goal,
409
+ lineage=[],
410
+ prd_content=prd_content,
411
+ depth=0,
412
+ max_depth=max_depth,
413
+ ambiguities=ambiguities,
414
+ provider=provider,
415
+ )
416
+ tree.append(node)
417
+
418
+ tech_spec = render_tech_spec(tree, ambiguities)
419
+ amb_report = render_ambiguity_report(ambiguities)
420
+
421
+ # Extract title from PRD (first heading or first line)
422
+ prd_title = "Untitled"
423
+ for line in prd_content.splitlines():
424
+ stripped = line.strip()
425
+ if stripped.startswith("# "):
426
+ prd_title = stripped[2:].strip()
427
+ break
428
+ if stripped:
429
+ prd_title = stripped[:80]
430
+ break
431
+
432
+ return StressTestResult(
433
+ prd_title=prd_title,
434
+ tree=tree,
435
+ ambiguities=ambiguities,
436
+ tech_spec_markdown=tech_spec,
437
+ ambiguity_report=amb_report,
438
+ )
439
+
440
+
441
+ async def stress_test_prd_stream(
442
+ prd_content: str, provider, max_depth: int = 3
443
+ ) -> AsyncGenerator[dict, None]:
444
+ """Async streaming variant of :func:`stress_test_prd`.
445
+
446
+ Yields progress event dicts suitable for SSE delivery as each top-level
447
+ goal is decomposed, so a UI can render incremental output:
448
+
449
+ - ``{"type": "goals_extracted", "goals": [...]}``
450
+ - ``{"type": "goal_analyzed", "goal": str, "classification": str,
451
+ "ambiguities_so_far": int}`` (once per top-level goal)
452
+ - ``{"type": "complete", "ambiguity_count": int,
453
+ "ambiguities": [ambiguity_to_dict(...)],
454
+ "tech_spec_markdown": str, "ambiguity_report": str}``
455
+ - ``{"type": "error", "message": str}`` if decomposition raises
456
+
457
+ The underlying ``provider.complete()`` calls are synchronous and blocking,
458
+ so each is offloaded via :func:`asyncio.to_thread` to keep the event loop
459
+ responsive. This function stays headless (no FastAPI/HTTP imports).
460
+ """
461
+ try:
462
+ goals = await asyncio.to_thread(extract_goals, prd_content, provider)
463
+ yield {"type": "goals_extracted", "goals": goals}
464
+
465
+ ambiguities: list[Ambiguity] = []
466
+ tree: list[DecompositionNode] = []
467
+
468
+ for goal in goals:
469
+ node = await asyncio.to_thread(
470
+ recursive_decompose,
471
+ goal, # title
472
+ goal, # description
473
+ [], # lineage
474
+ prd_content,
475
+ 0, # depth
476
+ max_depth,
477
+ ambiguities,
478
+ provider,
479
+ )
480
+ tree.append(node)
481
+ yield {
482
+ "type": "goal_analyzed",
483
+ "goal": node.title,
484
+ "classification": node.classification.value,
485
+ "ambiguities_so_far": len(ambiguities),
486
+ }
487
+
488
+ tech_spec = render_tech_spec(tree, ambiguities)
489
+ amb_report = render_ambiguity_report(ambiguities)
490
+ yield {
491
+ "type": "complete",
492
+ "ambiguity_count": len(ambiguities),
493
+ "ambiguities": [ambiguity_to_dict(a) for a in ambiguities],
494
+ "tech_spec_markdown": tech_spec,
495
+ "ambiguity_report": amb_report,
496
+ }
497
+ except Exception as exc: # noqa: BLE001 — surface any failure to the client
498
+ logger.warning("Stress test stream failed: %s", exc, exc_info=True)
499
+ yield {"type": "error", "message": str(exc)}
@@ -0,0 +1,126 @@
1
+ """Progress tracking for batch execution.
2
+
3
+ Provides ETA estimation based on completed task durations.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass
12
+ class BatchProgress:
13
+ """Tracks batch execution progress for ETA calculation."""
14
+
15
+ total_tasks: int
16
+ completed_tasks: int = 0
17
+ failed_tasks: int = 0
18
+ blocked_tasks: int = 0
19
+ started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
20
+ task_durations: list[float] = field(default_factory=list)
21
+ task_start_times: dict[str, datetime] = field(default_factory=dict)
22
+
23
+ @property
24
+ def processed_tasks(self) -> int:
25
+ """Tasks that are no longer pending."""
26
+ return self.completed_tasks + self.failed_tasks + self.blocked_tasks
27
+
28
+ @property
29
+ def remaining_tasks(self) -> int:
30
+ """Tasks still to be processed."""
31
+ return max(0, self.total_tasks - self.processed_tasks)
32
+
33
+ @property
34
+ def running_tasks(self) -> int:
35
+ """Tasks currently in progress."""
36
+ return len(self.task_start_times)
37
+
38
+ @property
39
+ def average_task_duration(self) -> Optional[float]:
40
+ """Average seconds per completed task."""
41
+ if not self.task_durations:
42
+ return None
43
+ return sum(self.task_durations) / len(self.task_durations)
44
+
45
+ @property
46
+ def eta_seconds(self) -> Optional[float]:
47
+ """Estimated seconds to completion."""
48
+ avg = self.average_task_duration
49
+ if avg is None or self.remaining_tasks == 0:
50
+ return None
51
+ return avg * self.remaining_tasks
52
+
53
+ def format_eta(self) -> str:
54
+ """Human-readable ETA string."""
55
+ eta = self.eta_seconds
56
+ if eta is None:
57
+ if self.remaining_tasks == 0:
58
+ return "complete"
59
+ return "calculating..."
60
+ hours, remainder = divmod(int(eta), 3600)
61
+ minutes, seconds = divmod(remainder, 60)
62
+ if hours > 0:
63
+ return f"{hours}h {minutes}m"
64
+ if minutes > 0:
65
+ return f"{minutes}m {seconds}s"
66
+ return f"{seconds}s"
67
+
68
+ @property
69
+ def progress_percent(self) -> float:
70
+ """Completion percentage (0-100)."""
71
+ if self.total_tasks == 0:
72
+ return 100.0
73
+ return (self.processed_tasks / self.total_tasks) * 100
74
+
75
+ @property
76
+ def elapsed_seconds(self) -> float:
77
+ """Seconds since batch started."""
78
+ return (datetime.now(timezone.utc) - self.started_at).total_seconds()
79
+
80
+ def format_elapsed(self) -> str:
81
+ """Human-readable elapsed time."""
82
+ elapsed = int(self.elapsed_seconds)
83
+ hours, remainder = divmod(elapsed, 3600)
84
+ minutes, seconds = divmod(remainder, 60)
85
+ if hours > 0:
86
+ return f"{hours}h {minutes}m {seconds}s"
87
+ if minutes > 0:
88
+ return f"{minutes}m {seconds}s"
89
+ return f"{seconds}s"
90
+
91
+ def record_task_start(self, task_id: str) -> None:
92
+ """Record when a task starts for duration tracking."""
93
+ self.task_start_times[task_id] = datetime.now(timezone.utc)
94
+
95
+ def record_task_complete(self, task_id: str) -> None:
96
+ """Record task completion and calculate duration."""
97
+ self.completed_tasks += 1
98
+ start_time = self.task_start_times.pop(task_id, None)
99
+ if start_time:
100
+ duration = (datetime.now(timezone.utc) - start_time).total_seconds()
101
+ self.task_durations.append(duration)
102
+
103
+ def record_task_failed(self, task_id: str) -> None:
104
+ """Record task failure."""
105
+ self.failed_tasks += 1
106
+ self.task_start_times.pop(task_id, None)
107
+
108
+ def record_task_blocked(self, task_id: str) -> None:
109
+ """Record task blocked."""
110
+ self.blocked_tasks += 1
111
+ self.task_start_times.pop(task_id, None)
112
+
113
+ def status_summary(self) -> str:
114
+ """One-line status summary."""
115
+ parts = []
116
+ if self.completed_tasks:
117
+ parts.append(f"{self.completed_tasks} completed")
118
+ if self.failed_tasks:
119
+ parts.append(f"{self.failed_tasks} failed")
120
+ if self.blocked_tasks:
121
+ parts.append(f"{self.blocked_tasks} blocked")
122
+ if self.running_tasks:
123
+ parts.append(f"{self.running_tasks} running")
124
+ if self.remaining_tasks - self.running_tasks > 0:
125
+ parts.append(f"{self.remaining_tasks - self.running_tasks} pending")
126
+ return " | ".join(parts) if parts else "starting..."
@@ -0,0 +1,34 @@
1
+ """PROOF9: Quality memory system with evidence-based verification.
2
+
3
+ Turns every failure into a permanent proof obligation. Requirements are
4
+ tracked in a ledger, obligations are enforced on every run, and evidence
5
+ artifacts prove compliance.
6
+
7
+ This package is headless — no FastAPI or HTTP dependencies.
8
+ """
9
+
10
+ from codeframe.core.proof.models import (
11
+ Evidence,
12
+ EvidenceRule,
13
+ Gate,
14
+ GlitchType,
15
+ Obligation,
16
+ ReqStatus,
17
+ Requirement,
18
+ RequirementScope,
19
+ Source,
20
+ Waiver,
21
+ )
22
+
23
+ __all__ = [
24
+ "Evidence",
25
+ "EvidenceRule",
26
+ "Gate",
27
+ "GlitchType",
28
+ "Obligation",
29
+ "ReqStatus",
30
+ "Requirement",
31
+ "RequirementScope",
32
+ "Source",
33
+ "Waiver",
34
+ ]