codetrail-cli 1.4.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 (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/config.py ADDED
@@ -0,0 +1,485 @@
1
+ """Codetrail's configuration and folders (design sections 2.3 and 9).
2
+
3
+ The global settings live in `<config>/config.toml` and one file per target in `<config>/targets/<name>.toml`. Both are
4
+ validated at load time; unknown keys are refused by name, and a key that is absent takes its default.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import tomllib
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+ from urllib.parse import urlsplit
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
20
+
21
+ from codetrail.errors import CodetrailError
22
+
23
+ TARGET_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Paths:
28
+ """Where Codetrail keeps its configuration, data and state, following the XDG base directories."""
29
+
30
+ config_dir: Path
31
+ data_dir: Path
32
+ state_dir: Path
33
+
34
+ @classmethod
35
+ def from_environment(cls, environ: Mapping[str, str] = os.environ) -> Paths:
36
+ home = Path(environ.get("HOME") or Path.home())
37
+
38
+ def base(variable: str, default: Path) -> Path:
39
+ value = environ.get(variable)
40
+ # The XDG specification says relative values are invalid and must be ignored.
41
+ return Path(value) if value and Path(value).is_absolute() else default
42
+
43
+ return cls(
44
+ config_dir=base("XDG_CONFIG_HOME", home / ".config") / "codetrail",
45
+ data_dir=base("XDG_DATA_HOME", home / ".local" / "share") / "codetrail",
46
+ state_dir=base("XDG_STATE_HOME", home / ".local" / "state") / "codetrail",
47
+ )
48
+
49
+ def target_file(self, name: str) -> Path:
50
+ return self.config_dir / "targets" / f"{name}.toml"
51
+
52
+ def ignore_file(self, name: str) -> Path:
53
+ return self.config_dir / "targets" / f"{name}.ignore"
54
+
55
+ def target_data(self, name: str) -> Path:
56
+ return self.data_dir / name
57
+
58
+ def target_state(self, name: str) -> Path:
59
+ return self.state_dir / name
60
+
61
+
62
+ class Settings(BaseModel):
63
+ model_config = ConfigDict(extra="forbid", frozen=True)
64
+
65
+
66
+ class ToolsSettings(Settings):
67
+ gitleaks: str = "gitleaks"
68
+ gitleaks_timeout_seconds: float = Field(default=600, gt=0, le=86_400) # each gitleaks and `mise which` run
69
+
70
+
71
+ class BridgeSettings(Settings):
72
+ max_question_chars: int = Field(default=4000, gt=0, le=20_000)
73
+ max_turns: int = Field(default=20, gt=0)
74
+ max_budget_usd: float = Field(default=1.0, gt=0)
75
+ max_session_answers: int = Field(default=20, gt=0, le=200) # unsaved answers kept per session (16.4)
76
+
77
+
78
+ class SearchSettings(Settings):
79
+ max_query_chars: int = Field(default=200, gt=0, le=1000)
80
+ max_results: int = Field(default=20, gt=0, le=100)
81
+
82
+
83
+ class LearnSettings(Settings):
84
+ grading_cooldown_seconds: int = Field(default=10, ge=0)
85
+ max_budget_usd: float = Field(default=0.25, gt=0)
86
+
87
+
88
+ class AssistantSettings(Settings):
89
+ retry_attempts: int = Field(default=2, ge=0, le=5)
90
+
91
+
92
+ class ClaudeCodeSettings(Settings):
93
+ command: str = "claude"
94
+ auth: Literal["subscription", "api_key"] = "subscription"
95
+ timeout_seconds: int = Field(default=900, gt=0)
96
+ hook_timeout_seconds: int = Field(default=30, gt=0)
97
+
98
+
99
+ LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
100
+
101
+
102
+ class LocalSettings(Settings):
103
+ base_url: str = "http://127.0.0.1:11434/v1" # Ollama; LM Studio is http://127.0.0.1:1234/v1
104
+ max_turns: int = Field(default=30, gt=0)
105
+ max_tokens_per_call: int = Field(default=200_000, gt=0)
106
+ timeout_seconds: int = Field(default=900, gt=0)
107
+ max_read_bytes: int = Field(default=200_000, gt=0)
108
+
109
+ @field_validator("base_url")
110
+ @classmethod
111
+ def loopback_only(cls, base_url: str) -> str:
112
+ """Prompts carry the repository's text, so they go only to this machine (design section 15.5)."""
113
+ parts = urlsplit(base_url)
114
+ if (
115
+ parts.scheme not in ("http", "https")
116
+ or parts.username
117
+ or parts.password
118
+ or (parts.hostname not in LOOPBACK_HOSTS)
119
+ ):
120
+ raise ValueError(f"{base_url!r} isn't a loopback address (127.0.0.1, ::1 or localhost, with no user).")
121
+ return base_url
122
+
123
+
124
+ class CodexSettings(Settings):
125
+ command: str = "codex"
126
+ auth: Literal["subscription", "api_key"] = "subscription"
127
+ max_tokens_per_call: int = Field(default=400_000, gt=0)
128
+ timeout_seconds: int = Field(default=900, gt=0)
129
+
130
+
131
+ class ExtractSettings(Settings):
132
+ max_file_bytes: int = Field(default=1_000_000, gt=0)
133
+ max_attribute_chars: int = Field(default=300, ge=10)
134
+ max_tsconfig_paths: int = Field(default=100, gt=0, le=10_000) # path patterns and targets read from a tsconfig
135
+ max_workflow_steps: int = Field(default=5000, gt=0, le=100_000) # steps read from one workflow file
136
+ max_resource_paths: int = Field(default=20, gt=0, le=1000) # path attributes kept on one Terraform resource
137
+
138
+
139
+ class ServerSettings(Settings):
140
+ # The host is always 127.0.0.1 and can't be configured (design section 7.4).
141
+ port: int = Field(default=8765, ge=1024, le=65535)
142
+ login_code_ttl_seconds: int = Field(default=60, gt=0)
143
+ session_minutes: int = Field(default=480, gt=0)
144
+ update_cooldown_seconds: int = Field(default=300, ge=0)
145
+ estimate_ttl_seconds: int = Field(default=300, gt=0)
146
+ update_log_lines: int = Field(default=200, gt=0) # the update panel's steps the server keeps
147
+
148
+
149
+ class InterfaceSettings(Settings):
150
+ default_language: str = "en"
151
+ max_pins: int = Field(default=9, ge=1, le=50) # pinned pages the sidebar keeps
152
+
153
+
154
+ class SignalSettings(Settings):
155
+ cache_seconds: int = Field(default=60, ge=0)
156
+
157
+
158
+ class DiagramSettings(Settings):
159
+ max_nodes: int = Field(default=25, gt=0)
160
+
161
+
162
+ class AgentSettings(Settings):
163
+ """The map, briefs and tool server for coding agents (design section 23.6)."""
164
+
165
+ max_map_words: int = Field(default=2500, ge=500, le=20_000)
166
+ max_brief_words: int = Field(default=300, ge=50, le=2000)
167
+ key_files_per_part: int = Field(default=5, ge=0, le=20)
168
+ recent_digests: int = Field(default=5, ge=0, le=50)
169
+ max_briefs_per_call: int = Field(default=3, ge=1, le=10)
170
+ max_response_words: int = Field(default=4000, ge=500, le=20_000)
171
+
172
+
173
+ # A file name or suffix, lower-cased, and its language (design section 19.6); `[metrics.languages]` replaces it.
174
+ DEFAULT_LANGUAGES = {
175
+ ".py": "Python", ".pyi": "Python",
176
+ ".ts": "TypeScript", ".tsx": "TypeScript", ".mts": "TypeScript", ".cts": "TypeScript",
177
+ ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
178
+ ".astro": "Astro", ".swift": "Swift", ".kt": "Kotlin", ".kts": "Kotlin", ".java": "Java", ".go": "Go",
179
+ ".rs": "Rust", ".rb": "Ruby", ".php": "PHP", ".c": "C", ".h": "C", ".cc": "C++", ".cpp": "C++", ".hpp": "C++",
180
+ ".cs": "C#", ".m": "Objective-C", ".mm": "Objective-C", ".scala": "Scala",
181
+ ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "CSS",
182
+ ".vue": "Vue", ".svelte": "Svelte", ".tf": "Terraform", ".yaml": "YAML", ".yml": "YAML", ".json": "JSON",
183
+ ".toml": "TOML", ".xml": "XML", ".proto": "Protocol Buffers", ".graphql": "GraphQL",
184
+ "dockerfile": "Dockerfile", "makefile": "Makefile", "justfile": "just",
185
+ } # fmt: skip
186
+
187
+
188
+ class MetricsSettings(Settings):
189
+ """The documentation metrics' and repository statistics' limits (design sections 18.5 and 19.6)."""
190
+
191
+ commit_window: int = Field(default=200, gt=0, le=10_000) # the latest non-merge commits the commit metric reads
192
+ proposed_adr_days: int = Field(default=30, ge=0) # a proposed ADR older than this needs attention
193
+ trend_updates: int = Field(default=12, ge=2, le=200) # updates a trend line shows
194
+ max_listed: int = Field(default=50, gt=0, le=5000) # items a list shows before the rest fold away
195
+ max_message_chars: int = Field(default=20_000, gt=0, le=1_000_000) # of a commit body, matched for a why
196
+ activity_months: int = Field(default=24, gt=0, le=600) # months the activity bars show
197
+ history_limit: int = Field(default=1_000_000, gt=0, le=100_000_000) # commits whose dates are read
198
+ largest_files: int = Field(default=10, gt=0, le=1000) # files the Repository page lists by size
199
+ churn_window: int = Field(default=500, gt=0, le=100_000) # the latest non-merge commits the hot spots read
200
+ quiet_days: int = Field(default=365, gt=0, le=36_500) # code no commit changed for this long is quiet
201
+ folder_depth: int = Field(default=2, ge=1, le=6) # levels of folders the change heat map groups by
202
+ heatmap_tiles: int = Field(default=60, gt=0, le=1000) # tiles a heat map level draws, the largest by lines
203
+ languages: dict[str, str] = DEFAULT_LANGUAGES
204
+
205
+ @field_validator("languages")
206
+ @classmethod
207
+ def lower_case_names(cls, languages: dict[str, str]) -> dict[str, str]:
208
+ return {name.lower(): language for name, language in languages.items()}
209
+
210
+
211
+ class ProvidersSettings(Settings):
212
+ claude_code: ClaudeCodeSettings = ClaudeCodeSettings()
213
+ codex: CodexSettings = CodexSettings()
214
+ local: LocalSettings = LocalSettings()
215
+
216
+
217
+ class Price(Settings):
218
+ """USD per million tokens, at API list prices: used for estimates and budgets only (design section 15.4)."""
219
+
220
+ input: float = Field(ge=0)
221
+ output: float = Field(ge=0)
222
+ cached_input: float | None = Field(default=None, ge=0)
223
+
224
+
225
+ # API list prices on 5 October 2026 (https://platform.claude.com/docs/en/about-claude/pricing); they change, so a
226
+ # [prices] table in the configuration adds to or replaces these.
227
+ DEFAULT_PRICES = {
228
+ "claude-opus-5-5": Price(input=4.0, output=20.0, cached_input=0.4),
229
+ "claude-sonnet-5-5": Price(input=2.0, output=10.0, cached_input=0.2),
230
+ }
231
+
232
+
233
+ class TokenGuess(Settings):
234
+ input: int = Field(ge=0)
235
+ output: int = Field(ge=0)
236
+
237
+
238
+ class EstimateSettings(Settings):
239
+ """Starting tokens per call, used until Codetrail has history of its own (design section 15.4)."""
240
+
241
+ history_size: int = Field(default=20, ge=3)
242
+ plan: TokenGuess = TokenGuess(input=60_000, output=8_000)
243
+ write: TokenGuess = TokenGuess(input=120_000, output=6_500)
244
+ revise: TokenGuess = TokenGuess(input=40_000, output=2_500)
245
+ digest: TokenGuess = TokenGuess(input=40_000, output=3_000)
246
+ answer: TokenGuess = TokenGuess(input=30_000, output=1_500)
247
+ grade: TokenGuess = TokenGuess(input=4_000, output=500)
248
+
249
+
250
+ class GlobalConfig(Settings):
251
+ tools: ToolsSettings = ToolsSettings()
252
+ assistant: AssistantSettings = AssistantSettings()
253
+ providers: ProvidersSettings = ProvidersSettings()
254
+ prices: dict[str, Price] = DEFAULT_PRICES
255
+ estimates: EstimateSettings = EstimateSettings()
256
+
257
+ @field_validator("prices")
258
+ @classmethod
259
+ def keep_default_prices(cls, prices: dict[str, Price]) -> dict[str, Price]:
260
+ return DEFAULT_PRICES | prices
261
+
262
+ bridge: BridgeSettings = BridgeSettings()
263
+ search: SearchSettings = SearchSettings()
264
+ learn: LearnSettings = LearnSettings()
265
+ extract: ExtractSettings = ExtractSettings()
266
+ server: ServerSettings = ServerSettings()
267
+ ui: InterfaceSettings = InterfaceSettings()
268
+ signal: SignalSettings = SignalSettings()
269
+ diagrams: DiagramSettings = DiagramSettings()
270
+ agents: AgentSettings = AgentSettings()
271
+ metrics: MetricsSettings = MetricsSettings()
272
+
273
+
274
+ class AdrSettings(Settings):
275
+ paths: list[str] = ["docs/adr/*.md"]
276
+
277
+
278
+ class OpenApiSettings(Settings):
279
+ paths: list[str] = ["**/openapi.json", "**/openapi.yaml", "**/openapi.yml"]
280
+
281
+
282
+ class GenerationSettings(Settings):
283
+ max_pages_per_update: int = Field(default=20, ge=0)
284
+ concurrency: int = Field(default=2, gt=0, le=8)
285
+ max_turns: int = Field(default=30, gt=0)
286
+ max_budget_usd_per_call: float = Field(default=1.0, gt=0)
287
+ max_budget_usd_per_update: float = Field(default=10.0, gt=0)
288
+ # Counts every provider, including models with no configured price (design section 15.3).
289
+ max_tokens_per_update: int = Field(default=5_000_000, gt=0)
290
+ # An affected page with at most this many fact and link changes in its scope since it was written is revised:
291
+ # only the sections the changes affect are rewritten (design section 6.3). 0 writes every page whole.
292
+ revise_max_changes: int = Field(default=20, ge=0)
293
+
294
+
295
+ class TargetMetricsSettings(Settings):
296
+ """What counts as a document, a commit that explains why, a test and a commit type here (sections 18.5, 19.6)."""
297
+
298
+ document_globs: list[str] = ["README*", "*.md", "*.rst", "*.adoc", "*.txt", "docs/**"] # gitignore syntax
299
+ # Spaces and tabs only, never \s: whitespace that could cross lines would take quadratic time on a long run of
300
+ # blank lines in a message.
301
+ commit_why_pattern: str = r"(?im)^[ \t]*(?:#+[ \t]*)?why\b"
302
+
303
+ @field_validator("commit_why_pattern")
304
+ @classmethod
305
+ def compiles(cls, pattern: str) -> str:
306
+ try:
307
+ re.compile(pattern)
308
+ except re.error as error:
309
+ raise ValueError(f"isn't a valid regular expression: {error}") from error
310
+ return pattern
311
+
312
+ @property
313
+ def why(self) -> re.Pattern[str]:
314
+ return re.compile(self.commit_why_pattern)
315
+
316
+ test_globs: list[str] = ["test/**", "tests/**", "**/test_*.py", "**/*_test.*", "**/*.test.*", "**/*.spec.*",
317
+ "**/__tests__/**", "**/Tests/**"] # fmt: skip
318
+ commit_type_pattern: str = r"^(?P<type>[A-Za-z]+)(?:\((?P<scope>[^()\r\n]*)\))?!?:[ \t]" # a Conventional Commit
319
+
320
+ @field_validator("commit_type_pattern")
321
+ @classmethod
322
+ def has_a_type(cls, pattern: str) -> str:
323
+ try:
324
+ compiled = re.compile(pattern)
325
+ except re.error as error:
326
+ raise ValueError(f"isn't a valid regular expression: {error}") from error
327
+ if "type" not in compiled.groupindex:
328
+ raise ValueError("needs a (?P<type>...) group")
329
+ return pattern
330
+
331
+ @property
332
+ def types(self) -> re.Pattern[str]:
333
+ return re.compile(self.commit_type_pattern)
334
+
335
+
336
+ PROVIDERS = ("claude_code", "codex", "local")
337
+
338
+
339
+ def model_choice(value: str) -> tuple[str, str]:
340
+ """A `[models]` value as (provider, model): `local:qwen3:14b` is ("local", "qwen3:14b"); no prefix: claude_code."""
341
+ provider, separator, model = value.partition(":")
342
+ if separator and provider in PROVIDERS:
343
+ return provider, model
344
+ return "claude_code", value
345
+
346
+
347
+ class ModelSettings(Settings):
348
+ """The provider and model of each kind of call, as `provider:model` (design section 15.1)."""
349
+
350
+ plan: str = "claude-opus-5-5"
351
+ write: str = "claude-sonnet-5-5"
352
+ digest: str = "claude-sonnet-5-5"
353
+ answer: str = "claude-sonnet-5-5"
354
+ grade: str = "claude-sonnet-5-5"
355
+
356
+ @field_validator("plan", "write", "digest", "answer", "grade")
357
+ @classmethod
358
+ def known_provider(cls, value: str) -> str:
359
+ provider, separator, _model = value.partition(":")
360
+ if separator and provider not in PROVIDERS:
361
+ raise ValueError(f"{provider!r} isn't a provider; use one of {', '.join(PROVIDERS)}.")
362
+ return value
363
+
364
+ def uses(self, provider: str) -> bool:
365
+ return any(model_choice(getattr(self, kind))[0] == provider for kind in type(self).model_fields)
366
+
367
+
368
+ class TargetAssistantSettings(Settings):
369
+ # Codex can read and run anything the reader can, so a target opts in to it (design section 15.5).
370
+ allow_codex: bool = False
371
+
372
+
373
+ class TargetConfig(Settings):
374
+ repository: Path
375
+ branch: str
376
+ extractors: list[Literal["python", "adr", "openapi", "terraform", "swift", "typescript", "github_actions"]] = [
377
+ "python",
378
+ "adr",
379
+ "openapi",
380
+ "terraform",
381
+ "swift",
382
+ "typescript",
383
+ "github_actions",
384
+ ]
385
+ adr: AdrSettings = AdrSettings()
386
+ openapi: OpenApiSettings = OpenApiSettings()
387
+ generation: GenerationSettings = GenerationSettings()
388
+ models: ModelSettings = ModelSettings()
389
+ assistant: TargetAssistantSettings = TargetAssistantSettings()
390
+ metrics: TargetMetricsSettings = TargetMetricsSettings()
391
+
392
+ @model_validator(mode="after")
393
+ def codex_needs_consent(self) -> TargetConfig:
394
+ if self.models.uses("codex") and not self.assistant.allow_codex:
395
+ raise ValueError(
396
+ "models: Codex can read outside the repository's allowed files (design section 15.5); to use it for "
397
+ "this target, set [assistant] allow_codex = true."
398
+ )
399
+ return self
400
+
401
+ @field_validator("repository")
402
+ @classmethod
403
+ def expand_home(cls, repository: Path) -> Path:
404
+ return repository.expanduser()
405
+
406
+
407
+ def validate_target_name(name: str) -> str:
408
+ if not TARGET_NAME.fullmatch(name):
409
+ raise CodetrailError(
410
+ f"{name!r} isn't a valid target name: use lower-case letters, digits and hyphens, starting with a letter "
411
+ "or digit, at most 63 characters."
412
+ )
413
+ return name
414
+
415
+
416
+ def load_global(paths: Paths) -> GlobalConfig:
417
+ file = paths.config_dir / "config.toml"
418
+ if not file.exists():
419
+ return GlobalConfig()
420
+ return _validate(GlobalConfig, _read_toml(file), file)
421
+
422
+
423
+ def load_target(paths: Paths, name: str) -> TargetConfig:
424
+ file = paths.target_file(validate_target_name(name))
425
+ if not file.exists():
426
+ raise CodetrailError(f"No target named {name!r}. Add it with: codetrail target add {name} <path>")
427
+ return _validate(TargetConfig, _read_toml(file), file)
428
+
429
+
430
+ def check_containment(paths: Paths, repository: Path) -> None:
431
+ """Codetrail's folders and the repository must not contain each other (AGENTS.md, Never do, rule 6)."""
432
+ repository = repository.expanduser().resolve()
433
+ for folder in (paths.config_dir, paths.data_dir, paths.state_dir):
434
+ folder = folder.expanduser().resolve()
435
+ if folder.is_relative_to(repository) or repository.is_relative_to(folder):
436
+ raise CodetrailError(
437
+ f"Codetrail's folder {folder} can't be inside the repository, nor the repository inside it."
438
+ )
439
+
440
+
441
+ def write_target(paths: Paths, name: str, repository: Path, branch: str) -> Path:
442
+ """Writes a new target's configuration file and returns its path."""
443
+ file = paths.target_file(validate_target_name(name))
444
+ repository = repository.expanduser().resolve()
445
+ check_containment(paths, repository)
446
+ validate_branch_name(branch)
447
+ # json.dumps with ASCII escapes writes a valid TOML basic string for any text.
448
+ text = (
449
+ f"# Codetrail target {name!r}, written by `codetrail target add`.\n"
450
+ f"repository = {json.dumps(str(repository))}\n"
451
+ f"branch = {json.dumps(branch)}\n"
452
+ )
453
+ file.parent.mkdir(parents=True, exist_ok=True)
454
+ try:
455
+ with file.open("x", encoding="utf-8") as handle:
456
+ handle.write(text)
457
+ except FileExistsError as error:
458
+ raise CodetrailError(f"A target named {name!r} already exists: {file}") from error
459
+ return file
460
+
461
+
462
+ def validate_branch_name(branch: str) -> None:
463
+ from codetrail.repo.git import run_git # the git runner, not the repository package's readers
464
+
465
+ try:
466
+ run_git(["check-ref-format", "--branch", branch])
467
+ except CodetrailError as error:
468
+ raise CodetrailError(f"{branch!r} isn't a valid branch name.") from error
469
+
470
+
471
+ def _read_toml(file: Path) -> dict[str, Any]:
472
+ try:
473
+ return tomllib.loads(file.read_text(encoding="utf-8"))
474
+ except tomllib.TOMLDecodeError as error:
475
+ raise CodetrailError(f"{file} isn't valid TOML: {error}") from error
476
+
477
+
478
+ def _validate[T: Settings](model: type[T], data: dict[str, Any], file: Path) -> T:
479
+ try:
480
+ return model.model_validate(data)
481
+ except ValidationError as error:
482
+ problems = "; ".join(
483
+ f"{'.'.join(str(part) for part in issue['loc'])}: {issue['msg']}" for issue in error.errors()
484
+ )
485
+ raise CodetrailError(f"{file}: {problems}") from error
@@ -0,0 +1,60 @@
1
+ """The target's SQLite database: facts and learning state, migrated with ordered SQL files (design section 10).
2
+
3
+ Each migration is `migrations/NNNN_name.sql`; `PRAGMA user_version` holds the last one applied.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import sqlite3
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+
13
+ from codetrail.errors import CodetrailError
14
+
15
+ MIGRATION_NAME = re.compile(r"(\d{4})_[a-z0-9_]+\.sql")
16
+
17
+
18
+ def migrations() -> list[tuple[int, str]]:
19
+ found = []
20
+ for item in files("codetrail.database").joinpath("migrations").iterdir():
21
+ match = MIGRATION_NAME.fullmatch(item.name)
22
+ if match:
23
+ found.append((int(match.group(1)), item.read_text(encoding="utf-8")))
24
+ return sorted(found)
25
+
26
+
27
+ def connect(path: Path) -> sqlite3.Connection:
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ connection = sqlite3.connect(path, autocommit=True) # transactions are explicit, and executescript keeps them
30
+ connection.row_factory = sqlite3.Row
31
+ try:
32
+ connection.execute("PRAGMA foreign_keys = ON")
33
+ connection.execute("PRAGMA journal_mode = WAL")
34
+ available = migrations()
35
+ latest = available[-1][0] if available else 0
36
+ if _version(connection, path, latest) < latest:
37
+ # Another connection may be migrating too (the page's requests and its update thread): the write lock
38
+ # makes this one wait for it, and the version read again under the lock says what's left to apply.
39
+ connection.execute("BEGIN IMMEDIATE")
40
+ try:
41
+ current = _version(connection, path, latest)
42
+ for number, script in available:
43
+ if number > current:
44
+ connection.executescript(f"{script}\nPRAGMA user_version = {number};")
45
+ connection.execute("COMMIT")
46
+ except BaseException:
47
+ if connection.in_transaction: # SQLite ends it itself after some errors
48
+ connection.execute("ROLLBACK")
49
+ raise
50
+ except BaseException:
51
+ connection.close()
52
+ raise
53
+ return connection
54
+
55
+
56
+ def _version(connection: sqlite3.Connection, path: Path, latest: int) -> int:
57
+ current = int(connection.execute("PRAGMA user_version").fetchone()[0])
58
+ if current > latest:
59
+ raise CodetrailError(f"{path} was written by a newer Codetrail (schema {current}, this one knows {latest}).")
60
+ return current
@@ -0,0 +1,34 @@
1
+ -- The fact store (design section 4.2; ADR 0003). Each fact version is one row, valid from first_seen to last_seen
2
+ -- (inclusive); last_seen is NULL while the version is current.
3
+ CREATE TABLE snapshots (
4
+ id INTEGER PRIMARY KEY,
5
+ commit_sha TEXT NOT NULL,
6
+ taken_at TEXT NOT NULL
7
+ );
8
+
9
+ CREATE TABLE entities (
10
+ id TEXT NOT NULL,
11
+ kind TEXT NOT NULL,
12
+ attributes TEXT NOT NULL,
13
+ hash TEXT NOT NULL,
14
+ sources TEXT NOT NULL,
15
+ first_seen INTEGER NOT NULL REFERENCES snapshots (id),
16
+ last_seen INTEGER REFERENCES snapshots (id),
17
+ PRIMARY KEY (id, first_seen)
18
+ );
19
+ CREATE UNIQUE INDEX entities_current ON entities (id) WHERE last_seen IS NULL;
20
+ CREATE INDEX entities_kind ON entities (kind) WHERE last_seen IS NULL;
21
+
22
+ CREATE TABLE relations (
23
+ source_id TEXT NOT NULL,
24
+ kind TEXT NOT NULL,
25
+ target_id TEXT NOT NULL,
26
+ attributes TEXT NOT NULL,
27
+ hash TEXT NOT NULL,
28
+ sources TEXT NOT NULL,
29
+ first_seen INTEGER NOT NULL REFERENCES snapshots (id),
30
+ last_seen INTEGER REFERENCES snapshots (id),
31
+ PRIMARY KEY (source_id, kind, target_id, first_seen)
32
+ );
33
+ CREATE UNIQUE INDEX relations_current ON relations (source_id, kind, target_id) WHERE last_seen IS NULL;
34
+ CREATE INDEX relations_target ON relations (target_id) WHERE last_seen IS NULL;
@@ -0,0 +1,5 @@
1
+ -- The reader's settings, such as the interface language (design section 8.4).
2
+ CREATE TABLE settings (
3
+ key TEXT PRIMARY KEY,
4
+ value TEXT NOT NULL
5
+ );
@@ -0,0 +1,34 @@
1
+ -- Learning state (design section 8.4): marks apply to a page version; passes to a check's hash.
2
+ CREATE TABLE page_marks (
3
+ page_id TEXT PRIMARY KEY,
4
+ read_version TEXT,
5
+ read_at TEXT,
6
+ learned_version TEXT,
7
+ learned_commit TEXT,
8
+ learned_at TEXT
9
+ );
10
+
11
+ CREATE TABLE check_passes (
12
+ page_id TEXT NOT NULL,
13
+ check_id TEXT NOT NULL,
14
+ check_hash TEXT NOT NULL,
15
+ passed_at TEXT NOT NULL,
16
+ PRIMARY KEY (page_id, check_id, check_hash)
17
+ );
18
+
19
+ CREATE TABLE check_attempts (
20
+ id INTEGER PRIMARY KEY,
21
+ page_id TEXT NOT NULL,
22
+ check_id TEXT NOT NULL,
23
+ check_hash TEXT NOT NULL,
24
+ answer TEXT NOT NULL,
25
+ verdict TEXT NOT NULL,
26
+ feedback TEXT NOT NULL,
27
+ language TEXT NOT NULL,
28
+ attempted_at TEXT NOT NULL
29
+ );
30
+
31
+ CREATE TABLE digest_reads (
32
+ digest_id TEXT PRIMARY KEY,
33
+ read_at TEXT NOT NULL
34
+ );
@@ -0,0 +1,24 @@
1
+ -- What each assistant call used (design section 15.3): tokens, and its cost in dollars when known.
2
+ CREATE TABLE assistant_calls (
3
+ id INTEGER PRIMARY KEY,
4
+ called_at TEXT NOT NULL,
5
+ kind TEXT NOT NULL,
6
+ provider TEXT NOT NULL,
7
+ model TEXT NOT NULL,
8
+ input_tokens INTEGER NOT NULL,
9
+ cached_input_tokens INTEGER NOT NULL,
10
+ output_tokens INTEGER NOT NULL,
11
+ cost_usd REAL
12
+ );
13
+
14
+ CREATE INDEX assistant_calls_by_kind ON assistant_calls (kind, provider, model, id);
15
+
16
+ -- The latest reading of each subscription usage window a provider reported, and when it was read.
17
+ CREATE TABLE plan_usage (
18
+ provider TEXT NOT NULL,
19
+ window TEXT NOT NULL,
20
+ utilization REAL NOT NULL,
21
+ resets_at INTEGER NOT NULL,
22
+ observed_at TEXT NOT NULL,
23
+ PRIMARY KEY (provider, window)
24
+ );
@@ -0,0 +1,7 @@
1
+ -- Pages whose drafts failed validation twice (design section 6.5), with the hash of their scope at the time: an
2
+ -- update skips such a page until its scope changes, or until the reader asks for a retry.
3
+ CREATE TABLE page_failures (
4
+ page_id TEXT PRIMARY KEY,
5
+ scope_hash TEXT NOT NULL,
6
+ failed_at TEXT NOT NULL
7
+ );
@@ -0,0 +1,9 @@
1
+ -- Each documentation metric's value at each update (design section 18.3): its numerator and denominator, the
2
+ -- denominator empty for a count, so a small share can be shown as "3 of 4".
3
+ CREATE TABLE metric_values (
4
+ snapshot INTEGER NOT NULL REFERENCES snapshots(id),
5
+ metric TEXT NOT NULL,
6
+ numerator INTEGER NOT NULL,
7
+ denominator INTEGER,
8
+ PRIMARY KEY (snapshot, metric)
9
+ );
codetrail/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """Codetrail's error type."""
2
+
3
+
4
+ class CodetrailError(Exception):
5
+ """An error whose message is fit to show the user as it is."""