traceforge-toolkit 0.1.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 (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,181 @@
1
+ """Pipeline runner — resolves detected frameworks into runnable pipelines and multiplexes sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import AsyncIterator
10
+
11
+ from traceforge.config.models import (
12
+ ConsoleSinkConfig,
13
+ MappedJsonAdapterConfig,
14
+ SqliteSinkConfig,
15
+ )
16
+ from traceforge.sources.auto_detect import DetectedFramework
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ─── Mapping loader ──────────────────────────────────────────────────────────
22
+
23
+ _BUNDLED_MAPPINGS_DIR = Path(__file__).resolve().parent.parent / "mappings"
24
+
25
+
26
+ def load_mapping_path(name: str) -> Path:
27
+ """Resolve a mapping name to its YAML file path.
28
+
29
+ Searches bundled mappings directory. Raises FileNotFoundError if not found.
30
+ """
31
+ path = _BUNDLED_MAPPINGS_DIR / f"{name}.yaml"
32
+ if path.exists():
33
+ return path
34
+ raise FileNotFoundError(f"No mapping found for {name!r} (searched {_BUNDLED_MAPPINGS_DIR})")
35
+
36
+
37
+ # ─── Adapter resolution ─────────────────────────────────────────────────────
38
+
39
+ ADAPTER_MAP: dict[str, MappedJsonAdapterConfig] = {
40
+ "claude": MappedJsonAdapterConfig(type="mapped_json", mapping="claude"),
41
+ "codex": MappedJsonAdapterConfig(type="mapped_json", mapping="codex"),
42
+ "continue": MappedJsonAdapterConfig(type="mapped_json", mapping="continue_dev"),
43
+ "cline": MappedJsonAdapterConfig(type="mapped_json", mapping="cline"),
44
+ "goose": MappedJsonAdapterConfig(type="mapped_json", mapping="goose"),
45
+ "amazonq": MappedJsonAdapterConfig(type="mapped_json", mapping="amazonq"),
46
+ "aider_markdown": MappedJsonAdapterConfig(type="mapped_json", mapping="aider"),
47
+ }
48
+
49
+
50
+ # ─── Resolved pipeline config ───────────────────────────────────────────────
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ResolvedPipeline:
55
+ """A fully-resolved pipeline ready to run."""
56
+
57
+ name: str
58
+ source_path: Path
59
+ ingestion_mode: str
60
+ adapter: MappedJsonAdapterConfig
61
+ sinks: list = field(default_factory=list)
62
+
63
+
64
+ def resolve_pipelines(
65
+ detected: list[DetectedFramework],
66
+ explicit_source_paths: set[str] | None = None,
67
+ default_sinks: list | None = None,
68
+ ) -> list[ResolvedPipeline]:
69
+ """Map detected frameworks to runnable pipeline configs.
70
+
71
+ Args:
72
+ detected: Frameworks found by auto-detection.
73
+ explicit_source_paths: Paths already covered by explicit pipeline configs.
74
+ Auto-detected frameworks at these paths are skipped (explicit wins).
75
+ default_sinks: Sink configs to attach when none specified.
76
+
77
+ Returns:
78
+ List of resolved pipelines ready to instantiate.
79
+ """
80
+ explicit = explicit_source_paths or set()
81
+ sinks = default_sinks if default_sinks is not None else _default_sinks()
82
+ pipelines: list[ResolvedPipeline] = []
83
+
84
+ for fw in detected:
85
+ path_key = str(fw.path)
86
+ if path_key in explicit:
87
+ logger.debug("Skipping auto-detected %s (explicit pipeline exists)", fw.name)
88
+ continue
89
+
90
+ adapter = ADAPTER_MAP.get(fw.adapter)
91
+ if adapter is None:
92
+ logger.warning("No adapter mapping for framework %r (adapter=%r)", fw.name, fw.adapter)
93
+ continue
94
+
95
+ pipelines.append(
96
+ ResolvedPipeline(
97
+ name=fw.name,
98
+ source_path=fw.path,
99
+ ingestion_mode=fw.ingestion_mode,
100
+ adapter=adapter,
101
+ sinks=sinks,
102
+ )
103
+ )
104
+
105
+ return pipelines
106
+
107
+
108
+ def _default_sinks() -> list:
109
+ """Default sinks when user hasn't configured any."""
110
+ return [
111
+ SqliteSinkConfig(type="sqlite", path=Path.home() / ".traceforge" / "traceforge.db"),
112
+ ConsoleSinkConfig(type="console"),
113
+ ]
114
+
115
+
116
+ # ─── Source multiplexer ──────────────────────────────────────────────────────
117
+
118
+
119
+ async def watch_jsonl_file(path: Path, start_at: str = "end") -> AsyncIterator[str]:
120
+ """Watch a JSONL file for new lines using polling.
121
+
122
+ Yields raw JSON line strings as they appear.
123
+ """
124
+ if not path.exists():
125
+ logger.info("Waiting for file to appear: %s", path)
126
+ while not path.exists():
127
+ await asyncio.sleep(1.0)
128
+
129
+ offset = path.stat().st_size if start_at == "end" else 0
130
+
131
+ while True:
132
+ current_size = path.stat().st_size
133
+ if current_size > offset:
134
+ with open(path, "r", encoding="utf-8") as f:
135
+ f.seek(offset)
136
+ for line in f:
137
+ stripped = line.strip()
138
+ if stripped:
139
+ yield stripped
140
+ offset = f.tell()
141
+ await asyncio.sleep(0.5)
142
+
143
+
144
+ async def watch_directory(
145
+ directory: Path, pattern: str = "*.jsonl", start_at: str = "end"
146
+ ) -> AsyncIterator[tuple[Path, str]]:
147
+ """Watch a directory for new JSONL files and yield (path, line) tuples.
148
+
149
+ Monitors existing files for growth and picks up new files as they appear.
150
+ """
151
+ known_files: dict[Path, int] = {}
152
+
153
+ # Initial scan
154
+ if directory.exists():
155
+ for f in directory.rglob(pattern):
156
+ if f.is_file():
157
+ known_files[f] = f.stat().st_size if start_at == "end" else 0
158
+
159
+ while True:
160
+ # Check for new files
161
+ if directory.exists():
162
+ for f in directory.rglob(pattern):
163
+ if f.is_file() and f not in known_files:
164
+ known_files[f] = 0
165
+ logger.info("Discovered new file: %s", f)
166
+
167
+ # Read new content from all tracked files
168
+ for fpath, offset in list(known_files.items()):
169
+ if not fpath.exists():
170
+ continue
171
+ current_size = fpath.stat().st_size
172
+ if current_size > offset:
173
+ with open(fpath, "r", encoding="utf-8") as fh:
174
+ fh.seek(offset)
175
+ for line in fh:
176
+ stripped = line.strip()
177
+ if stripped:
178
+ yield (fpath, stripped)
179
+ known_files[fpath] = fh.tell()
180
+
181
+ await asyncio.sleep(1.0)
@@ -0,0 +1,217 @@
1
+ """Score API — lightweight HTTP server for preflight tool-call scoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from typing import TYPE_CHECKING
10
+
11
+ import click
12
+
13
+ if TYPE_CHECKING:
14
+ from traceforge.governance.pipeline import GovernancePipeline
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class _ScoreHandler(BaseHTTPRequestHandler):
20
+ """HTTP handler for POST /score endpoint."""
21
+
22
+ pipeline: "GovernancePipeline" # set on class before server starts
23
+
24
+ def do_POST(self) -> None:
25
+ if self.path != "/score":
26
+ self._respond(404, {"error": "not found"})
27
+ return
28
+
29
+ content_length = int(self.headers.get("Content-Length", 0))
30
+ if content_length == 0:
31
+ self._respond(400, {"error": "empty body"})
32
+ return
33
+
34
+ try:
35
+ body = json.loads(self.rfile.read(content_length))
36
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
37
+ self._respond(400, {"error": f"invalid JSON: {exc}"})
38
+ return
39
+
40
+ # Required fields
41
+ tool_name = body.get("tool_name")
42
+ arguments = body.get("arguments")
43
+ session_id = body.get("session_id", "anonymous")
44
+
45
+ if not tool_name:
46
+ self._respond(400, {"error": "missing required field: tool_name"})
47
+ return
48
+ if arguments is None:
49
+ self._respond(400, {"error": "missing required field: arguments"})
50
+ return
51
+
52
+ # Ensure arguments is a dict for tool_input
53
+ if isinstance(arguments, str):
54
+ try:
55
+ arguments = json.loads(arguments)
56
+ except json.JSONDecodeError:
57
+ arguments = {"raw": arguments}
58
+
59
+ # Build payload dict matching score_tool_call's expected shape
60
+ score_payload = {
61
+ "tool_name": tool_name,
62
+ "tool_input": arguments,
63
+ "session_id": session_id,
64
+ }
65
+ if body.get("server"):
66
+ score_payload["server"] = body["server"]
67
+ if body.get("effect"):
68
+ score_payload["effect"] = body["effect"]
69
+ if body.get("role"):
70
+ score_payload["role"] = body["role"]
71
+
72
+ try:
73
+ result = self.pipeline.score_tool_call(score_payload)
74
+ self._respond(200, _serialize_session_meta(result))
75
+ except Exception as exc:
76
+ logger.exception("Score API error")
77
+ self._respond(500, {"error": str(exc)})
78
+
79
+ def do_GET(self) -> None:
80
+ if self.path == "/health":
81
+ self._respond(200, {"status": "ok"})
82
+ else:
83
+ self._respond(404, {"error": "not found"})
84
+
85
+ def _respond(self, status: int, body: dict) -> None:
86
+ payload = json.dumps(body, default=str).encode("utf-8")
87
+ self.send_response(status)
88
+ self.send_header("Content-Type", "application/json")
89
+ self.send_header("Content-Length", str(len(payload)))
90
+ self.end_headers()
91
+ self.wfile.write(payload)
92
+
93
+ def log_message(self, format: str, *args) -> None: # noqa: A002
94
+ logger.debug("ScoreAPI: %s", format % args)
95
+
96
+
97
+ def _serialize_session_meta(meta) -> dict:
98
+ """Serialize SessionMeta or EventTrace to JSON-safe dict."""
99
+ from traceforge.governance.results import SessionMeta
100
+ from traceforge.trace import EventTrace
101
+
102
+ if isinstance(meta, EventTrace):
103
+ result: dict = {}
104
+ if meta.risk_score is not None:
105
+ result["risk_assessment"] = {
106
+ "score": meta.risk_score,
107
+ "level": meta.risk_band,
108
+ }
109
+ if meta.suggested_action is not None:
110
+ result["recommendation"] = {
111
+ "action": meta.suggested_action,
112
+ "reason_code": meta.reason,
113
+ }
114
+ if meta.mechanism or meta.effect:
115
+ result["evidence"] = {
116
+ "canonical_tool": meta.canonical_tool,
117
+ "mechanism": meta.mechanism,
118
+ "effect": meta.effect,
119
+ "scope": list(meta.scope) if meta.scope else [],
120
+ "role": list(meta.role) if meta.role else [],
121
+ }
122
+ result["stage"] = meta.stage
123
+ return result
124
+
125
+ if isinstance(meta, SessionMeta):
126
+ result = {}
127
+ if meta.risk_assessment is not None:
128
+ result["risk_assessment"] = {
129
+ "score": meta.risk_assessment.score,
130
+ "level": meta.risk_assessment.level,
131
+ "confidence": meta.risk_assessment.confidence,
132
+ }
133
+ if meta.recommendation is not None:
134
+ result["recommendation"] = {
135
+ "action": meta.recommendation.recommended_action.value,
136
+ "reason_code": meta.recommendation.reason_code,
137
+ "canonical_id": meta.recommendation.canonical_id,
138
+ }
139
+ if meta.evidence is not None:
140
+ e = meta.evidence
141
+ result["evidence"] = {
142
+ "canonical_id": e.canonical_id,
143
+ "mechanism": e.mechanism,
144
+ "effect": e.effect,
145
+ "scope": list(e.scope) if e.scope else [],
146
+ "role": list(e.role) if e.role else [],
147
+ }
148
+ return result
149
+ # Fallback
150
+ return {"raw": str(meta)}
151
+
152
+
153
+ class ScoreServer:
154
+ """Wraps ThreadingHTTPServer for the Score API."""
155
+
156
+ def __init__(self, pipeline: "GovernancePipeline", listen: str = "localhost:7331") -> None:
157
+ self._pipeline = pipeline
158
+ host, _, port_str = listen.rpartition(":")
159
+ self._host = host or "localhost"
160
+ self._port = int(port_str) if port_str else 7331
161
+ self._server: ThreadingHTTPServer | None = None
162
+ self._thread: threading.Thread | None = None
163
+
164
+ def start_background(self) -> None:
165
+ """Start the Score API in a daemon thread."""
166
+ _ScoreHandler.pipeline = self._pipeline
167
+ self._server = ThreadingHTTPServer((self._host, self._port), _ScoreHandler)
168
+ self._thread = threading.Thread(
169
+ target=self._server.serve_forever,
170
+ name="traceforge-score-api",
171
+ daemon=True,
172
+ )
173
+ self._thread.start()
174
+ logger.info("Score API listening on %s:%d", self._host, self._port)
175
+
176
+ def stop(self) -> None:
177
+ """Shut down the Score API server."""
178
+ if self._server:
179
+ self._server.shutdown()
180
+ self._server = None
181
+ if self._thread:
182
+ self._thread.join(timeout=5.0)
183
+ self._thread = None
184
+
185
+
186
+ # ─── CLI command ─────────────────────────────────────────────────────────────
187
+
188
+
189
+ @click.command()
190
+ @click.option(
191
+ "--listen", default="localhost:7331", help="Host:port to bind (default: localhost:7331)."
192
+ )
193
+ @click.option("--config", "config_path", type=click.Path(exists=True), default=None)
194
+ def score(listen: str, config_path: str | None) -> None:
195
+ """Run the Score API server standalone (no file watching)."""
196
+ from pathlib import Path
197
+
198
+ from traceforge.cli.factory import create_default_pipeline
199
+ from traceforge.governance.persistence import SystemStore
200
+
201
+ db_path = Path.home() / ".traceforge" / "system.db"
202
+ db_path.parent.mkdir(parents=True, exist_ok=True)
203
+ store = SystemStore(db_path)
204
+
205
+ pipeline = create_default_pipeline(store)
206
+
207
+ server = ScoreServer(pipeline, listen=listen)
208
+ click.echo(f"Starting Score API on {listen} ...")
209
+ server.start_background()
210
+
211
+ try:
212
+ # Block until Ctrl+C
213
+ threading.Event().wait()
214
+ except KeyboardInterrupt:
215
+ click.echo("\nShutting down...")
216
+ server.stop()
217
+ store.close()
@@ -0,0 +1,65 @@
1
+ """Status command — show traceforge system state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+
13
+ _DB_PATH = Path.home() / ".traceforge" / "system.db"
14
+
15
+
16
+ @click.command()
17
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON.")
18
+ @click.option("--db", "db_path", type=click.Path(), default=None, help="Override DB path.")
19
+ def status(as_json: bool, db_path: str | None) -> None:
20
+ """Show traceforge system state from the governance database."""
21
+ path = Path(db_path) if db_path else _DB_PATH
22
+ if not path.exists():
23
+ click.echo("No system database found. Has traceforge run yet?")
24
+ sys.exit(1)
25
+
26
+ conn = sqlite3.connect(str(path), check_same_thread=False)
27
+ conn.execute("PRAGMA journal_mode = WAL")
28
+
29
+ stats: dict = {}
30
+
31
+ # Session count
32
+ row = conn.execute("SELECT COUNT(*) FROM session_state").fetchone()
33
+ stats["active_sessions"] = row[0] if row else 0
34
+
35
+ # Processed events
36
+ row = conn.execute("SELECT COUNT(*) FROM processed_events").fetchone()
37
+ stats["processed_events"] = row[0] if row else 0
38
+
39
+ # MCP tool profiles
40
+ row = conn.execute("SELECT COUNT(*) FROM mcp_profiles").fetchone()
41
+ stats["mcp_profiles"] = row[0] if row else 0
42
+
43
+ # Session summaries
44
+ row = conn.execute("SELECT COUNT(*) FROM session_summaries").fetchone()
45
+ stats["completed_sessions"] = row[0] if row else 0
46
+
47
+ # Recent recommendations breakdown
48
+ row = conn.execute(
49
+ "SELECT recommendation_counts_json FROM session_summaries ORDER BY ended_at DESC LIMIT 1"
50
+ ).fetchone()
51
+ stats["last_session_recommendations"] = json.loads(row[0]) if row and row[0] else None
52
+
53
+ conn.close()
54
+
55
+ if as_json:
56
+ click.echo(json.dumps(stats, indent=2))
57
+ else:
58
+ click.echo("Traceforge System Status")
59
+ click.echo("─" * 40)
60
+ click.echo(f" Active sessions: {stats['active_sessions']}")
61
+ click.echo(f" Processed events: {stats['processed_events']}")
62
+ click.echo(f" MCP profiles: {stats['mcp_profiles']}")
63
+ click.echo(f" Completed sessions: {stats['completed_sessions']}")
64
+ if stats["last_session_recommendations"]:
65
+ click.echo(f" Last session recs: {stats['last_session_recommendations']}")