agentsclimarketplace

P9

Skill broomva/skills/skills/orchestration/p9

P9 — Broomva productive-wait primitive (the wait optimizer). Convert any blocking external operation — PR CI checks, push-triggered deploys, builds, long-running index ops — into work on the next priority. The reference implementation is a PR CI watcher: drains a context-scoped deferred-work queue while `gh pr checks --watch` runs in the background, classifies failures, and self-heals known categories. Non-PR waits get the same lifecycle via `p9 wait-for` (deploy readiness, long extractions — poll a predicate command with heartbeats and re-arm). Every watcher and wait upholds the termination invariant (BRO-1701): on success, failure, OR kill it reports state + next action and pushes through the configured notify channels — killed watchers never die silently; `p9 stuck-scan` catches live-but-wedged ones. Merge authorization stays with the existing control metalayer (.control/policy.yaml).From its SKILL.md

Install
npx -y skills add broomva/skills --skill p9

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

18.7 KB, ~4.8k tokens by cl100k_base, as published. Nobody here has run it

P9 — Productive Wait (Wait-Optimizer Skill)

Cardinal rule

Never sleep on a blocking wait. Whether you're waiting on PR CI, a push-triggered deploy, a long build, or an index sync — convert the wait into productive work on the next priority. For PR CI, p9 watch <pr> spawns the observer in the background and the agent pulls work from the wait-queue. For non-PR waits, p9 wait-for <name> --cmd '<predicate>' gives the wait the same lifecycle (state row, heartbeat, termination report, re-arm). Sleep is a footgun — it burns clock time the agent could be using to validate definitions, refresh the knowledge graph, or draft the next slice.

When to invoke

TriggerAction
git push opens or updates a PRp9 watch <pr> --background immediately
run_in_background task notification fires for the watcherp9 status --pr <n> to read terminal state
gh pr checks returned non-zerop9 heal <pr> --classify to inspect failure
Push triggers a deploy / long non-PR operationp9 wait-for <name> --cmd '<predicate>' --detach (or --preset railway|vercel)
Fresh session picking up after a kill/crash/rebootp9 report to read state + next action; p9 rearm to re-arm dead watchers
A watcher/wait looks wedgedp9 stuck-scan — structured dump + notification
About to sleepDon't. Pull from p9 wait-queue pop instead

Parallel agent sessions (BRO-1529)

P9 state lives in one shared dir (~/.config/broomva/p9/). Concurrent agents stay collision-free by scoping every record to a session id.

Contract: each parallel agent session/worktree/wave-plan MUST export BROOMVA_P9_SESSION=<stable-unique-id> before calling p9. Fanout (P5) worktrees, bstack wave plans, and autonomous runs each set their own. If unset, p9 falls back to a single persisted id (session-default.id) — i.e. backward-compatible global behavior, not isolation. No env var ⇒ no parallel safety.

What the session id buys you:

DimensionBehavior
Concurrency ceilingmax_concurrent_prs is counted per session, and over the scope ci_watch.max_concurrent_prs_scope names (repo, the default, or global). A session's own second watch in the same scope still blocks.
PR identityKeyed by (repo, pr) — the same PR number in two repos never collides, in the state table and in the ceiling count.
Wait-queuepop/list/clear default to the current session's view (its items + legacy-unowned). --all crosses sessions. This is what "context-scoped" finally means in code.
Watcher de-dupA second p9 watch on a PR that already has a live watcher is refused (--force to supersede). A dead watcher is superseded automatically once aged, or now via --adopt.

Repo identity (BRO-1988)

Every lifecycle command (watch, merge-ready, merge-status, auto-merge, abandon) resolves the repo once, before it reads state, and uses that one answer for both the read key and the write key. Resolution order:

  1. --repo OWNER/REPO
  2. BROOMVA_P9_REPO — the deterministic hook for tests and for harnesses that already know the target. - pins "no repo"; an empty value means unset (shell convention), so export BROOMVA_P9_REPO=$(cmd_that_failed) degrades to normal detection instead of silently dropping into repo-less state. A value that does not reduce to OWNER/REPO gets a stderr notice.
  3. gh repo viewgit remote get-url origin from cwd (memoized per process). The git fallback matters wherever gh is absent or unauthenticated (CI).

Any spelling normalizes to owner/namehttps://host/o/r.git, git@host:o/r.git, ssh://git@host/o/r.git, trailing slashes — and comparison is case-insensitive, so one logical repo never splits into two keys. Host parsing is generic, not a github.com allowlist: an allowlist made every repo on a GHE/GitLab host collapse to a single key. The host is stripped rather than keyed on, matching gh --repo (which takes a bare OWNER/REPO and gets its host from GH_HOST).

Ceiling scopeci_watch.max_concurrent_prs_scope: repo | global (default repo). repo counts in-flight PRs per (session, repo); global is the pre-BRO-1988 cross-repo count — one bounded merge train across every repo, which is what the design spec describes. repo is the default because a global count let an in-flight PR in one repo refuse p9 watch in an unrelated one, and since watch is the only transition into GREEN that made the whole lifecycle unreachable there. The tradeoff is real and is why the knob exists: at max_concurrent_prs: 1 across N repos a session holds N watchers, and the defer-into-wait-queue discipline stops firing cross-repo. Spec reconciliation is tracked separately. With no resolvable repo the count falls back to global — an ambiguous identity must not silently disable the ceiling.

Rows with no recorded repo. Rows written before repo stamping carry repo: "", and p9 does not migrate them. It keeps that key: "" is the row's true identity and a perfectly good one — it collides with no real repo, so it can neither shadow one nor (under a repo-scoped ceiling) hold its slot. current_pr_state(pr, "") still reaches such a row, and reap / rearm still drain it. Nothing is discarded; p9 simply declines to invent the one thing it does not know.

Attributing them to the ambient repo was tried and reverted, because:

  • it put a gh repo view call on every state read;
  • it let p9 rearm re-watch the PR against the ambient repo — and omitting --repo from the child argv does not help, because the child calls resolve_repo(None) itself and, sharing cwd and env, resolves the same value. The observed chain ended in gh pr merge on a PR nobody targeted;
  • and when the ambient repo genuinely had a PR of the same number, the guess shadowed the real row — recreating the exact defect BRO-1988 fixes.

So a row with no recorded repo is folded and not re-armed. There is no correct repo to re-arm against; that is the whole content of "no repo recorded". Folding frees the concurrency slot, and recovery is an explicit p9 watch <pr> --repo <owner/name> --adopt — a human naming the repo p9 could not.

Lifecycle / self-healing

  • p9 reap — reconcile dead-watcher rows (pid gone) to ABANDONED, freeing the concurrency slot a crashed/closed session would otherwise hold forever. --now ignores the grace window; --no-reconcile skips the gh enrichment query. watch and status run a liveness-only reap as a cheap preflight, so the ceiling self-heals without manual cleanup.
  • p9 watch <pr> --adopt — re-watch a PR whose prior watcher pid is gone (orphan recovery after a session ends mid-watch).
  • Queue TTL — items are pruned once their PR reaches a terminal state, or after BROOMVA_P9_QUEUE_TTL_DAYS (default 14).
  • p9 heal <pr> --apply — run the classified heal_command under heal.lock (serialized workspace-wide, so a heal in a parallel session can't race on shared codegen/cache). Auto-classifiable failures only; --dry-run prints the command. --classify stays read-only.

Wait-time work selection (priority order)

When the watcher is running, drain work from these sources in priority order (higher = pulled first):

  1. session — TODOs already on the agent's TaskList tagged wait_ok=true.
  2. memory — items from ~/.claude/.../memory/MEMORY.md flagged "needs follow-up" within the last 24h.
  3. graph — knowledge-graph entities adjacent to files-touched-in-PR (BFS depth 1 via bookkeeping.py query).
  4. docs — cross-refs from the current PR's diff (mentioned files not yet updated).
  5. linear — tickets in the current cycle, label-matched to PR's Linear ID.

Isolation tier (per spec §5.5)

Each pop returns the inferred isolation tier:

Work typeTierWhere it happens
research, docs, knowledge-graph mutations, Linear updatesnonecurrent worktree, no separate branch
code that's independent of the in-flight PRworktreenew P5 worktree off main
code that depends on the in-flight PRstacked_branchbranch off feat/X+1 from feat/X HEAD
anything touching CLAUDE.md / AGENTS.md / .control/blockednot auto-handled; surface to user

Wakeup protocol

When the bg task notification fires:

1. p9 status --pr <n> --json
2. parse `to_state`:
   - GREEN          → p9 merge-ready <n>; defer to control metalayer
   - RED_CLASSIFIED → p9 heal <n> --classify; if classified+evaluator-positive,
                      apply heal_command (in PR scope only); push amend; loop
   - RED_UNCLASSIFIED, ESCALATED → notify user via Linear ticket; stop healing,
                                    keep watcher alive in case human pushes a fix
   - ABANDONED      → surface failure to user; remove watcher; skip cleanup

The watcher exit code is necessary-not-sufficient (BRO-1489)

GREEN only means gh pr checks --watch exited 0 — which it does on a subset of checks (required-only) and before async bot reviews (CodeRabbit) settle. Observed three times on bstack PR #78: exit 0 while the PR was UNSTABLE / had a pending review.

p9 merge-ready therefore verifies the real merge predicate before marking MERGE_READY: it queries gh pr view --json mergeable,mergeStateStatus,reviewDecision plus a best-effort gh api graphql unresolved-thread count, and is ready iff mergeStateStatus ∈ {CLEAN, UNSTABLE} with no CHANGES_REQUESTED and zero unresolved review threads. BLOCKED/DIRTY/BEHIND/DRAFT/UNKNOWN, an open thread, or any gh error → refused (fail-safe). Pass --no-verify to skip (test/offline only).

Query it directly without transitioning state:

p9 merge-status <n> [--json]   # exit 0 iff merge-ready; prints the verdict + reason

Termination conditions

The agent exits the heal loop when any of:

  • to_state ∈ {MERGED, ESCALATED, ABANDONED} (terminal)
  • attempt ≥ ci_heal.max_attempts (default 5)
  • evaluator returned stalled=true for two consecutive cycles
  • user interrupt (Ctrl-C in terminal, or chat message)
  • session ends (the Stop hook leaves watchers running for next session pickup)

Examples

Example 1 — Green on first try (happy path)

$ git push origin feat/my-change
$ gh pr create ... ; PR=42
$ p9 watch $PR --background
watcher_id=ab12cd34ef56 pid=78901 pr=42 repo=broomva/workspace

# Run watcher in foreground/background; meanwhile drain queue
$ p9 wait-queue pop
{"id": "...", "source": "graph", "item": "verify entities adjacent to ...", "isolation_tier": "none"}

# ... agent does the work ...

# bg task notification fires; check terminal state
$ p9 status --pr 42 --json
{"open_prs": [{"pr": 42, "to_state": "GREEN", ...}]}

$ p9 merge-ready 42
PR #42 marked MERGE_READY (control metalayer authorizes merge)

# control-gate-hook authorizes; agent runs `gh pr merge`

Example 2 — Lint-failure self-heal

$ p9 status --pr 42 --json
{"open_prs": [{"pr": 42, "to_state": "RED_CLASSIFIED", "attempt": 0}]}

$ p9 heal 42 --classify
{"failure_type": "lint", "classified": true, "confidence": 0.8, "heal_command": "bun run lint:fix", "rationale": "matched lint at confidence 0.80"}

# agent runs heal_command, scoped to PR diff files
$ bun run lint:fix
$ git commit -am "fix(lint): heal CI"
$ git push --force-with-lease   # only if existing P6 policy permits
$ p9 watch 42 --background       # new WATCHING cycle; attempt=1

Example 3 — Unclassified-failure escalation

$ p9 heal 42 --classify
{"failure_type": "unclassified", "classified": false, "confidence": 0.0, "heal_command": null, "rationale": "no rubric pattern matched"}

# Agent does NOT attempt to heal. Creates a Linear ticket via MCP:
#   title: "[P9 ESCALATION] PR #42: feat/my-change"
#   body:  failure signature + log excerpt
#   label: ci-heal-escalation
# Watcher stays running — if a human pushes a fix, watcher resumes and
# the next green check transitions to MERGE_READY.

Background-work visibility (BRO-1701)

Termination invariant (hard rule)

On watcher termination — success, failure, OR kill — P9 always reports state + next action. Killed watchers must not die silently.

Every exit path of p9 watch and p9 wait-for (green, red, timeout, SIGTERM/SIGINT/SIGHUP, unexpected exception) folds a state event, prints a P9-TERMINATION-REPORT {json} line to stderr (stdout stays machine-parseable), and pushes through the notify channels. The report carries state, cause, and a concrete next_action (e.g. ABANDONED → "re-arm: p9 watch <pr> --adopt"). SIGKILL and machine death can't be trapped — that path is covered by p9 reap (emits the same report shape when it reconciles a dead row) and p9 rearm.

Read-side: p9 report [--pr <n>] [--json] renders the latest report for every tracked watcher/wait — this is what a fresh session (or the Tier-1 #1 post-notification reconcile rule) consumes to learn what happened and what to do next. Report fields are additive-only.

gh pr checks --watch output now lands in $P9_HOME/logs/watch-<id>.log (not the void): the log's mtime is a progress signal for stuck-scan and its tail rides along in reports — full details to understand why, not just that, something died.

Notify channels (push-to-phone)

p9 notify <title> [--body ...] and every termination/stuck event fan out to channels in $P9_HOME/notify.json:

{"channels": [
  {"type": "ntfy",    "topic": "broomva-p9", "url": "https://ntfy.sh"},
  {"type": "webhook", "url": "https://example.com/hook"},
  {"type": "command", "cmd": "scripts/p9-escalate-notify.sh"}
]}
  • ntfy — reaches a phone with zero infra (install the ntfy app, subscribe to the topic). Quick-config without a file: export BROOMVA_P9_NTFY_TOPIC=<topic>.
  • webhook — generic JSON POST ({title, body, payload}).
  • command — JSON on stdin to any hook script; this is the seam for Omnara, Telegram, Discord, or claude-remote-sessions relays.
  • Escalation-class events (termination:escalated, stuck, and any kind containing escalat) additionally fire the policy's ci_heal.escalation_channel.notify_hook (previously dead config — now invoked).

Delivery is best-effort and per-channel isolated: a failing channel is recorded and skipped, never raised — a notification must never take down the watcher it reports on. Every attempt (even with zero channels) appends an audit row to $P9_HOME/notify.jsonl; that audit floor is what makes the termination invariant verifiable after the fact.

In-session protocol (PushNotification/Omnara): when an agent session receives a termination report or stuck dump while the user is off-terminal, it MUST surface it through the harness PushNotification tool (or the Omnara session surface) — the file-level channels cover the no-session case; the harness tool covers the live-session case.

Non-PR waits — p9 wait-for

p9 wait-for railway-deploy --preset railway --interval 30 --timeout 1800 --detach
p9 wait-for vercel-deploy  --preset vercel --target <deployment-url> --detach
p9 wait-for extraction     --cmd 'test -f /tmp/extract.done' --interval 60 --timeout 7200 --detach

Polls the predicate command until exit 0 (SUCCEEDED), deadline (TIMED_OUT), first-poll exit 126/127 (FAILED fast), or signal (KILLED) — each terminal state folds + reports + notifies exactly like a PR watch. State lives in $P9_HOME/waits.jsonl (its own stream — PR-state consumers never see wait states); every poll touches a heartbeat file that stuck-scan reads. Presets are convenience templates over --cmd — deploy-CLI output shapes drift, so verify against your installed CLI and fall back to an explicit --cmd when they do.

Re-arm after kill — p9 rearm

p9 rearm [--dry-run] [--now] scans for dead-but-unfinished work: PR rows whose watcher pid is gone re-enter via a detached p9 watch --adopt; dead waits are folded ABANDONED and re-spawned from their recorded argv (with rearmed_from lineage). This closes the loop the July-1 leverage audit flagged: watchers killed before their notification fires now leave a report AND come back.

Stuck-detector — p9 stuck-scan

p9 stuck-scan [--threshold-min N] [--json] flags live watchers/waits with no progress (state event / watch-log mtime / heartbeat) inside the threshold (default 45 min, env BROOMVA_P9_STUCK_MIN): structured failure dump (pid, ages, log tail, next action) + notification, deduped to one per stall episode (--renotify overrides; a new episode starts when progress moves). Dead pids are reap/rearm territory, not stuck. Exit code 1 when anything is stuck — wire it into cron/governor loops as a cheap probe.

Cardinal invariant (hard rule)

P9 never silently drops state. Every failure produces (a) a state.jsonl event, (b) a Linear ticket, or (c) both. If P9 cannot write to state.jsonl AND cannot reach Linear, it crashes loudly (exit 99) — degraded silent operation is forbidden.

See also

  • Spec: docs/superpowers/specs/2026-05-04-p9-ci-watcher-design.md
  • Rubric: references/scoring-rubric.md
  • CLI: scripts/p9.py (run python3 scripts/p9.py --help)
  • Related primitives: P1 (Conversation Bridge), P2 (Control Gate), P3 (Linear Tickets), P4 (PR Pipeline), P5 (Parallel Agents), P6 (Knowledge Bookkeeping), P8 (Branch + Worktree Janitor), P10 (Worktree Hygiene Discipline), P11 (Empirical Feedback Loop).

What ships with it: 25 files

405.5 KB alongside SKILL.md, 12 of them executable

evals/

references/

scripts/

Keep looking

Skills are one crate of 326,144. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.