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
@@ -0,0 +1,185 @@
1
+ """The four tools coding agents call: the map, a brief, a page and the changes (design sections 23.5 and 23.7).
2
+
3
+ They read only the target's exported `index.json`: never `source/`, the mirror, the guide or the database. They run
4
+ no git, call no assistant and never take the update lock, so a reader's `codetrail update` runs while agents call
5
+ them; each call holds the target's shared in-use lock for its own length, as the page's requests do. Every argument
6
+ is checked before it is used, and a path is only matched against the pages' scopes, never opened.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+ from collections.abc import Callable, Mapping
14
+ from contextlib import ExitStack
15
+ from datetime import date
16
+
17
+ from codetrail.agents.convert import limit_words
18
+ from codetrail.agents.export import AgentExport, ExportedPage, read_export
19
+ from codetrail.config import AgentSettings, Paths
20
+ from codetrail.errors import CodetrailError
21
+ from codetrail.generate.outline import in_scope
22
+ from codetrail.guide import PAGE_ID
23
+ from codetrail.lock import target_in_use
24
+ from codetrail.repo.source import safe_path
25
+
26
+ COMMIT = re.compile(r"[0-9a-f]{7,40}")
27
+ DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
28
+ MAX_PATH_CHARS = 1000
29
+
30
+
31
+ class ToolInputError(CodetrailError):
32
+ """A bad argument: the agent is told what's wrong, as a tool error."""
33
+
34
+
35
+ def _schema(properties: dict[str, object], required: list[str]) -> dict[str, object]:
36
+ return {"type": "object", "properties": properties, "required": required, "additionalProperties": False}
37
+
38
+
39
+ PAGE = {"type": "string", "description": "A guide page id, such as areas/api or concepts/contract-first"}
40
+ TOOLS: list[dict[str, object]] = [
41
+ {
42
+ "name": "codetrail_map",
43
+ "description": "Codetrail's map of the repository: its parts, decisions, recent changes and guide pages. Call "
44
+ "it before starting a task.",
45
+ "inputSchema": _schema({}, []),
46
+ },
47
+ {
48
+ "name": "codetrail_brief",
49
+ "description": "The brief for coding agents of a guide page, or of the pages covering a path: key files, "
50
+ "conventions and the reasons for them. Give a page or a path, not both.",
51
+ "inputSchema": _schema(
52
+ {"page": PAGE, "path": {"type": "string", "description": "A path relative to the repository's root"}}, []
53
+ ),
54
+ },
55
+ {
56
+ "name": "codetrail_page",
57
+ "description": "A whole guide page, for the full explanation behind a brief.",
58
+ "inputSchema": _schema({"page": PAGE}, ["page"]),
59
+ },
60
+ {
61
+ "name": "codetrail_changes",
62
+ "description": "The guide's digests of what changed since a commit or a date, newest first.",
63
+ "inputSchema": _schema(
64
+ {
65
+ "since": {
66
+ "type": "string",
67
+ "description": "A commit (7 to 40 lower-case hex characters) or a date (YYYY-MM-DD)",
68
+ }
69
+ },
70
+ ["since"],
71
+ ),
72
+ },
73
+ ]
74
+ ARGUMENTS = {tool["name"]: set(tool["inputSchema"]["properties"]) for tool in TOOLS} # type: ignore[index]
75
+
76
+
77
+ class AgentTools:
78
+ def __init__(self, paths: Paths, name: str, settings: AgentSettings, repository: str) -> None:
79
+ self.paths = paths
80
+ self.name = name
81
+ self.settings = settings
82
+ self.repository = repository
83
+
84
+ def definitions(self) -> list[dict[str, object]]:
85
+ """The tools, each saying which repository it covers: the server is registered for the reader, so agents
86
+ working in other repositories see it too."""
87
+ scope = f"For {self.name}, the repository at {self.repository}, only; in any other repository don't call it. "
88
+ return [{**tool, "description": scope + str(tool["description"])} for tool in TOOLS]
89
+
90
+ def call(self, tool: str, arguments: Mapping[str, object]) -> str:
91
+ """The tool's answer; raises ToolInputError for a bad argument and KeyError for an unknown tool."""
92
+ handlers: dict[str, Callable[[AgentExport, Mapping[str, object]], str]] = {
93
+ "codetrail_map": lambda export, _arguments: export.map_text,
94
+ "codetrail_brief": self._brief,
95
+ "codetrail_page": lambda export, given: _page(export, given["page"]).text,
96
+ "codetrail_changes": self._changes,
97
+ }
98
+ handler = handlers[tool]
99
+ unknown = set(arguments) - ARGUMENTS[tool]
100
+ if unknown:
101
+ raise ToolInputError(f"{tool} takes no {', '.join(sorted(unknown))}.")
102
+ with ExitStack() as stack:
103
+ try:
104
+ stack.enter_context(target_in_use(self.paths, self.name))
105
+ except CodetrailError:
106
+ return f"Codetrail no longer has the target {self.name!r}."
107
+ export = read_export(self.paths.target_data(self.name) / "agents")
108
+ if export is None:
109
+ return (f"Codetrail has no context for {self.name} yet: run codetrail update {self.name} or "
110
+ f"codetrail agents {self.name}.") # fmt: skip
111
+ answer = handler(export, arguments)
112
+ limit = self.settings.max_response_words
113
+ text, left_out = limit_words(answer.rstrip(), limit)
114
+ if left_out:
115
+ text += f"\n\n(cut at {limit} words; ask for one page with codetrail_page or codetrail_brief.)"
116
+ return f"{text}\n\nDescribes {export.commit[:12]}.\n"
117
+
118
+ def _brief(self, export: AgentExport, arguments: Mapping[str, object]) -> str:
119
+ if ("page" in arguments) == ("path" in arguments):
120
+ raise ToolInputError("Give codetrail_brief either a page or a path, not both.")
121
+ if "page" in arguments:
122
+ page = _page(export, arguments["page"])
123
+ if page.kind == "digest":
124
+ raise ToolInputError(f"{page.id} is a digest; digests have no brief.")
125
+ return page.brief
126
+ path = _path(arguments["path"])
127
+ best: tuple[int, ExportedPage] | None = None
128
+ for page in export.pages:
129
+ if page.kind == "area":
130
+ for scope in page.scope_paths:
131
+ if in_scope(path, [scope]) and (best is None or len(scope.strip("/")) > best[0]):
132
+ best = (len(scope.strip("/")), page)
133
+ concepts = [page for page in export.pages if page.kind == "concept" and in_scope(path, page.scope_paths)]
134
+ chosen = ([best[1]] if best else []) + concepts
135
+ if not chosen:
136
+ return f"No page covers {path}; the map's Pages list what the guide covers."
137
+ return "\n\n".join(page.brief.rstrip() for page in chosen[: self.settings.max_briefs_per_call])
138
+
139
+ def _changes(self, export: AgentExport, arguments: Mapping[str, object]) -> str:
140
+ since = arguments.get("since")
141
+ digests = [page for page in export.pages if page.kind == "digest"]
142
+ notice = ""
143
+ if isinstance(since, str) and DATE.fullmatch(since):
144
+ try:
145
+ date.fromisoformat(since)
146
+ except ValueError as error:
147
+ raise ToolInputError(f"{since} isn't a date.") from error
148
+ found = [page for page in digests if page.written_at[:10] >= since]
149
+ elif isinstance(since, str) and COMMIT.fullmatch(since):
150
+ matches = {commit for commit in export.snapshots if commit.startswith(since)}
151
+ if len(matches) > 1:
152
+ raise ToolInputError(f"{since} matches several commits; give more characters.")
153
+ if matches:
154
+ positions = {commit: index for index, commit in enumerate(export.snapshots)} # each commit's last
155
+ after = positions[matches.pop()]
156
+ found = [page for page in digests if positions.get(page.built_at, -1) > after]
157
+ else:
158
+ notice = f"{since} isn't a commit this guide knows; here are the latest digests.\n\n"
159
+ found = digests[: self.settings.recent_digests]
160
+ else:
161
+ raise ToolInputError("Give a commit (7 to 40 lower-case hexadecimal characters) or a date (YYYY-MM-DD).")
162
+ return notice + ("\n\n".join(page.text.rstrip() for page in found) or "No digests since then.")
163
+
164
+
165
+ def _page(export: AgentExport, value: object) -> ExportedPage:
166
+ if not isinstance(value, str) or not PAGE_ID.fullmatch(value):
167
+ raise ToolInputError("A page id looks like areas/<name> or concepts/<name>.")
168
+ for page in export.pages:
169
+ if page.id == value:
170
+ return page
171
+ raise ToolInputError(f"{value} isn't a page in this guide; the map's Pages list them.")
172
+
173
+
174
+ def _path(value: object) -> str:
175
+ if not isinstance(value, str) or len(value) > MAX_PATH_CHARS:
176
+ raise ToolInputError(f"A path is text of at most {MAX_PATH_CHARS} characters.")
177
+ if any(unicodedata.category(character) in ("Cc", "Cf") for character in value):
178
+ raise ToolInputError("A path can't hold control characters.")
179
+ path = value.strip()
180
+ while path.startswith("./"):
181
+ path = path[2:]
182
+ path = path.rstrip("/")
183
+ if not safe_path(path):
184
+ raise ToolInputError(f"{path or value!r} isn't a path relative to the repository's root.")
185
+ return path
@@ -0,0 +1,161 @@
1
+ """The interface Codetrail needs from an assistant, shaped by Codetrail's tasks, not by any vendor (design 6.9, 15).
2
+
3
+ Each provider (Claude Code, Codex, a local model) has its own adapter, the only code that knows it; tests use the fake.
4
+ Every draft carries the call's usage: its tokens, and its cost when the provider or the configured prices give one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncIterator
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Protocol
12
+
13
+ from codetrail.errors import CodetrailError
14
+
15
+
16
+ class AssistantError(CodetrailError):
17
+ """The assistant couldn't complete a task: an error, a limit reached, or no usable answer."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PlanWindow:
22
+ """How much of a subscription's usage window is used (0 to 1), and when it resets (seconds since the epoch)."""
23
+
24
+ window: str # "five_hour", "seven_day"...
25
+ utilization: float
26
+ resets_at: int
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Usage:
31
+ """What one call used: tokens, and its cost in dollars when known (the provider's figure, or tokens x prices).
32
+
33
+ `input_tokens` counts input not read from a cache; `cached_input_tokens` counts input read from one.
34
+ """
35
+
36
+ provider: str = ""
37
+ model: str = ""
38
+ input_tokens: int = 0
39
+ cached_input_tokens: int = 0
40
+ output_tokens: int = 0
41
+ cost_usd: float | None = None
42
+ plan_windows: tuple[PlanWindow, ...] = ()
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PlanRequest:
47
+ target: str
48
+ facts: str # a rolled-up summary of the facts
49
+ existing_outline: str # YAML, empty on the first run
50
+ uncovered: list[str] = field(default_factory=list) # fact ids in no page's scope
51
+ paths_only: bool = False # propose guided paths through the existing pages, and no new pages
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class PlanDraft:
56
+ pages: list[dict[str, Any]]
57
+ paths: list[dict[str, Any]] = field(default_factory=list)
58
+ files_read: list[str] = field(default_factory=list)
59
+ cost_usd: float = 0.0
60
+ usage: Usage = field(default_factory=Usage)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class PageRequest:
65
+ page_id: str
66
+ kind: str
67
+ title: str
68
+ scope_paths: list[str]
69
+ facts: str
70
+ decisions: str
71
+ history: str
72
+ problems: list[str] = field(default_factory=list) # from a failed validation, for the retry
73
+ previous_body: str = ""
74
+ # A revision (design 6.3): the page as it stands and what changed in its scope since it was written. With
75
+ # `changes`, the assistant returns only the sections to replace, and new checks only if the old ones don't fit.
76
+ current_body: str = ""
77
+ current_checks: list[dict[str, Any]] = field(default_factory=list)
78
+ changes: list[str] = field(default_factory=list)
79
+ brief_words: int = 300 # the brief for coding agents (design 23.3) has at most this many words
80
+ current_brief: str = "" # a revision's brief as it stands; empty when the page has none yet
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class PageDraft:
85
+ body: str # empty for a revision
86
+ checks: list[dict[str, Any]] = field(default_factory=list)
87
+ files_read: list[str] = field(default_factory=list)
88
+ cost_usd: float = 0.0
89
+ usage: Usage = field(default_factory=Usage)
90
+ sections: list[dict[str, Any]] = field(default_factory=list) # a revision's sections: {"heading", "body"}
91
+ brief: str = "" # the brief for coding agents; a revision may return none, keeping the page's
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class DigestRequest:
96
+ target: str
97
+ commits: str
98
+ fact_changes: str
99
+ pages_changed: list[str] = field(default_factory=list)
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class DigestDraft:
104
+ title: str
105
+ body: str
106
+ files_read: list[str] = field(default_factory=list)
107
+ cost_usd: float = 0.0
108
+ usage: Usage = field(default_factory=Usage)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class QuestionRequest:
113
+ target: str
114
+ question: str
115
+ language: str # a validated, installed language code
116
+ page_title: str = ""
117
+ page_body: str = ""
118
+ page_facts: list[str] = field(default_factory=list)
119
+ diagrams: list[str] = field(default_factory=list) # placeholders Codetrail can draw from this guide's facts
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class AnswerChunk:
124
+ """A piece of a streamed answer; the last one has `done` set, with the files read, the cost and the usage."""
125
+
126
+ text: str = ""
127
+ done: bool = False
128
+ files_read: list[str] = field(default_factory=list)
129
+ cost_usd: float = 0.0
130
+ usage: Usage = field(default_factory=Usage)
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class GradeRequest:
135
+ question: str
136
+ rubric: list[dict[str, Any]]
137
+ page_title: str
138
+ page_body: str
139
+ answer: str
140
+ language: str
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Verdict:
145
+ verdict: str # "pass", "partial" or "fail"; anything else is an error
146
+ missed: list[str] = field(default_factory=list)
147
+ feedback: str = ""
148
+ cost_usd: float = 0.0
149
+ usage: Usage = field(default_factory=Usage)
150
+
151
+
152
+ class Assistant(Protocol):
153
+ async def plan(self, request: PlanRequest) -> PlanDraft: ...
154
+
155
+ async def write_page(self, request: PageRequest) -> PageDraft: ...
156
+
157
+ async def write_digest(self, request: DigestRequest) -> DigestDraft: ...
158
+
159
+ def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]: ...
160
+
161
+ async def grade(self, request: GradeRequest) -> Verdict: ...
@@ -0,0 +1,330 @@
1
+ """The claude_code adapter: the reader's own `claude -p`, read-only, on the reader's sign-in (design 15.1, 15.2).
2
+
3
+ Each call runs `claude -p` in `source/` with the prompt on stdin and two independent read-only layers: Claude Code's
4
+ own permissions (only Read, Grep and Glob, allowed only under the working folder, `dontAsk` refusing the rest) and
5
+ Codetrail's tool guard as a PreToolUse hook. No settings, MCP servers or slash commands of the reader's or the
6
+ target's load. The environment comes from an allowlist, so with a subscription no key is visible. Structured answers
7
+ come back through the `StructuredOutput` tool; usage, cost and the plan's usage windows come from the stream.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import shlex
15
+ import sys
16
+ import tempfile
17
+ from collections.abc import AsyncIterator, Iterator, Mapping
18
+ from contextlib import aclosing, contextmanager
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Final
22
+
23
+ import anyio
24
+
25
+ from codetrail.assistant import (
26
+ AnswerChunk,
27
+ AssistantError,
28
+ DigestDraft,
29
+ DigestRequest,
30
+ GradeRequest,
31
+ PageDraft,
32
+ PageRequest,
33
+ PlanDraft,
34
+ PlanRequest,
35
+ PlanWindow,
36
+ QuestionRequest,
37
+ Usage,
38
+ Verdict,
39
+ runner,
40
+ )
41
+ from codetrail.assistant.environment import child_environment, resolve_program
42
+ from codetrail.assistant.guard import ALLOWED_TOOLS
43
+ from codetrail.assistant.prompts import (
44
+ ANSWER_RULES,
45
+ DIGEST_SCHEMA,
46
+ GRADE_RULES,
47
+ GRADE_SCHEMA,
48
+ GROUND_RULES,
49
+ PAGE_SYNTAX,
50
+ PLAN_SCHEMA,
51
+ answer_prompt,
52
+ digest_prompt,
53
+ grade_prompt,
54
+ neutralize,
55
+ page_task,
56
+ plan_prompt,
57
+ )
58
+ from codetrail.config import ClaudeCodeSettings, GenerationSettings
59
+
60
+ PROVIDER: Final = "claude_code"
61
+
62
+
63
+ class _ProgramFailed(AssistantError):
64
+ """The program ended without a result: worth another try."""
65
+
66
+
67
+ @dataclass
68
+ class _Outcome:
69
+ result: dict[str, Any] | None = None
70
+ windows: dict[str, PlanWindow] = field(default_factory=dict)
71
+
72
+
73
+ class ClaudeCodeAssistant:
74
+ def __init__(
75
+ self,
76
+ source_root: Path,
77
+ models: Mapping[str, str],
78
+ settings: ClaudeCodeSettings,
79
+ limits: GenerationSettings,
80
+ retries: int = 2,
81
+ answer_limits: tuple[int, float] = (20, 1.0),
82
+ grade_budget_usd: float = 0.25,
83
+ environ: Mapping[str, str] = os.environ,
84
+ ) -> None:
85
+ self.root = source_root.resolve()
86
+ self.models = dict(models)
87
+ self.settings = settings
88
+ self.limits = limits
89
+ self.retries = retries
90
+ self.answer_limits = answer_limits # max turns and budget of one bridge answer
91
+ self.grade_budget_usd = grade_budget_usd
92
+ self.environment = child_environment(PROVIDER, settings.auth, environ)
93
+ self.program = resolve_program(settings.command, self.environment.get("PATH", ""))
94
+
95
+ async def plan(self, request: PlanRequest) -> PlanDraft:
96
+ data, files, usage = await self._run("plan", plan_prompt(request), PLAN_SCHEMA, GROUND_RULES)
97
+ return PlanDraft(list(data.get("pages", [])), list(data.get("paths", [])), files, _cost(usage), usage)
98
+
99
+ async def write_page(self, request: PageRequest) -> PageDraft:
100
+ prompt, schema = page_task(request) # a new page, or a revision's sections
101
+ data, files, usage = await self._run("write", prompt, schema, GROUND_RULES + "\n" + PAGE_SYNTAX)
102
+ return PageDraft(str(data.get("body", "")), list(data.get("checks", [])), files, _cost(usage), usage,
103
+ list(data.get("sections", [])), str(data.get("brief", ""))) # fmt: skip
104
+
105
+ async def write_digest(self, request: DigestRequest) -> DigestDraft:
106
+ data, files, usage = await self._run(
107
+ "digest", digest_prompt(request), DIGEST_SCHEMA, GROUND_RULES + "\n" + PAGE_SYNTAX
108
+ )
109
+ return DigestDraft(str(data.get("title", "")), str(data.get("body", "")), files, _cost(usage), usage)
110
+
111
+ async def grade(self, request: GradeRequest) -> Verdict:
112
+ """Grades with no tools at all: only the check, the rubric, the page and the answer (design section 8.2)."""
113
+ data, _files, usage = await self._run(
114
+ "grade", grade_prompt(request), GRADE_SCHEMA, GRADE_RULES, tools=(), budget_usd=self.grade_budget_usd
115
+ )
116
+ missed = [str(item) for item in data.get("missed", [])]
117
+ return Verdict(str(data.get("verdict", "")), missed, str(data.get("feedback", "")), _cost(usage), usage)
118
+
119
+ async def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]:
120
+ """Streams the answer's text as Claude writes it, then a final chunk with the files read and the usage."""
121
+ model = self.models["answer"]
122
+ outcome = _Outcome()
123
+ with self._call_files() as (settings_file, read_log):
124
+ command = self._command(
125
+ model,
126
+ None,
127
+ GROUND_RULES + "\n" + PAGE_SYNTAX + "\n" + ANSWER_RULES,
128
+ ALLOWED_TOOLS,
129
+ self.answer_limits[0],
130
+ self.answer_limits[1],
131
+ settings_file,
132
+ partial=True,
133
+ )
134
+ lines = runner.run_program(
135
+ command, neutralize(answer_prompt(request)), self.root, self.environment, self.settings.timeout_seconds
136
+ )
137
+ async with aclosing(lines):
138
+ async for event in _events(lines):
139
+ text = _text_delta(event)
140
+ if text:
141
+ yield AnswerChunk(text=text)
142
+ _note(event, outcome)
143
+ result = _finished(outcome)
144
+ usage = _usage(result, model, outcome)
145
+ yield AnswerChunk(done=True, files_read=_read(read_log), cost_usd=_cost(usage), usage=usage)
146
+
147
+ async def _run(
148
+ self,
149
+ kind: str,
150
+ prompt: str,
151
+ schema: dict[str, Any],
152
+ system_prompt: str,
153
+ tools: tuple[str, ...] = ALLOWED_TOOLS,
154
+ budget_usd: float | None = None,
155
+ ) -> tuple[dict[str, Any], list[str], Usage]:
156
+ model = self.models[kind]
157
+ budget = self.limits.max_budget_usd_per_call if budget_usd is None else budget_usd
158
+ last_error: Exception | None = None
159
+ for attempt in range(self.retries + 1):
160
+ if attempt:
161
+ await anyio.sleep(2)
162
+ outcome = _Outcome()
163
+ with self._call_files() as (settings_file, read_log):
164
+ command = self._command(
165
+ model, schema, system_prompt, tools, self.limits.max_turns, budget, settings_file
166
+ )
167
+ try:
168
+ lines = runner.run_program(
169
+ command, neutralize(prompt), self.root, self.environment, self.settings.timeout_seconds
170
+ )
171
+ async with aclosing(lines):
172
+ async for event in _events(lines):
173
+ _note(event, outcome)
174
+ except AssistantError as error:
175
+ if outcome.result is not None:
176
+ raise
177
+ last_error = _ProgramFailed(str(error))
178
+ continue
179
+ result = _finished(outcome)
180
+ data = result.get("structured_output")
181
+ if not isinstance(data, dict):
182
+ raise AssistantError("Claude Code finished without a structured answer.")
183
+ return data, _read(read_log), _usage(result, model, outcome)
184
+ raise last_error or AssistantError("Claude Code couldn't be run.")
185
+
186
+ def _command(
187
+ self,
188
+ model: str,
189
+ schema: dict[str, Any] | None,
190
+ system_prompt: str,
191
+ tools: tuple[str, ...],
192
+ max_turns: int,
193
+ budget_usd: float,
194
+ settings_file: Path,
195
+ partial: bool = False,
196
+ ) -> list[str]:
197
+ command = [
198
+ self.program,
199
+ "-p",
200
+ "--output-format",
201
+ "stream-json",
202
+ "--verbose",
203
+ "--tools",
204
+ ",".join(tools),
205
+ "--permission-mode",
206
+ "dontAsk",
207
+ "--setting-sources",
208
+ "",
209
+ "--strict-mcp-config",
210
+ "--disable-slash-commands",
211
+ "--no-session-persistence",
212
+ "--settings",
213
+ str(settings_file),
214
+ "--max-turns",
215
+ str(max_turns),
216
+ "--max-budget-usd",
217
+ f"{budget_usd:g}",
218
+ "--model",
219
+ model,
220
+ "--system-prompt",
221
+ system_prompt,
222
+ ]
223
+ if tools:
224
+ # Reads are allowed only under the working folder; dontAsk refuses everything else, hook or not.
225
+ command += ["--allowedTools", *(f"{tool}(./**)" for tool in tools)]
226
+ if schema is not None:
227
+ command += ["--json-schema", json.dumps(schema)]
228
+ if partial:
229
+ command.append("--include-partial-messages")
230
+ return command
231
+
232
+ @contextmanager
233
+ def _call_files(self) -> Iterator[tuple[Path, Path]]:
234
+ """The settings file with the guard hook, and the hook's read log: private, outside `source/`, removed after."""
235
+ with tempfile.TemporaryDirectory(prefix="codetrail-call-") as folder:
236
+ read_log = Path(folder) / "reads.log"
237
+ # -I: the hook must import Codetrail's guard, never a `codetrail` folder in `source/` (the working folder).
238
+ parts = (sys.executable, "-I", "-m", "codetrail.assistant.guard_hook", str(self.root), str(read_log))
239
+ hook = " ".join(shlex.quote(part) for part in parts)
240
+ settings = {
241
+ "hooks": {
242
+ "PreToolUse": [
243
+ {
244
+ "matcher": "*",
245
+ "hooks": [
246
+ {"type": "command", "command": hook, "timeout": self.settings.hook_timeout_seconds}
247
+ ],
248
+ }
249
+ ]
250
+ }
251
+ }
252
+ settings_file = Path(folder) / "settings.json"
253
+ descriptor = os.open(settings_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
254
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
255
+ json.dump(settings, handle)
256
+ yield settings_file, read_log
257
+
258
+
259
+ async def _events(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]:
260
+ """The stream's JSON events; a line that isn't a JSON object is skipped."""
261
+ async for line in lines:
262
+ try:
263
+ event = json.loads(line)
264
+ except ValueError:
265
+ continue
266
+ if isinstance(event, dict):
267
+ yield event
268
+
269
+
270
+ def _note(event: dict[str, Any], outcome: _Outcome) -> None:
271
+ if event.get("type") == "result":
272
+ outcome.result = event
273
+ elif event.get("type") == "rate_limit_event":
274
+ windows = (event.get("rate_limit_info") or {}).get("unifiedWindows") or {}
275
+ for name, window in windows.items():
276
+ if isinstance(window, dict):
277
+ try:
278
+ outcome.windows[str(name)] = PlanWindow(
279
+ str(name), float(window["utilization"]), int(window["resetsAt"])
280
+ )
281
+ except KeyError, TypeError, ValueError:
282
+ continue
283
+
284
+
285
+ def _text_delta(event: dict[str, Any]) -> str:
286
+ if event.get("type") != "stream_event":
287
+ return ""
288
+ inner = event.get("event") or {}
289
+ delta = inner.get("delta") or {}
290
+ if inner.get("type") == "content_block_delta" and delta.get("type") == "text_delta":
291
+ return str(delta.get("text", ""))
292
+ return ""
293
+
294
+
295
+ def _finished(outcome: _Outcome) -> dict[str, Any]:
296
+ result = outcome.result
297
+ if result is None:
298
+ raise AssistantError("Claude Code ended without a result.")
299
+ if result.get("is_error"):
300
+ raise AssistantError(f"Claude Code didn't finish the task ({result.get('subtype', 'error')}).")
301
+ return result
302
+
303
+
304
+ def _usage(result: dict[str, Any], model: str, outcome: _Outcome) -> Usage:
305
+ usage = result.get("usage") or {}
306
+
307
+ def count(key: str) -> int:
308
+ value = usage.get(key, 0)
309
+ return int(value) if isinstance(value, int | float) else 0
310
+
311
+ cost = result.get("total_cost_usd")
312
+ return Usage(
313
+ PROVIDER,
314
+ model,
315
+ input_tokens=count("input_tokens") + count("cache_creation_input_tokens"),
316
+ cached_input_tokens=count("cache_read_input_tokens"),
317
+ output_tokens=count("output_tokens"),
318
+ cost_usd=float(cost) if isinstance(cost, int | float) else None,
319
+ plan_windows=tuple(outcome.windows.values()),
320
+ )
321
+
322
+
323
+ def _cost(usage: Usage) -> float:
324
+ return usage.cost_usd or 0.0
325
+
326
+
327
+ def _read(read_log: Path) -> list[str]:
328
+ if not read_log.exists():
329
+ return []
330
+ return list(dict.fromkeys(line for line in read_log.read_text(encoding="utf-8").splitlines() if line))