Skip to content

Commit 7968d67

Browse files
Jammy2211claude
authored andcommitted
agents: sessions end at their deliverable — synced policy block + PreToolUse hook (repos_sync --write)
The rule: a session ends when it reports its deliverable — never arm anything that outlives the turn (send_later, subscribe_pr_activity, CronCreate, ScheduleWakeup, /loop, RemoteTrigger create/update/run) to wait for CI, a review or a merge; judge once, report, stop, and let the human re-run /prm. Measured cause: five 2026-08-31 batch members armed hourly check-ins, and a mobile /prm re-armed a 60-minute send_later hourly all night on 2026-09-03 with no task active, draining usage. Canonical source (single-sourced and checked by repos_sync): PyAutoMind/policy/end_at_deliverable.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KoFfaxano8nN644BAncrF
1 parent 62feb7e commit 7968d67

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
# GENERATED — canonical source: PyAutoMind/policy/end_at_deliverable_hook.sh
3+
# Installed into every checked-out repo as .claude/hooks/end-at-deliverable.sh by
4+
# `python3 PyAutoMind/scripts/repos_sync.py --write`, and drift-checked by
5+
# `--check`. Edit the canonical file, never a copy.
6+
#
7+
# ---------------------------------------------------------------------------
8+
# Sessions end at their deliverable (PyAutoMind/policy/end_at_deliverable.md).
9+
#
10+
# A PreToolUse guard on the tools that outlive the turn. It exists because the
11+
# prose rule was already written and was still broken twice: five batch members
12+
# armed hourly check-ins on 2026-08-31 (fixed for batch members only), and on
13+
# 2026-09-02/03 a mobile `/prm` re-armed a 60-minute `send_later` hourly from
14+
# 02:39 to 12:11 UTC with no task active, leaving twenty fired one-shots and a
15+
# drained usage window. A rule a session can talk itself past is not a rule, so
16+
# this one is enforced by the harness.
17+
#
18+
# Registered under `hooks.PreToolUse` with the matcher
19+
# ^(send_later|subscribe_pr_activity|ScheduleWakeup|CronCreate|RemoteTrigger|mcp__.*(send_later|subscribe_pr_activity).*)$
20+
#
21+
# Allowed through:
22+
# * `RemoteTrigger` with action list / get / list_runs / get_run_log — reading
23+
# what already exists never outlives the turn;
24+
# * anything at all when PYAUTO_ALLOW_TIMERS=1 — the human-authorised escape
25+
# for a routine the human actually asked for.
26+
#
27+
# Everything else exits 2 (the harness treats stderr as the reason and blocks
28+
# the call). An unreadable payload also exits 2: this fails closed, because the
29+
# failure it guards against is silent and costs a night of usage.
30+
set -uo pipefail
31+
32+
# Human-authorised routine: the one way past this guard, and it has to be set
33+
# deliberately in the environment.
34+
[ "${PYAUTO_ALLOW_TIMERS:-}" = "1" ] && exit 0
35+
36+
payload="$(cat)"
37+
38+
# python3 rather than jq: python3 is present everywhere this hook is installed
39+
# (it is what the SessionStart hook guarantees), jq is not. The payload rides in
40+
# the environment rather than on stdin so the heredoc keeps its own stdin.
41+
PYAUTO_HOOK_PAYLOAD="$payload" python3 <<'PY'
42+
import json
43+
import os
44+
import sys
45+
46+
# Read-only RemoteTrigger actions: they inspect what exists and schedule nothing.
47+
READ_ONLY_REMOTE_TRIGGER = {"list", "get", "list_runs", "get_run_log"}
48+
49+
REASON = (
50+
"policy end_at_deliverable: sessions end at their deliverable "
51+
"— {tool} would outlive the turn.\n"
52+
"Report and stop; the human re-runs /prm. Set PYAUTO_ALLOW_TIMERS=1 only "
53+
"for a routine the human asked for.\n"
54+
)
55+
56+
57+
def block(tool):
58+
sys.stderr.write(REASON.format(tool=tool))
59+
raise SystemExit(2)
60+
61+
62+
try:
63+
event = json.loads(os.environ.get("PYAUTO_HOOK_PAYLOAD", ""))
64+
if not isinstance(event, dict):
65+
raise ValueError("PreToolUse payload is not a JSON object")
66+
tool = event.get("tool_name")
67+
if not isinstance(tool, str) or not tool:
68+
raise ValueError("PreToolUse payload carries no tool_name")
69+
tool_input = event.get("tool_input")
70+
if not isinstance(tool_input, dict):
71+
tool_input = {}
72+
except Exception:
73+
# Fail closed. A payload this hook cannot read is a call it cannot clear.
74+
block("an unreadable tool call")
75+
76+
if "RemoteTrigger" in tool:
77+
action = tool_input.get("action")
78+
if isinstance(action, str) and action in READ_ONLY_REMOTE_TRIGGER:
79+
raise SystemExit(0)
80+
81+
block(tool)
82+
PY

.claude/settings.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@
99
}
1010
]
1111
}
12+
],
13+
"PreToolUse": [
14+
{
15+
"matcher": "^(send_later|subscribe_pr_activity|ScheduleWakeup|CronCreate|RemoteTrigger|mcp__.*(send_later|subscribe_pr_activity).*)$",
16+
"hooks": [
17+
{
18+
"type": "command",
19+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/end-at-deliverable.sh"
20+
}
21+
]
22+
}
1223
]
1324
}
1425
}

AGENTS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,15 @@ tracked repo, no force-push to `main`, no fresh-start "Initial commit", no
126126
`filter-repo` / `filter-branch` / `rebase -i` on pushed branches. To get a
127127
clean tree: `git fetch origin && git reset --hard origin/main && git clean -fd`.
128128
<!-- repos_sync:history:end -->
129+
130+
<!-- repos_sync:deliverable:begin -->
131+
## Sessions end at their deliverable
132+
133+
A session ends when it reports its deliverable — never arm anything that
134+
outlives the turn to wait for CI, a review or a merge: no `send_later`, no
135+
`subscribe_pr_activity`, no `CronCreate`, no `ScheduleWakeup`, no `/loop`, no
136+
`RemoteTrigger` create/update/run. Judge once, report, stop; the human re-runs
137+
`/prm` (or the batch review) when it is green. Measured: five batch members
138+
armed hourly check-ins on 2026-08-31, and a mobile `/prm` re-armed a 60-minute
139+
`send_later` hourly all night on 2026-09-03 with no task active, draining usage.
140+
<!-- repos_sync:deliverable:end -->

0 commit comments

Comments
 (0)