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,56 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Protocol
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+
7
+
8
+ class CommandResult(CodeAlmanacModel):
9
+ returncode: int
10
+ stdout: str = ""
11
+ stderr: str = ""
12
+
13
+
14
+ class CommandRunner(Protocol):
15
+ def run(
16
+ self,
17
+ command: str,
18
+ args: tuple[str, ...],
19
+ cwd: Path,
20
+ timeout_seconds: int,
21
+ stdin: str | None = None,
22
+ ) -> CommandResult:
23
+ """Run a local command and return captured text output."""
24
+
25
+
26
+ class SubprocessCommandRunner:
27
+ def run(
28
+ self,
29
+ command: str,
30
+ args: tuple[str, ...],
31
+ cwd: Path,
32
+ timeout_seconds: int,
33
+ stdin: str | None = None,
34
+ ) -> CommandResult:
35
+ completed = subprocess.run(
36
+ (command, *args),
37
+ cwd=cwd,
38
+ text=True,
39
+ input=stdin,
40
+ capture_output=True,
41
+ timeout=timeout_seconds,
42
+ check=False,
43
+ )
44
+ return CommandResult(
45
+ returncode=completed.returncode,
46
+ stdout=completed.stdout,
47
+ stderr=completed.stderr,
48
+ )
49
+
50
+
51
+ def first_line(*values: str) -> str:
52
+ for value in values:
53
+ lines = [line.strip() for line in value.splitlines() if line.strip()]
54
+ if lines:
55
+ return lines[0]
56
+ return ""
@@ -0,0 +1,7 @@
1
+ from codealmanac.integrations.harnesses.claude.adapter import ClaudeCliHarnessAdapter
2
+ from codealmanac.integrations.harnesses.codex.adapter import CodexCliHarnessAdapter
3
+ from codealmanac.services.harnesses.ports import HarnessAdapter
4
+
5
+
6
+ def default_harness_adapters() -> tuple[HarnessAdapter, ...]:
7
+ return (ClaudeCliHarnessAdapter(), CodexCliHarnessAdapter())
@@ -0,0 +1 @@
1
+ """Claude Code CLI harness adapter."""
@@ -0,0 +1,217 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
5
+
6
+ from codealmanac.core.text import required_text
7
+ from codealmanac.integrations.command import (
8
+ CommandResult,
9
+ CommandRunner,
10
+ SubprocessCommandRunner,
11
+ first_line,
12
+ )
13
+ from codealmanac.integrations.harnesses.git_status import (
14
+ changed_paths,
15
+ git_status_snapshot,
16
+ parse_git_status_paths,
17
+ )
18
+ from codealmanac.services.harnesses.models import (
19
+ HarnessKind,
20
+ HarnessReadiness,
21
+ HarnessRunResult,
22
+ HarnessRunStatus,
23
+ HarnessTranscriptRef,
24
+ terminal_harness_event,
25
+ )
26
+ from codealmanac.services.harnesses.requests import RunHarnessRequest
27
+
28
+ CLAUDE_COMMAND = "claude"
29
+ CLAUDE_RUN_TIMEOUT_SECONDS = 900
30
+ CLAUDE_STATUS_TIMEOUT_SECONDS = 10
31
+ CLAUDE_ALLOWED_TOOLS = "Read,Write,Edit,MultiEdit,Glob,Grep,LS"
32
+
33
+
34
+ class ClaudeAuthStatus(BaseModel):
35
+ model_config = ConfigDict(extra="ignore", frozen=True)
36
+
37
+ logged_in: bool = Field(validation_alias="loggedIn")
38
+ auth_method: str | None = Field(default=None, validation_alias="authMethod")
39
+
40
+ @classmethod
41
+ def from_json(cls, value: str) -> "ClaudeAuthStatus":
42
+ return cls.model_validate_json(value)
43
+
44
+
45
+ class ClaudeCliResult(BaseModel):
46
+ model_config = ConfigDict(extra="ignore", frozen=True)
47
+
48
+ type: str
49
+ subtype: str | None = None
50
+ is_error: bool = False
51
+ result: str
52
+ session_id: str | None = None
53
+
54
+ @field_validator("result")
55
+ @classmethod
56
+ def require_result(cls, value: str) -> str:
57
+ return required_text(value, "Claude result")
58
+
59
+
60
+ class ClaudeCliHarnessAdapter:
61
+ kind = HarnessKind.CLAUDE
62
+
63
+ def __init__(
64
+ self,
65
+ runner: CommandRunner | None = None,
66
+ command: str = CLAUDE_COMMAND,
67
+ run_timeout_seconds: int = CLAUDE_RUN_TIMEOUT_SECONDS,
68
+ status_timeout_seconds: int = CLAUDE_STATUS_TIMEOUT_SECONDS,
69
+ ):
70
+ self.runner = runner or SubprocessCommandRunner()
71
+ self.command = command
72
+ self.run_timeout_seconds = run_timeout_seconds
73
+ self.status_timeout_seconds = status_timeout_seconds
74
+
75
+ def check(self) -> HarnessReadiness:
76
+ try:
77
+ result = self.runner.run(
78
+ self.command,
79
+ ("auth", "status"),
80
+ Path.cwd(),
81
+ self.status_timeout_seconds,
82
+ )
83
+ except FileNotFoundError:
84
+ return HarnessReadiness(
85
+ kind=self.kind,
86
+ available=False,
87
+ message="claude not found on PATH",
88
+ )
89
+ except subprocess.TimeoutExpired:
90
+ return HarnessReadiness(
91
+ kind=self.kind,
92
+ available=False,
93
+ message="claude auth status timed out",
94
+ )
95
+ if result.returncode != 0:
96
+ return HarnessReadiness(
97
+ kind=self.kind,
98
+ available=False,
99
+ message=first_line(result.stderr, result.stdout)
100
+ or f"claude auth status exited {result.returncode}",
101
+ )
102
+ try:
103
+ status = ClaudeAuthStatus.from_json(result.stdout)
104
+ except ValueError as error:
105
+ return HarnessReadiness(
106
+ kind=self.kind,
107
+ available=False,
108
+ message=str(error),
109
+ )
110
+ if not status.logged_in:
111
+ return HarnessReadiness(
112
+ kind=self.kind,
113
+ available=False,
114
+ message="claude is not logged in",
115
+ )
116
+ return HarnessReadiness(
117
+ kind=self.kind,
118
+ available=True,
119
+ message=status.auth_method or "claude authenticated",
120
+ )
121
+
122
+ def run(self, request: RunHarnessRequest) -> HarnessRunResult:
123
+ before = git_status_snapshot(self.runner, request.cwd)
124
+ try:
125
+ result = self.runner.run(
126
+ self.command,
127
+ claude_print_args(),
128
+ request.cwd,
129
+ self.run_timeout_seconds,
130
+ request.prompt,
131
+ )
132
+ except FileNotFoundError:
133
+ return failed_result("claude not found on PATH")
134
+ except subprocess.TimeoutExpired:
135
+ return failed_result("claude run timed out")
136
+ after = git_status_snapshot(self.runner, request.cwd)
137
+ changed_files = changed_paths(request.cwd, before, after)
138
+ if result.returncode != 0:
139
+ return failed_result(
140
+ first_line(result.stderr, result.stdout)
141
+ or f"claude exited {result.returncode}",
142
+ changed_files,
143
+ )
144
+ try:
145
+ parsed = ClaudeCliResult.model_validate_json(result.stdout)
146
+ except ValueError as error:
147
+ return failed_result(
148
+ f"claude returned invalid JSON: {error}",
149
+ changed_files,
150
+ )
151
+ transcript = claude_transcript_ref(parsed.session_id)
152
+ if parsed.is_error or parsed.subtype not in {None, "success"}:
153
+ return failed_result(parsed.result, changed_files, transcript)
154
+ return HarnessRunResult(
155
+ kind=self.kind,
156
+ status=HarnessRunStatus.SUCCEEDED,
157
+ output_text=parsed.result,
158
+ summary=first_line(parsed.result),
159
+ changed_files=changed_files,
160
+ transcript=transcript,
161
+ events=(
162
+ terminal_harness_event(
163
+ self.kind,
164
+ HarnessRunStatus.SUCCEEDED,
165
+ parsed.result,
166
+ ),
167
+ ),
168
+ )
169
+
170
+
171
+ def claude_print_args() -> tuple[str, ...]:
172
+ return (
173
+ "-p",
174
+ "--output-format",
175
+ "json",
176
+ "--no-session-persistence",
177
+ "--permission-mode",
178
+ "acceptEdits",
179
+ "--tools",
180
+ CLAUDE_ALLOWED_TOOLS,
181
+ )
182
+
183
+
184
+ def failed_result(
185
+ output_text: str,
186
+ changed_files: tuple[Path, ...] = (),
187
+ transcript: HarnessTranscriptRef | None = None,
188
+ ) -> HarnessRunResult:
189
+ return HarnessRunResult(
190
+ kind=HarnessKind.CLAUDE,
191
+ status=HarnessRunStatus.FAILED,
192
+ output_text=output_text,
193
+ changed_files=changed_files,
194
+ transcript=transcript,
195
+ events=(
196
+ terminal_harness_event(
197
+ HarnessKind.CLAUDE,
198
+ HarnessRunStatus.FAILED,
199
+ output_text,
200
+ ),
201
+ ),
202
+ )
203
+
204
+
205
+ def claude_transcript_ref(session_id: str | None) -> HarnessTranscriptRef | None:
206
+ if session_id is None:
207
+ return None
208
+ return HarnessTranscriptRef(kind=HarnessKind.CLAUDE, session_id=session_id)
209
+
210
+
211
+ __all__ = [
212
+ "CLAUDE_ALLOWED_TOOLS",
213
+ "ClaudeCliHarnessAdapter",
214
+ "CommandResult",
215
+ "claude_print_args",
216
+ "parse_git_status_paths",
217
+ ]
@@ -0,0 +1,3 @@
1
+ from codealmanac.integrations.harnesses.codex.adapter import CodexCliHarnessAdapter
2
+
3
+ __all__ = ["CodexCliHarnessAdapter"]
@@ -0,0 +1,221 @@
1
+ import subprocess
2
+ import tempfile
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ from codealmanac.core.paths import normalize_path
7
+ from codealmanac.integrations.command import (
8
+ CommandRunner,
9
+ SubprocessCommandRunner,
10
+ first_line,
11
+ )
12
+ from codealmanac.integrations.harnesses.git_status import (
13
+ changed_paths,
14
+ git_status_snapshot,
15
+ )
16
+ from codealmanac.integrations.sources.transcripts.codex import (
17
+ CODEX_SESSIONS_DIR,
18
+ read_codex_meta,
19
+ )
20
+ from codealmanac.services.harnesses.models import (
21
+ HarnessKind,
22
+ HarnessReadiness,
23
+ HarnessRunResult,
24
+ HarnessRunStatus,
25
+ HarnessTranscriptRef,
26
+ terminal_harness_event,
27
+ )
28
+ from codealmanac.services.harnesses.requests import RunHarnessRequest
29
+
30
+ CODEX_COMMAND = "codex"
31
+ CODEX_RUN_TIMEOUT_SECONDS = 900
32
+ CODEX_STATUS_TIMEOUT_SECONDS = 10
33
+
34
+
35
+ class CodexCliHarnessAdapter:
36
+ kind = HarnessKind.CODEX
37
+
38
+ def __init__(
39
+ self,
40
+ runner: CommandRunner | None = None,
41
+ command: str = CODEX_COMMAND,
42
+ run_timeout_seconds: int = CODEX_RUN_TIMEOUT_SECONDS,
43
+ status_timeout_seconds: int = CODEX_STATUS_TIMEOUT_SECONDS,
44
+ sessions_dir: Path | None = None,
45
+ ):
46
+ self.runner = runner or SubprocessCommandRunner()
47
+ self.command = command
48
+ self.run_timeout_seconds = run_timeout_seconds
49
+ self.status_timeout_seconds = status_timeout_seconds
50
+ self.sessions_dir = sessions_dir
51
+
52
+ def check(self) -> HarnessReadiness:
53
+ try:
54
+ result = self.runner.run(
55
+ self.command,
56
+ ("login", "status"),
57
+ Path.cwd(),
58
+ self.status_timeout_seconds,
59
+ )
60
+ except FileNotFoundError:
61
+ return HarnessReadiness(
62
+ kind=self.kind,
63
+ available=False,
64
+ message="codex not found on PATH",
65
+ )
66
+ except subprocess.TimeoutExpired:
67
+ return HarnessReadiness(
68
+ kind=self.kind,
69
+ available=False,
70
+ message="codex login status timed out",
71
+ )
72
+ if result.returncode != 0:
73
+ return HarnessReadiness(
74
+ kind=self.kind,
75
+ available=False,
76
+ message=first_line(result.stderr, result.stdout)
77
+ or f"codex login status exited {result.returncode}",
78
+ )
79
+ return HarnessReadiness(
80
+ kind=self.kind,
81
+ available=True,
82
+ message=first_line(result.stdout, result.stderr) or "codex authenticated",
83
+ )
84
+
85
+ def run(self, request: RunHarnessRequest) -> HarnessRunResult:
86
+ before = git_status_snapshot(self.runner, request.cwd)
87
+ started_at = datetime.now(UTC)
88
+ try:
89
+ with tempfile.TemporaryDirectory(prefix="codealmanac-codex-") as tempdir:
90
+ output_path = Path(tempdir) / "last-message.txt"
91
+ result = self.runner.run(
92
+ self.command,
93
+ codex_exec_args(request.cwd, output_path),
94
+ request.cwd,
95
+ self.run_timeout_seconds,
96
+ request.prompt,
97
+ )
98
+ output_text = output_file_text(output_path)
99
+ except FileNotFoundError:
100
+ return failed_result("codex not found on PATH")
101
+ except subprocess.TimeoutExpired:
102
+ return failed_result("codex run timed out")
103
+ after = git_status_snapshot(self.runner, request.cwd)
104
+ changed_files = changed_paths(request.cwd, before, after)
105
+ transcript = locate_codex_transcript(
106
+ request.cwd,
107
+ self.sessions_dir,
108
+ started_at,
109
+ )
110
+ if result.returncode != 0:
111
+ return failed_result(
112
+ first_line(result.stderr, result.stdout)
113
+ or f"codex exited {result.returncode}",
114
+ changed_files,
115
+ transcript,
116
+ )
117
+ if output_text == "":
118
+ return failed_result(
119
+ first_line(result.stdout, result.stderr)
120
+ or "codex produced no final message",
121
+ changed_files,
122
+ transcript,
123
+ )
124
+ return HarnessRunResult(
125
+ kind=self.kind,
126
+ status=HarnessRunStatus.SUCCEEDED,
127
+ output_text=output_text,
128
+ summary=first_line(output_text),
129
+ changed_files=changed_files,
130
+ transcript=transcript,
131
+ events=(
132
+ terminal_harness_event(
133
+ self.kind,
134
+ HarnessRunStatus.SUCCEEDED,
135
+ output_text,
136
+ ),
137
+ ),
138
+ )
139
+
140
+
141
+ def codex_exec_args(cwd: Path, output_path: Path) -> tuple[str, ...]:
142
+ return (
143
+ "exec",
144
+ "--config",
145
+ "mcp_servers={}",
146
+ "--config",
147
+ 'approval_policy="never"',
148
+ "--cd",
149
+ str(cwd),
150
+ "--ephemeral",
151
+ "--sandbox",
152
+ "workspace-write",
153
+ "--ignore-rules",
154
+ "--color",
155
+ "never",
156
+ "--output-last-message",
157
+ str(output_path),
158
+ "-",
159
+ )
160
+
161
+
162
+ def output_file_text(path: Path) -> str:
163
+ if not path.is_file():
164
+ return ""
165
+ return path.read_text(encoding="utf-8").strip()
166
+
167
+
168
+ def failed_result(
169
+ output_text: str,
170
+ changed_files: tuple[Path, ...] = (),
171
+ transcript: HarnessTranscriptRef | None = None,
172
+ ) -> HarnessRunResult:
173
+ return HarnessRunResult(
174
+ kind=HarnessKind.CODEX,
175
+ status=HarnessRunStatus.FAILED,
176
+ output_text=output_text,
177
+ changed_files=changed_files,
178
+ transcript=transcript,
179
+ events=(
180
+ terminal_harness_event(
181
+ HarnessKind.CODEX,
182
+ HarnessRunStatus.FAILED,
183
+ output_text,
184
+ ),
185
+ ),
186
+ )
187
+
188
+
189
+ def locate_codex_transcript(
190
+ cwd: Path,
191
+ sessions_dir: Path | None,
192
+ started_at: datetime,
193
+ ) -> HarnessTranscriptRef | None:
194
+ root = sessions_dir or Path.home() / CODEX_SESSIONS_DIR
195
+ if not root.is_dir():
196
+ return None
197
+ matches: list[tuple[datetime, Path, str]] = []
198
+ for path in root.rglob("*.jsonl"):
199
+ try:
200
+ modified_at = datetime.fromtimestamp(path.stat().st_mtime, UTC)
201
+ except OSError:
202
+ continue
203
+ if modified_at < started_at:
204
+ continue
205
+ meta = read_codex_meta(path)
206
+ if meta is None or meta.thread_source == "subagent":
207
+ continue
208
+ if normalize_path(Path(meta.cwd)) != normalize_path(cwd):
209
+ continue
210
+ matches.append((modified_at, path, meta.session_id))
211
+ if len(matches) == 0:
212
+ return None
213
+ _, transcript_path, session_id = max(
214
+ matches,
215
+ key=lambda match: (match[0], str(match[1])),
216
+ )
217
+ return HarnessTranscriptRef(
218
+ kind=HarnessKind.CODEX,
219
+ session_id=session_id,
220
+ transcript_path=normalize_path(transcript_path),
221
+ )
@@ -0,0 +1,49 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from codealmanac.integrations.command import CommandRunner
5
+
6
+
7
+ def git_status_snapshot(
8
+ runner: CommandRunner,
9
+ cwd: Path,
10
+ ) -> frozenset[Path]:
11
+ try:
12
+ result = runner.run(
13
+ "git",
14
+ ("-C", str(cwd), "status", "--porcelain=v1", "-z", "--untracked-files=all"),
15
+ cwd,
16
+ 10,
17
+ )
18
+ except (FileNotFoundError, subprocess.TimeoutExpired):
19
+ return frozenset()
20
+ if result.returncode != 0:
21
+ return frozenset()
22
+ return frozenset(parse_git_status_paths(result.stdout))
23
+
24
+
25
+ def parse_git_status_paths(value: str) -> tuple[Path, ...]:
26
+ paths: list[Path] = []
27
+ fields = [field for field in value.split("\0") if field]
28
+ skip_next = False
29
+ for field in fields:
30
+ if skip_next:
31
+ skip_next = False
32
+ continue
33
+ if len(field) < 4:
34
+ continue
35
+ status = field[:2]
36
+ path_text = field[3:]
37
+ paths.append(Path(path_text))
38
+ if status[0] in {"R", "C"} or status[1] in {"R", "C"}:
39
+ skip_next = True
40
+ return tuple(paths)
41
+
42
+
43
+ def changed_paths(
44
+ cwd: Path,
45
+ before: frozenset[Path],
46
+ after: frozenset[Path],
47
+ ) -> tuple[Path, ...]:
48
+ changed = sorted(after - before, key=lambda item: str(item))
49
+ return tuple(cwd / path for path in changed)
@@ -0,0 +1,29 @@
1
+ from codealmanac.integrations.sources.filesystem import FilesystemSourceRuntimeAdapter
2
+ from codealmanac.integrations.sources.git import GitSourceRuntimeAdapter
3
+ from codealmanac.integrations.sources.github import GitHubSourceRuntimeAdapter
4
+ from codealmanac.integrations.sources.transcripts import (
5
+ TranscriptSourceRuntimeAdapter,
6
+ default_transcript_discovery_adapters,
7
+ )
8
+ from codealmanac.integrations.sources.web import WebSourceRuntimeAdapter
9
+ from codealmanac.services.sources.ports import SourceRuntimeAdapter
10
+
11
+
12
+ def default_source_runtime_adapters() -> tuple[SourceRuntimeAdapter, ...]:
13
+ return (
14
+ FilesystemSourceRuntimeAdapter(),
15
+ GitSourceRuntimeAdapter(),
16
+ GitHubSourceRuntimeAdapter(),
17
+ TranscriptSourceRuntimeAdapter(),
18
+ WebSourceRuntimeAdapter(),
19
+ )
20
+
21
+ __all__ = [
22
+ "FilesystemSourceRuntimeAdapter",
23
+ "GitSourceRuntimeAdapter",
24
+ "GitHubSourceRuntimeAdapter",
25
+ "TranscriptSourceRuntimeAdapter",
26
+ "WebSourceRuntimeAdapter",
27
+ "default_source_runtime_adapters",
28
+ "default_transcript_discovery_adapters",
29
+ ]
@@ -0,0 +1,5 @@
1
+ from codealmanac.integrations.sources.filesystem.adapter import (
2
+ FilesystemSourceRuntimeAdapter,
3
+ )
4
+
5
+ __all__ = ["FilesystemSourceRuntimeAdapter"]