diff --git a/docs/changelogs/CHANGELOG.md b/docs/changelogs/CHANGELOG.md index 56dd309..f2ed853 100644 --- a/docs/changelogs/CHANGELOG.md +++ b/docs/changelogs/CHANGELOG.md @@ -1,10 +1,12 @@ # Changelog -## 0.6.1 - Unreleased +## 0.6.1 - 2026-10-15 -- Fixed the guard hook denying file changes with "State the lane" after the lane was stated, in two cases: +- Verification: hook-only release, so the 12-case behavior suite did not run (`docs/behavior-checks.md` requires it for skill-instruction changes). Instead, headless Claude Code 2.1.300 sessions with `claude-sonnet-5-5` replayed the reported failure on a typo fixture: the agent writes "The lane is Fast." and then edits. On 0.6.0 the hook blocked every edit (4 denials) and the agent gave up. On 0.6.1, 6 of 6 sessions edited with no denial. Without the re-read below, 3 of 4 sessions were denied once. Brief mode is not available in headless sessions, so the `SendUserMessage` path is covered by unit tests only. Codex does not load the hook and was not re-run. +- Fixed the guard hook denying file changes with "State the lane" after the lane was stated, in three cases: - Brief mode: the lane was stated through `SendUserMessage` (alias `Brief`). The hook read only assistant text blocks, so it never saw the statement. It now also reads the `message` of those tool calls. - Phrasing: "Lane is Fast" or "Lane for this: Fast" did not match, because only punctuation was allowed between "lane" and the lane name. Up to 20 characters on the same line are now allowed, the same as for "Fast lane". + - Same message: when the lane statement and the tool call were in one assistant message, the statement could reach the transcript after the hook read it. When no lane is found, the hook now re-reads the transcript every 0.1 s for up to 2 s before denying. In the sessions above the statement arrived within 0.7 s. A write with no stated lane is now denied after that 2 s wait. - The denial now gives an example of an accepted statement ("Lane: Fast"), so a retry does not depend on guessing the pattern. ## 0.6.0 - 2026-10-10 diff --git a/hooks/edit_guard.py b/hooks/edit_guard.py index 1aa5ccd..9c20c4d 100644 --- a/hooks/edit_guard.py +++ b/hooks/edit_guard.py @@ -3,6 +3,7 @@ import json import re import sys +import time from pathlib import Path EDIT_TOOLS = {'Edit', 'Write', 'MultiEdit', 'NotebookEdit'} @@ -24,7 +25,7 @@ def shellWritesOutsideSdlc(aCommand): return bool(unknown) or any('.sdlc' not in t for t in targets) -def decide(aEvent): +def decide(aEvent, aAttempt=0): tool, args = aEvent.get('tool_name'), aEvent.get('tool_input') or {} if tool in EDIT_TOOLS: if '.sdlc' in Path(args.get('file_path') or args.get('notebook_path') or '').parts: @@ -58,6 +59,10 @@ def decide(aEvent): states = [p.read_text(encoding='utf-8', errors='replace') for p in Path(aEvent.get('cwd') or '.').glob('.sdlc/changes/*/state.md')] lanes = [m.group(1) or m.group(2) for m in LANE.finditer('\n'.join(texts + states))] + # Text in the same message as this tool call can reach the transcript up to ~1 s after the hook starts. + if not lanes and aAttempt < 20: + time.sleep(0.1) + return decide(aEvent, aAttempt + 1) if not lanes: return 'State the lane (Fast, Standard, or Controlled) in a message before changing files, for example "Lane: Fast".' lane = lanes[-1].lower() diff --git a/scripts/test_edit_guard.py b/scripts/test_edit_guard.py index 686c24f..bee25e7 100644 --- a/scripts/test_edit_guard.py +++ b/scripts/test_edit_guard.py @@ -1,6 +1,7 @@ import json import sys import tempfile +import threading import unittest from pathlib import Path @@ -41,6 +42,13 @@ def test_decisions(self): {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'name': 'SendUserMessage', 'input': {'message': 'Lane: Fast.'}}]}}), encoding='utf-8') self.assertIsNone(decide({**edit, 'transcript_path': str(brief)})) + # The statement in the same message as the call can reach the transcript after the hook starts. + late = Path(transcript(folder)) + line = '\n' + json.dumps({'type': 'assistant', 'message': {'content': [{'type': 'text', 'text': 'Lane: Fast.'}]}}) + timer = threading.Timer(0.3, lambda: late.write_text(late.read_text(encoding='utf-8') + line, encoding='utf-8')) + timer.start() + self.assertIsNone(decide({**edit, 'transcript_path': str(late)})) + timer.join() path = transcript(folder, '**Lane:** Controlled, because this is a payment API.') self.assertIsNotNone(decide({**edit, 'transcript_path': path})) self.assertIsNotNone(decide({**heredoc, 'transcript_path': path}))