diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4a281..b6ecb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to devkit. Versions follow CalVer `YY.MM.PATCH` (PATCH restarts at 0 each month); tags are `vYY.MM.PATCH`. Sections follow [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Fixed + +- `dev agents test` reads the hooks log from the byte offset it recorded, so the guard check no longer reports + "no guard decision logged" when earlier lines hold multi-byte characters. + ## [26.11.1] - 2026-11-06 devkit is open source under the Apache License 2.0. diff --git a/lib/devkit/providers.py b/lib/devkit/providers.py index f9b38ac..addc70c 100644 --- a/lib/devkit/providers.py +++ b/lib/devkit/providers.py @@ -25,6 +25,15 @@ "3. Say whether you received any workspace context when the session started.") + +def _appended(path: Path, offset: int) -> str: + """The text written to `path` after it was `offset` bytes long. The offset is a byte count (stat), so the slice is + taken on bytes: slicing the decoded text would skip new lines whenever earlier lines hold multi-byte characters.""" + try: + return path.read_bytes()[offset:].decode("utf-8", "replace") + except OSError: + return "" + def _claude_status() -> dict: out = {"installed": core.have("claude"), "name": "Claude Code"} if not out["installed"]: @@ -181,10 +190,10 @@ def selftest(agent: str, model: str = None) -> dict: sess = STATE / "sessions" / f"{agent}-{sid}.json" if sid else None checks.append(("SessionStart hook (context)", bool(sess and sess.exists()), "session recorded by devkit" if sess and sess.exists() else "no session record")) - new_log = log.read_text()[log_before:] if log.exists() else "" + new_log = _appended(log, log_before) blocked = f"{agent}/devkit-selftest" in new_log and f"guard {agent} deny" in new_log checks.append(("PreToolUse guard", blocked, "tool-prefixed branch blocked" if blocked else "no guard decision logged")) - new_canary = canary_f.read_text()[canary_before:] if canary_f.exists() else "" + new_canary = _appended(canary_f, canary_before) recs = [json.loads(ln) for ln in new_canary.splitlines() if ln.strip()] mine = [r for r in recs if sid and r.get("session") == sid[:12]] checks.append(("Stop hook + instructions (canary)", bool(mine) and all(r["ok"] for r in mine), diff --git a/tests/test_quota.py b/tests/test_quota.py index c8dd73a..bb241fb 100644 --- a/tests/test_quota.py +++ b/tests/test_quota.py @@ -64,5 +64,19 @@ def test_no_rate_limits_still_draws_a_line(self): self.assertTrue(self._line({"model": {"display_name": "Claude Sonnet 5.5"}}).endswith("Sonnet 5.5")) + +class SelfTestLogTests(unittest.TestCase): + def test_reads_exactly_what_was_appended_after_multibyte_text(self): + from devkit import providers + with tempfile.TemporaryDirectory() as d: + log = Path(d) / "hooks.log" + log.write_text("guard claude deny: `cp` would overwrite … → use `dev cp` · ✓\n" * 50) + mark = log.stat().st_size # bytes, as the self-test records it + with log.open("a") as fh: + fh.write("guard claude deny: Branch `claude/devkit-selftest` is prefixed\n") + self.assertEqual(providers._appended(log, mark), + "guard claude deny: Branch `claude/devkit-selftest` is prefixed\n") + self.assertEqual(providers._appended(Path(d) / "missing.log", 0), "") + if __name__ == "__main__": unittest.main()