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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- 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,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
|
+
]
|