devtorch-core 3.0.1__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 (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2012 @@
1
+ """
2
+ Sprint 13 – Browser-based Org Dashboard REST API.
3
+
4
+ Serves governance state from a local `.GCC/` directory over HTTP.
5
+ This is a read-only surface: it never writes to `.GCC/`.
6
+
7
+ Endpoints
8
+ ---------
9
+ GET /api/v1/dashboard → DashboardData (full snapshot)
10
+ GET /api/v1/branches → list[BranchSummary]
11
+ GET /api/v1/sensitivities → list[SensitivityEvent] (query: limit=50)
12
+
13
+ Usage
14
+ -----
15
+ from devtorch_core.dashboard_api import run_dashboard_api
16
+ run_dashboard_api(port=8766, host="127.0.0.1", gcc_repo="/path/to/project")
17
+
18
+ Or run directly:
19
+ python -m devtorch_core.dashboard_api --port 8766 --repo /path/to/project
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import datetime as _dt
26
+ import json
27
+ import math
28
+ import os
29
+ import subprocess
30
+ from pathlib import Path
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ from devtorch_core.projects import ProjectRegistry
34
+ from devtorch_core.metrics.aggregate import (
35
+ aggregate_tokens,
36
+ aggregate_speedup,
37
+ )
38
+ from devtorch_core.metrics.shadow_ai import detect_shadow_ai
39
+ from devtorch_core.metrics.delivery_time import compute_delivery_time_summary
40
+ from devtorch_core.metrics.sprint_writer import (
41
+ compute_sprint_metrics as _compute_sprint_metrics,
42
+ read_sprint_metrics as _read_sprint_metrics,
43
+ )
44
+ from devtorch_core.broadcast import EventBroadcaster, GCCWatcher
45
+
46
+ try:
47
+ from fastapi import FastAPI, HTTPException, Query, Response
48
+ from fastapi.middleware.cors import CORSMiddleware
49
+ from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
50
+ from pydantic import BaseModel
51
+ import uvicorn
52
+ _FASTAPI_AVAILABLE = True
53
+ except ImportError: # pragma: no cover
54
+ _FASTAPI_AVAILABLE = False
55
+
56
+ class BaseModel: # type: ignore[no-redef]
57
+ """Fallback when FastAPI/Pydantic are not installed."""
58
+
59
+ def __init__(self, **data: Any) -> None:
60
+ pass
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Pydantic response models (mirrors src/api/types.ts)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ class GCCStatusModel(BaseModel):
68
+ branch: str
69
+ nodeState: str
70
+ lastCommitId: str
71
+ locked: bool
72
+ lockReason: Optional[str] = None
73
+
74
+
75
+ class ThetaEntryModel(BaseModel):
76
+ concept: str
77
+ meanConfidence: float
78
+ eventCount: int
79
+ disclosureLevel: Optional[str] = None
80
+ lastUpdated: Optional[str] = None
81
+
82
+
83
+ class SensitivityEventModel(BaseModel):
84
+ id: str
85
+ timestamp: str
86
+ concept: str
87
+ disclosureLevel: int
88
+ signal: str
89
+ confidence: Optional[float] = None
90
+ sourceNode: Optional[str] = None
91
+
92
+
93
+ class BranchSummaryModel(BaseModel):
94
+ name: str
95
+ tipCommitId: str
96
+ commitCount: int
97
+ mcsScore: Optional[float] = None
98
+ gitSha: Optional[str] = None
99
+
100
+
101
+ class DashboardSavingsModel(BaseModel):
102
+ tokensUsed: int
103
+ tokensSaved: int
104
+ latencySpeedup: float
105
+
106
+
107
+ class DashboardDataModel(BaseModel):
108
+ status: GCCStatusModel
109
+ theta: List[ThetaEntryModel]
110
+ recentSensitivities: List[SensitivityEventModel]
111
+ branches: List[BranchSummaryModel]
112
+ lastUpdated: str
113
+ githubUrl: Optional[str] = None
114
+ savings: DashboardSavingsModel
115
+
116
+
117
+ class RDPBudgetModel(BaseModel):
118
+ epsilon_used: float
119
+ epsilon_max: float
120
+ read_only: bool
121
+ exhausted: bool
122
+
123
+
124
+ class ConformanceModel(BaseModel):
125
+ I1_commit_backed: bool
126
+ I3_semantic_grounding: bool
127
+ A1_capability_gate: bool
128
+ A2_variance_control: bool
129
+ A3_sis_quarantine: bool
130
+ A4_deltaf_bound: bool
131
+
132
+
133
+ class ProjectSummaryModel(BaseModel):
134
+ name: str
135
+ path: str
136
+ branch: str
137
+ lastCommitId: str
138
+ lastActivity: str
139
+ tokensUsed30d: int
140
+ tokensSaved30d: int
141
+ latencySpeedup: float
142
+ healthScore: Optional[float] = None
143
+ healthLabel: str
144
+
145
+
146
+ class OrgDependencyNodeModel(BaseModel):
147
+ id: str
148
+ name: str
149
+ path: str
150
+ healthScore: Optional[float] = None
151
+
152
+
153
+ class OrgDependencyEdgeModel(BaseModel):
154
+ source: str
155
+ target: str
156
+ concept: str
157
+ weight: float
158
+
159
+
160
+ class OrgDependencyGraphModel(BaseModel):
161
+ nodes: List[OrgDependencyNodeModel]
162
+ edges: List[OrgDependencyEdgeModel]
163
+
164
+
165
+ class ProjectsSavingsModel(BaseModel):
166
+ projects: List[ProjectSummaryModel]
167
+ aggregateTokensUsed: int
168
+ aggregateTokensSaved: int
169
+ aggregateLatencySpeedup: float
170
+
171
+
172
+ class SensitivitySummaryModel(BaseModel):
173
+ total: int
174
+ by_disclosure_level: Dict[str, int]
175
+ high_disclosure_count: int
176
+
177
+
178
+ class CISODataModel(BaseModel):
179
+ rdp_budget: RDPBudgetModel
180
+ conformance: ConformanceModel
181
+ sensitivity_summary: SensitivitySummaryModel
182
+ sis_quarantine_count: int
183
+ variance_alert_count: int
184
+ last_pst_passed: Optional[bool] = None
185
+ last_updated: str
186
+
187
+
188
+ # S13 / S14 models -----------------------------------------------------------
189
+
190
+ class ForensicEventModel(BaseModel):
191
+ ts: str
192
+ type: str
193
+ detail: str
194
+ raw: Dict[str, Any]
195
+
196
+
197
+ class ForensicTimelineModel(BaseModel):
198
+ events: List[ForensicEventModel]
199
+
200
+
201
+ class AgentContributionModel(BaseModel):
202
+ agent_id: str
203
+ confidence: float
204
+ message: str
205
+
206
+
207
+ class CollisionModel(BaseModel):
208
+ concept: str
209
+ agents: List[AgentContributionModel]
210
+ severity: str # "HIGH" | "MEDIUM" | "LOW"
211
+
212
+
213
+ class SprintSummaryModel(BaseModel):
214
+ total_commits: int
215
+ commits_per_branch: Dict[str, int]
216
+ i3_violations: int
217
+ collisions_detected: int
218
+ top_concepts: List[ThetaEntryModel]
219
+ mcs_note: str
220
+
221
+
222
+ class MCSTrendPointModel(BaseModel):
223
+ date: str # ISO date "2026-04-13"
224
+ mcs: Optional[float] # None = no events that day
225
+ eventCount: int
226
+
227
+
228
+ class AuditExportMetaModel(BaseModel):
229
+ exported_at: str
230
+ gcc_version: str
231
+ event_count: int
232
+ events: List[Dict[str, Any]]
233
+
234
+
235
+ class ShadowAIModel(BaseModel):
236
+ git_commits: int
237
+ gcc_commits: int
238
+ unregistered: int
239
+ shadow_ratio: float
240
+ flagged: bool
241
+
242
+
243
+ class CompliancePostureModel(BaseModel):
244
+ event_log_integrity: bool
245
+ i1_compliance_rate: float
246
+ rdp_budget_exhausted: bool
247
+ rdp_read_only: bool
248
+ private_span_count: int
249
+ sis_quarantine_count: int
250
+ variance_alert_count: int
251
+ overall_score: int
252
+
253
+
254
+ class GovernancePolicyModel(BaseModel):
255
+ allowed_models: List[str]
256
+ max_tokens_per_session: int
257
+ racp_compliance_required: bool
258
+ racp_compliance_threshold: float
259
+ require_devtorch_commit: bool
260
+ blocked_concepts: List[str]
261
+
262
+
263
+ class ConceptDriftResponseModel(BaseModel):
264
+ concepts: List[str]
265
+ time_labels: List[str]
266
+ matrix: List[List[float]]
267
+ mean_confidences: List[List[float]]
268
+ event_counts: List[List[int]]
269
+
270
+
271
+ class SprintMetricsModel(BaseModel):
272
+ sprint_id: str
273
+ mcs_score: float
274
+ i3_violations: int
275
+ collisions_prevented: int
276
+ override_rate: float
277
+ acceptance_rate: float
278
+ branch_count: int
279
+ commit_count: int
280
+
281
+
282
+ class DeliveryTimeTrendPointModel(BaseModel):
283
+ date: str
284
+ current_min: float
285
+ baseline_min: float
286
+ delta_min: float
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # GCC reader helpers (read-only; never write)
291
+ # ---------------------------------------------------------------------------
292
+
293
+ GCC_DIR_NAME = ".GCC"
294
+ REFS_DIR_NAME = "refs"
295
+ BRANCHES_DIR_NAME = "branches"
296
+ HEAD_FILE_NAME = "HEAD"
297
+ COMMITS_DIR_NAME = "commits"
298
+ NODE_STATE_FILE_NAME = "NODE_STATE"
299
+ CONSENSUS_LOCK_FILE = "consensus_lock.json"
300
+ SENSITIVITIES_DIR_NAME = "sensitivities"
301
+ SENSITIVITY_EVENTS_FILE = "events.jsonl"
302
+ THETA_FILE = "theta.json"
303
+
304
+ # Map string disclosure levels to int for the API model
305
+ _DISCLOSURE_INT: Dict[str, int] = {
306
+ "PUBLIC": 0,
307
+ "PROTECTED": 1,
308
+ "PRIVATE": 2,
309
+ }
310
+
311
+
312
+ def _gcc_dir(gcc_repo: Path) -> Path:
313
+ return gcc_repo / GCC_DIR_NAME
314
+
315
+
316
+ def _is_initialized(gcc_repo: Path) -> bool:
317
+ return (_gcc_dir(gcc_repo) / "VERSION").exists()
318
+
319
+
320
+ def _read_current_branch(gcc_repo: Path) -> str:
321
+ head = _gcc_dir(gcc_repo) / REFS_DIR_NAME / HEAD_FILE_NAME
322
+ if not head.exists():
323
+ return "main"
324
+ return head.read_text(encoding="utf-8").strip() or "main"
325
+
326
+
327
+ def _read_branch_tip(gcc_repo: Path, branch: str) -> str:
328
+ ref = _gcc_dir(gcc_repo) / REFS_DIR_NAME / BRANCHES_DIR_NAME / branch
329
+ if not ref.exists():
330
+ return ""
331
+ return ref.read_text(encoding="utf-8").strip()
332
+
333
+
334
+ def _read_node_state(gcc_repo: Path) -> str:
335
+ node_state_file = _gcc_dir(gcc_repo) / NODE_STATE_FILE_NAME
336
+ if not node_state_file.exists():
337
+ return "ACTIVE"
338
+ try:
339
+ data = json.loads(node_state_file.read_text(encoding="utf-8"))
340
+ mode = data.get("mode", "idle")
341
+ # Map internal modes to display states
342
+ mode_map = {
343
+ "running": "ACTIVE",
344
+ "idle": "ACTIVE",
345
+ "paused": "DEGRADED",
346
+ "error": "ISOLATED",
347
+ }
348
+ return mode_map.get(mode.lower(), mode.upper())
349
+ except (json.JSONDecodeError, OSError):
350
+ return "ACTIVE"
351
+
352
+
353
+ def _read_lock_state(gcc_repo: Path) -> tuple[bool, Optional[str]]:
354
+ lock_file = _gcc_dir(gcc_repo) / CONSENSUS_LOCK_FILE
355
+ if not lock_file.exists():
356
+ return False, None
357
+ try:
358
+ data = json.loads(lock_file.read_text(encoding="utf-8"))
359
+ locked = bool(data.get("locked", False))
360
+ reason = data.get("reason") if locked else None
361
+ return locked, reason
362
+ except (json.JSONDecodeError, OSError):
363
+ return False, None
364
+
365
+
366
+ def _read_gcc_status(gcc_repo: Path) -> GCCStatusModel:
367
+ branch = _read_current_branch(gcc_repo)
368
+ last_commit_id = _read_branch_tip(gcc_repo, branch)
369
+ node_state = _read_node_state(gcc_repo)
370
+ locked, lock_reason = _read_lock_state(gcc_repo)
371
+ return GCCStatusModel(
372
+ branch=branch,
373
+ nodeState=node_state,
374
+ lastCommitId=last_commit_id,
375
+ locked=locked,
376
+ lockReason=lock_reason,
377
+ )
378
+
379
+
380
+ def _compute_health_score(gcc_repo: Path) -> Optional[float]:
381
+ """Best-effort Deployment Health Score proxy (0-100)."""
382
+ try:
383
+ from devtorch_core.metrics.shadow_ai import detect_shadow_ai
384
+ from devtorch_core.metrics.dhs import DHSComponents, compute_dhs
385
+
386
+ shadow = detect_shadow_ai(gcc_repo.parent)
387
+ components = DHSComponents(
388
+ commit_health=1.0 if shadow.get("gcc_commits", 0) > 0 else 0.0,
389
+ sensitivity_health=1.0,
390
+ capability_health=0.0,
391
+ variance_health=1.0,
392
+ )
393
+ return compute_dhs(components)
394
+ except Exception:
395
+ return None
396
+
397
+
398
+ def _health_label(score: Optional[float]) -> str:
399
+ if score is None:
400
+ return "UNKNOWN"
401
+ if score >= 90:
402
+ return "AUTONOMOUS"
403
+ if score >= 75:
404
+ return "STANDARD"
405
+ if score >= 60:
406
+ return "REVIEW"
407
+ return "BLOCKED"
408
+
409
+
410
+ def _read_theta(gcc_repo: Path) -> List[ThetaEntryModel]:
411
+ theta_file = _gcc_dir(gcc_repo) / THETA_FILE
412
+ if not theta_file.exists():
413
+ return []
414
+ try:
415
+ data = json.loads(theta_file.read_text(encoding="utf-8"))
416
+ cv: Dict[str, Any] = data.get("coordination_vector", {})
417
+ entries: List[ThetaEntryModel] = []
418
+ for concept, info in cv.items():
419
+ entries.append(
420
+ ThetaEntryModel(
421
+ concept=concept,
422
+ meanConfidence=float(info.get("mean_confidence", 0.0)),
423
+ eventCount=int(info.get("event_count", 0)),
424
+ disclosureLevel=info.get("disclosure_level"),
425
+ lastUpdated=info.get("last_updated"),
426
+ )
427
+ )
428
+ # Sort by confidence descending for consistent presentation
429
+ entries.sort(key=lambda e: e.meanConfidence, reverse=True)
430
+ return entries
431
+ except (json.JSONDecodeError, OSError, KeyError):
432
+ return []
433
+
434
+
435
+ def _disclosure_str_to_int(level: str) -> int:
436
+ """Convert disclosure level string to integer. Supports both str and int inputs."""
437
+ if isinstance(level, int):
438
+ return level
439
+ return _DISCLOSURE_INT.get(str(level).upper(), 0)
440
+
441
+
442
+ def _read_sensitivities(gcc_repo: Path, limit: int = 50, concept: Optional[str] = None) -> List[SensitivityEventModel]:
443
+ events_file = (
444
+ _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
445
+ )
446
+ if not events_file.exists():
447
+ return []
448
+ raw_events: List[dict] = []
449
+ try:
450
+ with events_file.open("r", encoding="utf-8") as fh:
451
+ for line in fh:
452
+ line = line.strip()
453
+ if not line:
454
+ continue
455
+ try:
456
+ raw_events.append(json.loads(line))
457
+ except json.JSONDecodeError:
458
+ continue
459
+ except OSError:
460
+ return []
461
+
462
+ # Newest first; apply concept filter before slicing so limit is per-concept
463
+ raw_events = raw_events[::-1]
464
+ if concept:
465
+ raw_events = [
466
+ ev for ev in raw_events
467
+ if ev.get("target_concept", ev.get("concept", "")) == concept
468
+ ]
469
+ raw_events = raw_events[:limit]
470
+
471
+ result: List[SensitivityEventModel] = []
472
+ for ev in raw_events:
473
+ ev_concept = ev.get("target_concept", ev.get("concept", "unknown"))
474
+ signal = ev.get("counterfactuals", ev.get("message", ev.get("signal", "")))
475
+ timestamp = ev.get("created_at", ev.get("timestamp", ""))
476
+ disclosure_raw = ev.get("disclosure_level", "PUBLIC")
477
+ raw_conf = ev.get("confidence")
478
+ confidence = float(raw_conf) if raw_conf is not None else None
479
+ result.append(
480
+ SensitivityEventModel(
481
+ id=ev.get("event_id", ev.get("id", "")),
482
+ timestamp=timestamp,
483
+ concept=ev_concept,
484
+ disclosureLevel=_disclosure_str_to_int(disclosure_raw),
485
+ signal=str(signal),
486
+ confidence=confidence,
487
+ sourceNode=ev.get("source_node"),
488
+ )
489
+ )
490
+ return result
491
+
492
+
493
+ def _list_branch_names(gcc_repo: Path) -> List[str]:
494
+ branches_dir = _gcc_dir(gcc_repo) / REFS_DIR_NAME / BRANCHES_DIR_NAME
495
+ if not branches_dir.exists():
496
+ return []
497
+ return sorted(
498
+ p.name for p in branches_dir.iterdir() if p.is_file()
499
+ )
500
+
501
+
502
+ def _count_commits(gcc_repo: Path, branch: str) -> int:
503
+ """Walk commit chain for branch and count commits."""
504
+ tip = _read_branch_tip(gcc_repo, branch)
505
+ if not tip:
506
+ return 0
507
+ commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
508
+ count = 0
509
+ current = tip
510
+ visited: set = set()
511
+ while current and current not in visited:
512
+ visited.add(current)
513
+ commit_file = commits_dir / f"{current}.json"
514
+ if not commit_file.exists():
515
+ break
516
+ try:
517
+ obj = json.loads(commit_file.read_text(encoding="utf-8"))
518
+ except (json.JSONDecodeError, OSError):
519
+ break
520
+ count += 1
521
+ current = obj.get("parent") or ""
522
+ return count
523
+
524
+
525
+ def _get_github_url(gcc_repo: Path) -> Optional[str]:
526
+ """Return the GitHub web URL for the repo (https://github.com/user/repo), or None."""
527
+ try:
528
+ r = subprocess.run(
529
+ ["git", "remote", "get-url", "origin"],
530
+ capture_output=True, text=True, cwd=gcc_repo, timeout=5,
531
+ )
532
+ if r.returncode != 0:
533
+ return None
534
+ url = r.stdout.strip()
535
+ # SSH → HTTPS: git@github.com:user/repo.git → https://github.com/user/repo
536
+ if url.startswith("git@"):
537
+ url = url.replace(":", "/", 1).replace("git@", "https://")
538
+ if url.endswith(".git"):
539
+ url = url[:-4]
540
+ return url or None
541
+ except (OSError, subprocess.TimeoutExpired):
542
+ return None
543
+
544
+
545
+ def _get_git_sha(gcc_repo: Path, branch: str) -> Optional[str]:
546
+ """Return the full git SHA for the tip of a branch, or None if not found."""
547
+ try:
548
+ r = subprocess.run(
549
+ ["git", "rev-parse", branch],
550
+ capture_output=True, text=True, cwd=gcc_repo, timeout=5,
551
+ )
552
+ if r.returncode != 0:
553
+ return None
554
+ sha = r.stdout.strip()
555
+ return sha if sha else None
556
+ except (OSError, subprocess.TimeoutExpired):
557
+ return None
558
+
559
+
560
+ def _read_branches(gcc_repo: Path) -> List[BranchSummaryModel]:
561
+ names = _list_branch_names(gcc_repo)
562
+ result: List[BranchSummaryModel] = []
563
+ for name in names:
564
+ tip = _read_branch_tip(gcc_repo, name)
565
+ commit_count = _count_commits(gcc_repo, name)
566
+ result.append(
567
+ BranchSummaryModel(
568
+ name=name,
569
+ tipCommitId=tip,
570
+ commitCount=commit_count,
571
+ mcsScore=None, # MCS not yet computed per-branch in S13
572
+ gitSha=_get_git_sha(gcc_repo, name),
573
+ )
574
+ )
575
+ return result
576
+
577
+
578
+ def _read_rdp_budget(gcc_repo: Path) -> RDPBudgetModel:
579
+ rdp_file = _gcc_dir(gcc_repo) / "rdp" / "rdp_state.json"
580
+ defaults = RDPBudgetModel(epsilon_used=0.0, epsilon_max=1.0, read_only=False, exhausted=False)
581
+ if not rdp_file.exists():
582
+ return defaults
583
+ try:
584
+ data = json.loads(rdp_file.read_text(encoding="utf-8"))
585
+ epsilon_used = float(data.get("epsilon_used", 0.0))
586
+ epsilon_max = float(data.get("epsilon_max", data.get("epsilon_budget", 1.0)))
587
+ read_only = bool(data.get("read_only", False))
588
+ exhausted = bool(data.get("exhausted", read_only))
589
+ return RDPBudgetModel(
590
+ epsilon_used=epsilon_used,
591
+ epsilon_max=epsilon_max,
592
+ read_only=read_only,
593
+ exhausted=exhausted,
594
+ )
595
+ except (json.JSONDecodeError, OSError, TypeError, ValueError):
596
+ return defaults
597
+
598
+
599
+ def _read_conformance(gcc_repo: Path) -> ConformanceModel:
600
+ gcc = _gcc_dir(gcc_repo)
601
+ # I1: commits/ non-empty
602
+ try:
603
+ commits_dir = gcc / "commits"
604
+ i1 = commits_dir.exists() and any(True for _ in commits_dir.iterdir())
605
+ except OSError:
606
+ i1 = False
607
+ # I3: concepts/ non-empty
608
+ try:
609
+ concepts_dir = gcc / "concepts"
610
+ i3 = concepts_dir.exists() and any(True for _ in concepts_dir.iterdir())
611
+ except OSError:
612
+ i3 = False
613
+ # A1: capabilities/omega.json exists
614
+ a1 = (gcc / "capabilities" / "omega.json").exists()
615
+ # A2: variance/ directory exists
616
+ a2 = (gcc / "variance").exists()
617
+ # A3: sis/ directory exists
618
+ a3 = (gcc / "sis").exists()
619
+ # A4: deltaf/deltaf_report.json exists
620
+ a4 = (gcc / "deltaf" / "deltaf_report.json").exists()
621
+ return ConformanceModel(
622
+ I1_commit_backed=i1,
623
+ I3_semantic_grounding=i3,
624
+ A1_capability_gate=a1,
625
+ A2_variance_control=a2,
626
+ A3_sis_quarantine=a3,
627
+ A4_deltaf_bound=a4,
628
+ )
629
+
630
+
631
+ def _read_sensitivity_summary(gcc_repo: Path) -> SensitivitySummaryModel:
632
+ events_file = _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
633
+ by_level: Dict[str, int] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
634
+ total = 0
635
+ if events_file.exists():
636
+ try:
637
+ with events_file.open("r", encoding="utf-8") as fh:
638
+ for line in fh:
639
+ line = line.strip()
640
+ if not line:
641
+ continue
642
+ try:
643
+ ev = json.loads(line)
644
+ except json.JSONDecodeError:
645
+ continue
646
+ total += 1
647
+ raw = ev.get("disclosure_level", 0)
648
+ # Normalize: string names map to int, numeric strings kept as-is
649
+ if isinstance(raw, str):
650
+ # Try numeric string first
651
+ try:
652
+ level_int = int(raw)
653
+ except ValueError:
654
+ # Named levels: treat PUBLIC=1, PROTECTED=3, PRIVATE=5
655
+ name_map = {
656
+ "PUBLIC": 1,
657
+ "PROTECTED": 3,
658
+ "PRIVATE": 5,
659
+ }
660
+ level_int = name_map.get(raw.upper(), 1)
661
+ else:
662
+ level_int = int(raw)
663
+ key = str(max(1, min(5, level_int)))
664
+ by_level[key] = by_level.get(key, 0) + 1
665
+ except OSError:
666
+ pass
667
+ high_disclosure_count = by_level.get("4", 0) + by_level.get("5", 0)
668
+ return SensitivitySummaryModel(
669
+ total=total,
670
+ by_disclosure_level=by_level,
671
+ high_disclosure_count=high_disclosure_count,
672
+ )
673
+
674
+
675
+ def _read_sis_quarantine_count(gcc_repo: Path) -> int:
676
+ sis_report = _gcc_dir(gcc_repo) / "sis" / "sis_tc_report.json"
677
+ if not sis_report.exists():
678
+ return 0
679
+ try:
680
+ data = json.loads(sis_report.read_text(encoding="utf-8"))
681
+ return int(data.get("quarantine_count", data.get("quarantined_count", 0)))
682
+ except (json.JSONDecodeError, OSError, TypeError, ValueError):
683
+ return 0
684
+
685
+
686
+ def _read_variance_alert_count(gcc_repo: Path) -> int:
687
+ events_log = _gcc_dir(gcc_repo) / "events.log.jsonl"
688
+ if not events_log.exists():
689
+ return 0
690
+ count = 0
691
+ try:
692
+ with events_log.open("r", encoding="utf-8") as fh:
693
+ for line in fh:
694
+ line = line.strip()
695
+ if not line:
696
+ continue
697
+ try:
698
+ ev = json.loads(line)
699
+ if ev.get("event_type") == "VARIANCE_ALERT":
700
+ count += 1
701
+ except json.JSONDecodeError:
702
+ continue
703
+ except OSError:
704
+ pass
705
+ return count
706
+
707
+
708
+ def _read_last_pst_passed(gcc_repo: Path) -> Optional[bool]:
709
+ pst_dir = _gcc_dir(gcc_repo) / "pst_reports"
710
+ if not pst_dir.exists():
711
+ return None
712
+ try:
713
+ report_files = sorted(
714
+ (f for f in pst_dir.iterdir() if f.suffix == ".json"),
715
+ key=lambda p: p.name,
716
+ )
717
+ if not report_files:
718
+ return None
719
+ latest = report_files[-1]
720
+ data = json.loads(latest.read_text(encoding="utf-8"))
721
+ passed = data.get("passed")
722
+ if passed is None:
723
+ return None
724
+ return bool(passed)
725
+ except (OSError, json.JSONDecodeError):
726
+ return None
727
+
728
+
729
+ def _read_ciso_data(gcc_repo: Path) -> CISODataModel:
730
+ rdp_budget = _read_rdp_budget(gcc_repo)
731
+ conformance = _read_conformance(gcc_repo)
732
+ sensitivity_summary = _read_sensitivity_summary(gcc_repo)
733
+ sis_quarantine_count = _read_sis_quarantine_count(gcc_repo)
734
+ variance_alert_count = _read_variance_alert_count(gcc_repo)
735
+ last_pst_passed = _read_last_pst_passed(gcc_repo)
736
+ last_updated = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
737
+ return CISODataModel(
738
+ rdp_budget=rdp_budget,
739
+ conformance=conformance,
740
+ sensitivity_summary=sensitivity_summary,
741
+ sis_quarantine_count=sis_quarantine_count,
742
+ variance_alert_count=variance_alert_count,
743
+ last_pst_passed=last_pst_passed,
744
+ last_updated=last_updated,
745
+ )
746
+
747
+
748
+ def _event_log_integrity_ok(gcc_repo: Path) -> bool:
749
+ """Best-effort hash-chain integrity check over events.log.jsonl."""
750
+ from devtorch_core.signing import verify_log
751
+ try:
752
+ return len(verify_log(_gcc_dir(gcc_repo))) == 0
753
+ except Exception:
754
+ return False
755
+
756
+
757
+ def _read_governance_policy(gcc_repo: Path) -> GovernancePolicyModel:
758
+ """Read the project governance policy file (.GCC/policy.json) or return defaults."""
759
+ policy_file = _gcc_dir(gcc_repo) / "policy.json"
760
+ try:
761
+ from devtorch_core.gateway.policy import GovernancePolicy
762
+ policy = GovernancePolicy.load_from_file(str(policy_file))
763
+ except Exception:
764
+ policy = None
765
+ if policy is None:
766
+ return GovernancePolicyModel(
767
+ allowed_models=[],
768
+ max_tokens_per_session=1_000_000,
769
+ racp_compliance_required=False,
770
+ racp_compliance_threshold=0.0,
771
+ require_devtorch_commit=False,
772
+ blocked_concepts=[],
773
+ )
774
+ return GovernancePolicyModel(
775
+ allowed_models=policy.allowed_models,
776
+ max_tokens_per_session=policy.max_tokens_per_session,
777
+ racp_compliance_required=policy.racp_compliance_required,
778
+ racp_compliance_threshold=policy.racp_compliance_threshold,
779
+ require_devtorch_commit=policy.require_devtorch_commit,
780
+ blocked_concepts=policy.blocked_concepts,
781
+ )
782
+
783
+
784
+ def _read_compliance_posture(gcc_repo: Path) -> CompliancePostureModel:
785
+ rdp = _read_rdp_budget(gcc_repo)
786
+ shadow = detect_shadow_ai(gcc_repo)
787
+ git_commits = max(1, shadow["git_commits"])
788
+ gcc_commits = shadow["gcc_commits"]
789
+ i1_rate = min(1.0, gcc_commits / git_commits)
790
+ private_count = _read_sensitivity_summary(gcc_repo).high_disclosure_count
791
+ sis_quarantine_count = _read_sis_quarantine_count(gcc_repo)
792
+ variance_alert_count = _read_variance_alert_count(gcc_repo)
793
+ integrity_ok = _event_log_integrity_ok(gcc_repo)
794
+
795
+ score = 100
796
+ if not integrity_ok:
797
+ score -= 25
798
+ if i1_rate < 0.5:
799
+ score -= 25
800
+ if rdp.exhausted:
801
+ score -= 15
802
+ if private_count > 0:
803
+ score -= 10
804
+ if sis_quarantine_count > 0:
805
+ score -= 10
806
+ if variance_alert_count > 0:
807
+ score -= 10
808
+ score = max(0, score)
809
+
810
+ return CompliancePostureModel(
811
+ event_log_integrity=integrity_ok,
812
+ i1_compliance_rate=i1_rate,
813
+ rdp_budget_exhausted=rdp.exhausted,
814
+ rdp_read_only=rdp.read_only,
815
+ private_span_count=private_count,
816
+ sis_quarantine_count=sis_quarantine_count,
817
+ variance_alert_count=variance_alert_count,
818
+ overall_score=score,
819
+ )
820
+
821
+
822
+ def _read_org_dependency_graph() -> OrgDependencyGraphModel:
823
+ """Build a cross-project concept-coupling graph from registered project Θ files."""
824
+ registry = ProjectRegistry()
825
+ project_concepts: Dict[str, Dict[str, float]] = {}
826
+ project_meta: Dict[str, Dict[str, Any]] = {}
827
+
828
+ for entry in registry.list():
829
+ project_root = Path(entry.path)
830
+ gcc_dir = project_root / ".GCC"
831
+ if not gcc_dir.exists():
832
+ continue
833
+ try:
834
+ theta = _read_theta(project_root)
835
+ except Exception:
836
+ continue
837
+ concepts: Dict[str, float] = {}
838
+ for t in theta:
839
+ concepts[t.concept] = t.meanConfidence
840
+ if concepts:
841
+ project_concepts[entry.name] = concepts
842
+ project_meta[entry.name] = {
843
+ "path": entry.path,
844
+ "health_score": _compute_health_score(gcc_dir),
845
+ }
846
+
847
+ nodes = [
848
+ OrgDependencyNodeModel(
849
+ id=name,
850
+ name=name,
851
+ path=meta["path"],
852
+ healthScore=meta["health_score"],
853
+ )
854
+ for name, meta in project_meta.items()
855
+ ]
856
+
857
+ edges: List[OrgDependencyEdgeModel] = []
858
+ project_names = list(project_concepts.keys())
859
+ for i in range(len(project_names)):
860
+ for j in range(i + 1, len(project_names)):
861
+ a, b = project_names[i], project_names[j]
862
+ shared = set(project_concepts[a].keys()) & set(project_concepts[b].keys())
863
+ for concept in shared:
864
+ weight = (project_concepts[a][concept] + project_concepts[b][concept]) / 2
865
+ edges.append(OrgDependencyEdgeModel(
866
+ source=a,
867
+ target=b,
868
+ concept=concept,
869
+ weight=weight,
870
+ ))
871
+
872
+ return OrgDependencyGraphModel(nodes=nodes, edges=edges)
873
+
874
+
875
+ def _read_dashboard(gcc_repo: Path, sensitivity_limit: int = 20) -> DashboardDataModel:
876
+ status = _read_gcc_status(gcc_repo)
877
+ theta = _read_theta(gcc_repo)
878
+ sensitivities = _read_sensitivities(gcc_repo, limit=sensitivity_limit)
879
+ branches = _read_branches(gcc_repo)
880
+ github_url = _get_github_url(gcc_repo)
881
+ tokens = aggregate_tokens(_gcc_dir(gcc_repo))
882
+ speedup = aggregate_speedup(_gcc_dir(gcc_repo))
883
+ last_updated = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
884
+ return DashboardDataModel(
885
+ status=status,
886
+ theta=theta,
887
+ recentSensitivities=sensitivities,
888
+ branches=branches,
889
+ githubUrl=github_url,
890
+ lastUpdated=last_updated,
891
+ savings=DashboardSavingsModel(
892
+ tokensUsed=tokens["total_tokens_used"],
893
+ tokensSaved=tokens["total_tokens_saved"],
894
+ latencySpeedup=speedup["latency_speedup_factor"],
895
+ ),
896
+ )
897
+
898
+
899
+ # ---------------------------------------------------------------------------
900
+ # S13 / S14 reader helpers
901
+ # ---------------------------------------------------------------------------
902
+
903
+ EVENT_LOG_FILE = "events.log.jsonl"
904
+ COMMIT_INDEX_FILE = "COMMIT_INDEX"
905
+
906
+
907
+ def _summarize_event(ev: Dict[str, Any]) -> str:
908
+ """Produce a human-readable one-liner for an event dict."""
909
+ etype = ev.get("event_type", ev.get("type", "UNKNOWN"))
910
+ concept = ev.get("concept", ev.get("target_concept", ""))
911
+ message = ev.get("message", ev.get("detail", ""))
912
+ commit_id = ev.get("commit_id", "")
913
+ branch = ev.get("branch", "")
914
+
915
+ parts: List[str] = []
916
+ if etype:
917
+ parts.append(f"[{etype}]")
918
+ if concept:
919
+ parts.append(f"concept={concept}")
920
+ if branch:
921
+ parts.append(f"branch={branch}")
922
+ if commit_id:
923
+ parts.append(f"commit={commit_id[:8]}")
924
+ if message:
925
+ parts.append(message[:120])
926
+ return " ".join(parts) if parts else str(ev)
927
+
928
+
929
+ def _read_events_log(gcc_repo: Path) -> List[Dict[str, Any]]:
930
+ """Read all events from events.log.jsonl; return empty list on any error."""
931
+ log_file = _gcc_dir(gcc_repo) / EVENT_LOG_FILE
932
+ events: List[Dict[str, Any]] = []
933
+ if not log_file.exists():
934
+ return events
935
+ try:
936
+ with log_file.open("r", encoding="utf-8") as fh:
937
+ for line in fh:
938
+ line = line.strip()
939
+ if not line:
940
+ continue
941
+ try:
942
+ events.append(json.loads(line))
943
+ except json.JSONDecodeError:
944
+ continue
945
+ except OSError:
946
+ pass
947
+ return events
948
+
949
+
950
+ def _read_forensic_timeline(
951
+ gcc_repo: Path,
952
+ limit: int = 100,
953
+ event_types_filter: str = "",
954
+ branch_filter: str = "",
955
+ ) -> ForensicTimelineModel:
956
+ raw_events = _read_events_log(gcc_repo)
957
+
958
+ # Apply event type filter
959
+ if event_types_filter:
960
+ wanted = {t.strip().upper() for t in event_types_filter.split(",") if t.strip()}
961
+ raw_events = [
962
+ ev for ev in raw_events
963
+ if ev.get("event_type", ev.get("type", "")).upper() in wanted
964
+ ]
965
+
966
+ # Apply branch filter
967
+ if branch_filter:
968
+ raw_events = [
969
+ ev for ev in raw_events
970
+ if ev.get("branch", "").lower() == branch_filter.lower()
971
+ ]
972
+
973
+ # Chronological order (already append-only so just slice)
974
+ raw_events = raw_events[:limit]
975
+
976
+ result: List[ForensicEventModel] = []
977
+ for ev in raw_events:
978
+ ts = ev.get("ts", ev.get("timestamp", ev.get("created_at", "")))
979
+ etype = ev.get("event_type", ev.get("type", "UNKNOWN"))
980
+ result.append(
981
+ ForensicEventModel(
982
+ ts=ts,
983
+ type=etype,
984
+ detail=_summarize_event(ev),
985
+ raw=ev,
986
+ )
987
+ )
988
+ return ForensicTimelineModel(events=result)
989
+
990
+
991
+ def _read_commit_chain(gcc_repo: Path, commit_id: str, max_depth: int = 10) -> List[Dict[str, Any]]:
992
+ """Return the commit object + up to max_depth parents."""
993
+ commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
994
+ chain: List[Dict[str, Any]] = []
995
+ current = commit_id
996
+ visited: set = set()
997
+ while current and current not in visited and len(chain) <= max_depth:
998
+ visited.add(current)
999
+ commit_file = commits_dir / f"{current}.json"
1000
+ if not commit_file.exists():
1001
+ break
1002
+ try:
1003
+ obj = json.loads(commit_file.read_text(encoding="utf-8"))
1004
+ chain.append(obj)
1005
+ current = obj.get("parent") or ""
1006
+ except (json.JSONDecodeError, OSError):
1007
+ break
1008
+ return chain
1009
+
1010
+
1011
+ def _read_sensitivity_events_between(
1012
+ gcc_repo: Path, parent_ts: str, child_ts: str
1013
+ ) -> List[Dict[str, Any]]:
1014
+ """Return all sensitivity events whose timestamp falls between parent and child."""
1015
+ events_file = _gcc_dir(gcc_repo) / "sensitivities" / "events.jsonl"
1016
+ result: List[Dict[str, Any]] = []
1017
+ if not events_file.exists():
1018
+ return result
1019
+ try:
1020
+ with events_file.open("r", encoding="utf-8") as fh:
1021
+ for line in fh:
1022
+ line = line.strip()
1023
+ if not line:
1024
+ continue
1025
+ try:
1026
+ ev = json.loads(line)
1027
+ ts = ev.get("created_at", ev.get("timestamp", ""))
1028
+ if parent_ts <= ts <= child_ts:
1029
+ result.append(ev)
1030
+ except json.JSONDecodeError:
1031
+ continue
1032
+ except OSError:
1033
+ pass
1034
+ return result
1035
+
1036
+
1037
+ def _read_invariant_violations_between(
1038
+ gcc_repo: Path, parent_ts: str, child_ts: str
1039
+ ) -> List[Dict[str, Any]]:
1040
+ """Return all INVARIANT_VIOLATION events between the two timestamps."""
1041
+ all_events = _read_events_log(gcc_repo)
1042
+ return [
1043
+ ev for ev in all_events
1044
+ if ev.get("event_type", ev.get("type", "")) == "INVARIANT_VIOLATION"
1045
+ and parent_ts <= ev.get("ts", ev.get("timestamp", "")) <= child_ts
1046
+ ]
1047
+
1048
+
1049
+ def _read_collision_list(gcc_repo: Path) -> List[CollisionModel]:
1050
+ """Detect collisions from theta.json where multiple source_nodes conflict (std_dev > 0.2)."""
1051
+ theta_file = _gcc_dir(gcc_repo) / THETA_FILE
1052
+ if not theta_file.exists():
1053
+ return []
1054
+ try:
1055
+ data = json.loads(theta_file.read_text(encoding="utf-8"))
1056
+ except (json.JSONDecodeError, OSError):
1057
+ return []
1058
+
1059
+ cv: Dict[str, Any] = data.get("coordination_vector", {})
1060
+ collisions: List[CollisionModel] = []
1061
+
1062
+ for concept, info in cv.items():
1063
+ source_nodes: List[Dict[str, Any]] = info.get("source_nodes", [])
1064
+ if not source_nodes or len(source_nodes) < 2:
1065
+ continue
1066
+
1067
+ confidences = [float(n.get("confidence", 0.0)) for n in source_nodes]
1068
+ mean_c = sum(confidences) / len(confidences)
1069
+ variance = sum((c - mean_c) ** 2 for c in confidences) / len(confidences)
1070
+ std_dev = math.sqrt(variance)
1071
+
1072
+ if std_dev <= 0.2:
1073
+ continue
1074
+
1075
+ if std_dev > 0.4:
1076
+ severity = "HIGH"
1077
+ else:
1078
+ severity = "MEDIUM"
1079
+
1080
+ agents: List[AgentContributionModel] = []
1081
+ for node in source_nodes:
1082
+ agents.append(
1083
+ AgentContributionModel(
1084
+ agent_id=str(node.get("agent_id", node.get("node_id", "unknown"))),
1085
+ confidence=float(node.get("confidence", 0.0)),
1086
+ message=str(node.get("message", node.get("signal", ""))),
1087
+ )
1088
+ )
1089
+
1090
+ collisions.append(CollisionModel(concept=concept, agents=agents, severity=severity))
1091
+
1092
+ return collisions
1093
+
1094
+
1095
+ def _read_commit_index(gcc_repo: Path) -> List[str]:
1096
+ """Read the flat COMMIT_INDEX file and return list of commit IDs."""
1097
+ index_file = _gcc_dir(gcc_repo) / COMMIT_INDEX_FILE
1098
+ if not index_file.exists():
1099
+ return []
1100
+ try:
1101
+ lines = index_file.read_text(encoding="utf-8").splitlines()
1102
+ return [line.strip() for line in lines if line.strip()]
1103
+ except OSError:
1104
+ return []
1105
+
1106
+
1107
+ def _read_sprint_summary(gcc_repo: Path) -> SprintSummaryModel:
1108
+ """Compute sprint review summary from commits + events."""
1109
+ commit_ids = _read_commit_index(gcc_repo)
1110
+ total_commits = len(commit_ids)
1111
+
1112
+ # commits_per_branch: walk each commit and group by branch field
1113
+ commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
1114
+ commits_per_branch: Dict[str, int] = {}
1115
+ for cid in commit_ids:
1116
+ cfile = commits_dir / f"{cid}.json"
1117
+ if not cfile.exists():
1118
+ continue
1119
+ try:
1120
+ obj = json.loads(cfile.read_text(encoding="utf-8"))
1121
+ branch = obj.get("branch", "main")
1122
+ commits_per_branch[branch] = commits_per_branch.get(branch, 0) + 1
1123
+ except (json.JSONDecodeError, OSError):
1124
+ continue
1125
+
1126
+ # Count I3 violations and collision events from events.log
1127
+ all_events = _read_events_log(gcc_repo)
1128
+ i3_violations = sum(
1129
+ 1 for ev in all_events
1130
+ if ev.get("event_type", ev.get("type", "")) == "INVARIANT_VIOLATION"
1131
+ and "I3" in str(ev.get("invariant", ev.get("detail", "")))
1132
+ )
1133
+ # Broaden to any INVARIANT_VIOLATION if no I3-specific found
1134
+ if i3_violations == 0:
1135
+ i3_violations = sum(
1136
+ 1 for ev in all_events
1137
+ if ev.get("event_type", ev.get("type", "")) == "INVARIANT_VIOLATION"
1138
+ )
1139
+ collisions_detected = sum(
1140
+ 1 for ev in all_events
1141
+ if ev.get("event_type", ev.get("type", "")) == "VARIANCE_ALERT"
1142
+ )
1143
+
1144
+ top_concepts = _read_theta(gcc_repo)[:5]
1145
+
1146
+ return SprintSummaryModel(
1147
+ total_commits=total_commits,
1148
+ commits_per_branch=commits_per_branch,
1149
+ i3_violations=i3_violations,
1150
+ collisions_detected=collisions_detected,
1151
+ top_concepts=top_concepts,
1152
+ mcs_note="MCS weights are DevTorch design choices pending empirical calibration",
1153
+ )
1154
+
1155
+
1156
+ def _build_audit_json(gcc_repo: Path, since: str = "") -> AuditExportMetaModel:
1157
+ """Build the audit export JSON payload."""
1158
+ all_events = _read_events_log(gcc_repo)
1159
+
1160
+ # Apply since filter
1161
+ if since:
1162
+ filtered: List[Dict[str, Any]] = []
1163
+ for ev in all_events:
1164
+ ts = ev.get("ts", ev.get("timestamp", ev.get("created_at", "")))
1165
+ if ts >= since:
1166
+ filtered.append(ev)
1167
+ all_events = filtered
1168
+
1169
+ # Read version
1170
+ version_file = _gcc_dir(gcc_repo) / "VERSION"
1171
+ gcc_version = "unknown"
1172
+ if version_file.exists():
1173
+ try:
1174
+ gcc_version = version_file.read_text(encoding="utf-8").strip()
1175
+ except OSError:
1176
+ pass
1177
+
1178
+ return AuditExportMetaModel(
1179
+ exported_at=_dt.datetime.now(tz=_dt.timezone.utc).isoformat(),
1180
+ gcc_version=gcc_version,
1181
+ event_count=len(all_events),
1182
+ events=all_events,
1183
+ )
1184
+
1185
+
1186
+ def _build_audit_text(gcc_repo: Path, since: str = "") -> str:
1187
+ """Build a human-readable plain-text audit report."""
1188
+ export = _build_audit_json(gcc_repo, since=since)
1189
+ status = _read_gcc_status(gcc_repo)
1190
+ branches = _read_branches(gcc_repo)
1191
+ sensitivity_events = _read_sensitivities(gcc_repo, limit=200)
1192
+
1193
+ now_str = _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1194
+ lines: List[str] = []
1195
+
1196
+ # Header
1197
+ lines.append("=" * 72)
1198
+ lines.append(" DEVTORCH AUDIT REPORT")
1199
+ lines.append(f" Generated: {now_str}")
1200
+ lines.append(f" GCC Version: {export.gcc_version}")
1201
+ lines.append("=" * 72)
1202
+ lines.append("")
1203
+ lines.append(
1204
+ "DISCLAIMER: This report was generated by DevTorch OSS. "
1205
+ "Weights and scores are design choices pending empirical calibration."
1206
+ )
1207
+ lines.append("")
1208
+
1209
+ # Repository Info
1210
+ lines.append("-" * 72)
1211
+ lines.append("REPOSITORY INFO")
1212
+ lines.append("-" * 72)
1213
+ lines.append(f" Current Branch : {status.branch}")
1214
+ lines.append(f" Node State : {status.nodeState}")
1215
+ lines.append(f" Last Commit ID : {status.lastCommitId or '(none)'}")
1216
+ lines.append(f" Locked : {'YES' if status.locked else 'NO'}")
1217
+ if status.lockReason:
1218
+ lines.append(f" Lock Reason : {status.lockReason}")
1219
+ lines.append("")
1220
+
1221
+ # Branch Summary
1222
+ lines.append("-" * 72)
1223
+ lines.append("BRANCH SUMMARY")
1224
+ lines.append("-" * 72)
1225
+ if branches:
1226
+ for br in branches:
1227
+ lines.append(f" {br.name:<20} commits={br.commitCount:<5} tip={br.tipCommitId[:12] if br.tipCommitId else '(empty)'}")
1228
+ else:
1229
+ lines.append(" (no branches)")
1230
+ lines.append("")
1231
+
1232
+ # Event Summary
1233
+ lines.append("-" * 72)
1234
+ lines.append("EVENT SUMMARY")
1235
+ lines.append("-" * 72)
1236
+ lines.append(f" Total events in export: {export.event_count}")
1237
+ if since:
1238
+ lines.append(f" Filtered since: {since}")
1239
+
1240
+ # Group by type
1241
+ type_counts: Dict[str, int] = {}
1242
+ for ev in export.events:
1243
+ etype = ev.get("event_type", ev.get("type", "UNKNOWN"))
1244
+ type_counts[etype] = type_counts.get(etype, 0) + 1
1245
+ if type_counts:
1246
+ lines.append("")
1247
+ lines.append(" Event type breakdown:")
1248
+ for etype, cnt in sorted(type_counts.items()):
1249
+ lines.append(f" {etype:<35} {cnt}")
1250
+ lines.append("")
1251
+
1252
+ # Sensitivity Events
1253
+ lines.append("-" * 72)
1254
+ lines.append("SENSITIVITY EVENTS (most recent 50)")
1255
+ lines.append("-" * 72)
1256
+ if sensitivity_events:
1257
+ for ev in sensitivity_events[:50]:
1258
+ lines.append(
1259
+ f" [{ev.timestamp[:19]}] L{ev.disclosureLevel} {ev.concept:<25} {ev.signal[:60]}"
1260
+ )
1261
+ else:
1262
+ lines.append(" (no sensitivity events)")
1263
+ lines.append("")
1264
+
1265
+ # Commit History
1266
+ lines.append("-" * 72)
1267
+ lines.append("COMMIT HISTORY")
1268
+ lines.append("-" * 72)
1269
+ commit_ids = _read_commit_index(gcc_repo)
1270
+ if commit_ids:
1271
+ commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
1272
+ for cid in commit_ids[-50:]: # last 50
1273
+ cfile = commits_dir / f"{cid}.json"
1274
+ if cfile.exists():
1275
+ try:
1276
+ obj = json.loads(cfile.read_text(encoding="utf-8"))
1277
+ msg = str(obj.get("message", ""))[:60]
1278
+ br = obj.get("branch", "?")
1279
+ ts = str(obj.get("created_at", ""))[:19]
1280
+ lines.append(f" {cid[:12]} [{ts}] ({br}) {msg}")
1281
+ except (json.JSONDecodeError, OSError):
1282
+ lines.append(f" {cid[:12]} (unreadable)")
1283
+ else:
1284
+ lines.append(" (no commits)")
1285
+ lines.append("")
1286
+
1287
+ # Invariant Status
1288
+ lines.append("-" * 72)
1289
+ lines.append("INVARIANT STATUS")
1290
+ lines.append("-" * 72)
1291
+ conformance = _read_conformance(gcc_repo)
1292
+ lines.append(f" I1 Commit-Backed : {'PASS' if conformance.I1_commit_backed else 'FAIL'}")
1293
+ lines.append(f" I3 Semantic Ground : {'PASS' if conformance.I3_semantic_grounding else 'FAIL'}")
1294
+ lines.append(f" A1 Capability Gate : {'PASS' if conformance.A1_capability_gate else 'FAIL'}")
1295
+ lines.append(f" A2 Variance Control : {'PASS' if conformance.A2_variance_control else 'FAIL'}")
1296
+ lines.append(f" A3 SIS Quarantine : {'PASS' if conformance.A3_sis_quarantine else 'FAIL'}")
1297
+ lines.append(f" A4 Deltaf Bound : {'PASS' if conformance.A4_deltaf_bound else 'FAIL'}")
1298
+ lines.append("")
1299
+ lines.append("=" * 72)
1300
+ lines.append("END OF REPORT")
1301
+ lines.append("=" * 72)
1302
+
1303
+ return "\n".join(lines)
1304
+
1305
+
1306
+ def _read_mcs_trend(gcc_repo: Path, days: int = 7) -> List[MCSTrendPointModel]:
1307
+ """
1308
+ Compute a per-day confidence average from sensitivity events for the last
1309
+ `days` days. Used as a proxy for MCS trend until sprint-level MCS scores
1310
+ are persisted by `devtorch merge`.
1311
+ """
1312
+ events_file = _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
1313
+ today = _dt.date.today()
1314
+
1315
+ # Build a bucket per day: date_str → [confidence, ...]
1316
+ buckets: Dict[str, List[float]] = {}
1317
+ for i in range(days):
1318
+ day = (today - _dt.timedelta(days=days - 1 - i)).isoformat()
1319
+ buckets[day] = []
1320
+
1321
+ if events_file.exists():
1322
+ try:
1323
+ with events_file.open("r", encoding="utf-8") as fh:
1324
+ for line in fh:
1325
+ line = line.strip()
1326
+ if not line:
1327
+ continue
1328
+ try:
1329
+ ev = json.loads(line)
1330
+ except json.JSONDecodeError:
1331
+ continue
1332
+ ts_raw = ev.get("created_at", ev.get("timestamp", ""))
1333
+ if not ts_raw:
1334
+ continue
1335
+ try:
1336
+ day_str = ts_raw[:10] # "2026-04-19"
1337
+ except (TypeError, IndexError):
1338
+ continue
1339
+ if day_str not in buckets:
1340
+ continue
1341
+ conf = ev.get("confidence")
1342
+ if conf is not None:
1343
+ try:
1344
+ buckets[day_str].append(float(conf))
1345
+ except (TypeError, ValueError):
1346
+ pass
1347
+ except OSError:
1348
+ pass
1349
+
1350
+ result: List[MCSTrendPointModel] = []
1351
+ for day_str, confs in buckets.items():
1352
+ mcs = (sum(confs) / len(confs)) if confs else None
1353
+ result.append(MCSTrendPointModel(date=day_str, mcs=mcs, eventCount=len(confs)))
1354
+ return result
1355
+
1356
+
1357
+ # ---------------------------------------------------------------------------
1358
+ # S27: Concept drift heatmap
1359
+ # ---------------------------------------------------------------------------
1360
+
1361
+ CONCEPT_DRIFT_MAX_CONCEPTS = 20
1362
+
1363
+
1364
+ def _compute_concept_drift(
1365
+ gcc_repo: Path,
1366
+ days: int = 14,
1367
+ ) -> ConceptDriftResponseModel:
1368
+ """
1369
+ Compute per-concept confidence drift over the last `days` days.
1370
+
1371
+ Drift is defined as the absolute change in mean confidence for a concept
1372
+ between two consecutive day buckets. The first populated bucket for each
1373
+ concept has drift 0.0. Empty buckets inherit the previous mean so that
1374
+ drift resumes when events reappear.
1375
+ """
1376
+ events_file = _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
1377
+ today = _dt.date.today()
1378
+
1379
+ time_labels: List[str] = []
1380
+ for i in range(days):
1381
+ day = (today - _dt.timedelta(days=days - 1 - i)).isoformat()
1382
+ time_labels.append(day)
1383
+
1384
+ # concept -> day -> list of confidence values
1385
+ raw_buckets: Dict[str, Dict[str, List[float]]] = {}
1386
+ if events_file.exists():
1387
+ try:
1388
+ with events_file.open("r", encoding="utf-8") as fh:
1389
+ for line in fh:
1390
+ line = line.strip()
1391
+ if not line:
1392
+ continue
1393
+ try:
1394
+ ev = json.loads(line)
1395
+ except json.JSONDecodeError:
1396
+ continue
1397
+ concept = ev.get("target_concept", ev.get("concept", ""))
1398
+ if not concept:
1399
+ continue
1400
+ ts = ev.get("created_at", ev.get("timestamp", ""))
1401
+ if not ts:
1402
+ continue
1403
+ day_str = ts[:10]
1404
+ if day_str not in time_labels:
1405
+ continue
1406
+ conf = ev.get("confidence")
1407
+ if conf is None:
1408
+ continue
1409
+ try:
1410
+ conf_val = float(conf)
1411
+ except (TypeError, ValueError):
1412
+ continue
1413
+ raw_buckets.setdefault(concept, {}).setdefault(day_str, []).append(conf_val)
1414
+ except OSError:
1415
+ pass
1416
+
1417
+ # Keep the top concepts by total event volume
1418
+ concept_counts = {
1419
+ concept: sum(len(vals) for vals in days.values())
1420
+ for concept, days in raw_buckets.items()
1421
+ }
1422
+ concepts = sorted(
1423
+ concept_counts.keys(),
1424
+ key=lambda c: concept_counts[c],
1425
+ reverse=True,
1426
+ )[:CONCEPT_DRIFT_MAX_CONCEPTS]
1427
+
1428
+ matrix: List[List[float]] = []
1429
+ mean_confidences: List[List[float]] = []
1430
+ event_counts: List[List[int]] = []
1431
+
1432
+ for concept in concepts:
1433
+ days_for_concept = raw_buckets.get(concept, {})
1434
+ means: List[Optional[float]] = []
1435
+ counts: List[int] = []
1436
+ for day in time_labels:
1437
+ vals = days_for_concept.get(day, [])
1438
+ if vals:
1439
+ means.append(sum(vals) / len(vals))
1440
+ counts.append(len(vals))
1441
+ else:
1442
+ means.append(None)
1443
+ counts.append(0)
1444
+
1445
+ drifts: List[float] = []
1446
+ prev_mean: Optional[float] = None
1447
+ for mean in means:
1448
+ if mean is None:
1449
+ drifts.append(0.0)
1450
+ else:
1451
+ if prev_mean is None:
1452
+ drifts.append(0.0)
1453
+ else:
1454
+ drifts.append(abs(mean - prev_mean))
1455
+ prev_mean = mean
1456
+
1457
+ matrix.append(drifts)
1458
+ mean_confidences.append([m if m is not None else 0.0 for m in means])
1459
+ event_counts.append(counts)
1460
+
1461
+ return ConceptDriftResponseModel(
1462
+ concepts=concepts,
1463
+ time_labels=time_labels,
1464
+ matrix=matrix,
1465
+ mean_confidences=mean_confidences,
1466
+ event_counts=event_counts,
1467
+ )
1468
+
1469
+
1470
+ # ---------------------------------------------------------------------------
1471
+ # S29: SSE broadcaster and watcher helpers
1472
+ # ---------------------------------------------------------------------------
1473
+
1474
+ _broadcaster: Optional[EventBroadcaster] = None
1475
+ _watcher: Optional[GCCWatcher] = None
1476
+
1477
+
1478
+ def _ensure_broadcaster(gcc_repo: Path) -> EventBroadcaster:
1479
+ """Return a global EventBroadcaster and start a GCCWatcher for this repo."""
1480
+ global _broadcaster, _watcher
1481
+ if _broadcaster is None:
1482
+ _broadcaster = EventBroadcaster()
1483
+ if _watcher is None and _is_initialized(gcc_repo):
1484
+ _watcher = GCCWatcher(_gcc_dir(gcc_repo), _broadcaster, poll_interval=0.5)
1485
+ _watcher.start()
1486
+ return _broadcaster
1487
+
1488
+
1489
+ def _stop_broadcaster() -> None:
1490
+ """Stop the global watcher (useful for tests)."""
1491
+ global _watcher
1492
+ if _watcher is not None:
1493
+ _watcher.stop()
1494
+ _watcher = None
1495
+
1496
+
1497
+ # ---------------------------------------------------------------------------
1498
+ # S29: Sprint metrics helpers
1499
+ # ---------------------------------------------------------------------------
1500
+
1501
+
1502
+ def _read_latest_sprint_metrics(gcc_repo: Path) -> Optional[Dict[str, Any]]:
1503
+ """Return the most recent sprint metrics file, or None if none exist."""
1504
+ sprint_dir = _gcc_dir(gcc_repo) / "metrics" / "sprint"
1505
+ if not sprint_dir.exists():
1506
+ return None
1507
+ files = sorted(
1508
+ (p for p in sprint_dir.iterdir() if p.suffix == ".json"),
1509
+ key=lambda p: p.name,
1510
+ )
1511
+ if not files:
1512
+ return None
1513
+ try:
1514
+ data = json.loads(files[-1].read_text(encoding="utf-8"))
1515
+ if isinstance(data, dict):
1516
+ return data
1517
+ except (json.JSONDecodeError, OSError):
1518
+ pass
1519
+ return None
1520
+
1521
+
1522
+ def _list_sprint_metrics(gcc_repo: Path) -> List[Dict[str, Any]]:
1523
+ """Return all sprint metrics files sorted by sprint_id."""
1524
+ sprint_dir = _gcc_dir(gcc_repo) / "metrics" / "sprint"
1525
+ if not sprint_dir.exists():
1526
+ return []
1527
+ results: List[Dict[str, Any]] = []
1528
+ for path in sorted(p for p in sprint_dir.iterdir() if p.suffix == ".json"):
1529
+ try:
1530
+ data = json.loads(path.read_text(encoding="utf-8"))
1531
+ if isinstance(data, dict):
1532
+ results.append(data)
1533
+ except (json.JSONDecodeError, OSError):
1534
+ continue
1535
+ return results
1536
+
1537
+
1538
+ # ---------------------------------------------------------------------------
1539
+ # S29: Delivery-time trend helper
1540
+ # ---------------------------------------------------------------------------
1541
+
1542
+
1543
+ def _session_effective_date(session_path: Path) -> Optional[str]:
1544
+ """Return ISO date for a session file (created_at field, then mtime)."""
1545
+ try:
1546
+ data = json.loads(session_path.read_text(encoding="utf-8"))
1547
+ if isinstance(data, dict):
1548
+ created_at = data.get("created_at")
1549
+ if created_at:
1550
+ return str(created_at)[:10]
1551
+ except (json.JSONDecodeError, OSError):
1552
+ pass
1553
+ try:
1554
+ mtime = session_path.stat().st_mtime
1555
+ return _dt.datetime.fromtimestamp(mtime, tz=_dt.timezone.utc).date().isoformat()
1556
+ except OSError:
1557
+ return None
1558
+
1559
+
1560
+ def _compute_delivery_time_trend(
1561
+ gcc_repo: Path, days: int = 30
1562
+ ) -> List[DeliveryTimeTrendPointModel]:
1563
+ """
1564
+ Return a per-day delivery-time trend over the last N days.
1565
+
1566
+ Each point uses the team calibration baseline and all sessions whose
1567
+ effective date is <= that day. Points are returned oldest-first.
1568
+ """
1569
+ metrics_dir = _gcc_dir(gcc_repo) / "metrics"
1570
+ session_dir = metrics_dir / "session"
1571
+ sessions: List[Dict[str, Any]] = []
1572
+ if session_dir.exists():
1573
+ for path in session_dir.glob("*.json"):
1574
+ try:
1575
+ data = json.loads(path.read_text(encoding="utf-8"))
1576
+ if isinstance(data, dict):
1577
+ data = dict(data)
1578
+ if "created_at" not in data:
1579
+ mdate = _session_effective_date(path)
1580
+ if mdate:
1581
+ data["created_at"] = f"{mdate}T00:00:00Z"
1582
+ sessions.append(data)
1583
+ except (json.JSONDecodeError, OSError):
1584
+ continue
1585
+
1586
+ # Load calibration from .GCC/metrics/calibration.json if present
1587
+ calibration: Dict[str, Any] = {}
1588
+ calibration_path = metrics_dir / "calibration.json"
1589
+ if calibration_path.exists():
1590
+ try:
1591
+ calibration = json.loads(calibration_path.read_text(encoding="utf-8"))
1592
+ if not isinstance(calibration, dict):
1593
+ calibration = {}
1594
+ except (json.JSONDecodeError, OSError):
1595
+ calibration = {}
1596
+
1597
+ baseline = float(calibration.get("complex_problem_delivery_time_baseline_min", 240.0))
1598
+
1599
+ today = _dt.date.today()
1600
+ time_labels: List[str] = []
1601
+ for i in range(days):
1602
+ day = (today - _dt.timedelta(days=days - 1 - i)).isoformat()
1603
+ time_labels.append(day)
1604
+
1605
+ # Group sessions by effective date
1606
+ sessions_by_day: Dict[str, List[Dict[str, Any]]] = {}
1607
+ for s in sessions:
1608
+ created_at = s.get("created_at", "")
1609
+ day = str(created_at)[:10] if created_at else ""
1610
+ if day:
1611
+ sessions_by_day.setdefault(day, []).append(s)
1612
+
1613
+ cumulative_sessions: List[Dict[str, Any]] = []
1614
+ result: List[DeliveryTimeTrendPointModel] = []
1615
+ for day in time_labels:
1616
+ cumulative_sessions.extend(sessions_by_day.get(day, []))
1617
+ # Build a synthetic metrics_dir with only cumulative sessions
1618
+ summary = compute_delivery_time_summary(
1619
+ metrics_dir,
1620
+ calibration,
1621
+ )
1622
+ # Override session count and recompute current based on cumulative sessions
1623
+ total_tokens = sum(s.get("tokens_used", 0) for s in cumulative_sessions)
1624
+ session_count = len(cumulative_sessions)
1625
+ complexity = 1.0 + (total_tokens / 100_000.0)
1626
+ current = baseline * complexity * (1.0 + session_count * 0.05)
1627
+ result.append(
1628
+ DeliveryTimeTrendPointModel(
1629
+ date=day,
1630
+ current_min=round(current, 2),
1631
+ baseline_min=round(baseline, 2),
1632
+ delta_min=round(current - baseline, 2),
1633
+ )
1634
+ )
1635
+ return result
1636
+
1637
+
1638
+ # ---------------------------------------------------------------------------
1639
+ # FastAPI application factory
1640
+ # ---------------------------------------------------------------------------
1641
+
1642
+ def create_app(gcc_repo: Optional[Path] = None) -> "FastAPI":
1643
+ """
1644
+ Build and return a FastAPI application instance.
1645
+
1646
+ Parameters
1647
+ ----------
1648
+ gcc_repo:
1649
+ Path to the project root containing the `.GCC/` directory.
1650
+ Falls back to the current working directory when None.
1651
+ """
1652
+ if not _FASTAPI_AVAILABLE:
1653
+ raise ImportError(
1654
+ "FastAPI and uvicorn are required for the dashboard API. "
1655
+ "Install them with: pip install fastapi uvicorn"
1656
+ )
1657
+
1658
+ resolved_repo: Path = gcc_repo if gcc_repo is not None else Path.cwd()
1659
+
1660
+ app = FastAPI(
1661
+ title="DevTorch Dashboard API",
1662
+ description="Read-only REST API serving DevTorch governance state from .GCC/",
1663
+ version="0.1.0",
1664
+ )
1665
+
1666
+ app.add_middleware(
1667
+ CORSMiddleware,
1668
+ allow_origins=["*"], # No auth in S13; tightened in enterprise tier
1669
+ allow_credentials=False,
1670
+ allow_methods=["GET"],
1671
+ allow_headers=["*"],
1672
+ )
1673
+
1674
+ # Start the global SSE broadcaster/watcher for this repo.
1675
+ _ensure_broadcaster(resolved_repo)
1676
+
1677
+ def _require_init() -> Path:
1678
+ if not _is_initialized(resolved_repo):
1679
+ raise HTTPException(
1680
+ status_code=503,
1681
+ detail=(
1682
+ f"No initialized .GCC/ directory found at '{resolved_repo}'. "
1683
+ "Run `devtorch init` in the project root first."
1684
+ ),
1685
+ )
1686
+ return resolved_repo
1687
+
1688
+ @app.get("/api/v1/dashboard", response_model=DashboardDataModel)
1689
+ def get_dashboard() -> DashboardDataModel:
1690
+ """Return a full governance snapshot."""
1691
+ repo = _require_init()
1692
+ return _read_dashboard(repo)
1693
+
1694
+ @app.get("/api/v1/branches", response_model=List[BranchSummaryModel])
1695
+ def get_branches() -> List[BranchSummaryModel]:
1696
+ """Return a list of branch summaries."""
1697
+ repo = _require_init()
1698
+ return _read_branches(repo)
1699
+
1700
+ @app.get("/api/v1/sensitivities", response_model=List[SensitivityEventModel])
1701
+ def get_sensitivities(
1702
+ limit: int = Query(default=50, ge=1, le=500),
1703
+ concept: Optional[str] = Query(default=None),
1704
+ ) -> List[SensitivityEventModel]:
1705
+ """Return sensitivity events, newest first. Filter by concept with ?concept=auth."""
1706
+ repo = _require_init()
1707
+ return _read_sensitivities(repo, limit=limit, concept=concept)
1708
+
1709
+ @app.get("/api/v1/concepts/drift", response_model=ConceptDriftResponseModel)
1710
+ def get_concept_drift(
1711
+ days: int = Query(default=14, ge=1, le=90),
1712
+ ) -> ConceptDriftResponseModel:
1713
+ """Per-concept confidence drift heatmap over the last N days."""
1714
+ repo = _require_init()
1715
+ return _compute_concept_drift(repo, days=days)
1716
+
1717
+ @app.get("/api/v1/ciso", response_model=CISODataModel)
1718
+ def get_ciso_data() -> CISODataModel:
1719
+ """CISO-focused governance metrics."""
1720
+ # CISO endpoint returns safe defaults for uninitialized repos — never 503
1721
+ return _read_ciso_data(resolved_repo)
1722
+
1723
+ @app.get("/api/v1/shadow-ai", response_model=ShadowAIModel)
1724
+ def get_shadow_ai() -> ShadowAIModel:
1725
+ """Shadow AI detection: compare git commits to .GCC COMMIT events."""
1726
+ from devtorch_core.metrics.shadow_ai import detect_shadow_ai
1727
+ result = detect_shadow_ai(resolved_repo)
1728
+ return ShadowAIModel(
1729
+ git_commits=result["git_commits"],
1730
+ gcc_commits=result["gcc_commits"],
1731
+ unregistered=result["unregistered"],
1732
+ shadow_ratio=result["shadow_ratio"],
1733
+ flagged=result["flagged"],
1734
+ )
1735
+
1736
+ @app.get("/api/v1/compliance-posture", response_model=CompliancePostureModel)
1737
+ def get_compliance_posture() -> CompliancePostureModel:
1738
+ """Compliance posture summary for CISO audit."""
1739
+ return _read_compliance_posture(resolved_repo)
1740
+
1741
+ @app.get("/api/v1/governance-policy", response_model=GovernancePolicyModel)
1742
+ def get_governance_policy() -> GovernancePolicyModel:
1743
+ """Read-only governance policy console for the project."""
1744
+ return _read_governance_policy(resolved_repo)
1745
+
1746
+ @app.get("/api/v1/org/dependencies", response_model=OrgDependencyGraphModel)
1747
+ def get_org_dependencies() -> OrgDependencyGraphModel:
1748
+ """Cross-project concept coupling graph."""
1749
+ return _read_org_dependency_graph()
1750
+
1751
+ @app.get("/api/v1/health")
1752
+ def get_health() -> dict:
1753
+ """Simple health probe."""
1754
+ initialized = _is_initialized(resolved_repo)
1755
+ return {
1756
+ "status": "ok" if initialized else "degraded",
1757
+ "initialized": initialized,
1758
+ "gcc_repo": str(resolved_repo),
1759
+ }
1760
+
1761
+ # -----------------------------------------------------------------------
1762
+ # S19: Multi-project endpoints
1763
+ # -----------------------------------------------------------------------
1764
+
1765
+ @app.get("/api/v1/projects", response_model=List[ProjectSummaryModel])
1766
+ def get_projects() -> List[ProjectSummaryModel]:
1767
+ """List all projects in the registry with health summary."""
1768
+ registry = ProjectRegistry()
1769
+ out: List[ProjectSummaryModel] = []
1770
+ for entry in registry.list():
1771
+ gcc_dir = Path(entry.path) / ".GCC"
1772
+ if not gcc_dir.exists():
1773
+ continue
1774
+ try:
1775
+ status = _read_gcc_status(gcc_dir)
1776
+ except Exception:
1777
+ continue
1778
+ tokens = aggregate_tokens(gcc_dir)
1779
+ speedup = aggregate_speedup(gcc_dir)
1780
+ health_score = _compute_health_score(gcc_dir)
1781
+ out.append(ProjectSummaryModel(
1782
+ name=entry.name,
1783
+ path=entry.path,
1784
+ branch=status.branch,
1785
+ lastCommitId=status.lastCommitId,
1786
+ lastActivity=entry.last_synced[:19],
1787
+ tokensUsed30d=tokens["total_tokens_used"],
1788
+ tokensSaved30d=tokens["total_tokens_saved"],
1789
+ latencySpeedup=speedup["latency_speedup_factor"],
1790
+ healthScore=health_score,
1791
+ healthLabel=_health_label(health_score),
1792
+ ))
1793
+ return out
1794
+
1795
+ @app.get("/api/v1/projects/savings", response_model=ProjectsSavingsModel)
1796
+ def get_projects_savings() -> ProjectsSavingsModel:
1797
+ """Cross-project aggregate of tokens saved and speedup."""
1798
+ registry = ProjectRegistry()
1799
+ projects: List[ProjectSummaryModel] = []
1800
+ total_used = 0
1801
+ total_saved = 0
1802
+ total_full = 0
1803
+ total_bundle = 0
1804
+ for entry in registry.list():
1805
+ gcc_dir = Path(entry.path) / ".GCC"
1806
+ if not gcc_dir.exists():
1807
+ continue
1808
+ try:
1809
+ status = _read_gcc_status(gcc_dir)
1810
+ except Exception:
1811
+ continue
1812
+ tokens = aggregate_tokens(gcc_dir)
1813
+ speedup = aggregate_speedup(gcc_dir)
1814
+ total_used += tokens["total_tokens_used"]
1815
+ total_saved += tokens["total_tokens_saved"]
1816
+ total_full += speedup["total_full_history_tokens"]
1817
+ total_bundle += speedup["total_bundle_tokens"]
1818
+ projects.append(ProjectSummaryModel(
1819
+ name=entry.name,
1820
+ path=entry.path,
1821
+ branch=status.branch,
1822
+ lastCommitId=status.lastCommitId,
1823
+ lastActivity=entry.last_synced[:19],
1824
+ tokensUsed30d=tokens["total_tokens_used"],
1825
+ tokensSaved30d=tokens["total_tokens_saved"],
1826
+ latencySpeedup=speedup["latency_speedup_factor"],
1827
+ healthScore=_compute_health_score(gcc_dir),
1828
+ healthLabel=_health_label(_compute_health_score(gcc_dir)),
1829
+ ))
1830
+ weighted_speedup = total_full / total_bundle if total_bundle > 0 else 1.0
1831
+ return ProjectsSavingsModel(
1832
+ projects=projects,
1833
+ aggregateTokensUsed=total_used,
1834
+ aggregateTokensSaved=total_saved,
1835
+ aggregateLatencySpeedup=weighted_speedup,
1836
+ )
1837
+
1838
+ # -----------------------------------------------------------------------
1839
+ # S13: Forensic Replay endpoints
1840
+ # -----------------------------------------------------------------------
1841
+
1842
+ @app.get("/api/v1/forensic/timeline", response_model=ForensicTimelineModel)
1843
+ def get_forensic_timeline(
1844
+ branch: str = Query(default=""),
1845
+ limit: int = Query(default=100, ge=1, le=1000),
1846
+ event_types: str = Query(default=""),
1847
+ ) -> ForensicTimelineModel:
1848
+ """Forensic event timeline from events.log.jsonl."""
1849
+ return _read_forensic_timeline(
1850
+ resolved_repo,
1851
+ limit=limit,
1852
+ event_types_filter=event_types,
1853
+ branch_filter=branch,
1854
+ )
1855
+
1856
+ @app.get("/api/v1/forensic/trace/{commit_id}")
1857
+ def get_forensic_trace(commit_id: str) -> dict:
1858
+ """Return commit + parent chain + sensitivity events between parent and this commit."""
1859
+ chain = _read_commit_chain(resolved_repo, commit_id)
1860
+ if not chain:
1861
+ return {"commit": {}, "parents": [], "sensitivities": [], "invariant_violations": []}
1862
+
1863
+ commit = chain[0]
1864
+ parents = chain[1:]
1865
+
1866
+ child_ts = str(commit.get("created_at", commit.get("ts", "")))
1867
+ parent_ts = str(parents[0].get("created_at", parents[0].get("ts", ""))) if parents else ""
1868
+
1869
+ sensitivities = _read_sensitivity_events_between(resolved_repo, parent_ts, child_ts)
1870
+ invariant_violations = _read_invariant_violations_between(resolved_repo, parent_ts, child_ts)
1871
+
1872
+ return {
1873
+ "commit": commit,
1874
+ "parents": parents,
1875
+ "sensitivities": sensitivities,
1876
+ "invariant_violations": invariant_violations,
1877
+ }
1878
+
1879
+ @app.get("/api/v1/collisions", response_model=List[CollisionModel])
1880
+ def get_collisions() -> List[CollisionModel]:
1881
+ """Return concept collisions where agent confidence diverges (std_dev > 0.2)."""
1882
+ return _read_collision_list(resolved_repo)
1883
+
1884
+ @app.get("/api/v1/sprint/summary", response_model=SprintSummaryModel)
1885
+ def get_sprint_summary() -> SprintSummaryModel:
1886
+ """Sprint review: commit stats, invariant violations, collisions, top concepts."""
1887
+ return _read_sprint_summary(resolved_repo)
1888
+
1889
+ @app.get("/api/v1/mcs/trend", response_model=List[MCSTrendPointModel])
1890
+ def get_mcs_trend(days: int = Query(default=7, ge=1, le=30)) -> List[MCSTrendPointModel]:
1891
+ """Per-day average AI confidence for the last N days (proxy for MCS trend)."""
1892
+ return _read_mcs_trend(resolved_repo, days=days)
1893
+
1894
+ # -----------------------------------------------------------------------
1895
+ # S29: Real-time SSE and auto sprint metrics
1896
+ # -----------------------------------------------------------------------
1897
+
1898
+ @app.get("/api/v1/events/stream")
1899
+ async def get_event_stream() -> StreamingResponse:
1900
+ """SSE stream of coordination events from the global broadcaster."""
1901
+ broadcaster = _ensure_broadcaster(resolved_repo)
1902
+ q = broadcaster.subscribe()
1903
+
1904
+ async def generate():
1905
+ try:
1906
+ # Initial connected event
1907
+ yield "data: " + json.dumps({"type": "connected"}) + "\n\n"
1908
+ while True:
1909
+ try:
1910
+ event = await asyncio.wait_for(q.get(), timeout=30.0)
1911
+ yield "data: " + json.dumps(event) + "\n\n"
1912
+ except asyncio.TimeoutError:
1913
+ yield ": keepalive\n\n"
1914
+ finally:
1915
+ broadcaster.unsubscribe(q)
1916
+
1917
+ return StreamingResponse(generate(), media_type="text/event-stream")
1918
+
1919
+ @app.get("/api/v1/sprint/latest", response_model=Optional[SprintMetricsModel])
1920
+ def get_sprint_latest() -> Optional[SprintMetricsModel]:
1921
+ """Return the most recent sprint metrics file."""
1922
+ data = _read_latest_sprint_metrics(resolved_repo)
1923
+ if data is None:
1924
+ return None
1925
+ return SprintMetricsModel(**data)
1926
+
1927
+ @app.get("/api/v1/sprint/metrics", response_model=List[SprintMetricsModel])
1928
+ def get_sprint_metrics() -> List[SprintMetricsModel]:
1929
+ """Return all sprint metrics files, sorted by sprint_id."""
1930
+ items = _list_sprint_metrics(resolved_repo)
1931
+ return [SprintMetricsModel(**d) for d in items]
1932
+
1933
+ @app.get("/api/v1/metrics/delivery-time/trend", response_model=List[DeliveryTimeTrendPointModel])
1934
+ def get_delivery_time_trend(
1935
+ days: int = Query(default=30, ge=1, le=90),
1936
+ ) -> List[DeliveryTimeTrendPointModel]:
1937
+ """Per-day complex-problem delivery-time trend over the last N days."""
1938
+ return _compute_delivery_time_trend(resolved_repo, days=days)
1939
+
1940
+ # -----------------------------------------------------------------------
1941
+ # S14: Audit Export endpoints
1942
+ # -----------------------------------------------------------------------
1943
+
1944
+ @app.get("/api/v1/audit/export.json")
1945
+ def get_audit_export_json(since: str = Query(default="")) -> JSONResponse:
1946
+ """Download all events as a JSON audit export."""
1947
+ export = _build_audit_json(resolved_repo, since=since)
1948
+ date_str = _dt.date.today().isoformat()
1949
+ return JSONResponse(
1950
+ content=export.model_dump(),
1951
+ headers={
1952
+ "Content-Disposition": f'attachment; filename="devtorch-audit-{date_str}.json"',
1953
+ },
1954
+ )
1955
+
1956
+ @app.get("/api/v1/audit/export.txt", response_class=PlainTextResponse)
1957
+ def get_audit_export_txt(
1958
+ since: str = Query(default=""),
1959
+ ) -> PlainTextResponse:
1960
+ """Download a human-readable plain-text audit report."""
1961
+ report = _build_audit_text(resolved_repo, since=since)
1962
+ date_str = _dt.date.today().isoformat()
1963
+ return PlainTextResponse(
1964
+ content=report,
1965
+ headers={
1966
+ "Content-Disposition": f'attachment; filename="devtorch-audit-{date_str}.txt"',
1967
+ },
1968
+ )
1969
+
1970
+ return app
1971
+
1972
+
1973
+ # ---------------------------------------------------------------------------
1974
+ # Launcher
1975
+ # ---------------------------------------------------------------------------
1976
+
1977
+ def run_dashboard_api(
1978
+ port: int = 8766,
1979
+ host: str = "127.0.0.1",
1980
+ gcc_repo: Optional[Path] = None,
1981
+ ) -> None:
1982
+ """
1983
+ Start the dashboard API server.
1984
+
1985
+ Parameters
1986
+ ----------
1987
+ port:
1988
+ TCP port to listen on (default 8766).
1989
+ host:
1990
+ Bind address (default 127.0.0.1; use 0.0.0.0 for external access).
1991
+ gcc_repo:
1992
+ Path to the project root. Defaults to CWD.
1993
+ """
1994
+ if not _FASTAPI_AVAILABLE:
1995
+ raise ImportError(
1996
+ "FastAPI and uvicorn are required. "
1997
+ "Install with: pip install fastapi uvicorn"
1998
+ )
1999
+ resolved = gcc_repo if gcc_repo is not None else Path.cwd()
2000
+ app = create_app(gcc_repo=resolved)
2001
+ uvicorn.run(app, host=host, port=port)
2002
+
2003
+
2004
+ if __name__ == "__main__": # pragma: no cover
2005
+ import argparse
2006
+
2007
+ parser = argparse.ArgumentParser(description="DevTorch Dashboard API server")
2008
+ parser.add_argument("--port", type=int, default=8766)
2009
+ parser.add_argument("--host", default="127.0.0.1")
2010
+ parser.add_argument("--repo", type=Path, default=Path.cwd())
2011
+ args = parser.parse_args()
2012
+ run_dashboard_api(port=args.port, host=args.host, gcc_repo=args.repo)