Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions lib/devkit/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down Expand Up @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions tests/test_quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading