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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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
|
+
]
|