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,530 @@
1
+ """Writing the guide during an update (design section 6).
2
+
3
+ The outline is planned on the first run and extended for new facts no page covers; affected pages are ranked and
4
+ written within the budget, `concurrency` at a time; each draft is validated, retried once with its problems, and kept
5
+ out of the guide if it fails again; a digest records what changed. Everything is committed once at the end, and any
6
+ unexpected failure discards the uncommitted changes. Each step is reported to `progress` as it happens, for the
7
+ page's update panel.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass, field, replace
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+
18
+ import anyio
19
+
20
+ from codetrail.assistant import (
21
+ Assistant,
22
+ AssistantError,
23
+ DigestDraft,
24
+ DigestRequest,
25
+ PageDraft,
26
+ PageRequest,
27
+ PlanDraft,
28
+ PlanRequest,
29
+ )
30
+ from codetrail.assistant.usage import UsageLog
31
+ from codetrail.errors import CodetrailError
32
+ from codetrail.facts import EntityKind, FactDiff
33
+ from codetrail.facts.store import FactStore
34
+ from codetrail.generate.failures import FailedPages
35
+ from codetrail.generate.outline import (
36
+ OutlineEntry,
37
+ OutlinePath,
38
+ outline_data,
39
+ outline_entries,
40
+ outline_paths,
41
+ uncovered_facts,
42
+ validate_outline,
43
+ validate_paths,
44
+ )
45
+ from codetrail.generate.revise import apply_sections
46
+ from codetrail.generate.scope import (
47
+ affected_reason,
48
+ changed_fact_count,
49
+ facts_in_scope,
50
+ page_meta,
51
+ page_snapshot,
52
+ scope_changes,
53
+ scope_hash,
54
+ )
55
+ from codetrail.generate.validate import FLAGGED_PROBLEM, ValidationContext, validate_brief, validate_page
56
+ from codetrail.guide import GuideRepository, Page
57
+ from codetrail.repo.history import Commit, commits_between, recent_commits
58
+ from codetrail.repo.secrets import SecretScanner
59
+ from codetrail.repo.source import SourceManifest
60
+
61
+ MAX_FACT_LINES = 200
62
+ PAGE_ATTEMPTS = 2 # a page that fails validation is written once more
63
+ HISTORY_COMMITS = 25
64
+
65
+ Progress = Callable[[dict[str, object]], None] # one step of an update, such as {"step": "page", "title": ...}
66
+
67
+
68
+ def no_progress(event: dict[str, object]) -> None:
69
+ """Reports nowhere: the command line prints its summary at the end instead."""
70
+
71
+
72
+ @dataclass
73
+ class GenerationContext:
74
+ target: str
75
+ guide: GuideRepository
76
+ store: FactStore
77
+ manifest: SourceManifest
78
+ source_root: Path
79
+ mirror: Path
80
+ scanner: SecretScanner
81
+ visible: Callable[[str], bool]
82
+ max_pages: int
83
+ concurrency: int
84
+ max_budget_usd: float
85
+ previous_commit: str | None # the snapshot before this update, or None on the first
86
+ diff: FactDiff
87
+ learned: set[str] = field(default_factory=set) # pages the reader has learned: rewritten first
88
+ usage: UsageLog | None = None # records each call's usage; its costs count against the update's budget
89
+ max_tokens: int | None = None # the update's token budget, which counts every provider, priced or not
90
+ progress: Progress = no_progress
91
+ failed_pages: FailedPages | None = None # pages that failed validation, skipped until their scope changes
92
+ retry_failed: bool = False # try them anyway
93
+ revise_max_changes: int = 0 # an affected page with at most this many changes in its scope is revised (6.3)
94
+ max_brief_words: int = 300 # the brief for coding agents each page carries (23.3)
95
+
96
+
97
+ @dataclass
98
+ class GenerationResult:
99
+ written: list[str] = field(default_factory=list)
100
+ failed: list[tuple[str, str]] = field(default_factory=list)
101
+ left_for_later: list[str] = field(default_factory=list)
102
+ skipped: list[str] = field(default_factory=list) # failed before, and nothing in their scope changed since
103
+ outline_problems: list[str] = field(default_factory=list)
104
+ digest: str | None = None
105
+ commit: str | None = None
106
+ cost_usd: float = 0.0
107
+ tokens: int = 0
108
+
109
+
110
+ async def generate_guide(context: GenerationContext, claude: Assistant) -> GenerationResult:
111
+ guide = context.guide
112
+ guide.ensure()
113
+ if guide.has_uncommitted_changes():
114
+ raise CodetrailError(
115
+ f"The guide at {guide.root} has uncommitted edits; commit or discard them before updating."
116
+ )
117
+ result = GenerationResult()
118
+ try:
119
+ entries = await _outline(context, claude, result)
120
+ selection = select_pages(context, entries)
121
+ to_write = selection.to_write
122
+ result.left_for_later = [entry.id for entry in selection.left_for_later]
123
+ result.skipped = [entry.id for entry in selection.skipped]
124
+ for entry in selection.skipped:
125
+ context.progress({"step": "page_skipped", "id": entry.id, "title": entry.title})
126
+ validation = ValidationContext(context.source_root, context.manifest, context.store, context.mirror,
127
+ context.scanner) # fmt: skip
128
+ limiter = anyio.Semaphore(context.concurrency)
129
+
130
+ async def write(planned: PlannedPage) -> None:
131
+ async with limiter:
132
+ entry = planned.entry
133
+ if _budget_spent(context, result): # the update's total budget, in dollars or tokens, is spent
134
+ result.left_for_later.append(entry.id)
135
+ context.progress({"step": "page_later", "id": entry.id, "title": entry.title})
136
+ return
137
+ await _write_page(planned, context, claude, validation, result)
138
+
139
+ try:
140
+ async with anyio.create_task_group() as group:
141
+ for planned in to_write:
142
+ group.start_soon(write, planned)
143
+ except ExceptionGroup as errors: # one page's unexpected error is the update's error
144
+ if len(errors.exceptions) == 1:
145
+ raise errors.exceptions[0] from None
146
+ raise
147
+ result.written.sort()
148
+ await _write_digest(context, claude, validation, result)
149
+ result.commit = guide.commit(
150
+ f"Update to {context.manifest.commit[:12]}: {len(result.written)} pages"
151
+ + (", a digest" if result.digest else "")
152
+ )
153
+ context.progress({"step": "committed", "pages": len(result.written), "failed": len(result.failed),
154
+ "later": len(result.left_for_later)}) # fmt: skip
155
+ except BaseException:
156
+ guide.discard()
157
+ raise
158
+ return result
159
+
160
+
161
+ async def _outline(context: GenerationContext, claude: Assistant, result: GenerationResult) -> list[OutlineEntry]:
162
+ guide, store = context.guide, context.store
163
+ stored = guide.read_outline()
164
+ entries = outline_entries(stored)
165
+ paths: list[OutlinePath] = []
166
+ if not entries:
167
+ context.progress({"step": "plan"})
168
+ draft = await claude.plan(PlanRequest(context.target, facts_summary(store, context.manifest), ""))
169
+ spent = _spend(context, result, "plan", draft)
170
+ entries, problems = validate_outline(draft.pages, store, context.manifest)
171
+ context.progress({"step": "planned", "pages": len(entries), **spent})
172
+ result.outline_problems += problems
173
+ if not entries:
174
+ raise CodetrailError("The assistant's outline had no page Codetrail could use: " + "; ".join(problems[:3]))
175
+ paths, problems = validate_paths(draft.paths, {entry.id for entry in entries})
176
+ result.outline_problems += problems
177
+ else:
178
+ paths = outline_paths(stored, {entry.id for entry in entries})
179
+ new_facts = set(context.diff.added_entities)
180
+ uncovered = [fact for fact in uncovered_facts(store, entries) if fact in new_facts]
181
+ if uncovered:
182
+ current = json.dumps(outline_data(entries, paths), indent=1)
183
+ context.progress({"step": "plan"})
184
+ draft = await claude.plan(PlanRequest(context.target, facts_summary(store, context.manifest), current,
185
+ uncovered[:200])) # fmt: skip
186
+ spent = _spend(context, result, "plan", draft)
187
+ added, problems = validate_outline(draft.pages, store, context.manifest, existing=entries)
188
+ context.progress({"step": "planned", "pages": len(added), **spent})
189
+ result.outline_problems += problems
190
+ entries = [*entries, *added]
191
+ if stored is not None and "paths" not in stored: # an outline written before paths existed: plan them once
192
+ current = json.dumps(outline_data(entries), indent=1)
193
+ context.progress({"step": "plan"})
194
+ try:
195
+ draft = await claude.plan(PlanRequest(context.target, "", current, paths_only=True))
196
+ except AssistantError as error:
197
+ result.outline_problems.append(f"Guided paths couldn't be planned: {error}")
198
+ else:
199
+ spent = _spend(context, result, "plan", draft)
200
+ context.progress({"step": "planned", "pages": 0, **spent})
201
+ paths, problems = validate_paths(draft.paths, {entry.id for entry in entries})
202
+ result.outline_problems += problems
203
+ if outline_data(entries, paths) != stored:
204
+ guide.write_outline(outline_data(entries, paths))
205
+ _write_paths(context, entries, paths)
206
+ return entries
207
+
208
+
209
+ def _budget_spent(context: GenerationContext, result: GenerationResult) -> bool:
210
+ over_tokens = context.max_tokens is not None and result.tokens >= context.max_tokens
211
+ return result.cost_usd >= context.max_budget_usd or over_tokens
212
+
213
+
214
+ def _spend(
215
+ context: GenerationContext, result: GenerationResult, kind: str, draft: PlanDraft | PageDraft | DigestDraft
216
+ ) -> dict[str, object]:
217
+ """Records the call's usage, and adds its cost (which counts against the update's budget) and tokens.
218
+
219
+ Returns what the call used, for the progress report: its provider, model, tokens and counted cost."""
220
+ usage = draft.usage
221
+ cost = context.usage.record(kind, usage) if context.usage is not None and usage.provider else draft.cost_usd
222
+ tokens = usage.input_tokens + usage.cached_input_tokens + usage.output_tokens
223
+ result.cost_usd += cost
224
+ result.tokens += tokens
225
+ return {"provider": usage.provider, "model": usage.model, "tokens": tokens, "cost_usd": cost}
226
+
227
+
228
+ def _write_paths(context: GenerationContext, entries: Sequence[OutlineEntry], paths: Sequence[OutlinePath]) -> None:
229
+ """Writes each guided path as a page, without Claude: its goal and its steps, by title."""
230
+ titles = {entry.id: entry.title for entry in entries}
231
+ wanted = {path.id for path in paths}
232
+ for page in context.guide.pages("path"):
233
+ if page.id.startswith("paths/") and page.id not in wanted:
234
+ context.guide.path_of(page.id).unlink()
235
+ for path in paths:
236
+ meta = {"id": path.id, "kind": "path", "title": path.title, "generated": True, "goal": path.goal,
237
+ "steps": path.steps} # fmt: skip
238
+ body = f"{path.goal}\n\n" + "\n".join(
239
+ f"{number}. [{titles.get(step, step)}](/pages/{step})" for number, step in enumerate(path.steps, start=1)
240
+ )
241
+ current = context.guide.read_page(path.id)
242
+ if current is None or current.meta != meta or current.body != body:
243
+ context.guide.write_page(Page(path.id, meta, body))
244
+
245
+
246
+ async def _write_page(
247
+ planned: PlannedPage,
248
+ context: GenerationContext,
249
+ claude: Assistant,
250
+ validation: ValidationContext,
251
+ result: GenerationResult,
252
+ ) -> None:
253
+ """Writes the page, or revises it when it has `planned.changes`: the assistant returns only the sections to
254
+ replace, which go into the page as it stands; either way the whole page is validated, and retried once."""
255
+ entry = planned.entry
256
+ current = context.guide.read_page(entry.id) if planned.changes else None
257
+ revising = current is not None
258
+ history = history_text(context, entry.scope_paths)
259
+ if current is not None:
260
+ checks = [dict(check) for check in current.meta.get("checks") or []]
261
+ request = PageRequest(entry.id, entry.kind, entry.title, entry.scope_paths, "", "", history,
262
+ current_body=current.body, current_checks=checks, changes=planned.changes,
263
+ brief_words=context.max_brief_words,
264
+ current_brief=str(current.meta.get("brief") or "")) # fmt: skip
265
+ else:
266
+ request = PageRequest(entry.id, entry.kind, entry.title, entry.scope_paths, facts_text(context.store, entry),
267
+ decisions_text(context.store), history, brief_words=context.max_brief_words) # fmt: skip
268
+ problems: list[str] = []
269
+ try:
270
+ for attempt in range(1, PAGE_ATTEMPTS + 1):
271
+ context.progress({"step": "page", "id": entry.id, "title": entry.title, "attempt": attempt,
272
+ "reason": planned.reason, "changed": planned.changed_now,
273
+ "revise": revising}) # fmt: skip
274
+ draft = await claude.write_page(request)
275
+ spent = _spend(context, result, "revise" if revising else "write", draft)
276
+ body, checks, brief = draft.body, draft.checks, draft.brief
277
+ if current is not None:
278
+ body = apply_sections(request.current_body, draft.sections)
279
+ checks = draft.checks or request.current_checks
280
+ brief = draft.brief or request.current_brief # a revision keeps the brief it doesn't replace
281
+ problems = validate_page(body, checks, validation)
282
+ problems += validate_brief(brief, validation, context.max_brief_words)
283
+ if not problems:
284
+ meta = page_meta(entry, context.store, context.manifest, draft.files_read)
285
+ meta["checks"] = checks
286
+ meta["brief"] = brief
287
+ if current is not None: # files it didn't read again keep the version the page was written from
288
+ kept = [
289
+ item for item in current.meta.get("files") or [] if item.get("path") not in draft.files_read
290
+ ]
291
+ meta["files"] = kept + meta["files"]
292
+ context.guide.write_page(Page(entry.id, meta, body))
293
+ result.written.append(entry.id)
294
+ if context.failed_pages is not None:
295
+ context.failed_pages.forget(entry.id)
296
+ unchanged = revising and not draft.sections and not draft.checks and not draft.brief
297
+ context.progress({"step": "page_written", "id": entry.id, "title": entry.title, "revise": revising,
298
+ "unchanged": unchanged, **spent}) # fmt: skip
299
+ return
300
+ if current is not None and any(problem.startswith(FLAGGED_PROBLEM) for problem in problems):
301
+ request = replace(request, problems=problems) # a flagged revision never goes back to the provider
302
+ elif current is not None: # the retry sees its own revision in the page, and what's wrong with it
303
+ request = replace(
304
+ request, problems=problems, current_body=body, current_checks=checks, current_brief=brief
305
+ )
306
+ else:
307
+ request = replace(request, problems=problems, previous_body=draft.body)
308
+ reason = "; ".join(problems[:3])
309
+ if context.failed_pages is not None: # its drafts failed validation: not worth paying for again unchanged
310
+ context.failed_pages.record(entry.id, scope_hash(context.store, entry))
311
+ except AssistantError as error:
312
+ reason = str(error)
313
+ result.failed.append((entry.id, reason))
314
+ context.progress({"step": "page_failed", "id": entry.id, "title": entry.title, "reason": reason})
315
+
316
+
317
+ async def _write_digest(
318
+ context: GenerationContext, claude: Assistant, validation: ValidationContext, result: GenerationResult
319
+ ) -> None:
320
+ head = context.manifest.commit
321
+ found = _digest_range(context, bool(result.written))
322
+ if found is None:
323
+ return
324
+ context.progress({"step": "digest"})
325
+ since, commits = found
326
+ page_id = f"digests/{datetime.now(UTC).strftime('%Y-%m-%d')}-{head[:12]}"
327
+ meta = {"id": page_id, "kind": "digest", "generated": True, "from_commit": since, "to_commit": head,
328
+ "written_at": datetime.now(UTC).isoformat(timespec="seconds"), "pages_changed": result.written} # fmt: skip
329
+ if not commits:
330
+ meta["title"] = "The guide was created"
331
+ body = "The guide's first pages:\n\n" + "\n".join(f"- [{page}](/pages/{page})" for page in result.written)
332
+ context.guide.write_page(Page(page_id, meta, body))
333
+ result.digest = page_id
334
+ return
335
+ request = DigestRequest(context.target, commits_text(commits), fact_changes_text(context.diff), result.written)
336
+ try:
337
+ if _budget_spent(context, result):
338
+ raise AssistantError("the update's budget is spent")
339
+ draft = await claude.write_digest(request)
340
+ _spend(context, result, "digest", draft)
341
+ if validate_page(draft.body, None, validation):
342
+ raise AssistantError("the digest failed validation")
343
+ meta["title"], body = draft.title, draft.body
344
+ except AssistantError:
345
+ meta["title"] = f"{len(commits)} commits since the last update"
346
+ body = "The assistant's digest couldn't be checked, so here are the commits:\n\n" + "\n".join(
347
+ f"- {commit.subject}" for commit in commits
348
+ )
349
+ context.guide.write_page(Page(page_id, meta, body))
350
+ result.digest = page_id
351
+
352
+
353
+ def _digest_range(context: GenerationContext, pages_written: bool) -> tuple[str | None, list[Commit]] | None:
354
+ """The commits a digest covers, from the last digest to the head; None when no digest is due."""
355
+ head = context.manifest.commit
356
+ digests = sorted(context.guide.pages("digest"), key=lambda page: str(page.meta.get("written_at", "")))
357
+ since = str(digests[-1].meta.get("to_commit")) if digests else context.previous_commit
358
+ if not digests and pages_written:
359
+ since = None # the guide's first pages: record its creation, whatever facts-only updates came before
360
+ if since == head or (since is None and not pages_written):
361
+ return None
362
+ commits = commits_between(context.mirror, since, head, context.visible, context.scanner) if since else []
363
+ if since and not commits and not pages_written:
364
+ return None
365
+ return since, commits
366
+
367
+
368
+ @dataclass(frozen=True)
369
+ class PlannedPage:
370
+ """A page the update will write, and why: "new", "outline" (its entry changed), "update" (facts in its scope
371
+ changed in this update) or "catching_up" (they changed in an earlier update that didn't rewrite it)."""
372
+
373
+ entry: OutlineEntry
374
+ reason: str
375
+ changed_now: int # facts in its scope this update added, changed or removed
376
+ changes: list[str] = field(default_factory=list) # set when the page is revised: what changed since it was written
377
+
378
+
379
+ @dataclass(frozen=True)
380
+ class PageSelection:
381
+ to_write: list[PlannedPage]
382
+ left_for_later: list[OutlineEntry] # beyond max_pages_per_update
383
+ skipped: list[OutlineEntry] # failed validation before, and their scope hasn't changed since
384
+
385
+
386
+ def select_pages(context: GenerationContext, entries: Sequence[OutlineEntry]) -> PageSelection:
387
+ """The affected pages, in the order they are written: those this update's facts touched first, then the ones
388
+ the reader learned, then new pages, then the backlog; within a group, the most changed facts first. A page that
389
+ failed validation is skipped while its scope is what it was then, unless `context.retry_failed`."""
390
+ touched = context.diff.changed_ids()
391
+ affected: list[tuple[tuple[bool, bool, bool, int], PlannedPage]] = []
392
+ skipped: list[OutlineEntry] = []
393
+ for entry in entries:
394
+ page = context.guide.read_page(entry.id)
395
+ reason = affected_reason(entry, page, context.store)
396
+ if reason is None:
397
+ continue
398
+ if context.failed_pages is not None and not context.retry_failed:
399
+ failed_with = context.failed_pages.scope_hash(entry.id)
400
+ if failed_with is not None and failed_with == scope_hash(context.store, entry):
401
+ skipped.append(entry)
402
+ continue
403
+ changed_now = sum(1 for entity in facts_in_scope(context.store, entry) if entity.id in touched)
404
+ kind = {"new": "new", "outline changed": "outline"}.get(reason, "update" if changed_now else "catching_up")
405
+ rank = (not changed_now, entry.id not in context.learned, kind not in ("new", "outline"),
406
+ -changed_fact_count(entry, page, context.store)) # fmt: skip
407
+ affected.append((rank, PlannedPage(entry, kind, changed_now, _revision(context, entry, page, kind))))
408
+ affected.sort(key=lambda item: item[0])
409
+ planned = [page for _, page in affected]
410
+ return PageSelection(planned[: context.max_pages], [page.entry for page in planned[context.max_pages :]], skipped)
411
+
412
+
413
+ def _revision(context: GenerationContext, entry: OutlineEntry, page: Page | None, kind: str) -> list[str]:
414
+ """What changed in the page's scope since it was written, when there are few enough changes to revise it rather
415
+ than write it again; empty for a new page, a changed outline entry, or too many changes."""
416
+ if page is None or kind not in ("update", "catching_up") or not context.revise_max_changes:
417
+ return []
418
+ since = page_snapshot(context.store, page)
419
+ changes = scope_changes(context.store, entry, since) if since is not None else []
420
+ return changes if len(changes) <= context.revise_max_changes else []
421
+
422
+
423
+ @dataclass(frozen=True)
424
+ class PlannedWork:
425
+ """The paid calls an update will make, counted without making any (design section 15.4)."""
426
+
427
+ plan_calls: int
428
+ pages_expected: int # pages written whole
429
+ page_calls_max: int # each page may be written twice: the retry after a failed validation
430
+ digest_expected: bool
431
+ digest_possible: bool # a digest also runs when commits came in and no page was written
432
+ pages: list[PlannedPage] = field(default_factory=list) # empty before the first outline
433
+ skipped: list[OutlineEntry] = field(default_factory=list)
434
+ revisions_expected: int = 0 # pages revised (section 6.3), each also retried once at most
435
+
436
+
437
+ def planned_work(context: GenerationContext) -> PlannedWork:
438
+ entries = outline_entries(context.guide.read_outline())
439
+ if not entries: # the first outline: a plan, then as many pages as the cap allows
440
+ return PlannedWork(1, context.max_pages, context.max_pages * PAGE_ATTEMPTS, True, True)
441
+ stored = context.guide.read_outline()
442
+ new_facts = set(context.diff.added_entities)
443
+ plan_calls = int(any(fact in new_facts for fact in uncovered_facts(context.store, entries)))
444
+ plan_calls += int(stored is not None and "paths" not in stored)
445
+ selection = select_pages(context, entries)
446
+ revisions = sum(1 for page in selection.to_write if page.changes)
447
+ expected = len(selection.to_write) - revisions
448
+ maximum = context.max_pages if plan_calls else expected # new pages can push revisions out and take every slot
449
+
450
+ def digest_due(pages_written: bool) -> bool:
451
+ found = _digest_range(context, pages_written)
452
+ return found is not None and bool(found[1])
453
+
454
+ possible = digest_due(True) or digest_due(False)
455
+ return PlannedWork(plan_calls, expected, maximum * PAGE_ATTEMPTS, digest_due(bool(selection.to_write)), possible,
456
+ selection.to_write, selection.skipped, revisions) # fmt: skip
457
+
458
+
459
+ def facts_summary(store: FactStore, manifest: SourceManifest) -> str:
460
+ """A rolled-up view of the facts for planning: folders, projects, decisions, packages."""
461
+ lines = ["Top-level folders (visible files):"]
462
+ folders: dict[str, int] = {}
463
+ for path in manifest.files:
464
+ parts = path.split("/")
465
+ key = "/".join(parts[:2]) if len(parts) > 2 else parts[0] if len(parts) > 1 else "(root)"
466
+ folders[key] = folders.get(key, 0) + 1
467
+ lines += [f"- {folder}: {count}" for folder, count in sorted(folders.items())]
468
+ for kind in (EntityKind.PROJECT, EntityKind.DECISION, EntityKind.PACKAGE, EntityKind.ROUTE,
469
+ EntityKind.TERRAFORM_MODULE, EntityKind.SWIFT_TARGET): # fmt: skip
470
+ lines.append(f"\n{kind} facts:")
471
+ lines += [f"- {entity.id} {json.dumps(dict(entity.attributes))}" for entity in store.entities(kind)]
472
+ modules: dict[str, int] = {}
473
+ for entity in store.entities(EntityKind.MODULE):
474
+ folder = "/".join(entity.id.removeprefix("module:").split("/")[:-1])
475
+ modules[folder] = modules.get(folder, 0) + 1
476
+ lines.append("\nmodule facts, counted by folder (ids are module:<path>):")
477
+ lines += [f"- {folder}: {count}" for folder, count in sorted(modules.items())]
478
+ return "\n".join(lines)
479
+
480
+
481
+ def facts_text(store: FactStore, entry: OutlineEntry) -> str:
482
+ entities = facts_in_scope(store, entry)
483
+ lines = [f"{entity.kind} {entity.id} {json.dumps(dict(entity.attributes))}" for entity in entities]
484
+ if len(lines) > MAX_FACT_LINES:
485
+ lines = [*lines[:MAX_FACT_LINES], f"... and {len(lines) - MAX_FACT_LINES} more in the same scope"]
486
+ return "\n".join(lines) or "(none)"
487
+
488
+
489
+ def decisions_text(store: FactStore) -> str:
490
+ return "\n".join(
491
+ f"- {entity.id} ({entity.attributes.get('status', '?')}): {entity.attributes.get('title', '')}"
492
+ f" — {entity.attributes.get('path', '')}"
493
+ for entity in store.entities(EntityKind.DECISION)
494
+ )
495
+
496
+
497
+ def history_text(context: GenerationContext, scope_paths: Sequence[str]) -> str:
498
+ commits = recent_commits(context.mirror, context.manifest.commit, scope_paths, HISTORY_COMMITS, context.visible,
499
+ context.scanner) # fmt: skip
500
+ return commits_text(commits)
501
+
502
+
503
+ def commits_text(commits: Sequence[Commit]) -> str:
504
+ blocks = []
505
+ for commit in commits:
506
+ why = _section(commit.body, "Why")
507
+ blocks.append(f"- {commit.sha[:12]} {commit.date[:10]} {commit.subject}" + (f"\n Why: {why}" if why else ""))
508
+ return "\n".join(blocks)
509
+
510
+
511
+ def fact_changes_text(diff: FactDiff) -> str:
512
+ lines = [f"added {fact}" for fact in diff.added_entities[:60]]
513
+ lines += [f"changed {fact}" for fact in diff.changed_entities[:60]]
514
+ lines += [f"removed {fact}" for fact in diff.removed_entities[:60]]
515
+ return "\n".join(lines)
516
+
517
+
518
+ def _section(body: str, name: str) -> str:
519
+ """The text of a commit body's section, written as a heading line or inline ("Why: ...")."""
520
+ lines = body.splitlines()
521
+ for index, line in enumerate(lines):
522
+ stripped = line.strip()
523
+ if stripped == name or stripped.startswith(f"{name}:"):
524
+ collected = [stripped[len(name) + 1 :].strip()] if stripped != name else []
525
+ for following in lines[index + 1 :]:
526
+ if not following.strip() or (following.strip().endswith(":") and following[:1].isupper()):
527
+ break
528
+ collected.append(following.strip())
529
+ return " ".join(part for part in collected if part)[:600]
530
+ return ""
@@ -0,0 +1,135 @@
1
+ """What a page covers, and whether it must be rewritten (design section 4.3).
2
+
3
+ A page records the facts it explains directly with their hashes, one hash over everything in its scope, the snapshot
4
+ it was written at, and the files Claude read with their blobs. Changed facts or a changed scope make it affected;
5
+ changed files only flag it. What changed in its scope since its snapshot is what a revision is told (section 6.3).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from collections.abc import Iterable
13
+ from typing import Any
14
+
15
+ from codetrail.facts import Entity, Relation
16
+ from codetrail.facts.store import FactStore
17
+ from codetrail.generate.outline import OutlineEntry, in_scope
18
+ from codetrail.guide import Page
19
+ from codetrail.repo.source import SourceManifest
20
+
21
+
22
+ def facts_in_scope(store: FactStore, entry: OutlineEntry) -> list[Entity]:
23
+ return entities_in_scope(store.entities(), entry)
24
+
25
+
26
+ def entities_in_scope(entities: list[Entity], entry: OutlineEntry) -> list[Entity]:
27
+ """The entities under the entry's scope paths (of its kinds, if it names any), and the facts it names."""
28
+ named = set(entry.facts)
29
+ found = []
30
+ for entity in entities:
31
+ kind_ok = not entry.scope_kinds or str(entity.kind) in entry.scope_kinds
32
+ under = any(in_scope(source.path, entry.scope_paths) for source in entity.sources)
33
+ if entity.id in named or (kind_ok and under):
34
+ found.append(entity)
35
+ return sorted(found, key=lambda entity: entity.id)
36
+
37
+
38
+ def page_snapshot(store: FactStore, page: Page) -> int | None:
39
+ """The snapshot the page was written at; for a page written before pages recorded it, the earliest snapshot at
40
+ its commit, which can only show more changes than happened since, never fewer."""
41
+ recorded = page.meta.get("snapshot")
42
+ if isinstance(recorded, int):
43
+ return recorded
44
+ found = store.first_snapshot(str(page.meta.get("built_at", "")))
45
+ return found.id if found else None
46
+
47
+
48
+ def scope_changes(store: FactStore, entry: OutlineEntry, since: int) -> list[str]:
49
+ """Each fact and link in the entry's scope added, changed or removed since the snapshot, one line each."""
50
+ before = {entity.id: entity for entity in entities_in_scope(store.entities_at(since), entry)}
51
+ now = {entity.id: entity for entity in entities_in_scope(store.entities(), entry)}
52
+ lines = []
53
+ for entity_id in sorted(before.keys() | now.keys()):
54
+ old, new = before.get(entity_id), now.get(entity_id)
55
+ if new is None and old is not None:
56
+ lines.append(f"removed {old.kind} {entity_id}")
57
+ elif new is not None and (old is None or old.hash != new.hash):
58
+ attributes = json.dumps(dict(new.attributes), sort_keys=True, ensure_ascii=False)
59
+ lines.append(f"{'added' if old is None else 'changed'} {new.kind} {entity_id} {attributes}")
60
+ old_links = _links(store.relations_at(since), before.keys())
61
+ new_links = _links(store.relations(), now.keys())
62
+ for key in sorted(old_links.keys() | new_links.keys()):
63
+ old_hash, new_hash = old_links.get(key), new_links.get(key)
64
+ if old_hash != new_hash:
65
+ change = "removed" if new_hash is None else "added" if old_hash is None else "changed"
66
+ lines.append(f"{change} link {' '.join(key)}")
67
+ return sorted(lines)
68
+
69
+
70
+ def _links(relations: list[Relation], sources: Iterable[str]) -> dict[tuple[str, str, str], str]:
71
+ """The relations from the given entities, as scope_hash counts them."""
72
+ ids = set(sources)
73
+ return {relation.key: relation.hash for relation in relations if relation.source_id in ids}
74
+
75
+
76
+ def scope_hash(store: FactStore, entry: OutlineEntry) -> str:
77
+ entities = facts_in_scope(store, entry)
78
+ ids = {entity.id for entity in entities}
79
+ relations = [relation for relation in store.relations() if relation.source_id in ids]
80
+ parts = [[entity.id, entity.hash] for entity in entities] + [[list(r.key), r.hash] for r in relations]
81
+ return hashlib.sha256(json.dumps(parts, sort_keys=True).encode()).hexdigest()
82
+
83
+
84
+ def page_meta(entry: OutlineEntry, store: FactStore, manifest: SourceManifest, files_read: list[str]) -> dict[str, Any]:
85
+ facts = []
86
+ for fact in entry.facts:
87
+ entity = store.entity(fact)
88
+ if entity is not None:
89
+ facts.append({"id": entity.id, "hash": entity.hash})
90
+ snapshot = store.latest_snapshot()
91
+ return {
92
+ "id": entry.id,
93
+ "kind": entry.kind,
94
+ "title": entry.title,
95
+ "generated": True,
96
+ "built_at": snapshot.commit if snapshot else manifest.commit,
97
+ "snapshot": snapshot.id if snapshot else None,
98
+ "facts": facts,
99
+ "scope": {"paths": entry.scope_paths, "kinds": entry.scope_kinds, "hash": scope_hash(store, entry)},
100
+ "files": [{"path": path, "blob": manifest.files[path]} for path in files_read if path in manifest.files],
101
+ }
102
+
103
+
104
+ def affected_reason(entry: OutlineEntry, page: Page | None, store: FactStore) -> str | None:
105
+ """Why the page must be rewritten, or None when it is current."""
106
+ if page is None:
107
+ return "new"
108
+ meta = page.meta
109
+ scope = meta.get("scope") or {}
110
+ if meta.get("title") != entry.title or scope.get("paths") != entry.scope_paths:
111
+ return "outline changed"
112
+ for fact in meta.get("facts") or []:
113
+ entity = store.entity(str(fact.get("id")))
114
+ if entity is None or entity.hash != fact.get("hash"):
115
+ return "facts changed"
116
+ if scope.get("hash") != scope_hash(store, entry):
117
+ return "scope changed"
118
+ return None
119
+
120
+
121
+ def changed_fact_count(entry: OutlineEntry, page: Page | None, store: FactStore) -> int:
122
+ """How many of the page's recorded facts changed or disappeared; used to rank affected pages."""
123
+ if page is None:
124
+ return len(entry.facts)
125
+ count = 0
126
+ for fact in page.meta.get("facts") or []:
127
+ entity = store.entity(str(fact.get("id")))
128
+ if entity is None or entity.hash != fact.get("hash"):
129
+ count += 1
130
+ return count
131
+
132
+
133
+ def sources_changed(page: Page, manifest: SourceManifest) -> bool:
134
+ """True when a file Claude read for the page has a different blob now (or is no longer visible)."""
135
+ return any(manifest.files.get(str(item.get("path"))) != item.get("blob") for item in page.meta.get("files") or [])