opencode-hermes-mcp 0.4.1__tar.gz → 0.4.2__tar.gz

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 (21) hide show
  1. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/PKG-INFO +22 -1
  2. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/README.md +21 -0
  3. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/__init__.py +1 -1
  4. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/controller.py +23 -2
  5. opencode_hermes_mcp-0.4.2/opencode_hermes_mcp/journal.py +121 -0
  6. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp.egg-info/PKG-INFO +22 -1
  7. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp.egg-info/SOURCES.txt +3 -1
  8. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/pyproject.toml +1 -1
  9. opencode_hermes_mcp-0.4.2/tests/test_journal.py +83 -0
  10. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/LICENSE +0 -0
  11. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/client.py +0 -0
  12. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/installer.py +0 -0
  13. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/models.py +0 -0
  14. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/pin.txt +0 -0
  15. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/server.py +0 -0
  16. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp/smoke_client.py +0 -0
  17. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp.egg-info/dependency_links.txt +0 -0
  18. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp.egg-info/entry_points.txt +0 -0
  19. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp.egg-info/requires.txt +0 -0
  20. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/opencode_hermes_mcp.egg-info/top_level.txt +0 -0
  21. {opencode_hermes_mcp-0.4.1 → opencode_hermes_mcp-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-hermes-mcp
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Deterministic MCP controller between Hermes (supervisor LLM) and the permanent OpenCode server. OpenCode pin defined in opencode_hermes_mcp/pin.txt (currently 1.18.21).
5
5
  Author-email: Arthur Hottier <arthur.ho2tier@gmail.com>
6
6
  License: MIT
@@ -195,6 +195,25 @@ The launcher reads the OpenCode server credentials from
195
195
  `python -m opencode_hermes_mcp.server` in the repo venv — `config.yaml` stays
196
196
  secret-free.
197
197
 
198
+ ## Delegation journal
199
+
200
+ Every `opencode_run` is recorded in a durable, append-only JSONL journal so
201
+ the delegation history survives sessions (the per-turn state file
202
+ `turn_<sid>.json` is cleared on completion). One line per record:
203
+
204
+ ```json
205
+ {"ts": 1787750000123, "kind": "start", "session_id": "ses_...", "directory": "/abs/repo", "agent": "build", "task": "...(truncated to 500 chars)"}
206
+ {"ts": 1787750100456, "kind": "end", "session_id": "ses_...", "directory": "/abs/repo", "state": "completed|error|aborted|timeout", "elapsed_ms": 100333, "files": 12, "additions": 1209, "deletions": 18, "changed_files": ["a.py", "..."]}
207
+ ```
208
+
209
+ - Path: `~/.local/state/opencode-hermes-mcp/delegations.jsonl` by default,
210
+ overridable with the `OPENCODE_HERMES_MCP_JOURNAL` env var.
211
+ - `ts` is epoch milliseconds; `state` is the run's terminal state;
212
+ `changed_files` is truncated to 50 entries.
213
+ - The journal is best-effort: a write failure (disk full, permissions) is
214
+ logged and swallowed — it never fails a run.
215
+ - Read-only access for consumers: `opencode_hermes_mcp.journal.read_journal()`.
216
+
198
217
  ## TUI attach helpers (watch OpenCode live)
199
218
 
200
219
  `install.sh` also drops two helpers into `~/.local/bin/` (sources:
@@ -277,8 +296,10 @@ smoke test and the integration suite, and the contribution conventions.
277
296
  | `opencode_hermes_mcp/controller.py` | state machine: submit / wait / resume / classify |
278
297
  | `opencode_hermes_mcp/client.py` | HTTP + SSE client for the OpenCode server |
279
298
  | `opencode_hermes_mcp/models.py` | data helpers for turns / interactions |
299
+ | `opencode_hermes_mcp/journal.py` | durable delegation journal (append-only JSONL) |
280
300
  | `opencode_hermes_mcp/smoke_client.py` | no-LLM smoke test (tool surface + basic calls) |
281
301
  | `tests/run_tests.py` | full integration suite (live LLM turns) |
302
+ | `tests/test_journal.py` | journal unit tests (pytest) |
282
303
  | `opencode_hermes_mcp/installer.py` | setup wizard (Python + rich; self-bootstrapping venv) |
283
304
  | `opencode_hermes_mcp/pin.txt` | the OpenCode version pin (single source of truth, one line) |
284
305
  | `scripts/install.sh` / `uninstall.sh` / `upgrade.sh` | lifecycle (`install.sh` is a thin wrapper around the wizard) |
@@ -167,6 +167,25 @@ The launcher reads the OpenCode server credentials from
167
167
  `python -m opencode_hermes_mcp.server` in the repo venv — `config.yaml` stays
168
168
  secret-free.
169
169
 
170
+ ## Delegation journal
171
+
172
+ Every `opencode_run` is recorded in a durable, append-only JSONL journal so
173
+ the delegation history survives sessions (the per-turn state file
174
+ `turn_<sid>.json` is cleared on completion). One line per record:
175
+
176
+ ```json
177
+ {"ts": 1787750000123, "kind": "start", "session_id": "ses_...", "directory": "/abs/repo", "agent": "build", "task": "...(truncated to 500 chars)"}
178
+ {"ts": 1787750100456, "kind": "end", "session_id": "ses_...", "directory": "/abs/repo", "state": "completed|error|aborted|timeout", "elapsed_ms": 100333, "files": 12, "additions": 1209, "deletions": 18, "changed_files": ["a.py", "..."]}
179
+ ```
180
+
181
+ - Path: `~/.local/state/opencode-hermes-mcp/delegations.jsonl` by default,
182
+ overridable with the `OPENCODE_HERMES_MCP_JOURNAL` env var.
183
+ - `ts` is epoch milliseconds; `state` is the run's terminal state;
184
+ `changed_files` is truncated to 50 entries.
185
+ - The journal is best-effort: a write failure (disk full, permissions) is
186
+ logged and swallowed — it never fails a run.
187
+ - Read-only access for consumers: `opencode_hermes_mcp.journal.read_journal()`.
188
+
170
189
  ## TUI attach helpers (watch OpenCode live)
171
190
 
172
191
  `install.sh` also drops two helpers into `~/.local/bin/` (sources:
@@ -249,8 +268,10 @@ smoke test and the integration suite, and the contribution conventions.
249
268
  | `opencode_hermes_mcp/controller.py` | state machine: submit / wait / resume / classify |
250
269
  | `opencode_hermes_mcp/client.py` | HTTP + SSE client for the OpenCode server |
251
270
  | `opencode_hermes_mcp/models.py` | data helpers for turns / interactions |
271
+ | `opencode_hermes_mcp/journal.py` | durable delegation journal (append-only JSONL) |
252
272
  | `opencode_hermes_mcp/smoke_client.py` | no-LLM smoke test (tool surface + basic calls) |
253
273
  | `tests/run_tests.py` | full integration suite (live LLM turns) |
274
+ | `tests/test_journal.py` | journal unit tests (pytest) |
254
275
  | `opencode_hermes_mcp/installer.py` | setup wizard (Python + rich; self-bootstrapping venv) |
255
276
  | `opencode_hermes_mcp/pin.txt` | the OpenCode version pin (single source of truth, one line) |
256
277
  | `scripts/install.sh` / `uninstall.sh` / `upgrade.sh` | lifecycle (`install.sh` is a thin wrapper around the wizard) |
@@ -3,4 +3,4 @@ LLM) and the permanent OpenCode server. No LLM: a state machine + HTTP/SSE
3
3
  client, pinned to the OpenCode version in opencode_hermes_mcp/pin.txt
4
4
  (currently 1.18.21)."""
5
5
 
6
- __version__ = "0.4.1"
6
+ __version__ = "0.4.2"
@@ -22,6 +22,7 @@ import os
22
22
  import time
23
23
  from typing import Any
24
24
 
25
+ from . import journal
25
26
  from .client import OpenCode, OpenCodeError, event_stream
26
27
  from .models import (
27
28
  assistant_text,
@@ -621,7 +622,17 @@ class Controller:
621
622
  turn["assistant_count_before"] = sum(
622
623
  1 for m in pre if (m.get("info") or {}).get("role") == "assistant"
623
624
  )
624
- return await self._wait(turn, timeout)
625
+ journal.append_start(sid, directory, turn.get("agent"), task)
626
+ result = await self._wait(turn, timeout)
627
+ if result.get("state") in ("completed", "error", "aborted", "timeout"):
628
+ diff = result.get("diff") or {}
629
+ journal.append_end(
630
+ sid, directory, result["state"],
631
+ (result.get("timing") or {}).get("elapsed_ms"),
632
+ diff.get("files"), diff.get("additions"), diff.get("deletions"),
633
+ diff.get("changed_files"),
634
+ )
635
+ return result
625
636
  else:
626
637
  try:
627
638
  sess = await self.oc.create_session(
@@ -665,7 +676,17 @@ class Controller:
665
676
  "error": f"failed to submit prompt: {exc}",
666
677
  "session_id": sid,
667
678
  }
668
- return await self._wait(turn, timeout)
679
+ journal.append_start(sid, directory, turn.get("agent"), task)
680
+ result = await self._wait(turn, timeout)
681
+ if result.get("state") in ("completed", "error", "aborted", "timeout"):
682
+ diff = result.get("diff") or {}
683
+ journal.append_end(
684
+ sid, directory, result["state"],
685
+ (result.get("timing") or {}).get("elapsed_ms"),
686
+ diff.get("files"), diff.get("additions"), diff.get("deletions"),
687
+ diff.get("changed_files"),
688
+ )
689
+ return result
669
690
 
670
691
  # ------------------------------------------------------------------ #
671
692
  # Resume primitives (answer / permission) — same turn, no resubmit
@@ -0,0 +1,121 @@
1
+ """Durable delegation journal (append-only JSONL).
2
+
3
+ Records the start and terminal state of every controller run so the
4
+ delegation history survives sessions (the per-turn state file
5
+ turn_<sid>.json is cleared on completion). The journal is best-effort:
6
+ any write failure is logged and swallowed — it must never fail a run.
7
+
8
+ Path: ~/.local/state/opencode-hermes-mcp/delegations.jsonl by default,
9
+ overridable via the OPENCODE_HERMES_MCP_JOURNAL env var.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ ENV_JOURNAL = "OPENCODE_HERMES_MCP_JOURNAL"
23
+ DEFAULT_JOURNAL = Path(
24
+ os.path.join(
25
+ os.path.expanduser("~"), ".local", "state", "opencode-hermes-mcp", "delegations.jsonl"
26
+ )
27
+ )
28
+ TASK_MAX_CHARS = 500
29
+ CHANGED_FILES_MAX = 50
30
+
31
+
32
+ def journal_path() -> Path:
33
+ """Journal path: env override (OPENCODE_HERMES_MCP_JOURNAL) or default."""
34
+ return Path(os.environ.get(ENV_JOURNAL) or DEFAULT_JOURNAL)
35
+
36
+
37
+ def _now_ms() -> int:
38
+ return int(time.time() * 1000)
39
+
40
+
41
+ def _append(record: dict[str, Any]) -> None:
42
+ """Append one JSON line (open 'a', one full write, flush + fsync).
43
+
44
+ Never raises: a journal failure is logged and swallowed.
45
+ """
46
+ try:
47
+ path = journal_path()
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ line = json.dumps(record, ensure_ascii=False) + "\n"
50
+ with open(path, "a", encoding="utf-8") as f:
51
+ f.write(line)
52
+ f.flush()
53
+ os.fsync(f.fileno())
54
+ except Exception as exc: # noqa: BLE001 — the journal must never fail a run
55
+ log.warning("journal: append %s failed: %s", record.get("kind"), exc)
56
+
57
+
58
+ def append_start(session_id: str, directory: str, agent: str | None, task: str) -> None:
59
+ """Record a run start (session resolved, before the wait)."""
60
+ _append(
61
+ {
62
+ "ts": _now_ms(),
63
+ "kind": "start",
64
+ "session_id": session_id,
65
+ "directory": directory,
66
+ "agent": agent,
67
+ "task": (task or "")[:TASK_MAX_CHARS],
68
+ }
69
+ )
70
+
71
+
72
+ def append_end(
73
+ session_id: str,
74
+ directory: str,
75
+ state: str,
76
+ elapsed_ms: int | None = None,
77
+ files: int | None = None,
78
+ additions: int | None = None,
79
+ deletions: int | None = None,
80
+ changed_files: list[str] | None = None,
81
+ ) -> None:
82
+ """Record a run's terminal state (completed / error / aborted / timeout)."""
83
+ _append(
84
+ {
85
+ "ts": _now_ms(),
86
+ "kind": "end",
87
+ "session_id": session_id,
88
+ "directory": directory,
89
+ "state": state,
90
+ "elapsed_ms": elapsed_ms,
91
+ "files": files,
92
+ "additions": additions,
93
+ "deletions": deletions,
94
+ "changed_files": (changed_files or [])[:CHANGED_FILES_MAX],
95
+ }
96
+ )
97
+
98
+
99
+ def read_journal(path: str | Path | None = None) -> list[dict[str, Any]]:
100
+ """Read the journal (read-only, for tests and consumers).
101
+
102
+ Missing file -> []. Malformed lines are skipped.
103
+ """
104
+ p = Path(path) if path is not None else journal_path()
105
+ try:
106
+ with open(p, encoding="utf-8") as f:
107
+ lines = f.readlines()
108
+ except OSError:
109
+ return []
110
+ records: list[dict[str, Any]] = []
111
+ for line in lines:
112
+ line = line.strip()
113
+ if not line:
114
+ continue
115
+ try:
116
+ rec = json.loads(line)
117
+ except json.JSONDecodeError:
118
+ continue
119
+ if isinstance(rec, dict):
120
+ records.append(rec)
121
+ return records
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-hermes-mcp
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Deterministic MCP controller between Hermes (supervisor LLM) and the permanent OpenCode server. OpenCode pin defined in opencode_hermes_mcp/pin.txt (currently 1.18.21).
5
5
  Author-email: Arthur Hottier <arthur.ho2tier@gmail.com>
6
6
  License: MIT
@@ -195,6 +195,25 @@ The launcher reads the OpenCode server credentials from
195
195
  `python -m opencode_hermes_mcp.server` in the repo venv — `config.yaml` stays
196
196
  secret-free.
197
197
 
198
+ ## Delegation journal
199
+
200
+ Every `opencode_run` is recorded in a durable, append-only JSONL journal so
201
+ the delegation history survives sessions (the per-turn state file
202
+ `turn_<sid>.json` is cleared on completion). One line per record:
203
+
204
+ ```json
205
+ {"ts": 1787750000123, "kind": "start", "session_id": "ses_...", "directory": "/abs/repo", "agent": "build", "task": "...(truncated to 500 chars)"}
206
+ {"ts": 1787750100456, "kind": "end", "session_id": "ses_...", "directory": "/abs/repo", "state": "completed|error|aborted|timeout", "elapsed_ms": 100333, "files": 12, "additions": 1209, "deletions": 18, "changed_files": ["a.py", "..."]}
207
+ ```
208
+
209
+ - Path: `~/.local/state/opencode-hermes-mcp/delegations.jsonl` by default,
210
+ overridable with the `OPENCODE_HERMES_MCP_JOURNAL` env var.
211
+ - `ts` is epoch milliseconds; `state` is the run's terminal state;
212
+ `changed_files` is truncated to 50 entries.
213
+ - The journal is best-effort: a write failure (disk full, permissions) is
214
+ logged and swallowed — it never fails a run.
215
+ - Read-only access for consumers: `opencode_hermes_mcp.journal.read_journal()`.
216
+
198
217
  ## TUI attach helpers (watch OpenCode live)
199
218
 
200
219
  `install.sh` also drops two helpers into `~/.local/bin/` (sources:
@@ -277,8 +296,10 @@ smoke test and the integration suite, and the contribution conventions.
277
296
  | `opencode_hermes_mcp/controller.py` | state machine: submit / wait / resume / classify |
278
297
  | `opencode_hermes_mcp/client.py` | HTTP + SSE client for the OpenCode server |
279
298
  | `opencode_hermes_mcp/models.py` | data helpers for turns / interactions |
299
+ | `opencode_hermes_mcp/journal.py` | durable delegation journal (append-only JSONL) |
280
300
  | `opencode_hermes_mcp/smoke_client.py` | no-LLM smoke test (tool surface + basic calls) |
281
301
  | `tests/run_tests.py` | full integration suite (live LLM turns) |
302
+ | `tests/test_journal.py` | journal unit tests (pytest) |
282
303
  | `opencode_hermes_mcp/installer.py` | setup wizard (Python + rich; self-bootstrapping venv) |
283
304
  | `opencode_hermes_mcp/pin.txt` | the OpenCode version pin (single source of truth, one line) |
284
305
  | `scripts/install.sh` / `uninstall.sh` / `upgrade.sh` | lifecycle (`install.sh` is a thin wrapper around the wizard) |
@@ -5,6 +5,7 @@ opencode_hermes_mcp/__init__.py
5
5
  opencode_hermes_mcp/client.py
6
6
  opencode_hermes_mcp/controller.py
7
7
  opencode_hermes_mcp/installer.py
8
+ opencode_hermes_mcp/journal.py
8
9
  opencode_hermes_mcp/models.py
9
10
  opencode_hermes_mcp/pin.txt
10
11
  opencode_hermes_mcp/server.py
@@ -14,4 +15,5 @@ opencode_hermes_mcp.egg-info/SOURCES.txt
14
15
  opencode_hermes_mcp.egg-info/dependency_links.txt
15
16
  opencode_hermes_mcp.egg-info/entry_points.txt
16
17
  opencode_hermes_mcp.egg-info/requires.txt
17
- opencode_hermes_mcp.egg-info/top_level.txt
18
+ opencode_hermes_mcp.egg-info/top_level.txt
19
+ tests/test_journal.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-hermes-mcp"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Deterministic MCP controller between Hermes (supervisor LLM) and the permanent OpenCode server. OpenCode pin defined in opencode_hermes_mcp/pin.txt (currently 1.18.21)."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,83 @@
1
+ """Unit tests for the durable delegation journal (opencode_hermes_mcp.journal).
2
+
3
+ Pure unit tests: no LLM, no server. Run with:
4
+ .venv/bin/python -m pytest tests/test_journal.py
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from opencode_hermes_mcp import journal # noqa: E402
15
+
16
+
17
+ def test_journal_path_default(monkeypatch):
18
+ monkeypatch.delenv(journal.ENV_JOURNAL, raising=False)
19
+ p = journal.journal_path()
20
+ assert p.name == "delegations.jsonl"
21
+ assert p.parent.name == "opencode-hermes-mcp"
22
+ assert ".local" in p.parts and "state" in p.parts
23
+
24
+
25
+ def test_journal_path_env_override(tmp_path, monkeypatch):
26
+ custom = tmp_path / "custom" / "delegations.jsonl"
27
+ monkeypatch.setenv(journal.ENV_JOURNAL, str(custom))
28
+ assert journal.journal_path() == custom
29
+
30
+
31
+ def test_append_start_end_jsonl_format(tmp_path, monkeypatch):
32
+ custom = tmp_path / "sub" / "delegations.jsonl"
33
+ monkeypatch.setenv(journal.ENV_JOURNAL, str(custom))
34
+ journal.append_start("ses_1", "/repo", "build", "t" * 600)
35
+ journal.append_end(
36
+ "ses_1", "/repo", "completed",
37
+ elapsed_ms=1234, files=2, additions=10, deletions=1,
38
+ changed_files=[f"f{i}.py" for i in range(60)],
39
+ )
40
+ lines = custom.read_text(encoding="utf-8").splitlines()
41
+ assert len(lines) == 2
42
+ start, end = (json.loads(line) for line in lines)
43
+ assert start["kind"] == "start"
44
+ assert start["session_id"] == "ses_1"
45
+ assert start["directory"] == "/repo"
46
+ assert start["agent"] == "build"
47
+ assert start["task"] == "t" * 500
48
+ assert isinstance(start["ts"], int) and start["ts"] > 0
49
+ assert end["kind"] == "end"
50
+ assert end["session_id"] == "ses_1"
51
+ assert end["state"] == "completed"
52
+ assert end["elapsed_ms"] == 1234
53
+ assert end["files"] == 2
54
+ assert end["additions"] == 10
55
+ assert end["deletions"] == 1
56
+ assert len(end["changed_files"]) == 50
57
+ assert end["ts"] >= start["ts"]
58
+
59
+
60
+ def test_read_journal(tmp_path, monkeypatch):
61
+ custom = tmp_path / "d.jsonl"
62
+ monkeypatch.setenv(journal.ENV_JOURNAL, str(custom))
63
+ assert journal.read_journal() == []
64
+ journal.append_start("ses_2", "/r", "plan", "hello")
65
+ journal.append_end("ses_2", "/r", "error")
66
+ recs = journal.read_journal()
67
+ assert [r["kind"] for r in recs] == ["start", "end"]
68
+ assert recs[1]["state"] == "error"
69
+ assert journal.read_journal(custom) == recs
70
+
71
+
72
+ def test_read_journal_skips_bad_lines(tmp_path):
73
+ p = tmp_path / "bad.jsonl"
74
+ p.write_text('{"kind": "start", "session_id": "s"}\nnot-json\n\n', encoding="utf-8")
75
+ recs = journal.read_journal(p)
76
+ assert len(recs) == 1
77
+ assert recs[0]["kind"] == "start"
78
+
79
+
80
+ def test_append_never_raises_on_unwritable_path(tmp_path, monkeypatch):
81
+ monkeypatch.setenv(journal.ENV_JOURNAL, "/proc/no-such-dir-ocmcp/delegations.jsonl")
82
+ journal.append_start("ses_x", "/r", "build", "task")
83
+ journal.append_end("ses_x", "/r", "completed", elapsed_ms=1)