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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ The five questions and actions in the chart:
| Recall | a tool call failed | shows Claude the lesson that describes the failure |
| Capture | a call works after one failed | decides whether it is the fix, and if so tells Claude to record the lesson |
| Stop check | Claude is about to finish | refuses the stop once if a lesson is owed and not yet recorded or declined |
| In-flight check | Claude is about to finish | refuses the stop once if work is still in flight with nothing watching it: a detached process, a workflow run dispatched by hand, or the user's own pull request with pending checks, and no polling loop alive. Runs `hooks/inflight-stop-check.sh`, which can also be registered directly as a Stop hook |

Two things happen beside the chart. A request that keeps coming back is offered a skill,
as described above. And each time a session invokes a skill that compound lists, the use
Expand Down
51 changes: 50 additions & 1 deletion hooks/decisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ type World = {
// What `check` prints whatever the call, and what `log --json` prints, when the test says.
check: string | undefined
logReply: string | undefined
// What the in-flight check prints at a stop ('' = nothing in flight), and the stdin it got.
inflight: string
inflightStdin: string[]
}

let worlds = 0
Expand All @@ -230,7 +233,7 @@ let worlds = 0
function world(on: On): World {
worlds += 1
const n = worlds
const w: World = { bare: true, calls: [], logged: [], asked: 0, judge: '{"name":null}', show: '', verdict: { counted: true, ineffective: false }, fails: () => false, owed: [], check: undefined, logReply: undefined }
const w: World = { bare: true, calls: [], logged: [], asked: 0, judge: '{"name":null}', show: '', verdict: { counted: true, ineffective: false }, fails: () => false, owed: [], check: undefined, logReply: undefined, inflight: '', inflightStdin: [] }
mock.env(on, { HOME: '/home/me', COMPOUND_HOME: '/home/me/compound', COMPOUND_PROMPT_MIN_CHARS: '100000' })
on('session.id', () => ({ value: `decisions-${n}` }))
on('session.root', () => ({ value: `/work/decisions-${n}` }))
Expand All @@ -255,6 +258,10 @@ function world(on: On): World {
w.logged.push(sent)
return done(argv.includes('--json') ? (w.logReply ?? JSON.stringify({ ts: '2026-10-05T12:00:00Z', session: `decisions-${n}`, ...event })) : '')
}
if (String(argv[1] ?? '').endsWith('/hooks/inflight-stop-check.sh')) {
w.inflightStdin.push(e.init?.stdin ?? '')
return done(w.inflight)
}
if (verb === 'list') return done(LESSONS)
if (verb === 'show') return done(w.show)
if (verb === 'check') {
Expand Down Expand Up @@ -543,3 +550,45 @@ test('the stop refused for two owed lessons carries both ids, as the CLI listed
expect(stop.block).toContain('- 1: --settles ab12cd34')
expect(stop.block).toContain('- 2: --settles ef56ab78')
})

// ---- the in-flight check at the stop ------------------------------------------------------

const UNWATCHED = JSON.stringify({ decision: 'block', reason: 'Work is still in flight and NO waiter is running:\nDETACHED PROCESSES:\npid 6263 (up 17:12) python canary-runner.py' })

test('a stop with work in flight and no waiter is refused with what is in flight', async ($, on) => {
const w = world(on)
mock.clock(on, { now: 1_800_000_000_000 })
w.inflight = UNWATCHED
const stop = await $.classic.Stop({ stop_hook_active: false } as never)
expect(stop.block).toContain('[compound] Work is still in flight and NO waiter is running')
expect(stop.block).toContain('pid 6263')
expect(w.inflightStdin).toEqual([JSON.stringify({ stop_hook_active: false })])
})

test('the check is told when the stop follows a block, so the script can stay silent', async ($, on) => {
const w = world(on)
mock.clock(on, { now: 1_800_000_000_000 })
await $.classic.Stop({ stop_hook_active: true } as never)
expect(w.inflightStdin).toEqual([JSON.stringify({ stop_hook_active: true })])
})

test('nothing in flight, or a check that prints something unreadable, lets the stop through', async ($, on) => {
const w = world(on)
mock.clock(on, { now: 1_800_000_000_000 })
expect((await $.classic.Stop({ stop_hook_active: false } as never)).block).toBeUndefined()
w.inflight = 'not json at all'
expect((await $.classic.Stop({ stop_hook_active: false } as never)).block).toBeUndefined()
w.inflight = JSON.stringify({ decision: 'approve', reason: 'fine' })
expect((await $.classic.Stop({ stop_hook_active: false } as never)).block).toBeUndefined()
})

test('an owed lesson and unwatched work are both in the one refusal', async ($, on) => {
const w = world(on)
mock.clock(on, { now: 1_800_000_000_000 })
w.owed = [{ ts: '2026-10-05T12:00:00Z', type: 'capture', id: 'ab12cd34', call: 'c1', tool: 'Bash', failed: './deploy.sh', error: 'no target', fixed: './deploy.sh --target x' }]
w.inflight = UNWATCHED
const stop = await $.classic.Stop({ stop_hook_active: false } as never)
expect(stop.block).toContain('This session owes a lesson')
expect(stop.block).toContain('./deploy.sh --target x')
expect(stop.block).toContain('NO waiter is running')
})
35 changes: 35 additions & 0 deletions hooks/inflight-stop-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Stop hook: refuse to end a turn that leaves work in flight with nothing watching it.
# Blocks at most once per stop (stop_hook_active), and only for work that is plausibly
# this user's own: detached processes, runs they dispatched, their PRs with pending checks.
input=$(cat)
[ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)" = "true" ] && exit 0

items=""
# 1. Detached processes (reparented to init) that look like a test suite or a Claude-launched job.
procs=$(ps -eo ppid=,pid=,etime=,command= 2>/dev/null \
| awk '$1==1' | grep -E 'pytest|/claude-[0-9]+/|nohup' | grep -vE 'grep|inflight-stop-check' \
| awk '{printf "pid %s (up %s) %s\n", $2, $3, substr($0, index($0,$4), 110)}' | head -6)
[ -n "$procs" ] && items="${items}DETACHED PROCESSES:\n${procs}\n"

if command -v gh >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
# 2. Workflow runs I dispatched by hand that are still going (cron runs are not mine to watch).
me=$(gh api user --jq .login 2>/dev/null) # "@me" is not accepted by `gh run list --user`
runs=$(gh run list --user "$me" --event workflow_dispatch --status in_progress --limit 6 \
--json databaseId,workflowName --jq '.[]|"run \(.databaseId) \(.workflowName)"' 2>/dev/null)
[ -n "$runs" ] && items="${items}DISPATCHED RUNS IN PROGRESS:\n${runs}\n"
# 3. My open PRs with checks still pending.
for n in $(gh pr list --author @me --state open --limit 10 --json number --jq '.[].number' 2>/dev/null); do
p=$(gh pr checks "$n" --json state --jq '[.[]|select(.state=="IN_PROGRESS" or .state=="QUEUED" or .state=="PENDING")]|length' 2>/dev/null)
[ "${p:-0}" != "0" ] && items="${items}PR #$n: $p check(s) pending\n"
done
fi
[ -z "$items" ] && exit 0

# A waiter is a live polling loop: a shell whose command sleeps AND polls gh / a pid / a log.
waiters=$(ps -eo command= 2>/dev/null | grep -E 'sleep [0-9]+' \
| grep -E 'gh (run|pr) |ps -p|pgrep|grep -q' | grep -vE 'inflight-stop-check' | grep -vc '^grep')
[ "${waiters:-0}" -gt 0 ] && exit 0

reason=$(printf 'Work is still in flight and NO waiter is running:\n%b\nBefore stopping, either start a background waiter (run_in_background) that exits when these finish, with a deadline, or say plainly in the final message that nobody is watching them and when to look.' "$items")
jq -n --arg r "$reason" '{decision:"block", reason:$r}'
45 changes: 44 additions & 1 deletion hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,46 @@ function turnEnded(sid: string): undefined {
return undefined
}

// ---- the in-flight check at the stop ------------------------------------------------------

// hooks/inflight-stop-check.sh is the same script a person can register directly as a Stop
// hook. It answers one question: is work still in flight (a detached process, a workflow
// run dispatched by hand, the user's own pull request with pending checks) with no polling
// loop alive to see it finish? When it is, the script prints a Stop-hook JSON decision and
// its reason is the refusal. The script itself refuses only once per stop: it is told
// whether this stop follows a block and stays silent when it does.
//
// The failure it prevents: a turn starts a three-hour run, dispatches a workflow, opens
// pull requests, writes a status summary and ends. Nothing fails; the work finishes or
// stalls unseen. A lesson that says "keep a waiter going" is not enough, because the
// session that forgets the waiter forgets the lesson too.
//
// It never throws and never blocks on its own failure: a script that is missing, times
// out or prints something unreadable lets the stop through.
const INFLIGHT_SH = 'hooks/inflight-stop-check.sh'
const INFLIGHT_BUDGET_MS = 45000
const BASH = '/bin/bash'

async function inflight($: EngineInterface, followsBlock: boolean): Promise<string | undefined> {
if (await off($)) return undefined
const script = `${$.plugin.root}/${INFLIGHT_SH}`
try {
if (!(await $.fs.exists(script))) return undefined
const done = await $.process.run([BASH, script], {
cwd: await projectRoot($),
timeoutMs: INFLIGHT_BUDGET_MS,
stdin: JSON.stringify({ stop_hook_active: followsBlock }),
})
const printed = done.stdout.trim()
if (printed === '') return undefined
const said = JSON.parse(printed) as { decision?: unknown, reason?: unknown }
if (said.decision !== 'block' || typeof said.reason !== 'string' || said.reason === '') return undefined
return `[compound] ${said.reason}`
} catch {
return undefined
}
}

async function onStop($: EngineInterface, followsBlock: boolean): Promise<string | undefined> {
if (await off($)) return undefined
const sid = await $.session.id()
Expand Down Expand Up @@ -1743,7 +1783,10 @@ export const register: Register = on => {
on('classic.Stop', async ($, e, next) => {
const result = await next(e)
try {
const block = await onStop($, e.stop_hook_active === true)
const followsBlock = e.stop_hook_active === true
const owed = await onStop($, followsBlock)
const unwatched = await inflight($, followsBlock)
const block = owed === undefined ? unwatched : (unwatched === undefined ? owed : `${owed}\n\n${unwatched}`)
if (block === undefined) return result
return { ...result, block: result.block === undefined ? block : `${result.block}\n\n${block}` }
} catch (err) {
Expand Down
Loading