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.
138 lines
5.0 KiB
Go
138 lines
5.0 KiB
Go
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
|
|
}
|