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,130 @@
1
+ """Config command group — init, show, validate, dump."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import yaml
12
+
13
+ from traceforge.config.defaults import DEFAULT_CONFIG_YAML
14
+
15
+
16
+ _DEFAULT_CONFIG_PATH = Path.home() / ".traceforge" / "config.yaml"
17
+
18
+
19
+ @click.group()
20
+ def config() -> None:
21
+ """Manage traceforge configuration."""
22
+
23
+
24
+ @config.command()
25
+ @click.option("--force", is_flag=True, help="Overwrite existing config file.")
26
+ def init(force: bool) -> None:
27
+ """Write default config to ~/.traceforge/config.yaml."""
28
+ if _DEFAULT_CONFIG_PATH.exists() and not force:
29
+ click.echo(f"Config already exists: {_DEFAULT_CONFIG_PATH}")
30
+ click.echo("Use --force to overwrite.")
31
+ sys.exit(1)
32
+
33
+ _DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
34
+ _DEFAULT_CONFIG_PATH.write_text(DEFAULT_CONFIG_YAML, encoding="utf-8")
35
+ click.echo(f"Wrote default config to {_DEFAULT_CONFIG_PATH}")
36
+
37
+
38
+ @config.command()
39
+ @click.option("--config", "config_path", type=click.Path(exists=True), default=None)
40
+ def show(config_path: str | None) -> None:
41
+ """Print the effective merged configuration."""
42
+ path = Path(config_path) if config_path else _resolve_config_path()
43
+ if path is None or not path.exists():
44
+ click.echo("No config file found. Run `traceforge config init` to create one.")
45
+ sys.exit(1)
46
+
47
+ content = path.read_text(encoding="utf-8")
48
+ click.echo(f"# Source: {path}\n")
49
+ click.echo(content)
50
+
51
+
52
+ @config.command()
53
+ @click.option("--config", "config_path", type=click.Path(exists=True), default=None)
54
+ def validate(config_path: str | None) -> None:
55
+ """Validate config file without running."""
56
+ path = Path(config_path) if config_path else _resolve_config_path()
57
+ if path is None or not path.exists():
58
+ click.echo("No config file found.")
59
+ sys.exit(1)
60
+
61
+ try:
62
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
63
+ if not isinstance(data, dict):
64
+ raise ValueError("Config must be a YAML mapping")
65
+ except Exception as exc:
66
+ click.echo(f"✗ Config invalid: {exc}", err=True)
67
+ sys.exit(1)
68
+
69
+ # Emit the success line outside the try so a valid config is never
70
+ # misreported as invalid if the echo itself were to fail.
71
+ click.echo(f"✓ Config valid: {path}")
72
+
73
+
74
+ @config.command()
75
+ @click.option("--config", "config_path", type=click.Path(exists=True), default=None)
76
+ @click.option(
77
+ "--format",
78
+ "output_format",
79
+ type=click.Choice(["yaml", "json"]),
80
+ default="yaml",
81
+ help="Serialization format for the resolved config.",
82
+ )
83
+ def dump(config_path: str | None, output_format: str) -> None:
84
+ """Print the fully-resolved, effective configuration.
85
+
86
+ Unlike `show` (which prints the raw config file), `dump` runs the real
87
+ loader — applying the full precedence chain kwargs > env > project YAML >
88
+ user YAML > defaults — and serializes the typed `TraceforgeConfig` it
89
+ produces. With no config file present, the resolved defaults are dumped.
90
+ """
91
+ from traceforge.config.loader import load_config
92
+
93
+ if config_path:
94
+ # Point the real loader at the requested file via its runtime path
95
+ # override (TRACEFORGE_CONFIG), so the full precedence chain still
96
+ # applies — env vars and defaults still layer around the file.
97
+ previous = os.environ.get("TRACEFORGE_CONFIG")
98
+ os.environ["TRACEFORGE_CONFIG"] = str(Path(config_path))
99
+ try:
100
+ cfg = load_config()
101
+ finally:
102
+ if previous is None:
103
+ os.environ.pop("TRACEFORGE_CONFIG", None)
104
+ else:
105
+ os.environ["TRACEFORGE_CONFIG"] = previous
106
+ else:
107
+ cfg = load_config()
108
+
109
+ resolved = cfg.model_dump(mode="json")
110
+
111
+ if output_format == "json":
112
+ click.echo(json.dumps(resolved, indent=2))
113
+ else:
114
+ click.echo(yaml.safe_dump(resolved, sort_keys=False))
115
+
116
+
117
+ def _resolve_config_path() -> Path | None:
118
+ """Find config file via env var, local, or default location."""
119
+ env = os.environ.get("TRACEFORGE_CONFIG")
120
+ if env:
121
+ return Path(env)
122
+
123
+ local = Path("traceforge.yaml")
124
+ if local.exists():
125
+ return local
126
+
127
+ if _DEFAULT_CONFIG_PATH.exists():
128
+ return _DEFAULT_CONFIG_PATH
129
+
130
+ return None
@@ -0,0 +1,46 @@
1
+ """Detect command — discover installed AI coding agent frameworks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from traceforge.sources.auto_detect import detect_frameworks
10
+
11
+
12
+ @click.command()
13
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON array.")
14
+ @click.option(
15
+ "--frameworks",
16
+ default=None,
17
+ help="Comma-separated list of frameworks to check (default: all).",
18
+ )
19
+ def detect(as_json: bool, frameworks: str | None) -> None:
20
+ """Discover installed AI coding agent frameworks."""
21
+ fw_list = [f.strip() for f in frameworks.split(",")] if frameworks else None
22
+ detected = detect_frameworks(fw_list)
23
+
24
+ if not detected:
25
+ if as_json:
26
+ click.echo("[]")
27
+ else:
28
+ click.echo("No frameworks detected.")
29
+ return
30
+
31
+ if as_json:
32
+ rows = [
33
+ {
34
+ "name": d.name,
35
+ "path": str(d.path),
36
+ "adapter": d.adapter,
37
+ "ingestion_mode": d.ingestion_mode,
38
+ }
39
+ for d in detected
40
+ ]
41
+ click.echo(json.dumps(rows, indent=2))
42
+ else:
43
+ click.echo(f"{'Framework':<12} {'Mode':<12} {'Path'}")
44
+ click.echo("─" * 60)
45
+ for d in detected:
46
+ click.echo(f"{d.name:<12} {d.ingestion_mode:<12} {d.path}")
@@ -0,0 +1,74 @@
1
+ """traceforge download-model — (re)fetch the titler weights.
2
+
3
+ The titler weights ship in the separate ``traceforge-title-model`` package, which
4
+ is a hard dependency of ``traceforge`` and so is normally installed automatically.
5
+ This command is the repair/mirror fallback: it installs (or upgrades) that package
6
+ into the current environment, from PyPI by default or from the GitHub-release
7
+ mirror when PyPI is unavailable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import sys
14
+
15
+ import click
16
+
17
+ #: Model wheel version to target for the GitHub-release mirror. PyPI installs
18
+ #: resolve the newest compatible release, so this only pins the ``gh`` fallback.
19
+ _MIRROR_VERSION = "0.2.0"
20
+ _REPO = "dfinson/traceforge"
21
+
22
+
23
+ def _gh_wheel_url(version: str) -> str:
24
+ whl = f"traceforge_title_model-{version}-py3-none-any.whl"
25
+ return f"https://github.com/{_REPO}/releases/download/title-model-v{version}/{whl}"
26
+
27
+
28
+ @click.command("download-model")
29
+ @click.option(
30
+ "--source",
31
+ type=click.Choice(["pypi", "gh"]),
32
+ default="pypi",
33
+ show_default=True,
34
+ help="Where to fetch the titler weights from. 'gh' uses the GitHub-release mirror.",
35
+ )
36
+ @click.option(
37
+ "--version",
38
+ "version",
39
+ default=_MIRROR_VERSION,
40
+ show_default=True,
41
+ help="Model version to fetch from the GitHub mirror (ignored for --source pypi).",
42
+ )
43
+ def download_model(source: str, version: str) -> None:
44
+ """Install the traceforge titler weights (``traceforge-title-model``).
45
+
46
+ The weights ship as a dependency and are normally already present; this
47
+ command exists to repair a broken install or fetch from the GitHub mirror
48
+ when PyPI is unreachable (``--source gh``).
49
+ """
50
+ target = "traceforge-title-model" if source == "pypi" else _gh_wheel_url(version)
51
+ # --force-reinstall so this reliably *repairs*: a plain --upgrade would no-op
52
+ # ("already satisfied") when the same version is installed but its files are
53
+ # missing/corrupt/LFS-pointer stubs -- exactly the state this command fixes.
54
+ cmd = [sys.executable, "-m", "pip", "install", "--force-reinstall", target]
55
+ click.echo(f"Installing titler weights from {source}: {target}")
56
+ try:
57
+ subprocess.run(cmd, check=True)
58
+ except FileNotFoundError:
59
+ raise click.ClickException(
60
+ "pip is not available in this environment. Install the weights with your "
61
+ "package manager instead, e.g. uv pip install traceforge-title-model"
62
+ )
63
+ except subprocess.CalledProcessError as exc:
64
+ raise click.ClickException(f"install failed (exit {exc.returncode}). Target: {target}")
65
+
66
+ # Confirm the resolver now sees a complete head, so the message is truthful.
67
+ from traceforge.title._resolve import span_dir
68
+
69
+ if span_dir() is None:
70
+ raise click.ClickException(
71
+ "install completed but the span head is still not resolvable; the "
72
+ "environment may need to be restarted or the package is incomplete."
73
+ )
74
+ click.echo("Titler weights installed and resolvable.")
@@ -0,0 +1,60 @@
1
+ """Factory for creating a fully-wired GovernancePipeline with default components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from traceforge.governance.persistence import SystemStore
10
+ from traceforge.governance.pipeline import GovernancePipeline
11
+
12
+ if TYPE_CHECKING:
13
+ from traceforge.sdk.gate_policy import GatePolicy
14
+
15
+
16
+ def create_default_pipeline(
17
+ store: SystemStore,
18
+ project_root: str | None = None,
19
+ policy: "GatePolicy | None" = None,
20
+ ) -> GovernancePipeline:
21
+ """Create a GovernancePipeline with all default components.
22
+
23
+ This is the standard way to instantiate a pipeline for CLI and Score API use.
24
+ """
25
+ from traceforge.classify.config import get_default_engine
26
+ from traceforge.governance.budget import BudgetTracker
27
+ from traceforge.governance.integrity import IntegrityVerifier
28
+ from traceforge.governance.labeler import GovernanceLabeler
29
+ from traceforge.governance.rules import parse_rules
30
+
31
+ # watch/score/replay run inside the target repo, so cwd is its identity. Defaulting
32
+ # here (rather than leaving project_root unset) gives per-event integrity a real repo
33
+ # key instead of "unknown", so a persistent system.db doesn't bucket every repo the
34
+ # user watches under one namespace and raise cross-repo false drift.
35
+ if project_root is None:
36
+ project_root = os.getcwd()
37
+
38
+ # Load default recommendation rules from bundled data
39
+ rules_path = (
40
+ Path(__file__).resolve().parent.parent / "classify" / "data" / "recommendation_rules.yaml"
41
+ )
42
+ rules = parse_rules(rules_path)
43
+
44
+ engine = get_default_engine()
45
+ # Content integrity is live by default on this primary CLI/Score path. The verifier
46
+ # is per-event: it derives the repo key from each event's ctx.project_root, so no
47
+ # construction-time repo is needed.
48
+ integrity_verifier = IntegrityVerifier(store)
49
+ labeler = GovernanceLabeler(integrity_verifier=integrity_verifier)
50
+ tracker = BudgetTracker()
51
+
52
+ return GovernancePipeline(
53
+ store=store,
54
+ labeler=labeler,
55
+ budget_tracker=tracker,
56
+ rules=rules,
57
+ engine=engine,
58
+ project_root=project_root,
59
+ policy=policy,
60
+ )
@@ -0,0 +1,30 @@
1
+ """traceforge gate — CLI command for cross-process gating relay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+
8
+ @click.command("gate")
9
+ @click.option("--stdin", "from_stdin", is_flag=True, help="Read event JSON from stdin.")
10
+ @click.option(
11
+ "--format",
12
+ "output_format",
13
+ default="claude-code",
14
+ type=click.Choice(["claude-code", "json"]),
15
+ help="Output format for the verdict.",
16
+ )
17
+ def gate(from_stdin: bool, output_format: str) -> None:
18
+ """Relay a tool call event to the running Pipeline for gating.
19
+
20
+ Reads event JSON from stdin, looks up the Pipeline IPC server by session_id,
21
+ sends the event for scoring, and outputs the verdict in the specified format.
22
+
23
+ Typically invoked by agent hooks (e.g., Claude Code PreToolUse).
24
+ """
25
+ if not from_stdin:
26
+ raise click.UsageError("--stdin is required (only stdin mode is currently supported)")
27
+
28
+ from traceforge.gate.client import gate_from_stdin
29
+
30
+ gate_from_stdin(format=output_format)
@@ -0,0 +1,86 @@
1
+ """traceforge init — auto-inject hook configs for supported agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+
12
+ @click.command("init")
13
+ @click.argument("agent", type=click.Choice(["claude-code"]))
14
+ @click.option("--project", "-p", default=".", help="Project root directory.")
15
+ def init(agent: str, project: str) -> None:
16
+ """Auto-inject traceforge hook configuration for a supported agent.
17
+
18
+ Currently supported agents:
19
+ - claude-code: Writes PreToolUse hook to .claude/settings.json
20
+ """
21
+ project_root = Path(project).resolve()
22
+
23
+ if agent == "claude-code":
24
+ _init_claude_code(project_root)
25
+
26
+
27
+ def _init_claude_code(project_root: Path) -> None:
28
+ """Write Claude Code PreToolUse hook config."""
29
+ settings_dir = project_root / ".claude"
30
+ settings_dir.mkdir(parents=True, exist_ok=True)
31
+ settings_file = settings_dir / "settings.json"
32
+
33
+ # Load existing or start fresh
34
+ if settings_file.exists():
35
+ try:
36
+ settings = json.loads(settings_file.read_text())
37
+ except (json.JSONDecodeError, OSError):
38
+ settings = {}
39
+ else:
40
+ settings = {}
41
+
42
+ # Determine the traceforge command
43
+ traceforge_cmd = _find_traceforge_command()
44
+
45
+ # Build hook config
46
+ hook_entry = {
47
+ "type": "command",
48
+ "command": f"{traceforge_cmd} gate --stdin",
49
+ }
50
+
51
+ # Insert into hooks.PreToolUse
52
+ hooks = settings.setdefault("hooks", {})
53
+ pre_tool_use = hooks.setdefault("PreToolUse", [])
54
+
55
+ # Check if traceforge hook already exists
56
+ for entry in pre_tool_use:
57
+ if isinstance(entry, dict):
58
+ hooks_list = entry.get("hooks", [])
59
+ for h in hooks_list:
60
+ if isinstance(h, dict) and "traceforge" in h.get("command", ""):
61
+ click.echo("✓ traceforge hook already configured in .claude/settings.json")
62
+ return
63
+
64
+ # Add a matcher for all tools with traceforge hook
65
+ pre_tool_use.append(
66
+ {
67
+ "matcher": ".*",
68
+ "hooks": [hook_entry],
69
+ }
70
+ )
71
+
72
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
73
+ click.echo(f"✓ Wrote PreToolUse hook to {settings_file}")
74
+ click.echo(f" Command: {traceforge_cmd} gate --stdin")
75
+
76
+
77
+ def _find_traceforge_command() -> str:
78
+ """Find the traceforge executable path."""
79
+ # If running as installed package, use the entry point
80
+ import shutil
81
+
82
+ traceforge_path = shutil.which("traceforge")
83
+ if traceforge_path:
84
+ return traceforge_path
85
+ # Fallback: use python -m
86
+ return f"{sys.executable} -m traceforge"
@@ -0,0 +1,130 @@
1
+ """Replay command — one-shot processing of captured session files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @click.command()
16
+ @click.argument("path", type=click.Path(exists=True))
17
+ @click.option("--adapter", required=True, help="Adapter/mapping name (e.g., claude, codex, cline).")
18
+ @click.option(
19
+ "--output",
20
+ "output_path",
21
+ type=click.Path(),
22
+ default=None,
23
+ help="Output file (JSONL). Default: console.",
24
+ )
25
+ @click.option(
26
+ "--log-level", default="INFO", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"])
27
+ )
28
+ def replay(path: str, adapter: str, output_path: str | None, log_level: str) -> None:
29
+ """Process a captured session file or directory through the governance pipeline."""
30
+ logging.basicConfig(
31
+ level=getattr(logging, log_level),
32
+ format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
33
+ datefmt="%H:%M:%S",
34
+ )
35
+
36
+ source = Path(path)
37
+ asyncio.run(_replay(source, adapter, output_path))
38
+
39
+
40
+ async def _replay(source: Path, adapter_name: str, output_path: str | None) -> None:
41
+ from traceforge.adapters.mapped_json import MappedJsonAdapter
42
+ from traceforge.cli.factory import create_default_pipeline
43
+ from traceforge.cli.runner import load_mapping_path
44
+ from traceforge.enricher import Enricher
45
+ from traceforge.governance.persistence import SystemStore
46
+ from traceforge.pipeline import EventPipeline
47
+ from traceforge.sinks.callback import CallbackSink
48
+ from traceforge.sinks.console import ConsoleSink
49
+ from traceforge.sinks.jsonl import JsonlSink
50
+
51
+ # Initialize governance
52
+ db_path = Path.home() / ".traceforge" / "system.db"
53
+ db_path.parent.mkdir(parents=True, exist_ok=True)
54
+ store = SystemStore(db_path)
55
+ governance = create_default_pipeline(store)
56
+
57
+ # Load adapter
58
+ mapping_path = load_mapping_path(adapter_name)
59
+ adapter = MappedJsonAdapter.from_yaml(str(mapping_path), session_id="replay")
60
+
61
+ # Output sink
62
+ if output_path:
63
+ output_sink = JsonlSink(path=Path(output_path))
64
+ else:
65
+ output_sink = ConsoleSink(filter_actions=None, color=True)
66
+
67
+ # Count governed events as the unified pipeline stamps and emits them.
68
+ total_governed = 0
69
+
70
+ async def _count(event) -> None:
71
+ nonlocal total_governed
72
+ if event.metadata is not None and event.metadata.governance is not None:
73
+ total_governed += 1
74
+
75
+ # Unified pipeline: adapt -> enrich -> classify -> govern (stage) -> sinks, so
76
+ # governance is one stage and each emitted event carries metadata.governance.
77
+ event_pipeline = EventPipeline(
78
+ sinks=[output_sink, CallbackSink(on_event=_count)],
79
+ enricher=Enricher(),
80
+ governance=governance,
81
+ )
82
+
83
+ # Collect files to process
84
+ files: list[Path] = []
85
+ if source.is_dir():
86
+ files = sorted(source.rglob("*.jsonl")) + sorted(source.rglob("*.json"))
87
+ else:
88
+ files = [source]
89
+
90
+ total_events = 0
91
+
92
+ try:
93
+ for f in files:
94
+ click.echo(f"Processing: {f.name}")
95
+ content = f.read_text(encoding="utf-8")
96
+
97
+ # Handle JSONL (line-delimited) vs single JSON
98
+ if f.suffix == ".jsonl":
99
+ lines = [l.strip() for l in content.splitlines() if l.strip()]
100
+ else:
101
+ # Single JSON file — try as array or single object
102
+ try:
103
+ parsed = json.loads(content)
104
+ lines = (
105
+ [json.dumps(item) for item in parsed]
106
+ if isinstance(parsed, list)
107
+ else [content]
108
+ )
109
+ except json.JSONDecodeError:
110
+ click.echo(f" Skipping (invalid JSON): {f.name}", err=True)
111
+ continue
112
+
113
+ for line in lines:
114
+ try:
115
+ data = json.loads(line)
116
+ except json.JSONDecodeError:
117
+ continue
118
+
119
+ for event in adapter.parse_dict(data):
120
+ total_events += 1
121
+ await event_pipeline.push(event)
122
+ finally:
123
+ # Flush buffered (unpaired tool-start) events and release resources even
124
+ # if processing raised, so the Enricher buffer is never silently dropped.
125
+ try:
126
+ await event_pipeline.close()
127
+ finally:
128
+ store.close()
129
+
130
+ click.echo(f"\nReplay complete: {total_events} events, {total_governed} governed")