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,755 @@
1
+ """PROOF9 REST API router — thin adapter over codeframe.core.proof.
2
+
3
+ Maps HTTP endpoints to core proof functions (capture, list, get, run, waive,
4
+ status, evidence). No business logic lives here.
5
+
6
+ Routes:
7
+ POST /api/v2/proof/requirements capture_requirement()
8
+ GET /api/v2/proof/requirements list_requirements()
9
+ GET /api/v2/proof/requirements/{req_id} get_requirement()
10
+ POST /api/v2/proof/run run_proof()
11
+ POST /api/v2/proof/requirements/{req_id}/waive waive_requirement()
12
+ GET /api/v2/proof/status aggregated status
13
+ GET /api/v2/proof/requirements/{req_id}/evidence list_evidence()
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+ import uuid
20
+ from datetime import date
21
+ from typing import Any, Literal, Optional
22
+
23
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
24
+ from pydantic import BaseModel, Field, ValidationError, field_validator
25
+
26
+ from codeframe.core.proof.capture import capture_requirement
27
+ from codeframe.core.proof.ledger import (
28
+ get_requirement,
29
+ get_run,
30
+ get_run_evidence,
31
+ list_evidence,
32
+ list_requirements,
33
+ list_runs,
34
+ waive_requirement,
35
+ )
36
+ from codeframe.core.proof.models import (
37
+ PROOF9_GATE_ORDER,
38
+ PROOF_CONFIG_FILENAME,
39
+ Gate,
40
+ ReqStatus,
41
+ Severity,
42
+ Source,
43
+ Waiver,
44
+ )
45
+ from codeframe.core.proof.runner import run_proof
46
+ from codeframe.core.workspace import Workspace
47
+ from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
48
+ from codeframe.ui.dependencies import get_v2_workspace
49
+ from codeframe.ui.response_models import ErrorCodes, api_error
50
+ from codeframe.ui.routers._helpers import atomic_write_json
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ router = APIRouter(prefix="/api/v2/proof", tags=["proof-v2"])
55
+
56
+ # Module-level cache: (workspace_path, run_id) → {results, passed, message, _ts}
57
+ # Bounded to _CACHE_MAX_SIZE entries; entries expire after _CACHE_TTL_SECONDS.
58
+ # NOTE: in-memory only — a process running multiple uvicorn workers will have
59
+ # separate caches per worker. Suitable for single-worker dev/demo deployments.
60
+ _run_cache: dict[tuple[str, str], dict] = {}
61
+ _CACHE_MAX_SIZE = 100
62
+ _CACHE_TTL_SECONDS = 300 # 5 minutes
63
+
64
+
65
+ def _evict_run_cache() -> None:
66
+ """Remove expired entries and trim to max size (oldest first)."""
67
+ now = time.time()
68
+ expired = [k for k, v in _run_cache.items() if now - v["_ts"] > _CACHE_TTL_SECONDS]
69
+ for k in expired:
70
+ del _run_cache[k]
71
+ if len(_run_cache) > _CACHE_MAX_SIZE:
72
+ oldest = sorted(_run_cache, key=lambda k: _run_cache[k]["_ts"])
73
+ for k in oldest[: len(_run_cache) - _CACHE_MAX_SIZE]:
74
+ del _run_cache[k]
75
+
76
+
77
+ # ============================================================================
78
+ # Request / Response Models
79
+ # ============================================================================
80
+
81
+
82
+ class CaptureRequirementRequest(BaseModel):
83
+ """Request body for capturing a requirement from a glitch."""
84
+
85
+ title: str = Field(..., min_length=1, description="Short title of the glitch")
86
+ description: str = Field(..., min_length=1, description="Detailed description for glitch classification")
87
+ where: str = Field(..., min_length=1, description="Location (file, route, API, tag) where glitch occurred")
88
+ severity: Severity = Field(..., description="Severity: critical, high, medium, low")
89
+ source: Source = Field(..., description="Source: production, qa, dogfooding, monitoring, user_report")
90
+ created_by: str = Field(default="human", description="Who captured this requirement")
91
+ source_issue: Optional[str] = Field(default=None, description="External issue reference (e.g. GH-123)")
92
+
93
+
94
+ class WaiveRequirementRequest(BaseModel):
95
+ """Request body for waiving a requirement."""
96
+
97
+ reason: str = Field(..., min_length=1, description="Why this requirement is being waived")
98
+ expires: Optional[date] = Field(default=None, description="ISO date when waiver expires (e.g. 2026-06-01)")
99
+ manual_checklist: list[str] = Field(default_factory=list, description="Manual verification steps")
100
+ approved_by: str = Field(default="", description="Who approved this waiver")
101
+
102
+
103
+ class RunProofRequest(BaseModel):
104
+ """Request body for running proof obligations."""
105
+
106
+ full: bool = Field(default=False, description="Run ALL obligations regardless of scope")
107
+ gate: Optional[Gate] = Field(default=None, description="Run only this gate (unit, sec, contract, etc.)")
108
+
109
+
110
+ class ScopeOut(BaseModel):
111
+ """Serialized requirement scope."""
112
+
113
+ routes: list[str] = Field(default_factory=list)
114
+ components: list[str] = Field(default_factory=list)
115
+ apis: list[str] = Field(default_factory=list)
116
+ files: list[str] = Field(default_factory=list)
117
+ tags: list[str] = Field(default_factory=list)
118
+
119
+
120
+ class ObligationOut(BaseModel):
121
+ """Serialized proof obligation."""
122
+
123
+ gate: str
124
+ status: str
125
+
126
+
127
+ class EvidenceRuleOut(BaseModel):
128
+ """Serialized evidence rule."""
129
+
130
+ test_id: str
131
+ must_pass: bool
132
+
133
+
134
+ class WaiverOut(BaseModel):
135
+ """Serialized waiver."""
136
+
137
+ reason: str
138
+ expires: Optional[str]
139
+ manual_checklist: list[str]
140
+ approved_by: str
141
+ waived_at: Optional[str] = None
142
+
143
+
144
+ class RequirementResponse(BaseModel):
145
+ """Full requirement response."""
146
+
147
+ id: str
148
+ title: str
149
+ description: str
150
+ severity: str
151
+ source: str
152
+ status: str
153
+ glitch_type: Optional[str]
154
+ obligations: list[ObligationOut]
155
+ evidence_rules: list[EvidenceRuleOut]
156
+ waiver: Optional[WaiverOut]
157
+ created_at: Optional[str]
158
+ satisfied_at: Optional[str]
159
+ created_by: str
160
+ source_issue: Optional[str]
161
+ related_reqs: list[str]
162
+ scope: Optional[ScopeOut] = None
163
+
164
+
165
+ class CaptureRequirementResponse(RequirementResponse):
166
+ """Capture response adds stubs_count."""
167
+
168
+ stubs_count: int = Field(description="Number of test stub files generated")
169
+
170
+
171
+ class RequirementListResponse(BaseModel):
172
+ """Response for list/filter endpoints."""
173
+
174
+ requirements: list[RequirementResponse]
175
+ total: int
176
+ by_status: dict[str, int]
177
+
178
+
179
+ class RunProofResponse(BaseModel):
180
+ """Response for POST /run."""
181
+
182
+ success: bool
183
+ run_id: str
184
+ results: dict[str, list[dict[str, Any]]]
185
+ message: str
186
+
187
+
188
+ class RunStatusResponse(BaseModel):
189
+ """Response for GET /runs/{run_id} — poll a completed run.
190
+
191
+ status is currently always "complete" because POST /run executes
192
+ synchronously before returning run_id. The "running" value is reserved
193
+ for a future async execution model.
194
+ """
195
+
196
+ run_id: str
197
+ status: str # "complete" (currently); "running" reserved for future async
198
+ results: dict[str, list[dict[str, Any]]]
199
+ passed: bool
200
+ message: str
201
+
202
+
203
+ class ProofStatusResponse(BaseModel):
204
+ """Aggregated proof status response."""
205
+
206
+ total: int
207
+ open: int
208
+ satisfied: int
209
+ waived: int
210
+ requirements: list[RequirementResponse]
211
+
212
+
213
+ class EvidenceResponse(BaseModel):
214
+ """Serialized evidence record."""
215
+
216
+ req_id: str
217
+ gate: str
218
+ satisfied: bool
219
+ artifact_path: str
220
+ artifact_checksum: str
221
+ timestamp: str
222
+ run_id: str
223
+
224
+
225
+ class EvidenceWithContentResponse(EvidenceResponse):
226
+ """Evidence record including artifact file contents."""
227
+
228
+ artifact_text: Optional[str] = None
229
+
230
+
231
+ class ProofRunSummaryResponse(BaseModel):
232
+ """Summary of a single proof gate run."""
233
+
234
+ run_id: str
235
+ started_at: str
236
+ completed_at: Optional[str]
237
+ triggered_by: str
238
+ overall_passed: bool
239
+ duration_ms: Optional[int]
240
+
241
+
242
+ class ProofRunDetailResponse(ProofRunSummaryResponse):
243
+ """Proof run detail including per-gate evidence with artifact content."""
244
+
245
+ evidence: list[EvidenceWithContentResponse]
246
+
247
+
248
+ # ============================================================================
249
+ # Helper
250
+ # ============================================================================
251
+
252
+
253
+ def _req_to_response(req) -> RequirementResponse:
254
+ """Convert a core Requirement dataclass to RequirementResponse."""
255
+ return RequirementResponse(
256
+ id=req.id,
257
+ title=req.title,
258
+ description=req.description,
259
+ severity=req.severity.value,
260
+ source=req.source.value,
261
+ status=req.status.value,
262
+ glitch_type=req.glitch_type.value if req.glitch_type else None,
263
+ obligations=[
264
+ ObligationOut(gate=o.gate.value, status=o.status)
265
+ for o in req.obligations
266
+ ],
267
+ evidence_rules=[
268
+ EvidenceRuleOut(test_id=r.test_id, must_pass=r.must_pass)
269
+ for r in req.evidence_rules
270
+ ],
271
+ waiver=WaiverOut(
272
+ reason=req.waiver.reason,
273
+ expires=req.waiver.expires.isoformat() if req.waiver.expires else None,
274
+ manual_checklist=req.waiver.manual_checklist,
275
+ approved_by=req.waiver.approved_by,
276
+ waived_at=req.waiver.waived_at.isoformat() if req.waiver.waived_at else None,
277
+ ) if req.waiver else None,
278
+ created_at=req.created_at.isoformat() if req.created_at else None,
279
+ satisfied_at=req.satisfied_at.isoformat() if req.satisfied_at else None,
280
+ created_by=req.created_by,
281
+ source_issue=req.source_issue,
282
+ related_reqs=req.related_reqs,
283
+ scope=ScopeOut(
284
+ routes=req.scope.routes,
285
+ components=req.scope.components,
286
+ apis=req.scope.apis,
287
+ files=req.scope.files,
288
+ tags=req.scope.tags,
289
+ ) if req.scope else None,
290
+ )
291
+
292
+
293
+ def _count_by_status(reqs) -> dict[str, int]:
294
+ """Aggregate requirement counts by status value."""
295
+ counts: dict[str, int] = {s.value: 0 for s in ReqStatus}
296
+ for req in reqs:
297
+ counts[req.status.value] = counts.get(req.status.value, 0) + 1
298
+ return counts
299
+
300
+
301
+ # ============================================================================
302
+ # Endpoints
303
+ # ============================================================================
304
+
305
+
306
+ @router.post("/requirements", response_model=CaptureRequirementResponse, status_code=201)
307
+ @rate_limit_standard()
308
+ async def capture_requirement_endpoint(
309
+ request: Request,
310
+ body: CaptureRequirementRequest,
311
+ workspace: Workspace = Depends(get_v2_workspace),
312
+ ) -> CaptureRequirementResponse:
313
+ """Capture a requirement from a glitch report.
314
+
315
+ Classifies the glitch, derives proof obligations, generates test stubs,
316
+ and persists the requirement to the ledger.
317
+ """
318
+ try:
319
+ req, stubs = capture_requirement(
320
+ workspace,
321
+ title=body.title,
322
+ description=body.description,
323
+ where=body.where,
324
+ severity=body.severity,
325
+ source=body.source,
326
+ created_by=body.created_by,
327
+ source_issue=body.source_issue,
328
+ )
329
+ resp = _req_to_response(req)
330
+ return CaptureRequirementResponse(**resp.model_dump(), stubs_count=len(stubs))
331
+ except Exception as e:
332
+ logger.error("Failed to capture requirement: %s", e, exc_info=True)
333
+ raise HTTPException(
334
+ status_code=500,
335
+ detail=api_error("Failed to capture requirement", ErrorCodes.EXECUTION_FAILED, str(e)),
336
+ )
337
+
338
+
339
+ @router.get("/requirements", response_model=RequirementListResponse)
340
+ @rate_limit_standard()
341
+ async def list_requirements_endpoint(
342
+ request: Request,
343
+ status: Optional[str] = Query(None, description="Filter by status: open, satisfied, waived"),
344
+ workspace: Workspace = Depends(get_v2_workspace),
345
+ ) -> RequirementListResponse:
346
+ """List all requirements, optionally filtered by status."""
347
+ status_filter = None
348
+ if status:
349
+ try:
350
+ status_filter = ReqStatus(status.lower())
351
+ except ValueError:
352
+ raise HTTPException(
353
+ status_code=400,
354
+ detail=api_error(
355
+ f"Invalid status: {status}",
356
+ ErrorCodes.VALIDATION_ERROR,
357
+ f"Valid values: {[s.value for s in ReqStatus]}",
358
+ ),
359
+ )
360
+
361
+ reqs = list_requirements(workspace, status=status_filter)
362
+ all_reqs = list_requirements(workspace) if status_filter else reqs
363
+
364
+ return RequirementListResponse(
365
+ requirements=[_req_to_response(r) for r in reqs],
366
+ total=len(reqs),
367
+ by_status=_count_by_status(all_reqs),
368
+ )
369
+
370
+
371
+ @router.get("/requirements/{req_id}", response_model=RequirementResponse)
372
+ @rate_limit_standard()
373
+ async def get_requirement_endpoint(
374
+ request: Request,
375
+ req_id: str,
376
+ workspace: Workspace = Depends(get_v2_workspace),
377
+ ) -> RequirementResponse:
378
+ """Get a single requirement by ID."""
379
+ req = get_requirement(workspace, req_id)
380
+ if not req:
381
+ raise HTTPException(
382
+ status_code=404,
383
+ detail=api_error(
384
+ f"Requirement not found: {req_id}",
385
+ ErrorCodes.NOT_FOUND,
386
+ f"No requirement with id {req_id}",
387
+ ),
388
+ )
389
+ return _req_to_response(req)
390
+
391
+
392
+ @router.post("/run", response_model=RunProofResponse)
393
+ @rate_limit_ai()
394
+ async def run_proof_endpoint(
395
+ request: Request,
396
+ body: RunProofRequest,
397
+ workspace: Workspace = Depends(get_v2_workspace),
398
+ ) -> RunProofResponse:
399
+ """Execute proof obligations and collect evidence.
400
+
401
+ Runs gate checks (pytest, ruff, etc.) for open requirements and records
402
+ evidence artifacts. Use full=True to run all obligations regardless of
403
+ changed scope.
404
+ """
405
+ try:
406
+ # Generate run_id before calling run_proof so the response ID matches evidence records
407
+ run_id = str(uuid.uuid4())[:8]
408
+ results = run_proof(
409
+ workspace,
410
+ full=body.full,
411
+ gate_filter=body.gate,
412
+ run_id=run_id,
413
+ )
414
+ # Serialize: dict[req_id → list[tuple[Gate, bool]]] → JSON-safe
415
+ serialized = {
416
+ req_id: [{"gate": gate.value, "satisfied": satisfied} for gate, satisfied in gate_results]
417
+ for req_id, gate_results in results.items()
418
+ }
419
+
420
+ # Use the strictness-aware overall_passed that run_proof() persisted,
421
+ # so warn-mode does not surface as failure via the cached run status.
422
+ # Fallback note: if save_run() ever silently failed, the raw `all(...)`
423
+ # below ignores strictness — accepted because that path indicates a
424
+ # deeper persistence bug we'd want to surface as a hard failure anyway.
425
+ persisted_run = get_run(workspace, run_id)
426
+ if persisted_run is not None:
427
+ passed = persisted_run.overall_passed
428
+ else:
429
+ passed = all(
430
+ satisfied
431
+ for gate_results in results.values()
432
+ for _, satisfied in gate_results
433
+ )
434
+ response = RunProofResponse(
435
+ success=True,
436
+ run_id=run_id,
437
+ results=serialized,
438
+ message=f"Proof run complete: {len(results)} requirement(s) evaluated.",
439
+ )
440
+ _evict_run_cache()
441
+ _run_cache[(str(workspace.repo_path), run_id)] = {
442
+ "results": serialized,
443
+ "passed": passed,
444
+ "message": response.message,
445
+ "_ts": time.time(),
446
+ }
447
+ return response
448
+ except Exception as e:
449
+ logger.error("Proof run failed: %s", e, exc_info=True)
450
+ raise HTTPException(
451
+ status_code=500,
452
+ detail=api_error("Proof run failed", ErrorCodes.EXECUTION_FAILED, str(e)),
453
+ )
454
+
455
+
456
+ @router.get("/runs/{run_id}", response_model=RunStatusResponse)
457
+ @rate_limit_standard()
458
+ async def get_run_status_endpoint(
459
+ request: Request,
460
+ run_id: str,
461
+ workspace: Workspace = Depends(get_v2_workspace),
462
+ ) -> RunStatusResponse:
463
+ """Get the status of a completed proof run by run_id.
464
+
465
+ Since POST /run is synchronous, a run is always complete immediately after
466
+ the POST returns. Returns 404 if run_id is unknown.
467
+ """
468
+ cached = _run_cache.get((str(workspace.repo_path), run_id))
469
+ if cached is None:
470
+ raise HTTPException(
471
+ status_code=404,
472
+ detail=api_error(
473
+ f"Run not found: {run_id}",
474
+ ErrorCodes.NOT_FOUND,
475
+ f"No proof run with id {run_id}",
476
+ ),
477
+ )
478
+ return RunStatusResponse(
479
+ run_id=run_id,
480
+ status="complete",
481
+ results=cached["results"],
482
+ passed=cached["passed"],
483
+ message=cached["message"],
484
+ )
485
+
486
+
487
+ @router.post("/requirements/{req_id}/waive", response_model=RequirementResponse)
488
+ @rate_limit_standard()
489
+ async def waive_requirement_endpoint(
490
+ request: Request,
491
+ req_id: str,
492
+ body: WaiveRequirementRequest,
493
+ workspace: Workspace = Depends(get_v2_workspace),
494
+ ) -> RequirementResponse:
495
+ """Waive a requirement with a reason and optional expiry date."""
496
+ existing = get_requirement(workspace, req_id)
497
+ if not existing:
498
+ raise HTTPException(
499
+ status_code=404,
500
+ detail=api_error(
501
+ f"Requirement not found: {req_id}",
502
+ ErrorCodes.NOT_FOUND,
503
+ f"No requirement with id {req_id}",
504
+ ),
505
+ )
506
+
507
+ waiver = Waiver(
508
+ reason=body.reason,
509
+ expires=body.expires,
510
+ manual_checklist=body.manual_checklist,
511
+ approved_by=body.approved_by,
512
+ )
513
+ updated = waive_requirement(workspace, req_id, waiver)
514
+ return _req_to_response(updated)
515
+
516
+
517
+ @router.get("/status", response_model=ProofStatusResponse)
518
+ @rate_limit_standard()
519
+ async def proof_status_endpoint(
520
+ request: Request,
521
+ workspace: Workspace = Depends(get_v2_workspace),
522
+ ) -> ProofStatusResponse:
523
+ """Get aggregated proof status: totals by status and full requirement list."""
524
+ reqs = list_requirements(workspace)
525
+ counts = _count_by_status(reqs)
526
+
527
+ return ProofStatusResponse(
528
+ total=len(reqs),
529
+ open=counts.get("open", 0),
530
+ satisfied=counts.get("satisfied", 0),
531
+ waived=counts.get("waived", 0),
532
+ requirements=[_req_to_response(r) for r in reqs],
533
+ )
534
+
535
+
536
+ @router.get("/runs", response_model=list[ProofRunSummaryResponse])
537
+ @rate_limit_standard()
538
+ async def list_runs_endpoint(
539
+ request: Request,
540
+ limit: int = Query(default=5, ge=1, le=50, description="Maximum number of runs to return"),
541
+ workspace: Workspace = Depends(get_v2_workspace),
542
+ ) -> list[ProofRunSummaryResponse]:
543
+ """List the most recent proof gate runs for this workspace."""
544
+ runs = list_runs(workspace, limit=limit)
545
+ return [
546
+ ProofRunSummaryResponse(
547
+ run_id=r.run_id,
548
+ started_at=r.started_at.isoformat(),
549
+ completed_at=r.completed_at.isoformat() if r.completed_at else None,
550
+ triggered_by=r.triggered_by,
551
+ overall_passed=r.overall_passed,
552
+ duration_ms=r.duration_ms,
553
+ )
554
+ for r in runs
555
+ ]
556
+
557
+
558
+ _ARTIFACT_LINE_LIMIT = 200
559
+
560
+
561
+ def _read_artifact_text(artifact_path: str, max_lines: int = _ARTIFACT_LINE_LIMIT) -> Optional[str]:
562
+ """Read artifact file content up to max_lines, returning None if the file is missing."""
563
+ from pathlib import Path
564
+ try:
565
+ p = Path(artifact_path)
566
+ if not p.exists():
567
+ return None
568
+ lines = p.read_text(errors="replace").splitlines(keepends=True)
569
+ return "".join(lines[:max_lines])
570
+ except Exception:
571
+ return None
572
+
573
+
574
+ @router.get("/runs/{run_id}/evidence", response_model=ProofRunDetailResponse)
575
+ @rate_limit_standard()
576
+ async def get_run_evidence_endpoint(
577
+ request: Request,
578
+ run_id: str,
579
+ workspace: Workspace = Depends(get_v2_workspace),
580
+ ) -> ProofRunDetailResponse:
581
+ """Get per-gate evidence with artifact content for a completed proof run."""
582
+ # Try to get run metadata from DB first; fall back to in-memory cache
583
+ run = get_run(workspace, run_id)
584
+
585
+ if run is None:
586
+ # Fall back to cache for very recent runs not yet in DB
587
+ cached = _run_cache.get((str(workspace.repo_path), run_id))
588
+ if cached is None:
589
+ raise HTTPException(
590
+ status_code=404,
591
+ detail=api_error(
592
+ f"Run not found: {run_id}",
593
+ ErrorCodes.NOT_FOUND,
594
+ f"No proof run with id {run_id}",
595
+ ),
596
+ )
597
+ # Build a minimal response from cache
598
+ evidence_list: list[EvidenceWithContentResponse] = []
599
+ for req_id, gate_results in cached["results"].items():
600
+ for gate_result in gate_results:
601
+ evidence_list.append(EvidenceWithContentResponse(
602
+ req_id=req_id,
603
+ gate=gate_result["gate"],
604
+ satisfied=gate_result["satisfied"],
605
+ artifact_path="",
606
+ artifact_checksum="",
607
+ timestamp="",
608
+ run_id=run_id,
609
+ artifact_text=None,
610
+ ))
611
+ import time as _time
612
+ ts = cached.get("_ts", _time.time())
613
+ from datetime import datetime as _dt, timezone as _tz
614
+ ts_str = _dt.fromtimestamp(ts, tz=_tz.utc).isoformat()
615
+ return ProofRunDetailResponse(
616
+ run_id=run_id,
617
+ started_at=ts_str,
618
+ completed_at=ts_str,
619
+ triggered_by="human",
620
+ overall_passed=cached["passed"],
621
+ duration_ms=None,
622
+ evidence=evidence_list,
623
+ )
624
+
625
+ evidence_records = get_run_evidence(workspace, run_id)
626
+ evidence_out = [
627
+ EvidenceWithContentResponse(
628
+ req_id=e.req_id,
629
+ gate=e.gate.value,
630
+ satisfied=e.satisfied,
631
+ artifact_path=e.artifact_path,
632
+ artifact_checksum=e.artifact_checksum,
633
+ timestamp=e.timestamp.isoformat(),
634
+ run_id=e.run_id,
635
+ artifact_text=_read_artifact_text(e.artifact_path),
636
+ )
637
+ for e in evidence_records
638
+ ]
639
+ return ProofRunDetailResponse(
640
+ run_id=run.run_id,
641
+ started_at=run.started_at.isoformat(),
642
+ completed_at=run.completed_at.isoformat() if run.completed_at else None,
643
+ triggered_by=run.triggered_by,
644
+ overall_passed=run.overall_passed,
645
+ duration_ms=run.duration_ms,
646
+ evidence=evidence_out,
647
+ )
648
+
649
+
650
+ # ============================================================================
651
+ # PROOF9 Config (issue #556)
652
+ #
653
+ # Persists which gates are enabled by default and the strictness setting
654
+ # (strict vs warn) to .codeframe/proof_config.json.
655
+ # ============================================================================
656
+
657
+
658
+ _VALID_GATES = {g.value for g in Gate}
659
+
660
+
661
+ def _proof_config_path(workspace: Workspace):
662
+ return workspace.state_dir / PROOF_CONFIG_FILENAME
663
+
664
+
665
+ def _default_proof_config() -> dict:
666
+ return {"enabled_gates": list(PROOF9_GATE_ORDER), "strictness": "strict"}
667
+
668
+
669
+ class ProofConfigResponse(BaseModel):
670
+ enabled_gates: list[str]
671
+ strictness: Literal["strict", "warn"]
672
+
673
+
674
+ class UpdateProofConfigRequest(BaseModel):
675
+ enabled_gates: list[str]
676
+ strictness: Literal["strict", "warn"]
677
+
678
+ @field_validator("enabled_gates")
679
+ @classmethod
680
+ def _validate_gates(cls, v: list[str]) -> list[str]:
681
+ unknown = [g for g in v if g not in _VALID_GATES]
682
+ if unknown:
683
+ raise ValueError(
684
+ f"Unknown gate(s): {unknown}. Valid: {list(PROOF9_GATE_ORDER)}"
685
+ )
686
+ # De-dupe while preserving submission order so the stored file never
687
+ # carries the same gate twice.
688
+ return list(dict.fromkeys(v))
689
+
690
+
691
+ @router.get("/config", response_model=ProofConfigResponse)
692
+ @rate_limit_standard()
693
+ async def get_proof_config(
694
+ request: Request,
695
+ workspace: Workspace = Depends(get_v2_workspace),
696
+ ) -> ProofConfigResponse:
697
+ """Load PROOF9 defaults for this workspace.
698
+
699
+ Returns the all-gates-enabled + strict defaults if no config file exists.
700
+ """
701
+ path = _proof_config_path(workspace)
702
+ if path.exists():
703
+ try:
704
+ data = json.loads(path.read_text())
705
+ return ProofConfigResponse(**data)
706
+ except (OSError, json.JSONDecodeError, ValueError, ValidationError) as e:
707
+ logger.warning("Invalid proof_config.json — falling back to defaults: %s", e)
708
+ return ProofConfigResponse(**_default_proof_config())
709
+
710
+
711
+ @router.put("/config", response_model=ProofConfigResponse)
712
+ @rate_limit_standard()
713
+ async def update_proof_config(
714
+ request: Request,
715
+ body: UpdateProofConfigRequest,
716
+ workspace: Workspace = Depends(get_v2_workspace),
717
+ ) -> ProofConfigResponse:
718
+ """Persist PROOF9 defaults to .codeframe/proof_config.json."""
719
+ payload = {"enabled_gates": body.enabled_gates, "strictness": body.strictness}
720
+ atomic_write_json(_proof_config_path(workspace), payload)
721
+ return ProofConfigResponse(**payload)
722
+
723
+
724
+ @router.get("/requirements/{req_id}/evidence", response_model=list[EvidenceResponse])
725
+ @rate_limit_standard()
726
+ async def list_evidence_endpoint(
727
+ request: Request,
728
+ req_id: str,
729
+ workspace: Workspace = Depends(get_v2_workspace),
730
+ ) -> list[EvidenceResponse]:
731
+ """List all evidence records for a requirement."""
732
+ req = get_requirement(workspace, req_id)
733
+ if not req:
734
+ raise HTTPException(
735
+ status_code=404,
736
+ detail=api_error(
737
+ f"Requirement not found: {req_id}",
738
+ ErrorCodes.NOT_FOUND,
739
+ f"No requirement with id {req_id}",
740
+ ),
741
+ )
742
+
743
+ evidence = list_evidence(workspace, req_id)
744
+ return [
745
+ EvidenceResponse(
746
+ req_id=e.req_id,
747
+ gate=e.gate.value,
748
+ satisfied=e.satisfied,
749
+ artifact_path=e.artifact_path,
750
+ artifact_checksum=e.artifact_checksum,
751
+ timestamp=e.timestamp.isoformat(),
752
+ run_id=e.run_id,
753
+ )
754
+ for e in evidence
755
+ ]