Watch and Act
Inputs
$signal: the external signal to watch (e.g. "CI on branch X", "deploy status"), optionally with
an until <condition/deadline> clause that seeds the bound.
Overview
Bridge your run to something happening elsewhere: poll an external signal on a sensible cadence, react to
the change, and stop when it resolves or the deadline passes. The discipline is in the cadence (cache-
aware, matched to the signal) and the bound (never forever) — a watcher that gets those wrong wastes cache
and tokens or hangs a run.
Phase 0: Confirm it's the right thing to watch
Before scheduling anything:
- Is this signal HARNESS-TRACKED? If it's a background Agent/Task/Workflow you started, DON'T watch it —
you'll be re-invoked on completion. Watching is only for state the harness can't notify you about.
- Can you just check it ONCE right now? If it's already resolved, do that and skip the loop.
- What's the transition you're waiting for, and what do you do on each outcome (green/red/ready/timeout)?
Success criteria: confirmed external + not-yet-resolved + a clear act-on-each-outcome plan.
Phase 1: Set the cadence and the bound
- Deadline / max polls (
budget-guard): how long is it worth waiting? (a CI run ~a few min; a deploy
~minutes; a queue ~unknown). Set a hard stop.
- Delay per poll — matched to how fast the state changes AND the ~5-min cache window:
- actively watching a minutes-scale signal → 60–270s (stays in cache);
- idle / slow-changing / a long fallback heartbeat → 1200–1800s+;
- never ~300s.
- Estimate the poll count = deadline / delay; if it's huge, the delay is too short — widen it.
Success criteria: a deadline and a justified per-poll delay (not ~300s; matched to the signal).
Phase 2: Poll → observe → act
Pick the wake mechanism, best-available-first:
- A native wait/monitor capability (e.g.
Monitor) — if the harness can block on the external
condition for you and re-enter on the transition, use it. No cross-turn bookkeeping is needed: the bound
is the capability's own timeout.
- A cross-turn schedule (
ScheduleWakeup, or a /loop) — when you must yield the turn and be
re-invoked later. This is where the bound MUST be made durable (below).
- No wake capability at all — do NOT block the run waiting forever. Persist the watch, emit a
resumable PENDING report, and let a later turn (or a human) resume it. Honest degradation beats a hung run.
When polling crosses a turn, record the bound as DURABLE, ATOMIC state with
scripts/watch-state.mjs (it reuses the checkpoint store's atomic write + lock). A re-invocation lands in a
FRESH process with no memory of when you started or how many times you've polled, so a deadline / max-poll
stop fires honestly ONLY if the absolute deadline and the count live on disk and each cycle reads-then-bumps
them. A relative "wait 30 min" held only in context evaporates on the next wake — and the loop never stops.
WS=watch-and-act/scripts/watch-state.mjs
# FIRST cycle — REQUIRES an external target + an absolute future deadline + a poll cap + a valid interval.
# REFUSES harness-tracked work (--harness-tracked always errors) and a ~300s dead-zone interval.
node "$WS" init .ulpi/runs/ci-watch.json --target "CI on feat-x" \
--deadline 2026-07-10T12:00:00Z --max-polls 20 --interval 120
# EACH cycle — one real observation, atomically counted; a terminal outcome can never silently restart.
node "$WS" observe .ulpi/runs/ci-watch.json --state pending --evidence "queued" # or success | failure
# Decide the next move from the DURABLE state. --wake reflects the capability you actually have; with none
# it returns a resumable PENDING report and NEVER blocks.
node "$WS" next .ulpi/runs/ci-watch.json --wake schedule # or native | none (default none)
Each cycle:
- Check the signal with a real command (
gh run list/gh pr checks, a curl health check, a queue
depth query) — read the ACTUAL state — and record it with observe (evidence + atomic count bump).
- Classify via
next: reached-target (success) / still-pending / terminal-failure / deadline /
exhausted. Terminal states are DURABLE — observe/init refuse to re-open them.
- Act on
next's reported action:
proceed (target reached) → do the follow-up action (next phase, notify, continue the run) and STOP;
schedule-next-poll (still pending, wake available) → schedule the next check at intervalSec
(re-evaluate the delay if the state's pace changed);
resume-when-woken (still pending, no wake capability) → emit the resumable PENDING report and STOP
the current turn — do NOT busy-wait;
diagnose-or-escalate (terminal failure) → STOP and diagnose/escalate (optionally kick a
converge-loop fix if it's yours to fix, e.g. a red CI you can address);
report-timeout / report-exhausted (deadline / max polls) → STOP and report "not reached in time".
Success criteria: each cycle reads the real state, records it durably, and takes the right action; the
loop advances toward a stop and the bound survives a fresh process.
Phase 3: Report
Report the final observed state, how long/many polls it took, and the action taken (proceeded / diagnosed
/ timed out). If it timed out or failed terminally, say so plainly with the last observed state.
Success criteria: an honest account of what the signal did and what you did about it.
Common Rationalizations
| Rationalization |
Reality |
| "I'll poll my background agent every minute to check progress." |
Background work re-invokes you on completion. Polling it is pure waste — just wait for the notification. |
| "I'll check every 5 minutes." |
~300s is the worst delay: it pays the cache miss without amortizing it. Drop to 270s (in-cache) or go 1200s+. |
| "I'll keep polling until it turns green." |
Some signals never turn green on their own. Bound it; a terminal failure is a stop-and-diagnose, not infinite retry. |
| "Poll every 10s so I don't miss the moment it flips." |
10s burns cache and tokens for a CI run that takes minutes. Match the delay to the state's real pace. |
| "It's probably green by now." |
"Probably" isn't observed. Check the real status; never assume the transition. |
| "There's no wake capability, so I'll just spin/wait in-line until it flips." |
Blocking a turn forever is a hung run. watch-state next with no wake capability returns a resumable PENDING report — persist and hand off; a later turn resumes from the durable file. |
| "I'll hold the deadline and count in context between wakes." |
A re-invocation is a fresh process with no memory. The bound must live on disk (watch-state.mjs) and be read-then-bumped each cycle, or the stop never fires. |
| "It errored, but I'll just re-init the watch and try again." |
A terminal watch is durable — init/observe refuse to silently restart it. Diagnose the failure or start a deliberately new watch id; don't re-open a closed one. |
Red Flags
- A
ScheduleWakeup scheduled to poll a background Agent/Task/Workflow the harness already tracks.
- No deadline / no max-poll bound on the watch.
- A ~300s delay, or a very short delay on a slow-changing signal.
- The loop re-polling a terminal failure hoping it self-resolves.
- A reported "green" with no actual status check in the transcript.
- A cross-turn watch whose deadline/count lives only in context (evaporates on the next wake).
- Blocking the turn in-line because there's no wake capability, instead of emitting a resumable PENDING report.
- Re-initing a watch over an existing durable one to "restart" a terminal outcome.
Guardrails
- Never watch harness-tracked background work; only genuinely external signals.
- Never poll unbounded; declare a deadline / max polls.
- Never pick ~300s; keep active polls in-cache (≤270s) or go long (≥1200s), matched to the signal.
- Never assume the signal; read the real state each cycle.
- Escalate a terminal failure instead of re-polling it.
- Never hold a cross-turn bound in context; persist it with
watch-state.mjs and read-then-bump each cycle.
- Never block a turn forever for want of a wake capability; degrade to a resumable PENDING report.
- Never re-open a terminal watch; a durable outcome is final.
Native goal/loop routing
On Claude Code, a watch IS a /loop: interval = the cadence table above, stop condition = the
deadline/target ("stop when CI on branch X is green, or after 30 min"). For a watch that gates a
larger objective, pair it with /goal so the independent verifier confirms the transition actually
happened rather than trusting the actor's report. Prefer a native Monitor/wait capability when it can
block on the condition and re-enter for you; ScheduleWakeup is the dynamic-pacing form when you're
self-pacing inside a session. On a platform (or in a run) with NO wake capability, don't block —
watch-state.mjs next degrades to a resumable PENDING report and the durable file lets a later turn or
a human resume the exact bound.
When To Load References
scripts/watch-state.mjs — the durable, atomic, resumable cross-turn watch state: init (bound +
refusals), observe (atomic count + evidence, terminal-no-restart), next (act / schedule /
resume-when-woken), status (read-only dump). Load it whenever a poll must cross a turn.
budget-guard (skill) — the deadline / max-poll bound and the escalation contract.
converge-loop (skill) — when a red signal is yours to fix, the bounded diagnose-and-fix loop.
schedule-recurring-agent (skill) — if the watch should become a standing recurring check rather than a
one-off wait.
Output Contract
Report:
- the signal watched (and confirmation it's external, not harness-tracked)
- the wake mechanism used (native monitor / scheduled cross-turn / none→resumable-pending) and, for a
cross-turn watch, the durable state file
- cadence used (delay + why) and the bound (deadline / max polls)
- the transition observed and the action taken (proceeded / diagnosed / escalated / timed out /
handed off a resumable PENDING report)
- total wait / poll count and the final state
1---2name: watch-and-act3description: Wait on an EXTERNAL signal the harness can't notify you about — CI, a deploy, a queue, an endpoint — polling on a cache-aware cadence (≤270s active, ≥1200s idle, never ~300s), bounded by a deadline, acting on the transition. Prefers a NATIVE wait/monitor capability when present; when polling must cross a turn it persists a durable, atomic, resumable watch state (`scripts/watch-state.mjs`) so the ORIGINAL bound survives a fresh process and a missing wake capability degrades to a resumable PENDING report instead of blocking. Never polls harness-tracked background work (that re-invokes you automatically). Use to bridge a run to something happening elsewhere.4---56<EXTREMELY-IMPORTANT>7A watcher that polls forever, or polls the wrong thing, is pure waste. Non-negotiable:81. NEVER POLL HARNESS-TRACKED WORK. Background agents, tasks, and workflows notify you on completion — you9 are re-invoked automatically. Scheduling a wakeup to check them is wasted tokens. Only watch state the10 harness CANNOT see: CI, deploys, remote queues, external endpoints.112. ALWAYS BOUNDED. Declare a deadline and/or a max-poll count up front. When it's hit without the target,12 STOP and report "not reached in time" — never poll indefinitely.133. CACHE-AWARE CADENCE. Pick the delay deliberately: 60–270s to stay within the ~5-minute prompt cache14 when ACTIVELY watching a signal that changes in minutes; 1200s+ when idle or the state changes slowly.15 Do NOT pick ~300s (worst of both — pays the cache miss without amortizing it). Match the delay to how16 fast the watched state actually changes.174. ACT ON THE TRANSITION, HONESTLY. Report the real observed state each check; act on the change (green →18 proceed, red → diagnose). Never assume the signal or fabricate that it flipped.195. ESCALATE ON TERMINAL FAILURE. A red/errored signal that won't self-resolve is a stop-and-surface, not a20 thing to keep re-polling hoping it turns green.21</EXTREMELY-IMPORTANT>2223# Watch and Act2425## Inputs2627- `$signal`: the external signal to watch (e.g. "CI on branch X", "deploy status"), optionally with28 an `until <condition/deadline>` clause that seeds the bound.2930## Overview3132Bridge your run to something happening elsewhere: poll an external signal on a sensible cadence, react to33the change, and stop when it resolves or the deadline passes. The discipline is in the cadence (cache-34aware, matched to the signal) and the bound (never forever) — a watcher that gets those wrong wastes cache35and tokens or hangs a run.3637## Phase 0: Confirm it's the right thing to watch3839Before scheduling anything:4041- Is this signal HARNESS-TRACKED? If it's a background Agent/Task/Workflow you started, DON'T watch it —42 you'll be re-invoked on completion. Watching is only for state the harness can't notify you about.43- Can you just check it ONCE right now? If it's already resolved, do that and skip the loop.44- What's the transition you're waiting for, and what do you do on each outcome (green/red/ready/timeout)?4546**Success criteria:** confirmed external + not-yet-resolved + a clear act-on-each-outcome plan.4748## Phase 1: Set the cadence and the bound4950- **Deadline / max polls** (`budget-guard`): how long is it worth waiting? (a CI run ~a few min; a deploy51 ~minutes; a queue ~unknown). Set a hard stop.52- **Delay per poll** — matched to how fast the state changes AND the ~5-min cache window:53 - actively watching a minutes-scale signal → 60–270s (stays in cache);54 - idle / slow-changing / a long fallback heartbeat → 1200–1800s+;55 - never ~300s.56- Estimate the poll count = deadline / delay; if it's huge, the delay is too short — widen it.5758**Success criteria:** a deadline and a justified per-poll delay (not ~300s; matched to the signal).5960## Phase 2: Poll → observe → act6162**Pick the wake mechanism, best-available-first:**63641. **A native wait/monitor capability** (e.g. `Monitor`) — if the harness can block on the external65 condition for you and re-enter on the transition, use it. No cross-turn bookkeeping is needed: the bound66 is the capability's own timeout.672. **A cross-turn schedule** (`ScheduleWakeup`, or a `/loop`) — when you must yield the turn and be68 re-invoked later. This is where the bound MUST be made durable (below).693. **No wake capability at all** — do NOT block the run waiting forever. Persist the watch, emit a70 **resumable PENDING report**, and let a later turn (or a human) resume it. Honest degradation beats a hung run.7172**When polling crosses a turn, record the bound as DURABLE, ATOMIC state** with73`scripts/watch-state.mjs` (it reuses the checkpoint store's atomic write + lock). A re-invocation lands in a74FRESH process with no memory of when you started or how many times you've polled, so a deadline / max-poll75stop fires honestly ONLY if the absolute deadline and the count live on disk and each cycle reads-then-bumps76them. A relative "wait 30 min" held only in context evaporates on the next wake — and the loop never stops.7778```bash79WS=watch-and-act/scripts/watch-state.mjs80# FIRST cycle — REQUIRES an external target + an absolute future deadline + a poll cap + a valid interval.81# REFUSES harness-tracked work (--harness-tracked always errors) and a ~300s dead-zone interval.82node "$WS" init .ulpi/runs/ci-watch.json --target "CI on feat-x" \83 --deadline 2026-07-10T12:00:00Z --max-polls 20 --interval 12084# EACH cycle — one real observation, atomically counted; a terminal outcome can never silently restart.85node "$WS" observe .ulpi/runs/ci-watch.json --state pending --evidence "queued" # or success | failure86# Decide the next move from the DURABLE state. --wake reflects the capability you actually have; with none87# it returns a resumable PENDING report and NEVER blocks.88node "$WS" next .ulpi/runs/ci-watch.json --wake schedule # or native | none (default none)89```9091Each cycle:92931. **Check** the signal with a real command (`gh run list`/`gh pr checks`, a curl health check, a queue94 depth query) — read the ACTUAL state — and record it with `observe` (evidence + atomic count bump).952. **Classify** via `next`: reached-target (success) / still-pending / terminal-failure / deadline /96 exhausted. Terminal states are DURABLE — `observe`/`init` refuse to re-open them.973. **Act** on `next`'s reported action:98 - `proceed` (target reached) → do the follow-up action (next phase, notify, continue the run) and STOP;99 - `schedule-next-poll` (still pending, wake available) → schedule the next check at `intervalSec`100 (re-evaluate the delay if the state's pace changed);101 - `resume-when-woken` (still pending, no wake capability) → emit the resumable PENDING report and STOP102 the current turn — do NOT busy-wait;103 - `diagnose-or-escalate` (terminal failure) → STOP and diagnose/escalate (optionally kick a104 `converge-loop` fix if it's yours to fix, e.g. a red CI you can address);105 - `report-timeout` / `report-exhausted` (deadline / max polls) → STOP and report "not reached in time".106107**Success criteria:** each cycle reads the real state, records it durably, and takes the right action; the108loop advances toward a stop and the bound survives a fresh process.109110## Phase 3: Report111112Report the final observed state, how long/many polls it took, and the action taken (proceeded / diagnosed113/ timed out). If it timed out or failed terminally, say so plainly with the last observed state.114115**Success criteria:** an honest account of what the signal did and what you did about it.116117## Common Rationalizations118119| Rationalization | Reality |120|---|---|121| "I'll poll my background agent every minute to check progress." | Background work re-invokes you on completion. Polling it is pure waste — just wait for the notification. |122| "I'll check every 5 minutes." | ~300s is the worst delay: it pays the cache miss without amortizing it. Drop to 270s (in-cache) or go 1200s+. |123| "I'll keep polling until it turns green." | Some signals never turn green on their own. Bound it; a terminal failure is a stop-and-diagnose, not infinite retry. |124| "Poll every 10s so I don't miss the moment it flips." | 10s burns cache and tokens for a CI run that takes minutes. Match the delay to the state's real pace. |125| "It's probably green by now." | "Probably" isn't observed. Check the real status; never assume the transition. |126| "There's no wake capability, so I'll just spin/wait in-line until it flips." | Blocking a turn forever is a hung run. `watch-state next` with no wake capability returns a resumable PENDING report — persist and hand off; a later turn resumes from the durable file. |127| "I'll hold the deadline and count in context between wakes." | A re-invocation is a fresh process with no memory. The bound must live on disk (`watch-state.mjs`) and be read-then-bumped each cycle, or the stop never fires. |128| "It errored, but I'll just re-init the watch and try again." | A terminal watch is durable — `init`/`observe` refuse to silently restart it. Diagnose the failure or start a deliberately new watch id; don't re-open a closed one. |129130## Red Flags131132- A `ScheduleWakeup` scheduled to poll a background Agent/Task/Workflow the harness already tracks.133- No deadline / no max-poll bound on the watch.134- A ~300s delay, or a very short delay on a slow-changing signal.135- The loop re-polling a terminal failure hoping it self-resolves.136- A reported "green" with no actual status check in the transcript.137- A cross-turn watch whose deadline/count lives only in context (evaporates on the next wake).138- Blocking the turn in-line because there's no wake capability, instead of emitting a resumable PENDING report.139- Re-initing a watch over an existing durable one to "restart" a terminal outcome.140141## Guardrails142143- Never watch harness-tracked background work; only genuinely external signals.144- Never poll unbounded; declare a deadline / max polls.145- Never pick ~300s; keep active polls in-cache (≤270s) or go long (≥1200s), matched to the signal.146- Never assume the signal; read the real state each cycle.147- Escalate a terminal failure instead of re-polling it.148- Never hold a cross-turn bound in context; persist it with `watch-state.mjs` and read-then-bump each cycle.149- Never block a turn forever for want of a wake capability; degrade to a resumable PENDING report.150- Never re-open a terminal watch; a durable outcome is final.151152## Native goal/loop routing153154On Claude Code, a watch IS a `/loop`: interval = the cadence table above, stop condition = the155deadline/target ("stop when CI on branch X is green, or after 30 min"). For a watch that gates a156larger objective, pair it with `/goal` so the independent verifier confirms the transition actually157happened rather than trusting the actor's report. Prefer a native `Monitor`/wait capability when it can158block on the condition and re-enter for you; `ScheduleWakeup` is the dynamic-pacing form when you're159self-pacing inside a session. On a platform (or in a run) with NO wake capability, don't block —160`watch-state.mjs next` degrades to a resumable PENDING report and the durable file lets a later turn or161a human resume the exact bound.162163## When To Load References164165- `scripts/watch-state.mjs` — the durable, atomic, resumable cross-turn watch state: `init` (bound +166 refusals), `observe` (atomic count + evidence, terminal-no-restart), `next` (act / schedule /167 resume-when-woken), `status` (read-only dump). Load it whenever a poll must cross a turn.168- `budget-guard` (skill) — the deadline / max-poll bound and the escalation contract.169- `converge-loop` (skill) — when a red signal is yours to fix, the bounded diagnose-and-fix loop.170- `schedule-recurring-agent` (skill) — if the watch should become a standing recurring check rather than a171 one-off wait.172173## Output Contract174175Report:1761771. the signal watched (and confirmation it's external, not harness-tracked)1782. the wake mechanism used (native monitor / scheduled cross-turn / none→resumable-pending) and, for a179 cross-turn watch, the durable state file1803. cadence used (delay + why) and the bound (deadline / max polls)1814. the transition observed and the action taken (proceeded / diagnosed / escalated / timed out /182 handed off a resumable PENDING report)1835. total wait / poll count and the final state