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
6 changes: 4 additions & 2 deletions docs/changelogs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 6 additions & 1 deletion hooks/edit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import re
import sys
import time
from pathlib import Path

EDIT_TOOLS = {'Edit', 'Write', 'MultiEdit', 'NotebookEdit'}
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions scripts/test_edit_guard.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import sys
import tempfile
import threading
import unittest
from pathlib import Path

Expand Down Expand Up @@ -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}))
Expand Down
Loading