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
8 changes: 8 additions & 0 deletions engine/hooks/unverified-tag-ledger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ fixtures in `tests/test_hooks.py`.
behaviour without preventing the turn from ending at all.
- **Discharge** — a claim is resolved when a later turn runs a verification tool
(`Bash`, `Read`, `Grep`, `Glob`, `NotebookRead`) and stops re-emitting it.
- **Where the turn's tool list comes from** — the transcript named by
`transcript_path`, read the way `scope-lock/detect.py` reads it. A Claude Code
Stop payload carries no tool list of any kind; the captured one in
`tests/fixtures/claude-stop-payload.json` is the record of that. The read has
three outcomes, not two: a set of names, an empty set, and *unchecked* when
the transcript is missing or unreadable. Unchecked is not "no tools" — an
unchecked turn is neither refused nor allowed to discharge a row, and the
reason is written to stderr.
- **Escalation** — a claim outstanding `ESCALATE_AFTER_TURNS` (3) turns or more
is reported as a reflect trigger rather than accumulating quietly.

Expand Down
174 changes: 149 additions & 25 deletions engine/hooks/unverified-tag-ledger/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,17 @@ def outstanding(rows: list[dict]) -> list[dict]:
return [row for row in rows if not row.get("resolved")]


def record_turn(session_id: str, message: str, tools_used: set[str], now=None) -> list[dict]:
"""Log new tags, discharge ones this turn verified and dropped."""
def record_turn(session_id: str, message: str, tools_used: set[str] | None, now=None) -> list[dict]:
"""Log new tags, discharge ones this turn verified and dropped.

`tools_used` is None when the turn's tool calls could not be read. That is
not the same as "ran no tools": an unchecked turn discharges nothing, so a
row stays outstanding rather than being retired on no evidence.
"""
stamp = now() if now else time.time()
rows = read_ledger(session_id)
present = {tag["claim"] for tag in parse_tags(message)}
verified = bool(tools_used & VERIFY_TOOLS)
verified = tools_used is not None and bool(tools_used & VERIFY_TOOLS)

for row in rows:
if row.get("resolved"):
Expand Down Expand Up @@ -171,13 +176,20 @@ def evaluate(payload: dict) -> dict:
"""
session_id = str(payload.get("session_id") or "")
message = _last_assistant_text(payload)
tools = _tools_used(payload)
tools = tools_used_this_turn(payload)
rows = record_turn(session_id, message, tools)

new_claims = {tag["claim"] for tag in parse_tags(message)}
if not new_claims:
return {"note": "", "block": ""}

if tools is None:
return {"note": (
f"unverified-tag-ledger: logged {len(new_claims)} CAT-UNVERIFIED claim(s), but this "
"turn's tool calls could not be read from transcript_path (see the line above), so "
"whether a check was attempted is UNCHECKED, not clean. Nothing was discharged and "
"the turn was not refused."), "block": ""}

if not tools & VERIFY_TOOLS and not payload.get("stop_hook_active"):
claims = "; ".join(sorted(new_claims)[:MAX_LISTED])
return {"note": "", "block": (
Expand All @@ -202,24 +214,136 @@ def decide_stop(payload: dict) -> str:


def _last_assistant_text(payload: dict) -> str:
for key in ("last_assistant_message", "assistant_message", "message"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value
transcript = payload.get("transcript") or []
if isinstance(transcript, list):
for entry in reversed(transcript):
if isinstance(entry, dict) and entry.get("role") == "assistant":
content = entry.get("content")
if isinstance(content, str):
return content
return ""


def _tools_used(payload: dict) -> set[str]:
raw = payload.get("tools_used") or payload.get("tool_names") or []
if isinstance(raw, str):
return {raw}
if isinstance(raw, list):
return {str(item) for item in raw}
return set()
"""The reply this Stop event is about.

`last_assistant_message` is the key a real Claude Code Stop payload
carries -- see tests/fixtures/claude-stop-payload.json, captured from a
live run. The transcript is the fallback for a payload that omits it.
"""
value = payload.get("last_assistant_message")
if isinstance(value, str) and value.strip():
return value
path = payload.get("transcript_path")
if not isinstance(path, str) or not path:
return ""
text = ""
try:
with open(path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(entry, dict) and _assistant_text(entry):
text = _assistant_text(entry)
except (OSError, UnicodeError) as exc:
sys.stderr.write(
f"unverified-tag-ledger: cannot read transcript {path}: {exc!r}\n")
return ""
return text


def _entry_content(entry: dict):
message = entry.get("message")
if isinstance(message, dict):
return str(message.get("role") or ""), message.get("content")
return str(entry.get("role") or ""), entry.get("content")


def _assistant_text(entry: dict) -> str:
role, content = _entry_content(entry)
if entry.get("type") != "assistant" and role != "assistant":
return ""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
return "\n".join(
str(block.get("text") or "")
for block in content
if isinstance(block, dict) and block.get("type") in {"text", "output_text"}
).strip()


def _tool_names(entry: dict) -> list[str]:
role, content = _entry_content(entry)
if entry.get("type") != "assistant" and role != "assistant":
return []
if not isinstance(content, list):
return []
return [
str(block.get("name") or "")
for block in content
if isinstance(block, dict) and block.get("type") == "tool_use"
]


def _starts_a_new_turn(entry: dict) -> bool:
"""True for a real user prompt -- the boundary this turn's tools start at.

A `tool_result` arrives as a user entry too, and so does the hook's own
feedback (`isMeta`). Neither is the user speaking, so neither ends the
turn whose tool calls we are counting.
"""
role, content = _entry_content(entry)
if entry.get("type") != "user" and role != "user":
return False
if entry.get("isMeta"):
return False
if isinstance(content, str):
return bool(content.strip())
if not isinstance(content, list):
return False
return not any(
isinstance(block, dict) and block.get("type") == "tool_result"
for block in content
)


def tools_used_this_turn(payload: dict) -> set[str] | None:
"""Tool names this turn actually called, or None when that cannot be read.

Claude Code's Stop payload has no tool list of any kind -- see the
captured fixture. The turn's tool calls live in the transcript, which is
how `scope-lock/detect.py` reads the same thing.

Three outcomes, not two: a set (checked), an empty set (checked, no tools),
and None (unchecked). None is not "no tools" -- a caller that collapses it
to an empty set would block a turn it never managed to inspect, and would
discharge ledger rows on no evidence.
"""
path = payload.get("transcript_path")
if not isinstance(path, str) or not path:
sys.stderr.write(
"unverified-tag-ledger: payload carries no transcript_path, "
"this turn's tool list is unchecked\n")
return None
names: set[str] = set()
try:
with open(path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError as exc:
sys.stderr.write(
f"unverified-tag-ledger: {path} has a non-JSON line, "
f"tool list is unchecked: {exc}\n")
return None
if not isinstance(entry, dict):
continue
if _starts_a_new_turn(entry):
names = set()
continue
names.update(name for name in _tool_names(entry) if name)
except (OSError, UnicodeError) as exc:
sys.stderr.write(
f"unverified-tag-ledger: cannot read transcript {path}, "
f"tool list is unchecked: {exc!r}\n")
return None
return names
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"session_id":"f4d915a9-b685-4a03-b2fb-63f560eb4a21","transcript_path":"/Users/edbertchan/.claude/projects/-private-tmp-claude-501--Users-edbertchan-Documents-GitHub-catstack-667f4e30-1169-4c52-8310-c66a5261dc95-scratchpad-capture-work/f4d915a9-b685-4a03-b2fb-63f560eb4a21.jsonl","cwd":"/private/tmp/claude-501/-Users-edbertchan-Documents-GitHub-catstack/667f4e30-1169-4c52-8310-c66a5261dc95/scratchpad/capture/work","prompt_id":"82dad848-caee-4ebd-9834-90f4c68ff6bf","permission_mode":"default","effort":{"level":"high"},"hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"done.","background_tasks":[],"session_crons":[]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"parentUuid":null,"isSidechain":false,"promptId":"82dad848-caee-4ebd-9834-90f4c68ff6bf","type":"user","message":{"role":"user","content":"Run the Bash tool once with the command: echo hello-capture. Then reply with exactly: done."},"uuid":"452f4560-6849-4545-87a9-8ee5b12868fe","timestamp":"2026-09-22T07:31:27.890Z","permissionMode":"default","promptSource":"sdk","turnOrigin":"sdk","userType":"external","entrypoint":"sdk-cli","cwd":"/private/tmp/claude-501/-Users-edbertchan-Documents-GitHub-catstack/667f4e30-1169-4c52-8310-c66a5261dc95/scratchpad/capture/work","sessionId":"f4d915a9-b685-4a03-b2fb-63f560eb4a21","version":"2.1.278","gitBranch":"HEAD"}
{"parentUuid":"88d4942c-23c1-4d6f-b02c-ae421e99230a","isSidechain":false,"message":{"model":"claude-sonnet-5","id":"msg_011CfJ62w9A2M9rhdykcDMGG","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"Eq4CCqgBCBIYAipA2/IPIRV/4tdfFDHn4quDINZ8tue8+3Fj2/AgycscQWBKZV/XcakNCrrVXNeTTjC6c+POgm7o2J1rWLjPeSBK7zIPY2xhdWRlLXNvbm5ldC01OABCCHRoaW5raW5nWiRiMThhNmYzZC01MDA4LTQyMGEtOTk1OC03OGFiNTU5MTI3NDFyEBYjjRHMxvzl8Lo/e0L/MyiIAQGoAdTdyNUGsAECEgzBpTQOAnMHDRdJLaEaDLuZ8BH+Kypl/xbkdCIwkDJ0GVthbchFvnzH5NR5DUcqkiR4TEcodkhqlTZVjI/8qlE4H/zGZLVtgJIcYDyeKjNCswG9GnINJGdyE6nGOZnhuqJT3scDBSpJqf4HCvkExe0Ho2DGSsYSYLtNZEJxayZ1M6sYAQ=="}],"container":null,"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":31990,"cache_read_input_tokens":18639,"output_tokens":95,"output_tokens_details":{"thinking_tokens":14},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":31990,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":95,"cache_read_input_tokens":18639,"cache_creation_input_tokens":31990,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":31990},"type":"message"}],"speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"apiBlockIndex":0,"requestId":"req_011CfJ62vq3wQ499FxAMXYuK","type":"assistant","uuid":"75efffd8-901b-4a8c-9bfd-faf3062e94d3","timestamp":"2026-09-22T07:31:33.025Z","effort":"high","perTurnEffort":null,"userType":"external","entrypoint":"sdk-cli","cwd":"/private/tmp/claude-501/-Users-edbertchan-Documents-GitHub-catstack/667f4e30-1169-4c52-8310-c66a5261dc95/scratchpad/capture/work","sessionId":"f4d915a9-b685-4a03-b2fb-63f560eb4a21","version":"2.1.278","gitBranch":"HEAD"}
{"parentUuid":"75efffd8-901b-4a8c-9bfd-faf3062e94d3","isSidechain":false,"message":{"model":"claude-sonnet-5","id":"msg_011CfJ62w9A2M9rhdykcDMGG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BWL7hmHoxVvT55xngKgrWA","name":"Bash","input":{"command":"echo hello-capture","description":"Print hello-capture"},"caller":{"type":"direct"}}],"container":null,"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":31990,"cache_read_input_tokens":18639,"output_tokens":95,"output_tokens_details":{"thinking_tokens":14},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":31990,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":95,"cache_read_input_tokens":18639,"cache_creation_input_tokens":31990,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":31990},"type":"message"}],"speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"wireToolInputs":{"toolu_01BWL7hmHoxVvT55xngKgrWA":{"command":"echo hello-capture","description":"Print hello-capture"}},"apiBlockIndex":1,"requestId":"req_011CfJ62vq3wQ499FxAMXYuK","type":"assistant","uuid":"7f6bd8f3-9a45-4334-bde4-9e16bb3fd52e","timestamp":"2026-09-22T07:31:33.027Z","effort":"high","perTurnEffort":null,"userType":"external","entrypoint":"sdk-cli","cwd":"/private/tmp/claude-501/-Users-edbertchan-Documents-GitHub-catstack/667f4e30-1169-4c52-8310-c66a5261dc95/scratchpad/capture/work","sessionId":"f4d915a9-b685-4a03-b2fb-63f560eb4a21","version":"2.1.278","gitBranch":"HEAD"}
{"parentUuid":"7f6bd8f3-9a45-4334-bde4-9e16bb3fd52e","isSidechain":false,"promptId":"82dad848-caee-4ebd-9834-90f4c68ff6bf","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BWL7hmHoxVvT55xngKgrWA","type":"tool_result","content":"hello-capture","is_error":false}]},"uuid":"922b472c-ec57-49eb-b784-8fd6606b6ee5","timestamp":"2026-09-22T07:31:35.475Z","toolUseResult":{"stdout":"hello-capture","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"sourceToolAssistantUUID":"7f6bd8f3-9a45-4334-bde4-9e16bb3fd52e","userType":"external","entrypoint":"sdk-cli","cwd":"/private/tmp/claude-501/-Users-edbertchan-Documents-GitHub-catstack/667f4e30-1169-4c52-8310-c66a5261dc95/scratchpad/capture/work","sessionId":"f4d915a9-b685-4a03-b2fb-63f560eb4a21","version":"2.1.278","gitBranch":"HEAD"}
{"parentUuid":"f6dd2d75-5353-4d8a-98ea-868abdd245e9","isSidechain":false,"message":{"model":"claude-sonnet-5","id":"msg_011CfJ63Hi9yPWxCGLJniyzr","type":"message","role":"assistant","content":[{"type":"text","text":"done."}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":145,"cache_read_input_tokens":50629,"output_tokens":4,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":145,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":50629,"cache_creation_input_tokens":145,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":145},"type":"message"}],"speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"apiBlockIndex":0,"requestId":"req_011CfJ63HNa5AVatjNvpR5eJ","type":"assistant","uuid":"8e60555e-f456-4803-b26e-9b4caa1cf68a","timestamp":"2026-09-22T07:31:36.925Z","effort":"high","perTurnEffort":null,"userType":"external","entrypoint":"sdk-cli","cwd":"/private/tmp/claude-501/-Users-edbertchan-Documents-GitHub-catstack/667f4e30-1169-4c52-8310-c66a5261dc95/scratchpad/capture/work","sessionId":"f4d915a9-b685-4a03-b2fb-63f560eb4a21","version":"2.1.278","gitBranch":"HEAD"}
Loading
Loading