From 98ecf029a0f7371ed08b1c14350c2d362a744c3e Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sat, 10 Oct 2026 18:17:40 -0400 Subject: [PATCH 1/2] Add lesson: end-of-turn-inflight-check --- lessons/end-of-turn-inflight-check/SKILL.md | 20 +++++++++++++++++++ .../inflight-check.sh | 19 ++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 lessons/end-of-turn-inflight-check/SKILL.md create mode 100755 lessons/end-of-turn-inflight-check/inflight-check.sh diff --git a/lessons/end-of-turn-inflight-check/SKILL.md b/lessons/end-of-turn-inflight-check/SKILL.md new file mode 100644 index 0000000..6d5a5b2 --- /dev/null +++ b/lessons/end-of-turn-inflight-check/SKILL.md @@ -0,0 +1,20 @@ +--- +name: end-of-turn-inflight-check +description: Use before ending a turn, giving a final answer or a status report, when the turn started background processes, pushed a branch, dispatched a workflow run, or opened a pull request whose checks are still running. +created: 2026-10-10 +--- +Before ending a turn, run inflight-check.sh (beside this lesson). It lists detached +processes, hosted runs in progress and open pull requests with pending checks, and exits 1 +when anything is still in flight. + +For each thing it lists, one of three must be true before the turn ends: a background +waiter is running that exits on that thing's completion (with a deadline), or the thing +notifies on its own (a command started in the background by the harness), or the final +message says plainly that nobody is watching it and when to look. A started job with no +waiter is not being monitored, however alive its process is. + +The wrong way: start a three-hour run, dispatch a workflow, open pull requests, write a +status summary and stop. Nothing fails. The work finishes or stalls unseen, and the next +anyone hears of it is the user asking for status and pointing out that the monitors are +gone. A detached `nohup ... &` process in particular sends no completion notice, unlike a +harness background command. diff --git a/lessons/end-of-turn-inflight-check/inflight-check.sh b/lessons/end-of-turn-inflight-check/inflight-check.sh new file mode 100755 index 0000000..a2df5f5 --- /dev/null +++ b/lessons/end-of-turn-inflight-check/inflight-check.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# What is still in flight that a turn is about to walk away from? +# Prints each kind of unattended work; exit 1 when anything is found, 0 when clear. +found=0 +# 1. Long-lived processes detached from a shell (nohup'd runs, background suites). +procs=$(ps -eo pid=,etime=,command= 2>/dev/null | grep -E 'pytest|nohup|run_in_background|canary|python .*-m ' | grep -vE 'grep|inflight-check' | head -8) +if [ -n "$procs" ]; then found=1; echo "PROCESSES still running:"; echo "$procs" | cut -c1-150; fi +# 2. Hosted runs in progress in this repository, and my open PRs with pending checks. +if command -v gh >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then + runs=$(gh run list --status in_progress --limit 8 --json databaseId,workflowName,createdAt \ + --jq '.[]|"\(.databaseId) \(.workflowName) since \(.createdAt)"' 2>/dev/null) + if [ -n "$runs" ]; then found=1; echo "HOSTED RUNS in progress:"; echo "$runs"; fi + for n in $(gh pr list --author @me --state open --json number --jq '.[].number' 2>/dev/null); do + pending=$(gh pr checks "$n" --json state --jq '[.[]|select(.state=="IN_PROGRESS" or .state=="QUEUED" or .state=="PENDING")]|length' 2>/dev/null) + if [ "${pending:-0}" != "0" ]; then found=1; echo "PR #$n has $pending check(s) still pending"; fi + done +fi +[ $found -eq 0 ] && echo "nothing in flight" +exit $found From 6cc0048116b37f9b6dc06a72d0493222ce000024 Mon Sep 17 00:00:00 2001 From: "Jeremy R. Manning" Date: Sat, 10 Oct 2026 18:42:44 -0400 Subject: [PATCH 2/2] Ship the in-flight check as a Stop hook, not as a lesson The first commit proposed a lesson with a script to run by hand. That is the softer version of what works: the session that forgets to keep a waiter going forgets the lesson that says to keep one too. What stopped the failure locally was a Stop hook that refuses the stop, so that is what ships. hooks/inflight-stop-check.sh is byte-identical to the hook registered locally. It refuses a stop once when work is still in flight with nothing watching it: a detached process that looks like a test suite or a launched job, a workflow run dispatched by hand (cron runs are not the session's to watch), or the user's own pull request with pending checks, and no polling loop alive. It reads stop_hook_active and stays silent on the stop that follows a block. The mod's classic.Stop handler runs it and joins its reason to any owed-lesson refusal. A script that is missing, times out or prints something unreadable lets the stop through. The same file can be registered directly in settings.json. The lesson is removed from the pool: it also failed test_docs and test_general_pool, which list everything the pool ships. claude plugin test: 286 pass (4 new). run_tests.sh: OK, 23 files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V7QkYvK7jaE1QVY1ZMfAt2 --- README.md | 1 + hooks/decisions.test.ts | 51 ++++++++++++++++++- hooks/inflight-stop-check.sh | 35 +++++++++++++ hooks/register.ts | 45 +++++++++++++++- lessons/end-of-turn-inflight-check/SKILL.md | 20 -------- .../inflight-check.sh | 19 ------- 6 files changed, 130 insertions(+), 41 deletions(-) create mode 100755 hooks/inflight-stop-check.sh delete mode 100644 lessons/end-of-turn-inflight-check/SKILL.md delete mode 100755 lessons/end-of-turn-inflight-check/inflight-check.sh diff --git a/README.md b/README.md index e194bdc..30ff383 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/hooks/decisions.test.ts b/hooks/decisions.test.ts index 451489b..c7d0a6f 100644 --- a/hooks/decisions.test.ts +++ b/hooks/decisions.test.ts @@ -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 @@ -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}` })) @@ -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') { @@ -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') +}) diff --git a/hooks/inflight-stop-check.sh b/hooks/inflight-stop-check.sh new file mode 100755 index 0000000..8f9a7d9 --- /dev/null +++ b/hooks/inflight-stop-check.sh @@ -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}' diff --git a/hooks/register.ts b/hooks/register.ts index 3821da8..36c3ed8 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -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 { + 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 { if (await off($)) return undefined const sid = await $.session.id() @@ -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) { diff --git a/lessons/end-of-turn-inflight-check/SKILL.md b/lessons/end-of-turn-inflight-check/SKILL.md deleted file mode 100644 index 6d5a5b2..0000000 --- a/lessons/end-of-turn-inflight-check/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: end-of-turn-inflight-check -description: Use before ending a turn, giving a final answer or a status report, when the turn started background processes, pushed a branch, dispatched a workflow run, or opened a pull request whose checks are still running. -created: 2026-10-10 ---- -Before ending a turn, run inflight-check.sh (beside this lesson). It lists detached -processes, hosted runs in progress and open pull requests with pending checks, and exits 1 -when anything is still in flight. - -For each thing it lists, one of three must be true before the turn ends: a background -waiter is running that exits on that thing's completion (with a deadline), or the thing -notifies on its own (a command started in the background by the harness), or the final -message says plainly that nobody is watching it and when to look. A started job with no -waiter is not being monitored, however alive its process is. - -The wrong way: start a three-hour run, dispatch a workflow, open pull requests, write a -status summary and stop. Nothing fails. The work finishes or stalls unseen, and the next -anyone hears of it is the user asking for status and pointing out that the monitors are -gone. A detached `nohup ... &` process in particular sends no completion notice, unlike a -harness background command. diff --git a/lessons/end-of-turn-inflight-check/inflight-check.sh b/lessons/end-of-turn-inflight-check/inflight-check.sh deleted file mode 100755 index a2df5f5..0000000 --- a/lessons/end-of-turn-inflight-check/inflight-check.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# What is still in flight that a turn is about to walk away from? -# Prints each kind of unattended work; exit 1 when anything is found, 0 when clear. -found=0 -# 1. Long-lived processes detached from a shell (nohup'd runs, background suites). -procs=$(ps -eo pid=,etime=,command= 2>/dev/null | grep -E 'pytest|nohup|run_in_background|canary|python .*-m ' | grep -vE 'grep|inflight-check' | head -8) -if [ -n "$procs" ]; then found=1; echo "PROCESSES still running:"; echo "$procs" | cut -c1-150; fi -# 2. Hosted runs in progress in this repository, and my open PRs with pending checks. -if command -v gh >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then - runs=$(gh run list --status in_progress --limit 8 --json databaseId,workflowName,createdAt \ - --jq '.[]|"\(.databaseId) \(.workflowName) since \(.createdAt)"' 2>/dev/null) - if [ -n "$runs" ]; then found=1; echo "HOSTED RUNS in progress:"; echo "$runs"; fi - for n in $(gh pr list --author @me --state open --json number --jq '.[].number' 2>/dev/null); do - pending=$(gh pr checks "$n" --json state --jq '[.[]|select(.state=="IN_PROGRESS" or .state=="QUEUED" or .state=="PENDING")]|length' 2>/dev/null) - if [ "${pending:-0}" != "0" ]; then found=1; echo "PR #$n has $pending check(s) still pending"; fi - done -fi -[ $found -eq 0 ] && echo "nothing in flight" -exit $found