Add the plugin's Stop hook so a written-down wait is never left unwatched (issue 1743)
A session could end its turn while a wait it wrote down with claude_code_wait had nothing following it. The plugin's Stop hook (claude-code stop <waits-dir>) refuses the stop while a wait is within its bound and unwatched, or past its bound and not said, reading the waits file directly with no network and a 5s bound; it fails open on a missing or malformed file or a panic, and lets a wait through after three refusals in a row rather than holding the session forever. Renames TestThePluginsOnlyHookIsTheGuard to cover the session-start and Stop hooks too.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, run by the
|
||||
// agent's own account before a session's turn is let end. It reads the session's mirrored waits file
|
||||
// directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision
|
||||
// is local, under 5 s, and needs no network.
|
||||
//
|
||||
// Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its
|
||||
// bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at
|
||||
// most three times running for the same wait (a counter file beside the waits file, reset once the blocking
|
||||
// wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing,
|
||||
// unreadable or malformed waits file, or a panic, always allows.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StopInput is what the hook is given on stdin.
|
||||
type StopInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
}
|
||||
|
||||
// MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the
|
||||
// turn end anyway (1743/AC5).
|
||||
const MostStopRefusals = 3
|
||||
|
||||
// StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call.
|
||||
type StopDecision struct {
|
||||
Refuse bool
|
||||
Reason string
|
||||
Counters map[string]int
|
||||
}
|
||||
|
||||
// waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4).
|
||||
func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
if w.Said {
|
||||
return false, ""
|
||||
}
|
||||
if !w.PastBound(now) {
|
||||
if w.Watcher != "" {
|
||||
return false, ""
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
|
||||
"either mesh_wait on %s or start a watcher that rereads it, and mark it watched: claude_code_wait "+
|
||||
"{\"op\":\"watched\",\"session\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address, familyOrAddress(w),
|
||||
session, w.ID)
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
|
||||
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
|
||||
"{\"op\":\"said\",\"session\":%q,\"id\":%q}.", w.ID, w.What, session, w.ID)
|
||||
}
|
||||
|
||||
func familyOrAddress(w Wait) string {
|
||||
if w.Family != "" {
|
||||
return w.Family
|
||||
}
|
||||
return w.Read.Address
|
||||
}
|
||||
|
||||
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
|
||||
// file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still
|
||||
// blocks the stop is named; the others are read again next time.
|
||||
func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision {
|
||||
if readErr != nil {
|
||||
return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}}
|
||||
}
|
||||
for _, w := range f.Waits {
|
||||
blocks, reason := waitBlocksStop(session, w, now)
|
||||
if !blocks {
|
||||
continue
|
||||
}
|
||||
count := previous[w.ID] + 1
|
||||
if count > MostStopRefusals {
|
||||
return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+
|
||||
"unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}}
|
||||
}
|
||||
return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}}
|
||||
}
|
||||
return StopDecision{Counters: map[string]int{}}
|
||||
}
|
||||
|
||||
func refusalCountersPath(waitsDir, session string) string {
|
||||
return filepath.Join(waitsDir, session+".refusals.json")
|
||||
}
|
||||
|
||||
func readRefusalCounters(waitsDir, session string) map[string]int {
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, session))
|
||||
if err != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// writeRefusalCounters is best-effort: a counter file that cannot be written only means the three-strikes
|
||||
// cap may be judged again from zero, never that the hook fails.
|
||||
func writeRefusalCounters(waitsDir, session string, m map[string]int) {
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(refusalCountersPath(waitsDir, session), raw, 0o600)
|
||||
}
|
||||
|
||||
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
|
||||
func runStop(waitsDir string, stdin io.Reader, stderr io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // a panic always allows (1743/AC6)
|
||||
}
|
||||
}()
|
||||
var in StopInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil || strings.TrimSpace(in.SessionID) == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := readWaitsFile(waitsFileIn(waitsDir, in.SessionID))
|
||||
d := DecideStop(in.SessionID, f, err, readRefusalCounters(waitsDir, in.SessionID), time.Now())
|
||||
writeRefusalCounters(waitsDir, in.SessionID, d.Counters)
|
||||
if d.Reason != "" {
|
||||
fmt.Fprintln(stderr, d.Reason)
|
||||
}
|
||||
if d.Refuse {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user