Skip to content
Closed
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
67 changes: 47 additions & 20 deletions engine/hooks/frustration-watchdog/claude_stop_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@
import os
import re
import sys
import hashlib
from datetime import datetime

sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk"))

from finding import Finding # noqa: E402
from runtime import run_hook # noqa: E402

IMPATIENCE_PATTERNS = [
("profanity", re.compile(r"\b(fuck\w*|wtf|shit\w*|goddamn|dammit|damn it|stupid)\b", re.I)),
("told-you", re.compile(r"\bi (already |just )?told you\b|\bi asked you not\b|\bi already said\b", re.I)),
Expand Down Expand Up @@ -83,6 +90,8 @@
"""How Claude Code records a tool call a PreToolUse hook refused: an is_error
tool_result whose text opens "PreToolUse:Bash hook error: [<command>]: ..."."""

RULE_PREFIX = "frustration-watchdog"


def _is_allcaps(text):
letters = [c for c in text if c.isalpha()]
Expand Down Expand Up @@ -203,27 +212,23 @@ def ends_the_wait(message):
return bool(NEXT_STEP_RE.search(message) or ETA_RE.search(message))


def main():
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
return
def detect(data):
if data.get("stop_hook_active") or data.get("agent_id"):
return
return []
message = data.get("last_assistant_message") or ""
transcript_path = data.get("transcript_path") or ""
if not message or not transcript_path or not os.path.isfile(transcript_path):
return
return []
try:
msgs = human_user_messages(transcript_path)
kinds = impatience_kinds(msgs)
except Exception as exc:
print(f"catstack-hook-error frustration-watchdog: {type(exc).__name__}: {exc}", file=sys.stderr)
return # fail open: a broken watchdog must never brick a session
return []
if not kinds:
return
return []
if ends_the_wait(message):
return
return []
try:
refused = turn_has_hook_refusal(transcript_path)
unchecked = None
Expand All @@ -236,26 +241,48 @@ def main():
"and this reply hands them nothing visible. "
)
if refused:
sys.stderr.write(
head + "A hook refused a tool call this turn, so you are the one blocked: "
feedback = (
head
+ "A hook refused a tool call this turn, so you are the one blocked: "
"do not hand the user steps to work around it. End the wait: ask them a "
"direct question, or state an explicit no-action window "
"(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules.\n"
"(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules."
)
else:
sys.stderr.write(
head + "End the wait: give exactly one "
feedback = (
head
+ "End the wait: give exactly one "
"concrete action for the user (\"click X\", \"run Y\", \"say Z\"), ask them a "
"direct question, or state an explicit no-action window "
"(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules.\n"
"(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules."
)
if unchecked:
sys.stderr.write(
f"(frustration-watchdog could not read this turn's tool results ({unchecked}), "
feedback = (
f"catstack-hook-error frustration-watchdog: {unchecked}\n"
+ feedback
+ "\n"
+ f"(frustration-watchdog could not read this turn's tool results ({unchecked}), "
"so it could not tell whether a hook refused a tool call; the wording above "
"is the default.)\n"
"is the default.)"
)
sys.exit(2)
primary_kind = sorted(set(kinds))[0]
return [
Finding(
rule_id=f"{RULE_PREFIX}.{primary_kind}",
subject="reply:" + hashlib.sha256(message.encode("utf-8")).hexdigest(),
message=feedback,
evidence=", ".join(sorted(set(kinds))),
)
]


def main():
try:
run_hook("frustration-watchdog", "claude", detect, "Stop")
except SystemExit as exc:
if exc.code == 0:
return
raise


if __name__ == "__main__":
Expand Down
20 changes: 20 additions & 0 deletions engine/hooks/frustration-watchdog/codex_stop_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Codex Stop entrypoint for frustration-watchdog."""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk"))

from claude_stop_check import detect # noqa: E402
from runtime import run_hook # noqa: E402


def main() -> None:
run_hook("frustration-watchdog", "codex", detect, "Stop")


if __name__ == "__main__":
main()
20 changes: 20 additions & 0 deletions engine/hooks/frustration-watchdog/cursor_stop_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Cursor stop entrypoint for frustration-watchdog."""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk"))

from claude_stop_check import detect # noqa: E402
from runtime import run_hook # noqa: E402


def main() -> None:
run_hook("frustration-watchdog", "cursor", detect, "stop")


if __name__ == "__main__":
main()
90 changes: 90 additions & 0 deletions engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

from test_hooks import NARRATION, WAITING, human, transcript_lines, tool_turn

HOOK_DIR = Path(__file__).resolve().parents[1]
HOOK = HOOK_DIR / "claude_stop_check.py"


def payload(transcript_path: str) -> dict[str, object]:
return {
"hook_event_name": "Stop",
"session_id": "frustration-watchdog-sdk-mode",
"transcript_path": transcript_path,
"last_assistant_message": NARRATION,
"stop_hook_active": False,
}


def run_hook(data: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]:
merged_env = os.environ.copy()
merged_env.pop("CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG", None)
merged_env.update(env)
return subprocess.run(
[sys.executable, str(HOOK)],
input=json.dumps(data),
capture_output=True,
text=True,
timeout=10,
env=merged_env,
)


class SdkModeTest(unittest.TestCase):
def test_mode_override_warn_turns_stop_into_warning(self) -> None:
transcript = transcript_lines([human(WAITING)])
try:
result = run_hook(
payload(transcript),
{"CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG": "warn"},
)
finally:
os.unlink(transcript)

self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("", result.stderr)
output = json.loads(result.stdout)
self.assertIn(
"The user's last message was impatience-shaped (waiting)",
output["hookSpecificOutput"]["additionalContext"],
)

def test_each_finding_writes_one_event_row_with_rule_id(self) -> None:
with tempfile.TemporaryDirectory() as metrics_dir:
transcript = transcript_lines([human(WAITING)] + tool_turn("Created PR #12", is_error=False))
try:
result = run_hook(
payload(transcript),
{
"CATSTACK_HOOK_METRICS_DIR": metrics_dir,
"CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG": "warn",
},
)
finally:
os.unlink(transcript)
rows = self._event_rows(metrics_dir)

self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(1, len(rows))
self.assertEqual("frustration-watchdog", rows[0]["hook"])
self.assertEqual("frustration-watchdog.waiting", rows[0]["rule_id"])
self.assertEqual("warn", rows[0]["mode"])
self.assertEqual("override", rows[0]["mode_source"])
self.assertEqual("warned", rows[0]["action"])

def _event_rows(self, metrics_dir: str) -> list[dict[str, object]]:
files = list(Path(metrics_dir).glob("events-*.jsonl"))
self.assertEqual(1, len(files))
return [json.loads(line) for line in files[0].read_text(encoding="utf-8").splitlines()]


if __name__ == "__main__":
unittest.main()
5 changes: 5 additions & 0 deletions product/skills/loop-generator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ Collect and resolve every field below before drafting. Don't improvise a differe
11. `fail_condition_rule` — repeated-attempt threshold and grouping key (e.g. "3 failures on the same (target, symptom) pair → stop and report, don't keep retrying").
12. `local_proxy_command` — the safest repeatable local/proxy verification command, or `none`.
13. `write_mode` — one of `diagnostic_only` (never mutate, just report), `worker_owned_writes` (the loop itself makes changes), or `choose_each_run` (ask each time).
14. `state_artifact` — a file or digest an existing worker/cron/daemon already maintains that records the same state (ledger, status file, folded report), or `none`. When set, `target_discovery_command` reads or folds that artifact instead of re-querying the live source, and live calls are reserved for entries the artifact marks as needing action.

Interview rules:

- Ask for missing `success_criteria` before drafting — don't infer it from a vague goal.
- Ask for missing edge cases when `human_only_blockers`, `evidence_sources`, `fail_condition_rule`, or `write_mode` would materially change behavior.
- Ask whether a `state_artifact` already exists before accepting a live `target_discovery_command` — re-deriving state a worker already records is the expensive default, not the neutral one.

Before drafting, post a short resolved summary: the filled fields, any defaults taken, open questions if any remain, and a direct check like "Ready to draft?"

Expand All @@ -68,6 +70,8 @@ Rules:
- Keep `Goal`, `Motivation`, success rules, fail rules, and blockers concrete, not aspirational.
- Record assumptions explicitly instead of hiding them.
- Say what the live target is, how it's rebuilt each round, and how the loop dedupes it — don't hide mutable-state risk.
- When `state_artifact` is set, name it in `Real target` and `Evidence sources`, and make the per-round read a fold/digest of that artifact — each round's read stays bounded in output size, not a full-history re-parse or a live sweep that streams raw results into a session transcript. Reserve live queries for entries the artifact marks as needing action.
- A watch loop states its terminal condition in `Exit conditions` — the loop ends when the watched scope reaches it (e.g. the stack merges), not when a human remembers to stop it.
- If the loop will run unattended, log one row per iteration with `show-me-your-work` instead of inventing a second trail format.

## Driver shell script contract
Expand All @@ -77,6 +81,7 @@ The generated driver must:
- parse `--target <id>` (repeatable), `--state-file <path>`, `--skip-local-check`, and `--help`;
- print loop context on start: cwd, branch (if applicable), and the state-file path;
- rebuild the live target set from `target_discovery_command` every run — never trust a cached list from a prior round;
- when `state_artifact` is set, implement that rebuild as a bounded fold/digest read of the artifact (latest state per target), not a full-history re-parse and not a live sweep of the underlying source;
- dedupe the target set by `target_identity_key`;
- print a repeated-failure summary keyed by `fail_condition_rule`, so a stuck target is visible instead of silently retried forever;
- when `--target` is passed for inspection, run any dry-run/probe command against a **copy** of mutable state, never the live state file;
Expand Down
7 changes: 7 additions & 0 deletions product/skills/loop-generator/tests/fires_example.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ This matches the trigger phrase "retry failed jobs until they pass" and
asks for a recurring watch/retry behavior, so the skill runs its
interview (loop_name, goal, target_discovery_command, fail_condition_rule,
write_mode, etc.) before drafting the instruction doc + driver script pair.

During the interview the skill also asks whether a `state_artifact` exists
— e.g. a CI status file or ledger a worker already maintains. If the user
answers yes, the generated doc names that artifact in `Real target` and
`Evidence sources`, the per-round read folds it instead of re-querying the
live source, and the `Exit conditions` section states the terminal
condition that ends the watch.
Loading