Skip to content

Commit 747fa72

Browse files
loningclaude
andcommitted
refactor(#160) phase1: codex_refactor_loop Python package + codex_loop.py CLI(parity,不删 shell)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2c8091d commit 747fa72

12 files changed

Lines changed: 856 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env python3
2+
"""Stable controller-facing CLI for codex-refactor-loop."""
3+
4+
from __future__ import annotations
5+
6+
from codex_refactor_loop.cli import main
7+
8+
9+
if __name__ == "__main__":
10+
raise SystemExit(main())
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Foundation primitives for the codex-refactor-loop controller."""
2+
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Controller-facing command router for codex-refactor-loop."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import os
7+
import subprocess
8+
import sys
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
from typing import Sequence
12+
13+
14+
SCRIPT_DIR = Path(__file__).resolve().parents[1]
15+
16+
17+
@dataclass(frozen=True)
18+
class CommandSpec:
19+
script: str
20+
description: str
21+
read_only: bool = False
22+
23+
24+
COMMANDS: dict[str, CommandSpec] = {
25+
"spawn-codex": CommandSpec("spawn-codex.sh", "run the existing codex spawn supervisor"),
26+
"peek": CommandSpec("peek.sh", "run the existing read-only state sweep", read_only=True),
27+
"restart-daemons": CommandSpec("restart-daemons.sh", "run the existing daemon restart helper"),
28+
"statusline": CommandSpec("statusline.sh", "read the existing statusline snapshot", read_only=True),
29+
"comment-monitor": CommandSpec("comment-monitor.sh", "run the existing comment monitor daemon"),
30+
"progress-reporter": CommandSpec("codex-progress-reporter.sh", "run the existing progress reporter daemon"),
31+
"merge-pr": CommandSpec("controller_lib.sh", "invoke controller_lib.sh merge_pr"),
32+
"open-pr": CommandSpec("controller_lib.sh", "invoke controller_lib.sh open_pr_with_label"),
33+
}
34+
35+
36+
class RuntimeCommandRouter:
37+
"""Stable command-name router that delegates to current scripts."""
38+
39+
def __init__(self, script_dir: Path = SCRIPT_DIR) -> None:
40+
self.script_dir = script_dir
41+
42+
def main(self, argv: Sequence[str] | None = None) -> int:
43+
parser = argparse.ArgumentParser(
44+
prog="codex_loop.py",
45+
description="codex-refactor-loop controller command router",
46+
)
47+
parser.add_argument("command", nargs="?")
48+
parser.add_argument("args", nargs=argparse.REMAINDER)
49+
args = parser.parse_args(argv)
50+
if not args.command:
51+
parser.print_help()
52+
return 0
53+
return self.run(args.command, list(args.args))
54+
55+
def run(self, command: str, args: Sequence[str]) -> int:
56+
spec = COMMANDS.get(command)
57+
if spec is None:
58+
sys.stderr.write(f"unknown command: {command}\n")
59+
return 2
60+
if command == "merge-pr":
61+
return self._run_controller_lib("merge_pr", args)
62+
if command == "open-pr":
63+
return self._run_controller_lib("open_pr_with_label", args)
64+
return self._exec_script(spec.script, args)
65+
66+
def _exec_script(self, script_name: str, args: Sequence[str]) -> int:
67+
script = self.script_dir / script_name
68+
cmd = ["bash", str(script), *args] if script.suffix == ".sh" else [sys.executable, str(script), *args]
69+
return subprocess.call(cmd)
70+
71+
def _run_controller_lib(self, function_name: str, args: Sequence[str]) -> int:
72+
quoted = " ".join(_shell_quote(arg) for arg in args)
73+
call = f"{function_name} {quoted}".rstrip()
74+
body = f"source {_shell_quote(str(self.script_dir / 'controller_lib.sh'))}; {call}"
75+
return subprocess.call(["bash", "-c", body], env=os.environ.copy())
76+
77+
78+
def main(argv: Sequence[str] | None = None) -> int:
79+
return RuntimeCommandRouter().main(argv)
80+
81+
82+
def _shell_quote(value: str) -> str:
83+
return "'" + value.replace("'", "'\"'\"'") + "'"
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Host context loading for codex-refactor-loop controller helpers."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import re
7+
import shlex
8+
import subprocess
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
from typing import Mapping
12+
13+
14+
class LoopContextError(RuntimeError):
15+
"""Raised when host context cannot be loaded safely."""
16+
17+
18+
_ASSIGNMENT_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
19+
20+
21+
@dataclass(frozen=True)
22+
class LoopPaths:
23+
"""Stable host artifact paths used by the loop."""
24+
25+
refactor_loop: Path
26+
logs: Path
27+
prompts: Path
28+
runs: Path
29+
state: Path
30+
dispatch_queue: Path
31+
dispatch_dispatched: Path
32+
dispatch_rejected: Path
33+
heartbeats: Path
34+
pending_events: Path
35+
statusline_snapshot: Path
36+
recent_pr_merges: Path
37+
38+
39+
@dataclass(frozen=True)
40+
class LoopContext:
41+
"""Resolved host repository and skill context."""
42+
43+
repo_root: Path
44+
skill_root: Path
45+
gh_repo_slug: str | None
46+
paths: LoopPaths
47+
host_env: dict[str, str]
48+
read_only: bool = False
49+
repo_root_source: str = "env"
50+
51+
@classmethod
52+
def load(
53+
cls,
54+
*,
55+
repo_root: str | Path | None = None,
56+
skill_root: str | Path | None = None,
57+
env: Mapping[str, str] | None = None,
58+
read_only: bool = False,
59+
allow_git_root_fallback: bool | None = None,
60+
cwd: str | Path | None = None,
61+
) -> "LoopContext":
62+
source_env = dict(os.environ if env is None else env)
63+
resolved_skill_root = Path(skill_root or source_env.get("CODEX_REFACTOR_LOOP_SKILL_ROOT") or Path(__file__).resolve().parents[2]).resolve()
64+
repo, repo_source = _resolve_repo_root(
65+
explicit=repo_root,
66+
env=source_env,
67+
read_only=read_only,
68+
allow_git_root_fallback=allow_git_root_fallback,
69+
cwd=cwd,
70+
)
71+
host_env = _load_host_env(repo)
72+
merged = {**host_env, **source_env}
73+
if repo_source == "host.env":
74+
merged["REPO_ROOT"] = str(repo)
75+
elif "REPO_ROOT" in host_env:
76+
_validate_repo_override(repo, host_env["REPO_ROOT"], "host.env")
77+
78+
slug = _github_repo_slug(merged)
79+
paths = _paths(repo)
80+
return cls(
81+
repo_root=repo,
82+
skill_root=resolved_skill_root,
83+
gh_repo_slug=slug,
84+
paths=paths,
85+
host_env=host_env,
86+
read_only=read_only,
87+
repo_root_source=repo_source,
88+
)
89+
90+
def env_for_subprocess(self) -> dict[str, str]:
91+
result = dict(os.environ)
92+
result.update(self.host_env)
93+
result["REPO_ROOT"] = str(self.repo_root)
94+
if self.gh_repo_slug:
95+
result["GH_REPO_SLUG"] = self.gh_repo_slug
96+
return result
97+
98+
99+
def _resolve_repo_root(
100+
*,
101+
explicit: str | Path | None,
102+
env: Mapping[str, str],
103+
read_only: bool,
104+
allow_git_root_fallback: bool | None,
105+
cwd: str | Path | None,
106+
) -> tuple[Path, str]:
107+
if explicit:
108+
return _existing_dir(explicit, "repo_root"), "arg"
109+
env_root = env.get("REPO_ROOT")
110+
if env_root:
111+
return _existing_dir(env_root, "REPO_ROOT"), "env"
112+
host_env_repo = _host_env_repo_root_from_cwd(cwd)
113+
if host_env_repo is not None:
114+
return host_env_repo, "host.env"
115+
fallback_allowed = env.get("ALLOW_GIT_ROOT_FALLBACK") == "1" if allow_git_root_fallback is None else allow_git_root_fallback
116+
if fallback_allowed:
117+
if not read_only:
118+
raise LoopContextError("ALLOW_GIT_ROOT_FALLBACK is only allowed for read-only commands")
119+
root = _git_root(cwd)
120+
if root:
121+
return root, "git"
122+
raise LoopContextError("REPO_ROOT is unset; source .refactor-loop/host.env or set ALLOW_GIT_ROOT_FALLBACK=1 for read-only use")
123+
124+
125+
def _host_env_repo_root_from_cwd(cwd: str | Path | None) -> Path | None:
126+
base = Path(cwd or os.getcwd())
127+
host_env = base / ".refactor-loop" / "host.env"
128+
if not host_env.exists():
129+
return None
130+
values = parse_host_env(host_env)
131+
raw_root = values.get("REPO_ROOT")
132+
if raw_root:
133+
return _existing_dir(raw_root, "host.env REPO_ROOT")
134+
return base.resolve()
135+
136+
137+
def _existing_dir(value: str | Path, label: str) -> Path:
138+
path = Path(value).expanduser().resolve()
139+
if not path.is_dir():
140+
raise LoopContextError(f"{label} is not a readable directory: {value}")
141+
return path
142+
143+
144+
def _git_root(cwd: str | Path | None) -> Path | None:
145+
result = subprocess.run(
146+
["git", "rev-parse", "--show-toplevel"],
147+
cwd=str(cwd) if cwd else None,
148+
capture_output=True,
149+
text=True,
150+
check=False,
151+
)
152+
if result.returncode != 0:
153+
return None
154+
raw = result.stdout.strip()
155+
if not raw:
156+
return None
157+
return _existing_dir(raw, "git root")
158+
159+
160+
def _load_host_env(repo_root: Path) -> dict[str, str]:
161+
for path in (repo_root / ".refactor-loop" / "host.env", repo_root / "host.env"):
162+
if path.exists():
163+
return parse_host_env(path)
164+
return {}
165+
166+
167+
def parse_host_env(path: Path) -> dict[str, str]:
168+
values: dict[str, str] = {}
169+
for line in path.read_text(encoding="utf-8").splitlines():
170+
stripped = line.strip()
171+
if not stripped or stripped.startswith("#"):
172+
continue
173+
match = _ASSIGNMENT_RE.match(stripped)
174+
if not match:
175+
continue
176+
key, raw_value = match.groups()
177+
try:
178+
parsed = shlex.split(raw_value, posix=True)
179+
except ValueError as exc:
180+
raise LoopContextError(f"invalid host.env assignment for {key}: {exc}") from exc
181+
values[key] = parsed[0] if parsed else ""
182+
return values
183+
184+
185+
def _validate_repo_override(repo_root: Path, raw_override: str, source: str) -> None:
186+
override = Path(raw_override).expanduser().resolve()
187+
if override != repo_root:
188+
raise LoopContextError(f"{source} REPO_ROOT override points outside resolved repo root: {raw_override}")
189+
190+
191+
def _github_repo_slug(env: Mapping[str, str]) -> str | None:
192+
slug = env.get("GH_REPO_SLUG")
193+
if slug:
194+
if "/" not in slug:
195+
raise LoopContextError(f"GH_REPO_SLUG must be OWNER/REPO; got {slug!r}")
196+
return slug
197+
repo = env.get("GH_REPO")
198+
if repo and "/" in repo:
199+
return repo
200+
owner = env.get("GH_OWNER")
201+
name = env.get("GH_REPO_NAME") or repo
202+
if owner and name:
203+
return f"{owner}/{name}"
204+
return None
205+
206+
207+
def _paths(repo_root: Path) -> LoopPaths:
208+
refactor_loop = repo_root / ".refactor-loop"
209+
state = refactor_loop / "state"
210+
return LoopPaths(
211+
refactor_loop=refactor_loop,
212+
logs=refactor_loop / "logs",
213+
prompts=refactor_loop / "prompts",
214+
runs=refactor_loop / "runs",
215+
state=state,
216+
dispatch_queue=refactor_loop / "dispatch-queue",
217+
dispatch_dispatched=refactor_loop / "dispatch-dispatched",
218+
dispatch_rejected=refactor_loop / "dispatch-rejected",
219+
heartbeats=refactor_loop / "heartbeats",
220+
pending_events=refactor_loop / ".controller-pending-events.log",
221+
statusline_snapshot=state / "statusline-snapshot.json",
222+
recent_pr_merges=state / "recent-pr-merges.json",
223+
)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Git subprocess primitives that preserve existing controller semantics."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
from typing import Sequence
9+
10+
11+
@dataclass(frozen=True)
12+
class Git:
13+
repo_root: Path
14+
15+
def run(self, args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
16+
result = subprocess.run(
17+
["git", "-C", str(self.repo_root), *args],
18+
capture_output=True,
19+
text=True,
20+
check=False,
21+
)
22+
if check and result.returncode != 0:
23+
raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed with exit {result.returncode}")
24+
return result
25+
26+
def safe_worktree(self, iteration: str | int, cluster: str, base_ref: str) -> tuple[Path, str]:
27+
wt_path = self.repo_root / ".worktrees" / f"iter{iteration}-{cluster}"
28+
branch = f"refactor/iter{iteration}-{cluster}"
29+
if wt_path.is_dir():
30+
return wt_path, branch
31+
(self.repo_root / ".worktrees").mkdir(parents=True, exist_ok=True)
32+
if self.run(["show-ref", "--quiet", f"refs/heads/{branch}"], check=False).returncode == 0:
33+
self.run(["worktree", "add", str(wt_path), branch])
34+
else:
35+
self.run(["worktree", "add", "-b", branch, str(wt_path), base_ref])
36+
return wt_path, branch
37+
38+
def merge_ff_only(self, ref: str) -> subprocess.CompletedProcess[str]:
39+
return self.run(["merge", "--ff-only", ref])
40+
41+
def push(self, remote: str, refspec: str, *, force_with_lease: bool = False) -> subprocess.CompletedProcess[str]:
42+
args = ["push"]
43+
if force_with_lease:
44+
args.append("--force-with-lease")
45+
args.extend([remote, refspec])
46+
return self.run(args)
47+

0 commit comments

Comments
 (0)