codealmanac 0.1.0.dev0__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 (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,413 @@
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
6
+
7
+ from codealmanac.core.errors import ExecutionFailed
8
+ from codealmanac.integrations.command import CommandRunner, SubprocessCommandRunner
9
+ from codealmanac.integrations.sources.runtime import (
10
+ bounded_text,
11
+ source_runtime_section,
12
+ surface_process_error,
13
+ )
14
+ from codealmanac.services.sources.models import (
15
+ SourceKind,
16
+ SourceRef,
17
+ SourceRuntime,
18
+ SourceRuntimeStatus,
19
+ )
20
+ from codealmanac.services.sources.requests import InspectSourceRuntimeRequest
21
+
22
+ GITHUB_RUNTIME_TIMEOUT_SECONDS = 30
23
+ DEFAULT_MAX_CHARS = 60_000
24
+ PULL_REQUEST_FIELDS = ",".join(
25
+ (
26
+ "title",
27
+ "state",
28
+ "author",
29
+ "body",
30
+ "url",
31
+ "createdAt",
32
+ "updatedAt",
33
+ "mergedAt",
34
+ "baseRefName",
35
+ "headRefName",
36
+ "commits",
37
+ "files",
38
+ "comments",
39
+ "reviews",
40
+ )
41
+ )
42
+ ISSUE_FIELDS = ",".join(
43
+ (
44
+ "title",
45
+ "state",
46
+ "author",
47
+ "body",
48
+ "url",
49
+ "createdAt",
50
+ "updatedAt",
51
+ "closedAt",
52
+ "labels",
53
+ "assignees",
54
+ "comments",
55
+ )
56
+ )
57
+
58
+
59
+ class GitHubCliModel(BaseModel):
60
+ model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
61
+
62
+
63
+ class GitHubActor(GitHubCliModel):
64
+ login: str
65
+ name: str | None = None
66
+ is_bot: bool | None = Field(default=None, alias="is_bot")
67
+
68
+
69
+ class GitHubLabel(GitHubCliModel):
70
+ name: str
71
+ description: str | None = None
72
+ color: str | None = None
73
+
74
+
75
+ class GitHubComment(GitHubCliModel):
76
+ author: GitHubActor | None = None
77
+ body: str | None = None
78
+ created_at: str | None = Field(default=None, alias="createdAt")
79
+ url: str | None = None
80
+ author_association: str | None = Field(default=None, alias="authorAssociation")
81
+ is_minimized: bool | None = Field(default=None, alias="isMinimized")
82
+ minimized_reason: str | None = Field(default=None, alias="minimizedReason")
83
+
84
+
85
+ class GitHubReview(GitHubCliModel):
86
+ author: GitHubActor | None = None
87
+ body: str | None = None
88
+ state: str | None = None
89
+ submitted_at: str | None = Field(default=None, alias="submittedAt")
90
+
91
+
92
+ class GitHubCommitAuthor(GitHubCliModel):
93
+ name: str | None = None
94
+ email: str | None = None
95
+ login: str | None = None
96
+
97
+
98
+ class GitHubCommit(GitHubCliModel):
99
+ oid: str
100
+ message_headline: str | None = Field(default=None, alias="messageHeadline")
101
+ message_body: str | None = Field(default=None, alias="messageBody")
102
+ authored_date: str | None = Field(default=None, alias="authoredDate")
103
+ committed_date: str | None = Field(default=None, alias="committedDate")
104
+ authors: tuple[GitHubCommitAuthor, ...] = ()
105
+
106
+
107
+ class GitHubFile(GitHubCliModel):
108
+ path: str
109
+ additions: int = 0
110
+ deletions: int = 0
111
+
112
+
113
+ class GitHubPullRequestPayload(GitHubCliModel):
114
+ title: str
115
+ state: str
116
+ author: GitHubActor | None = None
117
+ body: str | None = None
118
+ url: str
119
+ created_at: str | None = Field(default=None, alias="createdAt")
120
+ updated_at: str | None = Field(default=None, alias="updatedAt")
121
+ merged_at: str | None = Field(default=None, alias="mergedAt")
122
+ base_ref_name: str | None = Field(default=None, alias="baseRefName")
123
+ head_ref_name: str | None = Field(default=None, alias="headRefName")
124
+ commits: tuple[GitHubCommit, ...] = ()
125
+ files: tuple[GitHubFile, ...] = ()
126
+ comments: tuple[GitHubComment, ...] = ()
127
+ reviews: tuple[GitHubReview, ...] = ()
128
+
129
+
130
+ class GitHubIssuePayload(GitHubCliModel):
131
+ title: str
132
+ state: str
133
+ author: GitHubActor | None = None
134
+ body: str | None = None
135
+ url: str
136
+ created_at: str | None = Field(default=None, alias="createdAt")
137
+ updated_at: str | None = Field(default=None, alias="updatedAt")
138
+ closed_at: str | None = Field(default=None, alias="closedAt")
139
+ labels: tuple[GitHubLabel, ...] = ()
140
+ assignees: tuple[GitHubActor, ...] = ()
141
+ comments: tuple[GitHubComment, ...] = ()
142
+
143
+
144
+ class GitHubSourceRuntimeAdapter:
145
+ def __init__(
146
+ self,
147
+ runner: CommandRunner | None = None,
148
+ max_chars: int = DEFAULT_MAX_CHARS,
149
+ timeout_seconds: int = GITHUB_RUNTIME_TIMEOUT_SECONDS,
150
+ ):
151
+ self.runner = runner or SubprocessCommandRunner()
152
+ self.max_chars = max_chars
153
+ self.timeout_seconds = timeout_seconds
154
+
155
+ def supports(self, ref: SourceRef) -> bool:
156
+ return ref.kind in {
157
+ SourceKind.GITHUB_PULL_REQUEST,
158
+ SourceKind.GITHUB_ISSUE,
159
+ }
160
+
161
+ def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime:
162
+ if request.ref.kind == SourceKind.GITHUB_PULL_REQUEST:
163
+ return self._inspect_pull_request(request.cwd, request.ref)
164
+ if request.ref.kind == SourceKind.GITHUB_ISSUE:
165
+ return self._inspect_issue(request.cwd, request.ref)
166
+ return SourceRuntime(
167
+ ref=request.ref,
168
+ status=SourceRuntimeStatus.SKIPPED,
169
+ title=f"Unsupported GitHub source {request.ref.identity}",
170
+ )
171
+
172
+ def _inspect_pull_request(self, cwd: Path, ref: SourceRef) -> SourceRuntime:
173
+ try:
174
+ target_args = github_target_args(ref)
175
+ payload = self._gh_json(
176
+ cwd,
177
+ ("pr", "view", *target_args, "--json", PULL_REQUEST_FIELDS),
178
+ GitHubPullRequestPayload,
179
+ )
180
+ diff = self._gh_text(
181
+ cwd,
182
+ ("pr", "diff", *target_args, "--patch", "--color", "never"),
183
+ )
184
+ except (ExecutionFailed, ValidationError, json.JSONDecodeError) as error:
185
+ return unavailable_runtime(ref, "GitHub pull request unavailable", error)
186
+
187
+ content, truncated = bounded_text(
188
+ "\n\n".join(
189
+ (
190
+ source_runtime_section(
191
+ "metadata",
192
+ render_pull_request_metadata(payload),
193
+ ),
194
+ source_runtime_section("body", payload.body or ""),
195
+ source_runtime_section("files", render_files(payload.files)),
196
+ source_runtime_section("commits", render_commits(payload.commits)),
197
+ source_runtime_section(
198
+ "comments",
199
+ render_comments(payload.comments),
200
+ ),
201
+ source_runtime_section("reviews", render_reviews(payload.reviews)),
202
+ source_runtime_section("diff", diff),
203
+ )
204
+ ),
205
+ self.max_chars,
206
+ )
207
+ return SourceRuntime(
208
+ ref=ref,
209
+ status=SourceRuntimeStatus.AVAILABLE,
210
+ title=f"GitHub PR {payload.url}: {payload.title}",
211
+ content=content,
212
+ truncated=truncated,
213
+ )
214
+
215
+ def _inspect_issue(self, cwd: Path, ref: SourceRef) -> SourceRuntime:
216
+ try:
217
+ target_args = github_target_args(ref)
218
+ payload = self._gh_json(
219
+ cwd,
220
+ ("issue", "view", *target_args, "--json", ISSUE_FIELDS),
221
+ GitHubIssuePayload,
222
+ )
223
+ except (ExecutionFailed, ValidationError, json.JSONDecodeError) as error:
224
+ return unavailable_runtime(ref, "GitHub issue unavailable", error)
225
+
226
+ content, truncated = bounded_text(
227
+ "\n\n".join(
228
+ (
229
+ source_runtime_section("metadata", render_issue_metadata(payload)),
230
+ source_runtime_section("body", payload.body or ""),
231
+ source_runtime_section("labels", render_labels(payload.labels)),
232
+ source_runtime_section(
233
+ "assignees",
234
+ render_actors(payload.assignees),
235
+ ),
236
+ source_runtime_section(
237
+ "comments",
238
+ render_comments(payload.comments),
239
+ ),
240
+ )
241
+ ),
242
+ self.max_chars,
243
+ )
244
+ return SourceRuntime(
245
+ ref=ref,
246
+ status=SourceRuntimeStatus.AVAILABLE,
247
+ title=f"GitHub issue {payload.url}: {payload.title}",
248
+ content=content,
249
+ truncated=truncated,
250
+ )
251
+
252
+ def _gh_json(
253
+ self,
254
+ cwd: Path,
255
+ args: tuple[str, ...],
256
+ model: type[GitHubPullRequestPayload] | type[GitHubIssuePayload],
257
+ ) -> GitHubPullRequestPayload | GitHubIssuePayload:
258
+ return model.model_validate_json(self._gh_text(cwd, args))
259
+
260
+ def _gh_text(self, cwd: Path, args: tuple[str, ...]) -> str:
261
+ try:
262
+ result = self.runner.run("gh", args, cwd, self.timeout_seconds)
263
+ except FileNotFoundError as error:
264
+ raise ExecutionFailed("gh not found on PATH") from error
265
+ except subprocess.TimeoutExpired as error:
266
+ raise ExecutionFailed(f"gh {' '.join(args)} timed out") from error
267
+ if result.returncode != 0:
268
+ raise ExecutionFailed(
269
+ f"gh {' '.join(args)} failed: {surface_process_error(result)}"
270
+ )
271
+ return result.stdout.strip()
272
+
273
+
274
+ def github_target_args(ref: SourceRef) -> tuple[str, ...]:
275
+ if ref.url is not None:
276
+ return (ref.url,)
277
+ if ref.number is None:
278
+ raise ExecutionFailed(f"GitHub source missing number: {ref.identity}")
279
+ if ref.repository is None:
280
+ return (str(ref.number),)
281
+ return (str(ref.number), "--repo", ref.repository)
282
+
283
+
284
+ def unavailable_runtime(
285
+ ref: SourceRef,
286
+ title: str,
287
+ error: Exception,
288
+ ) -> SourceRuntime:
289
+ return SourceRuntime(
290
+ ref=ref,
291
+ status=SourceRuntimeStatus.UNAVAILABLE,
292
+ title=title,
293
+ diagnostics=(first_error_line(error),),
294
+ )
295
+
296
+
297
+ def first_error_line(error: Exception) -> str:
298
+ lines = [line.strip() for line in str(error).splitlines() if line.strip()]
299
+ if not lines:
300
+ return error.__class__.__name__
301
+ return lines[0]
302
+
303
+
304
+ def render_pull_request_metadata(payload: GitHubPullRequestPayload) -> str:
305
+ lines = [
306
+ f"title: {payload.title}",
307
+ f"state: {payload.state}",
308
+ f"url: {payload.url}",
309
+ f"author: {render_actor(payload.author)}",
310
+ f"base: {payload.base_ref_name or '(unknown)'}",
311
+ f"head: {payload.head_ref_name or '(unknown)'}",
312
+ f"created_at: {payload.created_at or '(unknown)'}",
313
+ f"updated_at: {payload.updated_at or '(unknown)'}",
314
+ ]
315
+ if payload.merged_at is not None:
316
+ lines.append(f"merged_at: {payload.merged_at}")
317
+ return "\n".join(lines)
318
+
319
+
320
+ def render_issue_metadata(payload: GitHubIssuePayload) -> str:
321
+ lines = [
322
+ f"title: {payload.title}",
323
+ f"state: {payload.state}",
324
+ f"url: {payload.url}",
325
+ f"author: {render_actor(payload.author)}",
326
+ f"created_at: {payload.created_at or '(unknown)'}",
327
+ f"updated_at: {payload.updated_at or '(unknown)'}",
328
+ ]
329
+ if payload.closed_at is not None:
330
+ lines.append(f"closed_at: {payload.closed_at}")
331
+ return "\n".join(lines)
332
+
333
+
334
+ def render_files(files: tuple[GitHubFile, ...]) -> str:
335
+ if len(files) == 0:
336
+ return ""
337
+ return "\n".join(
338
+ f"- {file.path} (+{file.additions}/-{file.deletions})" for file in files
339
+ )
340
+
341
+
342
+ def render_commits(commits: tuple[GitHubCommit, ...]) -> str:
343
+ if len(commits) == 0:
344
+ return ""
345
+ blocks: list[str] = []
346
+ for commit in commits:
347
+ header = commit.oid
348
+ if commit.message_headline:
349
+ header = f"{header} {commit.message_headline}"
350
+ body = (commit.message_body or "").strip()
351
+ if body:
352
+ blocks.append(f"- {header}\n{body}")
353
+ else:
354
+ blocks.append(f"- {header}")
355
+ return "\n".join(blocks)
356
+
357
+
358
+ def render_comments(comments: tuple[GitHubComment, ...]) -> str:
359
+ if len(comments) == 0:
360
+ return ""
361
+ blocks: list[str] = []
362
+ for comment in comments:
363
+ header = f"### {render_actor(comment.author)}"
364
+ if comment.created_at is not None:
365
+ header = f"{header} at {comment.created_at}"
366
+ flags = render_comment_flags(comment)
367
+ if flags:
368
+ header = f"{header} ({flags})"
369
+ blocks.append(f"{header}\n{(comment.body or '').strip()}")
370
+ return "\n\n".join(blocks)
371
+
372
+
373
+ def render_comment_flags(comment: GitHubComment) -> str:
374
+ flags: list[str] = []
375
+ if comment.author_association:
376
+ flags.append(f"association={comment.author_association}")
377
+ if comment.is_minimized is True:
378
+ reason = comment.minimized_reason or "unknown"
379
+ flags.append(f"minimized={reason}")
380
+ return ", ".join(flags)
381
+
382
+
383
+ def render_reviews(reviews: tuple[GitHubReview, ...]) -> str:
384
+ if len(reviews) == 0:
385
+ return ""
386
+ blocks: list[str] = []
387
+ for review in reviews:
388
+ state = review.state or "UNKNOWN"
389
+ header = f"### {state} by {render_actor(review.author)}"
390
+ if review.submitted_at is not None:
391
+ header = f"{header} at {review.submitted_at}"
392
+ blocks.append(f"{header}\n{(review.body or '').strip()}")
393
+ return "\n\n".join(blocks)
394
+
395
+
396
+ def render_labels(labels: tuple[GitHubLabel, ...]) -> str:
397
+ if len(labels) == 0:
398
+ return ""
399
+ return "\n".join(f"- {label.name}" for label in labels)
400
+
401
+
402
+ def render_actors(actors: tuple[GitHubActor, ...]) -> str:
403
+ if len(actors) == 0:
404
+ return ""
405
+ return "\n".join(f"- {render_actor(actor)}" for actor in actors)
406
+
407
+
408
+ def render_actor(actor: GitHubActor | None) -> str:
409
+ if actor is None:
410
+ return "unknown"
411
+ if actor.name:
412
+ return f"{actor.login} ({actor.name})"
413
+ return actor.login
@@ -0,0 +1,22 @@
1
+ from codealmanac.integrations.command import CommandResult, first_line
2
+
3
+
4
+ def source_runtime_section(name: str, body: str) -> str:
5
+ if body.strip() == "":
6
+ return f"## {name}\n\n(no output)"
7
+ return f"## {name}\n\n{body.strip()}"
8
+
9
+
10
+ def bounded_text(value: str, max_chars: int) -> tuple[str, bool]:
11
+ if len(value) <= max_chars:
12
+ return value, False
13
+ return value[:max_chars].rstrip() + "\n\n[truncated]", True
14
+
15
+
16
+ def surface_process_error(result: CommandResult) -> str:
17
+ message = first_line(result.stderr, result.stdout)
18
+ if message == "":
19
+ return f"exit {result.returncode}"
20
+ if len(message) > 500:
21
+ return f"{message[:500]}..."
22
+ return message
@@ -0,0 +1,33 @@
1
+ from codealmanac.integrations.sources.transcripts.claude import (
2
+ ClaudeTranscriptDiscoveryAdapter,
3
+ )
4
+ from codealmanac.integrations.sources.transcripts.codex import (
5
+ CodexTranscriptDiscoveryAdapter,
6
+ )
7
+ from codealmanac.integrations.sources.transcripts.runtime import (
8
+ TranscriptSourceRuntimeAdapter,
9
+ )
10
+ from codealmanac.services.sources.ports import (
11
+ SourceRuntimeAdapter,
12
+ TranscriptDiscoveryAdapter,
13
+ )
14
+
15
+
16
+ def default_transcript_discovery_adapters() -> tuple[TranscriptDiscoveryAdapter, ...]:
17
+ return (
18
+ ClaudeTranscriptDiscoveryAdapter(),
19
+ CodexTranscriptDiscoveryAdapter(),
20
+ )
21
+
22
+
23
+ def default_transcript_runtime_adapters() -> tuple[SourceRuntimeAdapter, ...]:
24
+ return (TranscriptSourceRuntimeAdapter(),)
25
+
26
+
27
+ __all__ = [
28
+ "ClaudeTranscriptDiscoveryAdapter",
29
+ "CodexTranscriptDiscoveryAdapter",
30
+ "TranscriptSourceRuntimeAdapter",
31
+ "default_transcript_discovery_adapters",
32
+ "default_transcript_runtime_adapters",
33
+ ]
@@ -0,0 +1,61 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+ from codealmanac.integrations.sources.transcripts.jsonl import (
5
+ candidate_from_meta,
6
+ collect_jsonl,
7
+ parse_json_object,
8
+ read_first_lines,
9
+ string_field,
10
+ )
11
+ from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
12
+ from codealmanac.services.sources.requests import DiscoverTranscriptsRequest
13
+
14
+ CLAUDE_PROJECTS_DIR = ".claude/projects"
15
+
16
+
17
+ class ClaudeTranscriptDiscoveryAdapter:
18
+ app = TranscriptApp.CLAUDE
19
+
20
+ def __init__(self, projects_dir: Path | None = None):
21
+ self.projects_dir = projects_dir
22
+
23
+ def discover(
24
+ self,
25
+ request: DiscoverTranscriptsRequest,
26
+ ) -> tuple[TranscriptCandidate, ...]:
27
+ root = self.projects_dir or request.home / CLAUDE_PROJECTS_DIR
28
+ candidates: list[TranscriptCandidate] = []
29
+ for path in collect_jsonl(root):
30
+ if "subagents" in path.parts:
31
+ continue
32
+ meta = read_claude_meta(path)
33
+ if meta is None:
34
+ continue
35
+ candidate = candidate_from_meta(
36
+ TranscriptApp.CLAUDE,
37
+ path,
38
+ meta.session_id,
39
+ meta.cwd,
40
+ request.almanac_roots,
41
+ )
42
+ if candidate is not None:
43
+ candidates.append(candidate)
44
+ return tuple(candidates)
45
+
46
+
47
+ class ClaudeTranscriptMeta(CodeAlmanacModel):
48
+ session_id: str
49
+ cwd: str
50
+
51
+
52
+ def read_claude_meta(path: Path) -> ClaudeTranscriptMeta | None:
53
+ for line in read_first_lines(path, 20):
54
+ parsed = parse_json_object(line)
55
+ if parsed is None:
56
+ continue
57
+ session_id = string_field(parsed, "sessionId")
58
+ cwd = string_field(parsed, "cwd")
59
+ if session_id is not None and cwd is not None:
60
+ return ClaudeTranscriptMeta(session_id=session_id, cwd=cwd)
61
+ return None
@@ -0,0 +1,69 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+ from codealmanac.integrations.sources.transcripts.jsonl import (
5
+ candidate_from_meta,
6
+ collect_jsonl,
7
+ object_field,
8
+ parse_json_object,
9
+ read_first_lines,
10
+ string_field,
11
+ )
12
+ from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
13
+ from codealmanac.services.sources.requests import DiscoverTranscriptsRequest
14
+
15
+ CODEX_SESSIONS_DIR = ".codex/sessions"
16
+
17
+
18
+ class CodexTranscriptDiscoveryAdapter:
19
+ app = TranscriptApp.CODEX
20
+
21
+ def __init__(self, sessions_dir: Path | None = None):
22
+ self.sessions_dir = sessions_dir
23
+
24
+ def discover(
25
+ self,
26
+ request: DiscoverTranscriptsRequest,
27
+ ) -> tuple[TranscriptCandidate, ...]:
28
+ root = self.sessions_dir or request.home / CODEX_SESSIONS_DIR
29
+ candidates: list[TranscriptCandidate] = []
30
+ for path in collect_jsonl(root):
31
+ meta = read_codex_meta(path)
32
+ if meta is None or meta.thread_source == "subagent":
33
+ continue
34
+ candidate = candidate_from_meta(
35
+ TranscriptApp.CODEX,
36
+ path,
37
+ meta.session_id,
38
+ meta.cwd,
39
+ request.almanac_roots,
40
+ )
41
+ if candidate is not None:
42
+ candidates.append(candidate)
43
+ return tuple(candidates)
44
+
45
+
46
+ class CodexTranscriptMeta(CodeAlmanacModel):
47
+ session_id: str
48
+ cwd: str
49
+ thread_source: str | None = None
50
+
51
+
52
+ def read_codex_meta(path: Path) -> CodexTranscriptMeta | None:
53
+ for line in read_first_lines(path, 20):
54
+ parsed = parse_json_object(line)
55
+ if parsed is None:
56
+ continue
57
+ payload = object_field(parsed, "payload")
58
+ if payload is None:
59
+ continue
60
+ session_id = string_field(payload, "id")
61
+ cwd = string_field(payload, "cwd")
62
+ if session_id is None or cwd is None:
63
+ continue
64
+ return CodexTranscriptMeta(
65
+ session_id=session_id,
66
+ cwd=cwd,
67
+ thread_source=string_field(payload, "thread_source"),
68
+ )
69
+ return None
@@ -0,0 +1,84 @@
1
+ import json
2
+ from datetime import UTC, datetime
3
+ from pathlib import Path
4
+
5
+ from codealmanac.core.paths import normalize_path
6
+ from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
7
+ from codealmanac.services.workspaces.roots import nearest_almanac_root
8
+
9
+
10
+ def collect_jsonl(root: Path) -> tuple[Path, ...]:
11
+ if not root.is_dir():
12
+ return ()
13
+ return tuple(sorted(path for path in root.rglob("*.jsonl") if path.is_file()))
14
+
15
+
16
+ def read_first_lines(path: Path, max_lines: int) -> tuple[str, ...]:
17
+ try:
18
+ with path.open("r", encoding="utf-8") as file:
19
+ lines = []
20
+ for _ in range(max_lines):
21
+ line = file.readline()
22
+ if line == "":
23
+ break
24
+ lines.append(line.rstrip("\r\n"))
25
+ return tuple(lines)
26
+ except OSError:
27
+ return ()
28
+
29
+
30
+ def parse_json_object(line: str) -> dict[str, object] | None:
31
+ if not line.strip():
32
+ return None
33
+ try:
34
+ parsed = json.loads(line)
35
+ except ValueError:
36
+ return None
37
+ if isinstance(parsed, dict):
38
+ return parsed
39
+ return None
40
+
41
+
42
+ def object_field(
43
+ value: dict[str, object],
44
+ key: str,
45
+ ) -> dict[str, object] | None:
46
+ field = value.get(key)
47
+ if isinstance(field, dict):
48
+ return field
49
+ return None
50
+
51
+
52
+ def string_field(value: dict[str, object], key: str) -> str | None:
53
+ field = value.get(key)
54
+ if isinstance(field, str) and field:
55
+ return field
56
+ return None
57
+
58
+
59
+ def candidate_from_meta(
60
+ app: TranscriptApp,
61
+ transcript_path: Path,
62
+ session_id: str,
63
+ cwd: str,
64
+ almanac_roots: tuple[Path, ...],
65
+ ) -> TranscriptCandidate | None:
66
+ match = nearest_almanac_root(Path(cwd), almanac_roots)
67
+ if match is None:
68
+ return None
69
+ try:
70
+ stat = transcript_path.stat()
71
+ except OSError:
72
+ return None
73
+ if not transcript_path.is_file():
74
+ return None
75
+ return TranscriptCandidate(
76
+ app=app,
77
+ session_id=session_id,
78
+ transcript_path=normalize_path(transcript_path),
79
+ cwd=normalize_path(Path(cwd)),
80
+ repo_root=match.repo_root,
81
+ almanac_path=match.almanac_path,
82
+ modified_at=datetime.fromtimestamp(stat.st_mtime, UTC),
83
+ size_bytes=stat.st_size,
84
+ )