codex2opencode 0.1.0__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.
@@ -0,0 +1,36 @@
1
+ EXIT_OK = 0
2
+ EXIT_OPENCODE_ERROR = 1
3
+ EXIT_TIMEOUT = 2
4
+ EXIT_LOCK_CONFLICT = 3
5
+ EXIT_INVALID_ARGS = 4
6
+ EXIT_STATE_ERROR = 5
7
+ EXIT_STREAM_ERROR = 6
8
+ EXIT_SESSION_ERROR = 7
9
+
10
+
11
+ class BridgeError(Exception):
12
+ exit_code = EXIT_OPENCODE_ERROR
13
+
14
+
15
+ class TimeoutError(BridgeError):
16
+ exit_code = EXIT_TIMEOUT
17
+
18
+
19
+ class LockConflictError(BridgeError):
20
+ exit_code = EXIT_LOCK_CONFLICT
21
+
22
+
23
+ class InvalidArgsError(BridgeError):
24
+ exit_code = EXIT_INVALID_ARGS
25
+
26
+
27
+ class StateError(BridgeError):
28
+ exit_code = EXIT_STATE_ERROR
29
+
30
+
31
+ class StreamError(BridgeError):
32
+ exit_code = EXIT_STREAM_ERROR
33
+
34
+
35
+ class SessionError(BridgeError):
36
+ exit_code = EXIT_SESSION_ERROR
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Iterable, Iterator, TextIO
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class StreamSummary:
10
+ session_id: str | None
11
+ text_output: str
12
+ malformed_lines: int
13
+ event_counts: dict[str, int]
14
+
15
+
16
+ def parse_event_lines(lines: Iterable[str]) -> StreamSummary:
17
+ session_id: str | None = None
18
+ text_parts: list[str] = []
19
+ malformed_lines = 0
20
+ event_counts: dict[str, int] = {}
21
+
22
+ for raw_line in lines:
23
+ line = raw_line.strip()
24
+ if not line:
25
+ continue
26
+ try:
27
+ event = json.loads(line)
28
+ except json.JSONDecodeError:
29
+ malformed_lines += 1
30
+ continue
31
+
32
+ if not isinstance(event, dict):
33
+ malformed_lines += 1
34
+ continue
35
+
36
+ event_type = str(event.get("type", "unknown"))
37
+ event_counts[event_type] = event_counts.get(event_type, 0) + 1
38
+ if session_id is None:
39
+ candidate_session_id = event.get("sessionID")
40
+ if isinstance(candidate_session_id, str):
41
+ session_id = candidate_session_id
42
+ part = event.get("part") or {}
43
+ if event_type == "text" and isinstance(part, dict):
44
+ text = part.get("text")
45
+ if isinstance(text, str):
46
+ text_parts.append(text)
47
+
48
+ return StreamSummary(
49
+ session_id=session_id,
50
+ text_output="".join(text_parts),
51
+ malformed_lines=malformed_lines,
52
+ event_counts=event_counts,
53
+ )
54
+
55
+
56
+ def iter_stream_events(handle: TextIO) -> Iterator[str]:
57
+ for line in handle:
58
+ yield line
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+
6
+ from .errors import LockConflictError
7
+
8
+ try:
9
+ import fcntl
10
+ except ImportError: # pragma: no cover
11
+ fcntl = None
12
+
13
+
14
+ @contextmanager
15
+ def acquire_thread_lock(lock_path: Path):
16
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
17
+ handle = lock_path.open("a+", encoding="utf-8")
18
+ try:
19
+ if fcntl is None:
20
+ raise LockConflictError("Locking unsupported on this platform.")
21
+ try:
22
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
23
+ except BlockingIOError as exc:
24
+ raise LockConflictError("Lock already held.") from exc
25
+ yield lock_path
26
+ finally:
27
+ if fcntl is not None:
28
+ try:
29
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
30
+ except OSError:
31
+ pass
32
+ handle.close()
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+
6
+ from .paths import logs_dir
7
+
8
+
9
+ def utc_now_iso() -> str:
10
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
11
+
12
+
13
+ def append_bridge_log(payload: dict[str, object]) -> None:
14
+ log_path = logs_dir() / "bridge.log"
15
+ try:
16
+ log_path.parent.mkdir(parents=True, exist_ok=True)
17
+ with log_path.open("a", encoding="utf-8") as handle:
18
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
19
+ except OSError:
20
+ return
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict
4
+
5
+ from .errors import StateError
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ThreadState:
10
+ thread_key: str
11
+ workspace_root: str
12
+ thread_name: str | None
13
+ opencode_session_id: str | None
14
+ opencode_title: str | None
15
+ created_at: str
16
+ last_used_at: str
17
+ last_status: str
18
+ last_run_mode: str | None
19
+ bridge_version: str
20
+ opencode_version: str | None
21
+ last_error: str | None
22
+ message_count: int
23
+ last_exported_at: str | None
24
+
25
+ def to_dict(self) -> dict[str, object]:
26
+ return asdict(self)
27
+
28
+ @classmethod
29
+ def from_dict(cls, data: dict[str, object]) -> "ThreadState":
30
+ required = {
31
+ "thread_key",
32
+ "workspace_root",
33
+ "thread_name",
34
+ "opencode_session_id",
35
+ "opencode_title",
36
+ "created_at",
37
+ "last_used_at",
38
+ "last_status",
39
+ "last_run_mode",
40
+ "bridge_version",
41
+ "opencode_version",
42
+ "last_error",
43
+ "message_count",
44
+ "last_exported_at",
45
+ }
46
+ missing = required - set(data.keys())
47
+ if missing:
48
+ raise StateError(f"Missing required state fields: {sorted(missing)}")
49
+
50
+ def expect_str(value: object, name: str) -> str:
51
+ if not isinstance(value, str):
52
+ raise StateError(f"Invalid type for {name}.")
53
+ return value
54
+
55
+ def expect_optional_str(value: object, name: str) -> str | None:
56
+ if value is None:
57
+ return None
58
+ if not isinstance(value, str):
59
+ raise StateError(f"Invalid type for {name}.")
60
+ return value
61
+
62
+ def expect_int(value: object, name: str) -> int:
63
+ if isinstance(value, bool) or not isinstance(value, int):
64
+ raise StateError(f"Invalid type for {name}.")
65
+ return value
66
+
67
+ return cls(
68
+ thread_key=expect_str(data["thread_key"], "thread_key"),
69
+ workspace_root=expect_str(data["workspace_root"], "workspace_root"),
70
+ thread_name=expect_optional_str(data["thread_name"], "thread_name"),
71
+ opencode_session_id=expect_optional_str(
72
+ data["opencode_session_id"], "opencode_session_id"
73
+ ),
74
+ opencode_title=expect_optional_str(data["opencode_title"], "opencode_title"),
75
+ created_at=expect_str(data["created_at"], "created_at"),
76
+ last_used_at=expect_str(data["last_used_at"], "last_used_at"),
77
+ last_status=expect_str(data["last_status"], "last_status"),
78
+ last_run_mode=expect_optional_str(data["last_run_mode"], "last_run_mode"),
79
+ bridge_version=expect_str(data["bridge_version"], "bridge_version"),
80
+ opencode_version=expect_optional_str(data["opencode_version"], "opencode_version"),
81
+ last_error=expect_optional_str(data["last_error"], "last_error"),
82
+ message_count=expect_int(data["message_count"], "message_count"),
83
+ last_exported_at=expect_optional_str(data["last_exported_at"], "last_exported_at"),
84
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from codex2opencode.errors import InvalidArgsError
8
+
9
+
10
+ def build_run_command(
11
+ prompt: str,
12
+ cwd: str,
13
+ session_id: str | None,
14
+ fork: bool,
15
+ title: str | None,
16
+ opencode_bin: str = "opencode",
17
+ ) -> list[str]:
18
+ if fork and not session_id:
19
+ raise InvalidArgsError("fork requires an existing session id")
20
+ command = [opencode_bin, "run", prompt, "--format", "json", "--dir", cwd]
21
+ if session_id:
22
+ command.extend(["--session", session_id])
23
+ if fork:
24
+ command.append("--fork")
25
+ if title:
26
+ command.extend(["--title", title])
27
+ return command
28
+
29
+
30
+ def build_export_command(session_id: str, opencode_bin: str = "opencode") -> list[str]:
31
+ return [opencode_bin, "export", session_id]
32
+
33
+
34
+ def build_delete_session_command(session_id: str, opencode_bin: str = "opencode") -> list[str]:
35
+ return [opencode_bin, "session", "delete", session_id]
36
+
37
+
38
+ def _run_command(command: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str] | None:
39
+ try:
40
+ return subprocess.run(
41
+ command,
42
+ check=False,
43
+ capture_output=True,
44
+ text=True,
45
+ cwd=cwd,
46
+ )
47
+ except FileNotFoundError:
48
+ return None
49
+ except OSError:
50
+ return None
51
+
52
+
53
+ def read_opencode_version(opencode_bin: str = "opencode", cwd: str | None = None) -> str | None:
54
+ completed = _run_command([opencode_bin, "--version"], cwd=cwd)
55
+ if not completed or completed.returncode != 0:
56
+ return None
57
+ output = completed.stdout.strip()
58
+ if not output:
59
+ return None
60
+ return output
61
+
62
+
63
+ def read_debug_paths(opencode_bin: str = "opencode", cwd: str | None = None) -> str | None:
64
+ completed = _run_command([opencode_bin, "debug", "paths"], cwd=cwd)
65
+ if not completed or completed.returncode != 0:
66
+ return None
67
+ output = completed.stdout.strip()
68
+ if not output:
69
+ return None
70
+ return output
71
+
72
+
73
+ def read_debug_config(opencode_bin: str = "opencode", cwd: str | None = None) -> dict[str, Any] | None:
74
+ completed = _run_command([opencode_bin, "debug", "config"], cwd=cwd)
75
+ if not completed or completed.returncode != 0:
76
+ return None
77
+ output = completed.stdout.strip()
78
+ if not output:
79
+ return None
80
+ try:
81
+ parsed = json.loads(output)
82
+ except json.JSONDecodeError:
83
+ return None
84
+ if not isinstance(parsed, dict):
85
+ return None
86
+ return parsed
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .errors import InvalidArgsError
7
+
8
+
9
+ def bridge_root() -> Path:
10
+ override = os.environ.get("CODEX2OPENCODE_HOME")
11
+ if override:
12
+ return Path(override).expanduser()
13
+ return Path.home() / ".codex" / "codex2opencode"
14
+
15
+
16
+ def threads_dir() -> Path:
17
+ return bridge_root() / "threads"
18
+
19
+
20
+ def runs_dir() -> Path:
21
+ return bridge_root() / "runs"
22
+
23
+
24
+ def logs_dir() -> Path:
25
+ return bridge_root() / "logs"
26
+
27
+
28
+ def _validate_thread_key(thread_key: str) -> None:
29
+ if not thread_key or thread_key.strip() == "":
30
+ raise InvalidArgsError("Thread key must be non-empty.")
31
+ if os.sep in thread_key:
32
+ raise InvalidArgsError("Thread key contains path separators.")
33
+ if os.altsep and os.altsep in thread_key:
34
+ raise InvalidArgsError("Thread key contains path separators.")
35
+ if thread_key in {".", ".."} or ".." in thread_key:
36
+ raise InvalidArgsError("Thread key contains invalid segments.")
37
+
38
+
39
+ def thread_file_path(thread_key: str) -> Path:
40
+ _validate_thread_key(thread_key)
41
+ return threads_dir() / f"{thread_key}.json"
42
+
43
+
44
+ def lock_file_path(thread_key: str) -> Path:
45
+ _validate_thread_key(thread_key)
46
+ return threads_dir() / f"{thread_key}.lock"
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .errors import StateError
7
+ from .models import ThreadState
8
+
9
+
10
+ def save_thread_state(path: Path, state: ThreadState) -> None:
11
+ try:
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ temp_path = path.with_suffix(".tmp")
14
+ temp_path.write_text(
15
+ json.dumps(state.to_dict(), indent=2, sort_keys=True),
16
+ encoding="utf-8",
17
+ )
18
+ temp_path.replace(path)
19
+ except OSError as exc:
20
+ raise StateError(f"Failed to write state to {path}: {exc}") from exc
21
+
22
+
23
+ def load_thread_state(path: Path) -> ThreadState:
24
+ try:
25
+ raw = path.read_text(encoding="utf-8")
26
+ payload = json.loads(raw)
27
+ except (OSError, json.JSONDecodeError) as exc:
28
+ raise StateError(f"Failed to read state from {path}.") from exc
29
+ if not isinstance(payload, dict):
30
+ raise StateError(f"Invalid state payload in {path}.")
31
+ return ThreadState.from_dict(payload)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def resolve_workspace_root(workspace_root: str) -> str:
9
+ return str(Path(workspace_root).expanduser().resolve())
10
+
11
+
12
+ def make_thread_key(workspace_root: str, thread_name: str | None) -> str:
13
+ payload = {
14
+ "workspace_root": resolve_workspace_root(workspace_root),
15
+ "thread_name": thread_name,
16
+ }
17
+ encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
18
+ return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,279 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex2opencode
3
+ Version: 0.1.0
4
+ Summary: Local Codex-to-Opencode bridge CLI
5
+ Author: OpenAI Codex
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # codex2opencode
13
+
14
+ `codex2opencode` is a local one-way bridge from Codex to Opencode.
15
+
16
+ It provides:
17
+
18
+ - a reusable Python CLI bridge
19
+ - Opencode session persistence via native `sessionID`
20
+ - deterministic per-thread locking
21
+ - export-backed session verification
22
+ - a thin Codex skill wrapper surface
23
+ - no non-stdlib Python runtime dependency inside the bridge
24
+
25
+ Current implementation target:
26
+
27
+ - macOS / POSIX environments with Python 3 and local Opencode CLI access
28
+ - not designed for Windows in its current `fcntl`-based form
29
+
30
+ ## Current Status
31
+
32
+ Implemented and locally verified in this repository:
33
+
34
+ - `ask`
35
+ - `status`
36
+ - `forget`
37
+ - `gc`
38
+ - `doctor`
39
+ - automatic resume via stored Opencode `sessionID`
40
+ - Opencode-native `--fork`
41
+ - Opencode-native `--title`
42
+ - same-thread lock conflict handling
43
+
44
+ Primary verification commands:
45
+
46
+ - `PYTHONPATH=bridge python3 -m unittest discover -s tests -p 'test_*.py' -v`
47
+ - direct `codex2opencode ask ...` smoke
48
+ - direct `codex2opencode doctor ...` smoke
49
+ - opt-in real Opencode smoke when local auth is available
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ python3 -m venv .venv
55
+ source .venv/bin/activate
56
+ python3 -m pip install -e .
57
+ ```
58
+
59
+ When a PyPI release is available:
60
+
61
+ ```bash
62
+ python3 -m pip install codex2opencode
63
+ ```
64
+
65
+ You can also run it directly without installing:
66
+
67
+ ```bash
68
+ PYTHONPATH=bridge python3 -m codex2opencode ask --prompt "Reply with ok only" --workspace "$PWD"
69
+ ```
70
+
71
+ After editable install, both of these work:
72
+
73
+ ```bash
74
+ python -m codex2opencode --help
75
+ codex2opencode --help
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ Ask Opencode in the current workspace thread:
81
+
82
+ ```bash
83
+ codex2opencode ask --prompt "Review this design" --workspace "$PWD"
84
+ ```
85
+
86
+ Successful `ask` output begins with a one-line metadata header before the reply body, for example:
87
+
88
+ ```text
89
+ [oc provider=opencode model=mimo-v2-omni-free session=ses_...]
90
+ ```
91
+
92
+ If session verification fails after a successful run, the bridge still prints the reply plus a minimal header with the session id, then exits non-zero.
93
+
94
+ Use a named thread:
95
+
96
+ ```bash
97
+ codex2opencode ask --prompt "Continue the review" --workspace "$PWD" --thread review
98
+ ```
99
+
100
+ Force a fresh session:
101
+
102
+ ```bash
103
+ codex2opencode ask --prompt "Start over" --workspace "$PWD" --new
104
+ ```
105
+
106
+ Fork the current mapped Opencode session:
107
+
108
+ ```bash
109
+ codex2opencode ask --prompt "Take this in a different direction" --workspace "$PWD" --fork
110
+ ```
111
+
112
+ Set an Opencode title on creation or fork:
113
+
114
+ ```bash
115
+ codex2opencode ask --prompt "Review the release checklist" --workspace "$PWD" --new --title "release-review"
116
+ ```
117
+
118
+ Inspect stored thread state:
119
+
120
+ ```bash
121
+ codex2opencode status --workspace "$PWD"
122
+ ```
123
+
124
+ Run diagnostics:
125
+
126
+ ```bash
127
+ codex2opencode doctor --workspace "$PWD"
128
+ ```
129
+
130
+ Forget the current thread and delete the mapped Opencode session:
131
+
132
+ ```bash
133
+ codex2opencode forget --workspace "$PWD"
134
+ ```
135
+
136
+ Remove stale bridge-owned files:
137
+
138
+ ```bash
139
+ codex2opencode gc --max-age-days 7
140
+ ```
141
+
142
+ ## Threading Model
143
+
144
+ By default, one workspace maps to one Opencode thread.
145
+
146
+ Use `--thread <name>` to split conversations inside the same repo:
147
+
148
+ ```bash
149
+ codex2opencode ask --prompt "Review API design" --workspace "$PWD" --thread api
150
+ codex2opencode ask --prompt "Review docs tone" --workspace "$PWD" --thread docs
151
+ ```
152
+
153
+ Use `--new` when you want a fresh Opencode session for the selected thread key.
154
+
155
+ Use `--fork` when you want Opencode to branch from the current mapped session instead of starting unrelated context. `--new` and `--fork` are intentionally mutually exclusive.
156
+
157
+ ## Codex Skill
158
+
159
+ The Codex-facing wrapper lives at:
160
+
161
+ ```text
162
+ skills/codex-to-opencode/SKILL.md
163
+ ```
164
+
165
+ The skill should stay thin. It should only:
166
+
167
+ - collect the user prompt
168
+ - choose default thread, named thread, `--new`, or `--fork`
169
+ - invoke `codex2opencode`
170
+ - return stdout or surface stderr
171
+
172
+ It must not own JSONL parsing, session storage, retries, export verification, or custom lock handling.
173
+
174
+ Recommended high-signal trigger phrases:
175
+
176
+ - `Use the codex-to-opencode skill`
177
+ - `ask Opencode about this`
178
+ - `ask oc about this`
179
+ - `send this to Opencode`
180
+ - `send this to oc`
181
+ - `let Opencode review this`
182
+ - `let oc review this`
183
+ - `give this to Opencode`
184
+ - `给 Opencode 看看`
185
+ - `给oc看看`
186
+ - `让 Opencode review 一下`
187
+ - `让oc review一下`
188
+ - `问问 Opencode`
189
+ - `问问oc`
190
+
191
+ Treat `oc` as a supported short alias for `Opencode` when it appears with a clear action.
192
+ Avoid relying on bare `opencode` by itself.
193
+ Avoid relying on bare `oc` by itself.
194
+ Trigger phrases should include a clear action like ask, review, check, continue, send, or look.
195
+
196
+ Practical everyday examples:
197
+
198
+ - `问问oc这个方案哪里最危险`
199
+ - `给oc看看这个 diff`
200
+ - `让oc review一下发布流程`
201
+ - `发给oc继续聊这个线程`
202
+ - `ask oc to review this design`
203
+
204
+ ## Activation Scope
205
+
206
+ These paths are expected to work:
207
+
208
+ - new Codex sessions started after the skill was installed
209
+ - `codex exec` runs started after the skill was installed
210
+ - direct `codex2opencode` CLI usage
211
+
212
+ Do not assume already-open Codex sessions will hot-reload newly installed or updated skills.
213
+
214
+ If you changed trigger phrases or installed the skill during an existing session, restart Codex or open a fresh session before testing.
215
+
216
+ ## Troubleshooting
217
+
218
+ If a trigger phrase does not route to Opencode:
219
+
220
+ 1. Start a new Codex session.
221
+ 2. Test with a high-signal phrase such as `Use the codex-to-opencode skill. Ask Opencode: reply with ok only.`
222
+ 3. Verify the bridge directly with `codex2opencode ask --prompt "Reply with ok only" --workspace "$PWD"`.
223
+ 4. Run `codex2opencode doctor --workspace "$PWD"` to confirm Opencode availability, debug-path output, and thread-state diagnostics.
224
+
225
+ If direct CLI usage works but a natural-language trigger does not, the issue is skill discovery in that session, not the bridge itself.
226
+
227
+ If `doctor` reports an `orphaned` thread state, the local mapping points at an Opencode session that no longer exports successfully. `forget` will remove that mapping and attempt remote deletion again.
228
+
229
+ ## Environment
230
+
231
+ - `CODEX2OPENCODE_OPENCODE_BIN`: override the Opencode executable path
232
+ - `CODEX2OPENCODE_HOME`: override the bridge state root without changing your real shell `HOME`
233
+ - `CODEX2OPENCODE_RUN_REAL=1`: enable opt-in real Opencode integration tests
234
+
235
+ ## Doctor Output
236
+
237
+ `codex2opencode doctor --workspace "$PWD"` prints JSON and checks:
238
+
239
+ - the resolved bridge root and key paths
240
+ - whether `opencode --version` is readable
241
+ - whether `opencode debug paths` is readable
242
+ - whether `opencode debug config` is parseable
243
+ - whether the selected thread state is `missing`, `ok`, `error`, or `orphaned`
244
+ - whether the mapped session can still be verified through `opencode export`
245
+ - whether the selected lock path is healthy or currently locked
246
+
247
+ It returns `0` when required checks are healthy and non-zero when Opencode availability checks fail, the state file is corrupted, or the mapped session is orphaned.
248
+
249
+ ## State Layout
250
+
251
+ Default bridge state root:
252
+
253
+ ```text
254
+ ~/.codex/codex2opencode/
255
+ threads/
256
+ runs/
257
+ logs/
258
+ ```
259
+
260
+ Key files:
261
+
262
+ - `threads/<thread_key>.json`: current thread state
263
+ - `threads/<thread_key>.lock`: per-thread lock file
264
+ - `runs/<thread_key>/*.json`: per-run artifacts
265
+ - `logs/bridge.log`: append-only JSONL bridge events
266
+
267
+ ## Boundaries
268
+
269
+ This repository is intentionally narrow:
270
+
271
+ - one-way Codex-to-Opencode only
272
+ - no Claude orchestration here
273
+ - no roundtable or multi-model discussion system
274
+ - no bridge-owned retry orchestration
275
+ - no over-generalized multi-backend framework
276
+
277
+ For design rationale and internals, see [ARCHITECTURE.md](./ARCHITECTURE.md).
278
+ For developer workflow, see [DEVELOPMENT.md](./DEVELOPMENT.md).
279
+ For contribution and release details, see [CONTRIBUTING.md](./CONTRIBUTING.md).