Skip to content

Commit 2b7ca27

Browse files
fix(hooks): ignore .brigade artifact writes in closeout gate (#483) (#516)
Detached brigade run output under .brigade/ no longer bumps last_verification_write_at, and brigade run bash is excluded from PostToolUse write tracking like routed verify already was. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6b7edb1 commit 2b7ca27

2 files changed

Lines changed: 235 additions & 1 deletion

File tree

src/brigade/claude_hooks/runtime.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,97 @@ def _is_brigade_verify(tokens: list[str]) -> bool:
11281128
return bool(invocation and invocation[0] == "brigade" and invocation[1][:2] == ["work", "verify"])
11291129

11301130

1131+
def _is_brigade_run_tokens(tokens: list[str]) -> bool:
1132+
names = [Path(token).name for token in tokens]
1133+
if names[:2] == ["brigade", "run"]:
1134+
return True
1135+
invocation = _python_module_invocation(tokens)
1136+
return bool(invocation and invocation[0] == "brigade" and invocation[1][:1] == ["run"])
1137+
1138+
1139+
def _is_brigade_run(command: object) -> bool:
1140+
if not isinstance(command, str) or not command.strip():
1141+
return False
1142+
try:
1143+
tokens = _shell_tokens(command)
1144+
except ValueError:
1145+
return False
1146+
segments: list[list[str]] = [[]]
1147+
for token in tokens:
1148+
if token in _SHELL_SEPARATORS:
1149+
segments.append([])
1150+
else:
1151+
segments[-1].append(token)
1152+
return any(_is_brigade_run_tokens(_strip_env(segment)) for segment in segments if segment)
1153+
1154+
1155+
def _is_brigade_internal_artifact_relative(relative: Path) -> bool:
1156+
parts = relative.parts
1157+
return bool(parts) and parts[0] == ".brigade"
1158+
1159+
1160+
def _relative_path_under_target(target: Path, raw: object) -> Path | None:
1161+
if not isinstance(raw, str) or not raw:
1162+
return None
1163+
path = Path(raw).expanduser()
1164+
if not path.is_absolute():
1165+
path = target / path
1166+
try:
1167+
return path.resolve().relative_to(target.expanduser().resolve())
1168+
except (OSError, ValueError):
1169+
return None
1170+
1171+
1172+
def _path_targets_brigade_internal_artifact(target: Path, raw: object) -> bool:
1173+
relative = _relative_path_under_target(target, raw)
1174+
return relative is not None and _is_brigade_internal_artifact_relative(relative)
1175+
1176+
1177+
def _bash_confident_write_touches_worktree(target: Path, command: object) -> bool:
1178+
if not isinstance(command, str) or not command.strip():
1179+
return False
1180+
try:
1181+
tokens = _shell_tokens(command)
1182+
except ValueError:
1183+
return True
1184+
if any(">" in token for token in tokens if token and set(token) <= set(";&|<>")):
1185+
return True
1186+
segments: list[list[str]] = [[]]
1187+
for token in tokens:
1188+
if token in _SHELL_SEPARATORS:
1189+
segments.append([])
1190+
else:
1191+
segments[-1].append(token)
1192+
touches_worktree = False
1193+
for segment in segments:
1194+
stripped = _strip_env(segment)
1195+
if not stripped:
1196+
continue
1197+
names = [Path(token).name for token in stripped]
1198+
command_name = names[0]
1199+
if command_name in _BASH_WRITE_COMMANDS:
1200+
candidates = [token for token in stripped[1:] if not token.startswith("-")]
1201+
if not candidates:
1202+
return True
1203+
if any(not _path_targets_brigade_internal_artifact(target, token) for token in candidates):
1204+
touches_worktree = True
1205+
elif command_name == "sed" and any(arg == "-i" or arg.startswith("-i") for arg in stripped[1:]):
1206+
candidates = [token for token in stripped[1:] if not token.startswith("-")]
1207+
if not candidates:
1208+
return True
1209+
if any(not _path_targets_brigade_internal_artifact(target, token) for token in candidates):
1210+
touches_worktree = True
1211+
elif command_name == "ruff" and names[1:2] == ["format"] and not {"--check", "--diff"}.intersection(names[2:]):
1212+
candidates = [token for token in stripped[2:] if not token.startswith("-")]
1213+
if not candidates:
1214+
return True
1215+
if any(not _path_targets_brigade_internal_artifact(target, token) for token in candidates):
1216+
touches_worktree = True
1217+
elif command_name == "git" and len(names) > 1 and names[1] in _GIT_WRITE_COMMANDS:
1218+
touches_worktree = True
1219+
return touches_worktree
1220+
1221+
11311222
def _shell_wrapper_payload(tokens: list[str]) -> str | None:
11321223
if not tokens or Path(tokens[0]).name not in _SHELL_WRAPPERS:
11331224
return None
@@ -1724,8 +1815,16 @@ def handle_payload(event: str, payload: dict[str, Any]) -> dict[str, Any] | None
17241815
tool_name = payload.get("tool_name")
17251816
raw_post_tool_input = payload.get("tool_input")
17261817
post_tool_input: dict[str, Any] = raw_post_tool_input if isinstance(raw_post_tool_input, dict) else {}
1818+
command = post_tool_input.get("command")
1819+
if tool_name == "Bash" and (_is_routed_verify(command) or _is_brigade_run(command)):
1820+
state.pop("pending_bash_fingerprint", None)
1821+
state.pop("pending_bash_started_at", None)
1822+
write_session_state(target, session_id, state)
1823+
return None
17271824
wrote = tool_name in _WRITE_TOOLS or (
1728-
tool_name == "Bash" and _is_confident_bash_write(post_tool_input.get("command"))
1825+
tool_name == "Bash"
1826+
and _is_confident_bash_write(command)
1827+
and _bash_confident_write_touches_worktree(target, command)
17291828
)
17301829
if not wrote and tool_name == "Bash":
17311830
wrote = _bash_write_detected(

tests/test_claude_hooks_runtime.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,141 @@ def test_mixed_bash_code_and_handoff_write_requires_new_verification(tmp_path: P
557557
assert blocked["decision"] == "block"
558558

559559

560+
def test_stop_ignores_postcapture_brigade_run_artifact_writes(tmp_path: Path, monkeypatch):
561+
"""Issue #483: detached brigade run artifacts under .brigade/runs/ must not re-arm closeout."""
562+
target = _wired_claude(tmp_path)
563+
session_id = "brigade-runs-artifact"
564+
monkeypatch.setattr(runtime, "_run_brief", lambda repo: "brief")
565+
runtime.handle_payload("SessionStart", _payload(target, "SessionStart", session_id=session_id))
566+
runtime.handle_payload(
567+
"PostToolUse",
568+
_payload(
569+
target,
570+
"PostToolUse",
571+
session_id=session_id,
572+
tool_name="Write",
573+
tool_input={"file_path": str(target / "file.py")},
574+
),
575+
)
576+
state = runtime.read_session_state(target, session_id)
577+
run_dir = target / ".brigade" / "work" / "verify-runs" / "run-1"
578+
run_dir.mkdir(parents=True)
579+
(run_dir / "receipt.json").write_text(
580+
json.dumps(
581+
{
582+
"run_id": "run-1",
583+
"status": "completed",
584+
"started_at": state["last_verification_write_at"],
585+
"harness_session": {
586+
"harness": "claude",
587+
"fingerprint": state["session_fingerprint"],
588+
},
589+
}
590+
)
591+
+ "\n"
592+
)
593+
artifact = target / ".brigade" / "runs" / "detached-1" / "run.json"
594+
runtime.handle_payload(
595+
"PostToolUse",
596+
_payload(
597+
target,
598+
"PostToolUse",
599+
session_id=session_id,
600+
tool_name="Bash",
601+
tool_input={"command": f"mkdir -p {artifact.parent} && touch {artifact}"},
602+
),
603+
)
604+
605+
updated = runtime.read_session_state(target, session_id)
606+
assert updated["last_verification_write_at"] == state["last_verification_write_at"]
607+
result = runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False))
608+
assert "decision" not in result
609+
610+
611+
def test_stop_still_blocks_after_source_edit_post_capture(tmp_path: Path, monkeypatch):
612+
"""Issue #483: real source edits after capture must still trip the closeout gate."""
613+
target = _wired_claude(tmp_path)
614+
session_id = "source-edit-post-capture"
615+
monkeypatch.setattr(runtime, "_run_brief", lambda repo: "brief")
616+
runtime.handle_payload("SessionStart", _payload(target, "SessionStart", session_id=session_id))
617+
runtime.handle_payload(
618+
"PostToolUse",
619+
_payload(
620+
target,
621+
"PostToolUse",
622+
session_id=session_id,
623+
tool_name="Write",
624+
tool_input={"file_path": str(target / "file.py")},
625+
),
626+
)
627+
state = runtime.read_session_state(target, session_id)
628+
run_dir = target / ".brigade" / "work" / "verify-runs" / "run-1"
629+
run_dir.mkdir(parents=True)
630+
(run_dir / "receipt.json").write_text(
631+
json.dumps(
632+
{
633+
"run_id": "run-1",
634+
"status": "completed",
635+
"started_at": state["last_verification_write_at"],
636+
"harness_session": {
637+
"harness": "claude",
638+
"fingerprint": state["session_fingerprint"],
639+
},
640+
}
641+
)
642+
+ "\n"
643+
)
644+
runtime.handle_payload(
645+
"PostToolUse",
646+
_payload(
647+
target,
648+
"PostToolUse",
649+
session_id=session_id,
650+
tool_name="Write",
651+
tool_input={"file_path": str(target / "src.py"), "content": "changed\n"},
652+
),
653+
)
654+
655+
updated = runtime.read_session_state(target, session_id)
656+
assert updated["last_verification_write_at"] > state["last_verification_write_at"]
657+
blocked = runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False))
658+
assert blocked["decision"] == "block"
659+
660+
661+
def test_posttooluse_brigade_run_does_not_record_write(tmp_path: Path, monkeypatch):
662+
"""Issue #483: brigade run bash must not bump verification write timestamps."""
663+
target = _wired_claude(tmp_path)
664+
session_id = "brigade-run-bash"
665+
monkeypatch.setattr(runtime, "_run_brief", lambda repo: "brief")
666+
runtime.handle_payload("SessionStart", _payload(target, "SessionStart", session_id=session_id))
667+
runtime.handle_payload(
668+
"PostToolUse",
669+
_payload(
670+
target,
671+
"PostToolUse",
672+
session_id=session_id,
673+
tool_name="Write",
674+
tool_input={"file_path": str(target / "file.py")},
675+
),
676+
)
677+
state = runtime.read_session_state(target, session_id)
678+
pretool = _payload(
679+
target,
680+
"PreToolUse",
681+
session_id=session_id,
682+
tool_name="Bash",
683+
tool_input={"command": "brigade run --detach -- echo noop"},
684+
)
685+
assert runtime.handle_payload("PreToolUse", pretool) is None
686+
assert "pending_bash_fingerprint" in runtime.read_session_state(target, session_id)
687+
688+
runtime.handle_payload("PostToolUse", {**pretool, "hook_event_name": "PostToolUse"})
689+
690+
updated = runtime.read_session_state(target, session_id)
691+
assert updated["last_verification_write_at"] == state["last_verification_write_at"]
692+
assert "pending_bash_fingerprint" not in updated
693+
694+
560695
def test_repo_worktree_fingerprint_detects_dirty_tracked_same_size_rewrite(tmp_path: Path):
561696
target = _git_wired_claude(tmp_path)
562697
tracked = target / "tracked.txt"

0 commit comments

Comments
 (0)