
P9
- 7 installs
- Updated June 12, 2026
- broomva/p9
P9 is a Claude Code skill that converts blocking waits like PR CI checks into productive work, with a reference PR CI watcher that classifies and self-heals failures.
About
This skill is a productive-wait primitive that turns any blocking external operation, such as PR CI checks or deploys, into work on the next priority instead of sleeping. Its reference implementation is a PR CI watcher that runs gh pr checks in the background, classifies failures and self-heals known categories. A developer uses it after a git push so the agent drains a prioritized work queue while CI runs and only merges through the control metalayer.
- Converts blocking waits (PR CI, deploys, builds) into work on the next priority
- PR CI watcher classifies failures and self-heals known categories like lint
- Merge authorization stays with the control metalayer; escalates unclassified failures
P9 by the numbers
- 7 all-time installs (skills.sh)
- Ranked #1,060 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
p9 capabilities & compatibility
- Capabilities
- ci monitoring · ci self heal · pr automation · productive wait
- Works with
- github · jira
- Use cases
- ci cd · code review · testing
- Pricing
- Free
What p9 says it does
Never `sleep` on a blocking wait.
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 catego
Merge authorization stays with the existing control metalayer (.control/policy.yaml).
npx skills add https://github.com/broomva/p9 --skill p9Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| Last updated | June 12, 2026 |
| Repository | broomva/p9 ↗ |
What it does
Use it after a git push to watch PR CI, self-heal known failures, and work the next priority instead of sleeping on the wait.
Who is it for?
Watching PR CI after a push, self-healing known CI failures, and doing next-priority work instead of sleeping on a wait
Skip if: Non-PR waits are only partially supported today (single direct check); it does not authorize merges itself
When should I use this skill?
After a git push opens or updates a PR, when a CI check fails, or whenever you are about to sleep on a wait
What you get
The wait becomes next-priority work while a background watcher classifies and self-heals CI failures toward merge-ready
- background CI watcher
- classified failure report
- self-heal command
By the numbers
- 5 wait-time work sources in priority order
- ci_heal max_attempts default 5
- 4 isolation tiers
Files
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 (today), do one direct check on completion
after kicking off next work. 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
| Trigger | Action |
|---|---|
git push opens or updates a PR | p9 watch <pr> --background immediately |
run_in_background task notification fires for the watcher | p9 status --pr <n> to read terminal state |
gh pr checks returned non-zero | p9 heal <pr> --classify to inspect failure |
About to sleep | Don't. Pull from p9 wait-queue pop instead |
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 type | Tier | Where it happens |
|---|---|---|
| research, docs, knowledge-graph mutations, Linear updates | none | current worktree, no separate branch |
| code that's independent of the in-flight PR | worktree | new P5 worktree off main |
| code that depends on the in-flight PR | stacked_branch | branch off feat/X+1 from feat/X HEAD |
anything touching CLAUDE.md / AGENTS.md / .control/ | blocked | not 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 cleanupTermination 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=truefor two consecutive cycles - user interrupt (Ctrl-C in terminal, or chat message)
- session ends (the
Stophook 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=1Example 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.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 cannotwrite 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(runpython3 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).
name: tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
pytest:
name: pytest (${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -r tests/requirements-dev.txt
- name: Run conformance battery
run: |
python scripts/p9.py conformance -v
- name: Smoke-test CLI surfaces
run: |
export BROOMVA_P9_HOME=/tmp/p9-smoke
export BROOMVA_P9_POLICY=tests/fixtures/policy-good.yaml
python scripts/p9.py --help
python scripts/p9.py doctor || true # gh may not be authed in CI
python scripts/p9.py status
python scripts/p9.py wait-queue list
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
.env
.DS_Store
MIT License
Copyright (c) 2026 Carlos D. Escobar-Valbuena (broomva)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
p9 — bstack P9: productive-wait primitive (the wait optimizer)
Never `sleep` on a blocking wait. P9 is a wait optimizer — turn any blocking external operation (PR CI, push-triggered deploys, builds, long indexing) into work on the next priority. PR CI is the canonical implementation today; the primitive is broader.
Why P9 exists
When an agent kicks off a long external operation and waits for it, the dumb pattern is sleep. P9 replaces it with productive-wait discipline:
1. Notification via `run_in_background` — for PR CI, the observer is gh pr checks --watch. No polling. 2. Productive wait — the agent drains a context-scoped queue (session → memory → graph → docs → Linear) while the operation runs. 3. Self-heal — when CI fails for known categories (lint, format, codegen drift, flaky tests), P9 classifies and applies the heal command. Unknown failures escalate to a Linear ticket. 4. Merge stays governed — P9 emits MERGE_READY; the existing .control/policy.yaml + control-gate-hook authorizes the actual merge. P9 owns mechanism, not policy.
Scope today vs. on roadmap
P9 currently tracks PR-scoped waits through the p9 watch <pr> CLI. That covers ~95% of agent waits in a typical session.
The remaining 5% — non-PR waits — are not yet wired into p9 watch. These include:
- Push-triggered deploys — a push to
mainthat fires a Vercel/Cloudflare deploy hook without opening a PR - Long-running test suites or builds — outside the CI surface
- External index / sync operations — content pipelines, embeddings, search-index rebuilds
For these, the productive-wait discipline still applies: kick off next-priority work, then do one direct check on the operation's completion. Never sleep. Wiring these into p9 watch <kind> <ref> is on the roadmap.
Quick start
# 1. Install dependencies (stdlib-only at runtime; pytest for tests)
python3 -m venv .venv && source .venv/bin/activate
pip install -r tests/requirements-dev.txt
# 2. Wire policy blocks
# Add ci_watch: and ci_heal: to your .control/policy.yaml. See
# tests/fixtures/policy-good.yaml for a complete example.
# 3. Verify install
python3 scripts/p9.py doctor
# → p9 doctor: ok
# 4. Use it
gh pr create ... ; PR=42
python3 scripts/p9.py watch $PR --background
# In parallel: pull productive work while CI runs
python3 scripts/p9.py wait-queue pop
# When the bg-task notification fires, check terminal state:
python3 scripts/p9.py status --pr $PR --jsonSubcommands
| Command | Purpose |
|---|---|
p9 watch <pr> | Spawn gh pr checks --watch in background, transition to WATCHING. |
p9 status [--pr N] | Show in-flight PRs and their state-machine position. |
p9 wait-queue {push,pop,list,clear} | Manage the productive-wait queue (5 sources, priority-ordered). |
p9 heal <pr> --classify | Read failure log, classify against the rubric (read-only — does not execute heals). |
p9 events tail | Stream P9 events (P1 conversation-bridge consumes these). |
p9 merge-ready <pr> | Mark a green PR as ready for metalayer-authorized merge. |
p9 doctor | Health-check: gh auth, state directory, policy blocks, rubric. |
Architecture (one paragraph)
Pure Python, stdlib-only at runtime. State lives in ~/.config/broomva/p9/state.jsonl (append-only, JSONL with flock serialization, partial-write recovery on read). Failure classification is regex-only — no LLM in the hot path — driven by references/scoring-rubric.md (markdown is human-canonical; _builtin_rubric() in scripts/p9.py is code-canonical; a unit test asserts they stay in sync). The evaluator scores progress with a four-term weighted sum (signature change + failures decreased + budget remaining + classifier confidence) and forces escalation after two consecutive sub-floor cycles. Multi-PR concurrency is bounded by ci_watch.max_concurrent_prs (default 1). Everything fails closed on missing or malformed policy blocks.
Cardinal invariant
P9 never silently drops state. Every failure produces (a) a state.jsonl event, (b) a Linear ticket via the escalation channel, or (c) both. If neither write succeeds, P9 crashes loudly (exit 99) — degraded silent operation is forbidden.Where it fits in the bstack
P3 (Linear ticket) → P4 (PR pipeline) → P9 (CI watcher + heal) → metalayer authorizes merge
↓ drains during wait
{context queue, P5 worktree, P6 bookkeeping}Composes with — does not duplicate — existing primitives. See AGENTS.md in the workspace repo for the full bstack specification.
Spec & design
The full design lives at `broomva/workspace` under docs/superpowers/specs/2026-05-04-p9-ci-watcher-design.md. Seven rounds of clarifying Q&A locked the architecture before a line of code was written.
Tests
python3 -m pytest tests/
# 46 passedLicense
MIT — see LICENSE.
Related
- broomva/bookkeeping — bstack P8 (knowledge graph engine)
- broomva/workspace — unified workspace + governance
P9 — Failure-Classifier Scoring Rubric
This file is the human-canonical description of P9's CI-failure classifier. The Python equivalent lives in _builtin_rubric() inside scripts/p9.py. A unit test asserts the two stay in sync — adding an entry here without updating the code (or vice versa) fails CI.
How the classifier works
For each failure type below, the classifier searches the failure log for any of the listed detection signatures (regexes). If at least one matches, the entry's score is 0.7 + 0.1 × (extra_matches), capped at 1.0. Among all matching entries, the highest-scoring one wins. If its score is below the entry's confidence_floor (default 0.7), the result drops to unclassified and the failure is escalated rather than auto-healed.
Cardinal rule
The classifier is narrow on purpose. False positives that "almost" match would burn heal attempts on the wrong fix. Prefer escalation to heal-by-guess.
---
Failure types
1. lint
What it catches: non-zero exits from biome, eslint, clippy, or generic "lint" markers in ::error annotations.
Detection signatures (any match → candidate):
biome\s+(check|lint).*(found|error)(case-insensitive)eslint.*\d+\s+(error|problem)clippy.*::error::error.*lint
Heal command: bun run lint:fix (project-specific; configurable per repo via .control/policy.yaml overrides — TBD in PR 3).
Idempotent: yes — running the lint fix twice leaves the same result.
---
2. format
What it catches: prettier/rustfmt drift.
Detection signatures:
prettier.*--check.*would reformatrustfmt.*Diff inwould reformat
Heal command: bun run format.
Idempotent: yes.
---
3. type
What it catches: TypeScript/Rust type errors.
Detection signatures:
tsc.*error TS\d+cargo check.*error\[E\d+\]type error.*at\s+[\w./]+:\d+
Heal command: none — type errors require human reasoning. Escalates on first occurrence.
Idempotent: N/A.
---
4. test_flaky
What it catches: tests that fail intermittently. Detected primarily by signature history (same test, same line, passed→failed→passed within the last N runs), not log-text match. The textual pattern below exists only as a fallback recognizer.
Detection signatures:
^\s*FAIL\s+(multiline; weak signal)
Heal command: gh run rerun --failed.
Idempotent: yes (re-running is safe).
Confidence floor: 0.9 (higher than default 0.7) — only confident if history confirms flakiness. Otherwise drops to unclassified so we don't mask real regressions.
---
5. codegen_drift
What it catches: generated files (graphql, prisma, openapi, etc.) out of sync with their source schema.
Detection signatures:
(generated|codegen).*(out of (date|sync)|stale)schema mismatchgraphql codegen.*diff
Heal command: bun run codegen (project-specific).
Idempotent: yes.
---
6. import_missing
What it catches: Cannot find module 'X' and equivalents.
Detection signatures:
Cannot find module ['"]([^'"]+)['"]unresolved import\s+\([^]+)\`Module not found
Heal command: none in v1 — automated dependency installation has high blast radius (which package version? which lockfile?). Escalate.
Idempotent: N/A in v1.
---
7. unclassified (terminal)
Anything not matched above with confidence ≥ floor. Always escalates — no heal attempt, no retry, no guessing. Creates a Linear ticket immediately.
---
Adding a new entry
1. Add the dataclass entry in _builtin_rubric() (scripts/p9.py). 2. Add the matching section here. 3. Add a fixture log under tests/fixtures/failures/<type>.txt. 4. Add a unit test in tests/test_p9_unit.py asserting the classifier matches the fixture with the expected confidence. 5. The rubric-sync test will confirm the markdown and code match.
Why not LLM-classify?
LLMs in the hot path of a CI loop are unsafe: latency, cost, and non-determinism. Classifier here is pure regex on logs — fast, predictable, auditable. The agent's intelligence belongs in the evaluator (deciding whether the heal worked), not the classifier (detecting which kind of failure happened).
#!/bin/bash
# p9-escalate-notify.sh — default escalation notify hook.
#
# Invoked by P9 after a Linear ticket is created for an unclassified or
# evaluator-stalled CI failure. Receives JSON on stdin:
# {pr: int, repo: str, failure_signature: str, linear_ticket: str, attempt: int}
#
# Default behavior: log to ~/.config/broomva/p9/escalations.log. Override
# this hook in .control/policy.yaml -> ci_heal.escalation_channel.notify_hook
# to wire Discord, Telegram, or any other channel via the existing
# claude-remote-sessions infra.
set -euo pipefail
LOG="${BROOMVA_P9_HOME:-${XDG_CONFIG_HOME:-$HOME/.config}/broomva/p9}/escalations.log"
mkdir -p "$(dirname "$LOG")"
PAYLOAD="$(cat)"
TS="$(date -u +%FT%TZ)"
echo "[$TS] $PAYLOAD" >> "$LOG"
exit 0
#!/usr/bin/env python3
"""
p9.py — Broomva CI watcher + productive-wait primitive (bstack P9).
Replaces sleep-based CI waits with an event-driven control loop:
- `gh pr checks <pr> --watch` via run_in_background as the notification
mechanism (not polling).
- context-scoped wait-queue draining session/memory/graph/docs/Linear
while CI runs.
- classifier (fast regex filter) + evaluator (progress score) self-heal
loop with stability-budget termination.
- merge authorization delegated to existing control metalayer
(.control/policy.yaml). P9 emits MERGE_READY; metalayer authorizes.
Stdlib-only at runtime. Test-only deps (pytest, vcrpy) live under
tests/requirements-dev.txt.
Spec: docs/superpowers/specs/2026-05-04-p9-ci-watcher-design.md
"""
from __future__ import annotations
import argparse
import contextlib
import dataclasses
import datetime as _dt
import enum
import errno
import fcntl
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Iterator
# ─────────────────────────────────────────────────────────────────────────────
# Exit codes (composable shell semantics)
# ─────────────────────────────────────────────────────────────────────────────
EXIT_OK = 0
EXIT_DEGRADED = 1 # recoverable: run again, may succeed
EXIT_POLICY_ERROR = 2 # policy.yaml missing/malformed (fail-closed)
EXIT_USAGE = 3 # bad CLI args
EXIT_EXTERNAL_ERROR = 4 # gh/Linear/network failure
EXIT_CONCURRENCY_CEILING = 5 # max_concurrent_prs reached
EXIT_HEAL_LOCK_TIMEOUT = 6
EXIT_AUTO_MERGE_BLOCKED = 7 # auto_merge policy says require_human / notify
EXIT_INVARIANT_VIOLATION = 99 # cardinal-rule breach: cannot persist state
# ─────────────────────────────────────────────────────────────────────────────
# Paths
# ─────────────────────────────────────────────────────────────────────────────
def p9_home() -> Path:
"""State directory; overridable via BROOMVA_P9_HOME for tests."""
override = os.environ.get("BROOMVA_P9_HOME")
if override:
return Path(override)
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(xdg) / "broomva" / "p9"
def state_jsonl() -> Path:
return p9_home() / "state.jsonl"
def wait_queue_jsonl() -> Path:
return p9_home() / "wait-queue.jsonl"
def pending_escalations_jsonl() -> Path:
return p9_home() / "pending-escalations.jsonl"
def heal_lock_path() -> Path:
return p9_home() / "heal.lock"
def state_lock_path() -> Path:
return p9_home() / "state.lock"
def queue_lock_path() -> Path:
return p9_home() / "queue.lock"
def policy_yaml_path() -> Path:
"""Resolve .control/policy.yaml.
Honors BROOMVA_P9_POLICY env var (used by tests). Otherwise walks from
cwd upward looking for `.control/policy.yaml`.
"""
override = os.environ.get("BROOMVA_P9_POLICY")
if override:
return Path(override)
cur = Path.cwd().resolve()
for parent in [cur, *cur.parents]:
p = parent / ".control" / "policy.yaml"
if p.exists():
return p
return cur / ".control" / "policy.yaml" # fail-closed marker
def rubric_md_path() -> Path:
"""Locate references/scoring-rubric.md alongside this script."""
return Path(__file__).resolve().parent.parent / "references" / "scoring-rubric.md"
# ─────────────────────────────────────────────────────────────────────────────
# Errors
# ─────────────────────────────────────────────────────────────────────────────
class P9Error(Exception):
"""Base exception with an exit code."""
code = EXIT_DEGRADED
class PolicyError(P9Error):
code = EXIT_POLICY_ERROR
class IllegalTransitionError(P9Error):
code = EXIT_INVARIANT_VIOLATION
class ConcurrencyCeilingError(P9Error):
code = EXIT_CONCURRENCY_CEILING
# ─────────────────────────────────────────────────────────────────────────────
# State machine
# ─────────────────────────────────────────────────────────────────────────────
class PRState(str, enum.Enum):
PUSHED = "PUSHED"
WATCHING = "WATCHING"
GREEN = "GREEN"
RED_CLASSIFIED = "RED_CLASSIFIED"
RED_UNCLASSIFIED = "RED_UNCLASSIFIED"
HEALING = "HEALING"
MERGE_READY = "MERGE_READY"
MERGED = "MERGED"
ESCALATED = "ESCALATED"
ABANDONED = "ABANDONED"
# Allowed (from -> to) transitions. All edges in spec §5.1.
_TRANSITIONS: set[tuple[PRState, PRState]] = {
(PRState.PUSHED, PRState.WATCHING),
(PRState.WATCHING, PRState.GREEN),
(PRState.WATCHING, PRState.RED_CLASSIFIED),
(PRState.WATCHING, PRState.RED_UNCLASSIFIED),
(PRState.RED_CLASSIFIED, PRState.HEALING),
(PRState.RED_CLASSIFIED, PRState.ESCALATED), # evaluator-stalled
(PRState.HEALING, PRState.WATCHING), # heal pushed; new watch cycle
(PRState.HEALING, PRState.ESCALATED), # heal corruption / scope violation
(PRState.RED_UNCLASSIFIED, PRState.ESCALATED),
(PRState.GREEN, PRState.MERGE_READY),
(PRState.MERGE_READY, PRState.MERGED),
(PRState.MERGE_READY, PRState.WATCHING), # rare: human pushed amend post-green
# Terminal "abandoned" reachable from any non-terminal — needed for
# `p9 abandon` and `p9 cleanup` to drain orphans regardless of the
# state they're parked in.
(PRState.PUSHED, PRState.ABANDONED),
(PRState.WATCHING, PRState.ABANDONED),
(PRState.HEALING, PRState.ABANDONED),
(PRState.RED_CLASSIFIED, PRState.ABANDONED),
(PRState.RED_UNCLASSIFIED, PRState.ABANDONED),
(PRState.GREEN, PRState.ABANDONED),
(PRState.MERGE_READY, PRState.ABANDONED),
}
def assert_legal_transition(curr: PRState, nxt: PRState) -> None:
if curr == nxt:
return # idempotent self-event allowed (e.g., status refresh)
if (curr, nxt) not in _TRANSITIONS:
raise IllegalTransitionError(
f"Illegal PR state transition: {curr.value} -> {nxt.value}"
)
def is_terminal(state: PRState) -> bool:
return state in {PRState.MERGED, PRState.ESCALATED, PRState.ABANDONED}
# ─────────────────────────────────────────────────────────────────────────────
# Dataclasses
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class IsolationTierMap:
research: str = "none"
docs: str = "none"
code_independent: str = "worktree"
code_dependent: str = "stacked_branch"
governance: str = "blocked"
@dataclass(frozen=True)
class CIWatchPolicy:
enabled: bool = True
max_concurrent_prs: int = 1
isolation_tier_map: IsolationTierMap = field(default_factory=IsolationTierMap)
@dataclass(frozen=True)
class EscalationChannel:
linear_team: str = "BRO"
linear_label: str = "ci-heal-escalation"
notify_hook: str = "skills/p9/scripts/p9-escalate-notify.sh"
@dataclass(frozen=True)
class CIHealPolicy:
enabled: bool = True
max_attempts: int = 5
stability_floor: float = 0.3
classified_failure_types: tuple[str, ...] = (
"lint", "format", "test_flaky", "codegen_drift", "import_missing",
)
escalation_channel: EscalationChannel = field(default_factory=EscalationChannel)
@dataclass(frozen=True)
class AutoMergeRule:
"""One auto-merge rule. Either branch_pattern or path_touched (not both).
branch_pattern: fnmatch glob against the PR's head branch (e.g. "docs/*").
path_touched: substring match against any file in the PR diff
(e.g. "CLAUDE.md" matches the literal path).
action: "auto" → run gh pr merge; "require_human" → hard-block,
exit 7; "notify" → emit MERGE_NOTIFIED event, exit 7.
Rules are evaluated in declaration order; first match wins.
Matches are short-circuited: `require_human` and `notify` are blocking
states even if a later rule would auto-merge.
"""
branch_pattern: str | None = None
path_touched: str | None = None
action: str = "notify"
@dataclass(frozen=True)
class AutoMergePolicy:
enabled: bool = False
require_no_requested_changes: bool = True
require_branch_up_to_date: bool = True
merge_method: str = "squash" # squash | merge | rebase
delete_branch: bool = True
rules: tuple[AutoMergeRule, ...] = ()
default_action: str = "notify" # fail-safe default
@dataclass(frozen=True)
class PolicyConfig:
ci_watch: CIWatchPolicy
ci_heal: CIHealPolicy
auto_merge: AutoMergePolicy = field(default_factory=AutoMergePolicy)
@dataclass
class PRStateEvent:
"""One row of state.jsonl."""
ts: str
pr: int
repo: str
from_state: str
to_state: str
watcher_id: str
attempt: int = 0
evaluator_score: float | None = None
extra: dict[str, Any] = field(default_factory=dict)
def to_jsonl(self) -> str:
return json.dumps(dataclasses.asdict(self), separators=(",", ":"))
@dataclass(frozen=True)
class ClassifierResult:
failure_type: str
classified: bool
confidence: float
heal_command: str | None
signature_hash: str
rationale: str
@dataclass(frozen=True)
class EvaluatorResult:
progress_score: float
signature_changed: bool
failures_decreased: bool
budget_remaining: float
classifier_confidence: float
stalled: bool
_QUEUE_PRIORITY = ("session", "memory", "graph", "docs", "linear")
@dataclass
class WaitQueueItem:
"""One row of wait-queue.jsonl."""
id: str
source: str
item: str
created_at: str
pr: int | None = None
isolation_tier: str = "none"
def to_jsonl(self) -> str:
return json.dumps(dataclasses.asdict(self), separators=(",", ":"))
# ─────────────────────────────────────────────────────────────────────────────
# Filesystem helpers — JSONL append, locks, corruption recovery
# ─────────────────────────────────────────────────────────────────────────────
@contextlib.contextmanager
def file_lock(lock_path: Path, timeout_s: float = 30.0) -> Iterator[None]:
"""Cross-process flock with timeout. Creates lock file if absent."""
lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
deadline = time.monotonic() + timeout_s
try:
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except OSError as e:
if e.errno not in (errno.EAGAIN, errno.EACCES):
raise
if time.monotonic() >= deadline:
raise P9Error(
f"flock timeout after {timeout_s}s on {lock_path}"
) from e
time.sleep(0.05)
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def jsonl_append(path: Path, payload: str, lock: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with file_lock(lock):
with path.open("a", encoding="utf-8") as f:
f.write(payload)
if not payload.endswith("\n"):
f.write("\n")
def jsonl_read_all(path: Path) -> tuple[list[dict[str, Any]], int]:
"""Read JSONL, skipping the last line if it's a partial/corrupt write.
Returns (rows, dropped) where dropped is the number of lines we skipped
(currently 0 or 1).
"""
if not path.exists():
return [], 0
raw = path.read_text(encoding="utf-8")
if not raw:
return [], 0
lines = raw.splitlines()
rows: list[dict[str, Any]] = []
dropped = 0
for i, line in enumerate(lines):
if not line.strip():
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
# JSONL append-only design: only the last line can be partial.
# If the corrupt line is not the last, that's an invariant violation.
if i == len(lines) - 1:
dropped = 1
else:
raise IllegalTransitionError(
f"Mid-file JSON corruption in {path} at line {i + 1}"
)
return rows, dropped
# ─────────────────────────────────────────────────────────────────────────────
# Policy loader (strict; fail-closed)
# ─────────────────────────────────────────────────────────────────────────────
def _yaml_loader():
"""Lazy-import PyYAML; fall back to a tiny parser for the keys we need."""
try:
import yaml # type: ignore
return yaml.safe_load
except ImportError:
return _minimal_yaml_load
def _minimal_yaml_load(text: str) -> dict[str, Any]:
"""Tiny YAML subset: top-level mappings, nested mappings, scalars,
inline lists `[a,b]`, and block-style list-of-dicts `- key: val`.
Sufficient for `.control/policy.yaml` ci_watch / ci_heal / auto_merge
blocks. Not a general YAML parser — intentionally narrow so it fails
noisily on anything unexpected.
"""
out: dict[str, Any] = {}
# stack tracks (indent, dict) frames for nested mappings.
stack: list[tuple[int, dict[str, Any]]] = [(-1, out)]
# list_stack tracks (indent, list) frames for active block lists.
list_stack: list[tuple[int, list[Any]]] = []
for raw_line in text.splitlines():
line = raw_line.rstrip()
if not line or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
body = line.strip()
if body.startswith("- "):
# block list item; pop deeper frames first
while list_stack and list_stack[-1][0] >= indent:
list_stack.pop()
while stack and stack[-1][0] >= indent:
stack.pop()
if not list_stack:
continue # orphan list item — ignore
target_list = list_stack[-1][1]
inner = body[2:].strip()
if ":" in inner and not (inner.startswith("[") or inner.startswith('"')):
# block-style list-of-dicts: `- key: val` starts a new dict
# and any subsequent more-indented `key: val` lines populate it
key, _, value_str = inner.partition(":")
value_str = value_str.strip()
new_dict: dict[str, Any] = {}
if value_str:
new_dict[key.strip()] = _scalar(value_str)
target_list.append(new_dict)
# Subsequent same-indent `- ` items pop this frame; nested
# `key: val` lines at deeper indent populate new_dict.
stack.append((indent, new_dict))
else:
# scalar list item
target_list.append(_scalar(inner))
continue
if ":" not in body:
continue
key, _, value_str = body.partition(":")
value_str = value_str.strip()
# Pop deeper frames
while stack and stack[-1][0] >= indent:
stack.pop()
while list_stack and list_stack[-1][0] >= indent:
list_stack.pop()
parent = stack[-1][1] if stack else out
if value_str == "":
# Mapping or list — we don't know yet. Default to mapping;
# if the next non-blank line is a `- `, the open list_stack
# frame below catches it.
new_map: dict[str, Any] = {}
new_list: list[Any] = []
parent[key.strip()] = new_map
stack.append((indent, new_map))
# Tentatively register a list at deeper indent; whichever
# the next line uses (mapping vs list) wins via stack pop.
# We need a sentinel: register the list only if the next
# `-` arrives at deeper indent. Easiest: pre-register both
# but only commit to one once content arrives.
# Concretely: replace mapping with list lazily on first `-`.
list_stack.append((indent, new_list))
# If the next content is a `- ` deeper than `indent`, we'll
# convert: replace parent[key] with new_list and pop the dict.
stack[-1] = (indent, new_map)
# Stash a reference so a `- ` line can swap mapping → list
new_map.setdefault("__p9_yaml_pending_list_holder__",
(parent, key.strip(), new_list))
elif value_str.startswith("[") and value_str.endswith("]"):
inner = value_str[1:-1].strip()
parent[key.strip()] = (
[_scalar(p.strip()) for p in inner.split(",") if p.strip()]
if inner else []
)
else:
parent[key.strip()] = _scalar(value_str)
# Second pass: any dict that still holds the pending-list sentinel
# AND has no other keys is actually an empty dict; any dict whose
# corresponding list got populated had its mapping replaced.
_resolve_pending_lists(out)
return out
def _resolve_pending_lists(node: Any) -> None:
"""Walk the parsed tree; convert mapping→list where a `-` block was
used. Sentinel removal must come before semantic validation."""
if isinstance(node, dict):
sentinel_key = "__p9_yaml_pending_list_holder__"
if sentinel_key in node:
holder = node[sentinel_key]
del node[sentinel_key]
parent, key, the_list = holder
if the_list:
# `-` lines populated the list; replace mapping with list
parent[key] = the_list
for v in list(node.values()):
_resolve_pending_lists(v)
elif isinstance(node, list):
for item in node:
_resolve_pending_lists(item)
def _scalar(s: str) -> Any:
s = s.strip().strip('"').strip("'")
if s.lower() == "true":
return True
if s.lower() == "false":
return False
if s.lower() in ("null", "none", "~"):
return None
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
pass
return s
def load_policy(path: Path | str | None = None) -> PolicyConfig:
"""Load .control/policy.yaml. **Fail-closed** on missing/malformed blocks.
Accepts both `Path` and `str` (str is coerced — historic callers were
inconsistent). None falls back to `policy_yaml_path()`.
"""
p = Path(path) if path is not None else policy_yaml_path()
if not p.exists():
raise PolicyError(f"policy.yaml not found at {p}")
try:
loader = _yaml_loader()
data = loader(p.read_text(encoding="utf-8"))
except Exception as e:
raise PolicyError(f"policy.yaml malformed: {e}") from e
if not isinstance(data, dict):
raise PolicyError("policy.yaml must be a mapping at the top level")
if "ci_watch" not in data:
raise PolicyError("policy.yaml missing required block: ci_watch")
if "ci_heal" not in data:
raise PolicyError("policy.yaml missing required block: ci_heal")
return _parse_policy(data)
def _parse_policy(data: dict[str, Any]) -> PolicyConfig:
cw_raw = data.get("ci_watch") or {}
ch_raw = data.get("ci_heal") or {}
iso_raw = cw_raw.get("isolation_tier_map") or {}
esc_raw = ch_raw.get("escalation_channel") or {}
types_raw = ch_raw.get("classified_failure_types") or ()
if not isinstance(types_raw, (list, tuple)):
raise PolicyError("ci_heal.classified_failure_types must be a list")
return PolicyConfig(
ci_watch=CIWatchPolicy(
enabled=bool(cw_raw.get("enabled", True)),
max_concurrent_prs=int(cw_raw.get("max_concurrent_prs", 1)),
isolation_tier_map=IsolationTierMap(
research=str(iso_raw.get("research", "none")),
docs=str(iso_raw.get("docs", "none")),
code_independent=str(iso_raw.get("code_independent", "worktree")),
code_dependent=str(iso_raw.get("code_dependent", "stacked_branch")),
governance=str(iso_raw.get("governance", "blocked")),
),
),
ci_heal=CIHealPolicy(
enabled=bool(ch_raw.get("enabled", True)),
max_attempts=int(ch_raw.get("max_attempts", 5)),
stability_floor=float(ch_raw.get("stability_floor", 0.3)),
classified_failure_types=tuple(str(t) for t in types_raw),
escalation_channel=EscalationChannel(
linear_team=str(esc_raw.get("linear_team", "BRO")),
linear_label=str(esc_raw.get("linear_label", "ci-heal-escalation")),
notify_hook=str(
esc_raw.get(
"notify_hook",
"skills/p9/scripts/p9-escalate-notify.sh",
)
),
),
),
auto_merge=_parse_auto_merge(data.get("auto_merge")),
)
_AUTO_MERGE_ACTIONS = ("auto", "require_human", "notify")
def _parse_auto_merge(raw: Any) -> AutoMergePolicy:
"""Parse the optional auto_merge: block. Absence is *not* an error —
auto-merge is opt-in; missing block defaults to disabled (fail-safe)."""
if raw is None:
return AutoMergePolicy()
if not isinstance(raw, dict):
raise PolicyError("auto_merge must be a mapping if present")
rules_raw = raw.get("rules") or []
if not isinstance(rules_raw, list):
raise PolicyError("auto_merge.rules must be a list")
rules: list[AutoMergeRule] = []
for i, r in enumerate(rules_raw):
if not isinstance(r, dict):
raise PolicyError(f"auto_merge.rules[{i}] must be a mapping")
action = str(r.get("action", "notify"))
if action not in _AUTO_MERGE_ACTIONS:
raise PolicyError(
f"auto_merge.rules[{i}].action must be one of {_AUTO_MERGE_ACTIONS}, "
f"got {action!r}"
)
bp = r.get("branch_pattern")
pt = r.get("path_touched")
if bp and pt:
raise PolicyError(
f"auto_merge.rules[{i}] cannot set both branch_pattern and path_touched"
)
if not bp and not pt:
raise PolicyError(
f"auto_merge.rules[{i}] must set either branch_pattern or path_touched"
)
rules.append(AutoMergeRule(
branch_pattern=str(bp) if bp else None,
path_touched=str(pt) if pt else None,
action=action,
))
default = str(raw.get("default_action", "notify"))
if default not in _AUTO_MERGE_ACTIONS:
raise PolicyError(
f"auto_merge.default_action must be one of {_AUTO_MERGE_ACTIONS}, "
f"got {default!r}"
)
method = str(raw.get("merge_method", "squash"))
if method not in ("squash", "merge", "rebase"):
raise PolicyError(
f"auto_merge.merge_method must be squash|merge|rebase, got {method!r}"
)
return AutoMergePolicy(
enabled=bool(raw.get("enabled", False)),
require_no_requested_changes=bool(raw.get("require_no_requested_changes", True)),
require_branch_up_to_date=bool(raw.get("require_branch_up_to_date", True)),
merge_method=method,
delete_branch=bool(raw.get("delete_branch", True)),
rules=tuple(rules),
default_action=default,
)
# ─────────────────────────────────────────────────────────────────────────────
# Auto-merge matcher
# ─────────────────────────────────────────────────────────────────────────────
import fnmatch # noqa: E402 (kept near use site for clarity)
def match_auto_merge_action(
policy: AutoMergePolicy,
*,
branch: str,
paths_touched: Iterable[str],
) -> tuple[str, str]:
"""First-match-wins evaluator. Returns (action, reason).
Path rules are evaluated FIRST regardless of order — a path-touched
`require_human` rule (e.g. CLAUDE.md) is a hard block that cannot be
overridden by a later branch rule. This implements the "governance
paths always block" invariant from the original brainstorming.
"""
paths = list(paths_touched)
# Pass 1: any path rule with require_human is a hard block.
for rule in policy.rules:
if rule.path_touched and rule.action == "require_human":
for p in paths:
if rule.path_touched in p:
return ("require_human",
f"path rule blocks: {rule.path_touched!r} in {p}")
# Pass 2: first match wins (path or branch).
for rule in policy.rules:
if rule.path_touched:
for p in paths:
if rule.path_touched in p:
return (rule.action,
f"path rule matched: {rule.path_touched!r} in {p}")
if rule.branch_pattern and fnmatch.fnmatch(branch, rule.branch_pattern):
return (rule.action, f"branch rule matched: {rule.branch_pattern!r}")
return (policy.default_action, "no rule matched; using default_action")
# ─────────────────────────────────────────────────────────────────────────────
# Failure classifier (rubric-driven regex matcher)
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class RubricEntry:
failure_type: str
patterns: tuple[re.Pattern[str], ...]
heal_command: str | None # None => escalate (cannot auto-fix)
confidence_floor: float = 0.7 # below this, drop to "unclassified"
def _builtin_rubric() -> tuple[RubricEntry, ...]:
"""Default rubric. Mirrors references/scoring-rubric.md.
Rubric markdown is the authoritative source for humans; this constant is
the authoritative source for code. Tests assert the two stay in sync.
"""
return (
RubricEntry(
failure_type="lint",
patterns=(
re.compile(r"biome\s+(check|lint).*(found|error)", re.IGNORECASE),
re.compile(r"eslint.*\d+\s+(error|problem)", re.IGNORECASE),
re.compile(r"clippy.*::error", re.IGNORECASE),
re.compile(r"::error.*lint", re.IGNORECASE),
),
heal_command="bun run lint:fix",
),
RubricEntry(
failure_type="format",
patterns=(
re.compile(r"prettier.*--check.*would reformat", re.IGNORECASE),
re.compile(r"rustfmt.*Diff in", re.IGNORECASE),
re.compile(r"would reformat", re.IGNORECASE),
),
heal_command="bun run format",
),
RubricEntry(
failure_type="type",
patterns=(
re.compile(r"\berror TS\d+\b"),
re.compile(r"\berror\[E\d+\]"),
re.compile(r"type error.*at\s+[\w./]+:\d+", re.IGNORECASE),
),
heal_command=None, # escalate — type errors need human reasoning
),
RubricEntry(
failure_type="test_flaky",
patterns=(
# Detected by signature-history rather than text — see
# classify_with_history. The pattern below matches generic
# test failure for fallback recognition only.
re.compile(r"^\s*FAIL\s+", re.MULTILINE),
),
heal_command="gh run rerun --failed",
confidence_floor=0.9, # only confident if history confirms flakiness
),
RubricEntry(
failure_type="codegen_drift",
patterns=(
re.compile(r"(generated|codegen).*(out of (date|sync)|stale)", re.IGNORECASE),
re.compile(r"schema mismatch", re.IGNORECASE),
re.compile(r"graphql codegen.*diff", re.IGNORECASE),
),
heal_command="bun run codegen",
),
RubricEntry(
failure_type="import_missing",
patterns=(
re.compile(r"Cannot find module ['\"]([^'\"]+)['\"]", re.IGNORECASE),
re.compile(r"unresolved import\s+`([^`]+)`", re.IGNORECASE),
re.compile(r"Module not found", re.IGNORECASE),
),
heal_command=None, # complex — escalate v1
),
)
def _signature_hash(log: str) -> str:
"""Stable signature for a failure log: first error-ish line, normalized."""
candidates = [
ln for ln in log.splitlines()
if re.search(r"\b(error|FAIL|fatal)\b", ln, re.IGNORECASE)
]
src = candidates[0] if candidates else log[:200]
norm = re.sub(r"\s+", " ", src).strip()
return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
def classify(
log: str,
rubric: tuple[RubricEntry, ...] | None = None,
) -> ClassifierResult:
"""Pure classifier: regex match against the rubric.
Returns the best match. If best confidence < that entry's floor, returns
an `unclassified` result (which the caller must treat as `escalate`).
"""
rubric = rubric or _builtin_rubric()
best: tuple[RubricEntry, float] | None = None
for entry in rubric:
score = _entry_score(entry, log)
if score == 0.0:
continue
if best is None or score > best[1]:
best = (entry, score)
sig = _signature_hash(log)
if best is None or best[1] < best[0].confidence_floor:
return ClassifierResult(
failure_type="unclassified",
classified=False,
confidence=best[1] if best else 0.0,
heal_command=None,
signature_hash=sig,
rationale=(
f"no rubric entry above floor (best={best[0].failure_type}@{best[1]:.2f})"
if best else "no rubric pattern matched"
),
)
entry, conf = best
return ClassifierResult(
failure_type=entry.failure_type,
classified=entry.heal_command is not None,
confidence=conf,
heal_command=entry.heal_command,
signature_hash=sig,
rationale=f"matched {entry.failure_type} at confidence {conf:.2f}",
)
def _entry_score(entry: RubricEntry, log: str) -> float:
"""How well does this rubric entry match? 0.0–1.0."""
matched = sum(1 for p in entry.patterns if p.search(log))
if matched == 0:
return 0.0
# Scale by fraction of patterns hit, then cap at 1.0. Multi-pattern hits
# boost confidence; single-pattern hits sit at ~0.7 — exactly the default
# confidence_floor, so single-pattern matches *just* qualify.
base = 0.7 + 0.1 * (matched - 1)
return min(base, 1.0)
# ─────────────────────────────────────────────────────────────────────────────
# Evaluator (progress-score brain)
# ─────────────────────────────────────────────────────────────────────────────
def evaluate(
*,
attempt: int,
max_attempts: int,
classifier_confidence: float,
prev_signature: str | None,
curr_signature: str,
prev_failure_count: int | None,
curr_failure_count: int,
stability_floor: float,
) -> EvaluatorResult:
"""Compute progress_score per spec §6.3.
progress_score =
0.4 × signature_changed? +
0.3 × failures_decreased? +
0.2 × (1 - attempt/max) +
0.1 × classifier_confidence
"""
sig_changed = prev_signature is not None and curr_signature != prev_signature
failures_dec = (
prev_failure_count is not None and curr_failure_count < prev_failure_count
)
budget = max(0.0, 1.0 - (attempt / max(1, max_attempts)))
score = (
0.4 * (1.0 if sig_changed else 0.0)
+ 0.3 * (1.0 if failures_dec else 0.0)
+ 0.2 * budget
+ 0.1 * classifier_confidence
)
return EvaluatorResult(
progress_score=round(score, 4),
signature_changed=sig_changed,
failures_decreased=failures_dec,
budget_remaining=round(budget, 4),
classifier_confidence=classifier_confidence,
stalled=score < stability_floor,
)
def stalled_for_two_cycles(
history: Iterable[float], stability_floor: float
) -> bool:
"""True iff the last two evaluator scores are both below floor."""
last_two = list(history)[-2:]
return len(last_two) >= 2 and all(s < stability_floor for s in last_two)
# ─────────────────────────────────────────────────────────────────────────────
# Wait queue
# ─────────────────────────────────────────────────────────────────────────────
def _validate_source(source: str) -> str:
if source not in _QUEUE_PRIORITY:
raise P9Error(
f"invalid source '{source}'; must be one of {_QUEUE_PRIORITY}"
)
return source
def queue_push(item: str, source: str, *, pr: int | None = None,
isolation_tier: str = "none") -> WaitQueueItem:
src = _validate_source(source)
entry = WaitQueueItem(
id=uuid.uuid4().hex[:12],
source=src,
item=item,
created_at=_utcnow(),
pr=pr,
isolation_tier=isolation_tier,
)
jsonl_append(wait_queue_jsonl(), entry.to_jsonl(), queue_lock_path())
return entry
def queue_list() -> list[WaitQueueItem]:
rows, _ = jsonl_read_all(wait_queue_jsonl())
items = [WaitQueueItem(**r) for r in rows]
items.sort(key=lambda it: (_QUEUE_PRIORITY.index(it.source), it.created_at))
return items
def queue_pop() -> WaitQueueItem | None:
"""Atomic pop: re-write the queue without the highest-priority entry."""
with file_lock(queue_lock_path()):
rows, _ = jsonl_read_all(wait_queue_jsonl())
if not rows:
return None
items = [WaitQueueItem(**r) for r in rows]
items.sort(key=lambda it: (_QUEUE_PRIORITY.index(it.source), it.created_at))
head, *rest = items
wait_queue_jsonl().write_text(
"".join(it.to_jsonl() + "\n" for it in rest), encoding="utf-8",
)
return head
def queue_clear() -> int:
with file_lock(queue_lock_path()):
if not wait_queue_jsonl().exists():
return 0
before = len(wait_queue_jsonl().read_text(encoding="utf-8").splitlines())
wait_queue_jsonl().write_text("", encoding="utf-8")
return before
# ─────────────────────────────────────────────────────────────────────────────
# State store
# ─────────────────────────────────────────────────────────────────────────────
def _utcnow() -> str:
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
def append_state_event(event: PRStateEvent) -> None:
assert_legal_transition(PRState(event.from_state), PRState(event.to_state))
jsonl_append(state_jsonl(), event.to_jsonl(), state_lock_path())
def current_pr_state(pr: int) -> PRState | None:
rows, _ = jsonl_read_all(state_jsonl())
last: PRState | None = None
for r in rows:
if r.get("pr") == pr:
last = PRState(r["to_state"])
return last
def open_prs() -> list[dict[str, Any]]:
"""All PRs that haven't reached a terminal state."""
rows, _ = jsonl_read_all(state_jsonl())
seen: dict[int, dict[str, Any]] = {}
for r in rows:
seen[r["pr"]] = r
return [r for r in seen.values() if not is_terminal(PRState(r["to_state"]))]
# ─────────────────────────────────────────────────────────────────────────────
# Watcher manager (subprocess control for `gh pr checks --watch`)
# ─────────────────────────────────────────────────────────────────────────────
def spawn_watcher(pr: int, repo: str | None = None,
*, dry_run: bool = False) -> subprocess.Popen[bytes] | None:
"""Spawn `gh pr checks <pr> --watch` detached. Returns the Popen or None
in dry_run mode (used by tests and `--background --dry-run`)."""
if dry_run:
return None
cmd = ["gh", "pr", "checks", str(pr), "--watch"]
if repo:
cmd += ["--repo", repo]
return subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def is_watcher_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
# ─────────────────────────────────────────────────────────────────────────────
# Concurrency
# ─────────────────────────────────────────────────────────────────────────────
def enforce_concurrency_ceiling(policy: PolicyConfig) -> None:
open_count = len(open_prs())
if open_count >= policy.ci_watch.max_concurrent_prs:
raise ConcurrencyCeilingError(
f"max_concurrent_prs={policy.ci_watch.max_concurrent_prs} "
f"already in flight ({open_count} open)"
)
# ─────────────────────────────────────────────────────────────────────────────
# Subcommand handlers
# ─────────────────────────────────────────────────────────────────────────────
def cmd_doctor(_args: argparse.Namespace) -> int:
problems: list[str] = []
# 1. gh present + authed
try:
out = subprocess.run(
["gh", "auth", "status"],
capture_output=True, text=True, timeout=10, check=False,
)
if out.returncode != 0:
problems.append(f"gh auth status non-zero: {out.stderr.strip()[:200]}")
except FileNotFoundError:
problems.append("gh CLI not installed")
except subprocess.TimeoutExpired:
problems.append("gh auth status timed out")
# 2. State directory writable
try:
p9_home().mkdir(parents=True, exist_ok=True)
probe = p9_home() / ".doctor-probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except OSError as e:
problems.append(f"state directory not writable: {e}")
# 3. Policy blocks present
try:
load_policy()
except PolicyError as e:
problems.append(f"policy: {e}")
# 4. Rubric file present (if absent, builtin still works but we warn)
if not rubric_md_path().exists():
problems.append(
f"references/scoring-rubric.md missing at {rubric_md_path()} "
f"(builtin rubric still available but markdown is the human-canonical source)"
)
if not problems:
print("p9 doctor: ok")
return EXIT_OK
print("p9 doctor: degraded")
for p in problems:
print(f" - {p}")
# Policy errors are exit 2 (fail-closed); other degradations exit 1.
if any(p.startswith("policy:") for p in problems):
return EXIT_POLICY_ERROR
return EXIT_DEGRADED
def cmd_abandon(args: argparse.Namespace) -> int:
"""Mark a PR as ABANDONED.
Idempotent on already-terminal states. Emits a terminal-state event so
`max_concurrent_prs` accounting is freed and `p9 cleanup` doesn't
re-flag it.
"""
pr = int(args.pr)
state = current_pr_state(pr)
if state is None:
print(f"PR #{pr}: no state to abandon", file=sys.stderr)
return EXIT_DEGRADED
if is_terminal(state):
print(f"PR #{pr}: already terminal ({state.value}); no-op")
return EXIT_OK
repo = args.repo or _detect_repo() or ""
append_state_event(PRStateEvent(
ts=_utcnow(), pr=pr, repo=repo,
from_state=state.value,
to_state=PRState.ABANDONED.value,
watcher_id="abandon",
extra={"reason": args.reason or "manual abandon"},
))
print(f"PR #{pr}: {state.value} → ABANDONED")
return EXIT_OK
def cmd_cleanup(args: argparse.Namespace) -> int:
"""Drain orphan watchers by polling GitHub for each open row's true state.
For every PR in a non-terminal local state, queries
`gh pr view --json state,mergedAt`. If GitHub reports MERGED or CLOSED,
appends a terminal ABANDONED event with the reason. If GitHub reports
OPEN, leaves the row alone. PRs that fail to query are reported but
not abandoned (no false-positive cleanup).
"""
rows = open_prs()
if not rows:
print("p9 cleanup: no open PRs")
return EXIT_OK
cleaned = 0
skipped = 0
for row in rows:
pr = row["pr"]
repo = row.get("repo") or _detect_repo() or ""
cmd = ["gh", "pr", "view", str(pr), "--json", "state,mergedAt"]
if repo:
cmd += ["--repo", repo]
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=30, check=False)
if result.returncode != 0:
print(f" #{pr}: cannot query gh; leaving as {row['to_state']} "
f"({result.stderr.strip()[:80]})")
skipped += 1
continue
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
print(f" #{pr}: gh returned non-JSON; leaving as {row['to_state']}")
skipped += 1
continue
gh_state = (data.get("state") or "").upper()
from_state = PRState(row["to_state"])
if gh_state in ("MERGED", "CLOSED"):
reason = ("merged outside p9" if gh_state == "MERGED"
else "closed outside p9")
append_state_event(PRStateEvent(
ts=_utcnow(), pr=pr, repo=repo,
from_state=from_state.value,
to_state=PRState.ABANDONED.value,
watcher_id="cleanup",
extra={"reason": reason, "gh_state": gh_state},
))
print(f" #{pr}: {from_state.value} → ABANDONED ({reason})")
cleaned += 1
else:
print(f" #{pr}: still OPEN; leaving as {from_state.value}")
print(f"p9 cleanup: drained {cleaned}, skipped {skipped}")
return EXIT_OK
def cmd_auto_merge(args: argparse.Namespace) -> int:
"""Auto-merge actuator. Closes the gap between MERGE_READY signal and
actual `gh pr merge` execution.
Flow:
1. Load policy → bail if auto_merge.enabled is false.
2. Verify PR is in MERGE_READY state.
3. Fetch branch + touched paths via `gh pr view`.
4. Match against policy rules → action ∈ {auto, require_human, notify}.
5. auto → run `gh pr merge` (or print plan in --dry-run mode), transition
MERGE_READY → MERGED, return 0.
require_human / notify → idempotent self-transition with extra payload
indicating block reason, return 7 (EXIT_AUTO_MERGE_BLOCKED).
"""
pr = int(args.pr)
repo = args.repo or _detect_repo() or ""
policy = load_policy()
if not policy.auto_merge.enabled:
print("auto_merge.enabled=false in policy; refusing to merge", file=sys.stderr)
return EXIT_POLICY_ERROR
state = current_pr_state(pr)
if state != PRState.MERGE_READY:
print(
f"PR #{pr} not in MERGE_READY (current={state.value if state else 'UNKNOWN'}); "
f"call `p9 merge-ready` first",
file=sys.stderr,
)
return EXIT_DEGRADED
branch, paths = _gh_pr_branch_and_paths(pr, repo)
action, reason = match_auto_merge_action(
policy.auto_merge, branch=branch, paths_touched=paths,
)
if action != "auto":
# Block: emit idempotent state event with rationale; never merge.
append_state_event(PRStateEvent(
ts=_utcnow(),
pr=pr, repo=repo,
from_state=PRState.MERGE_READY.value,
to_state=PRState.MERGE_READY.value,
watcher_id="auto-merge",
extra={"auto_merge": {"action": action, "reason": reason,
"branch": branch, "paths": list(paths)[:20]}},
))
print(f"auto-merge blocked: action={action}; reason={reason}",
file=sys.stderr)
return EXIT_AUTO_MERGE_BLOCKED
# Auto path
if args.dry_run:
print(f"auto-merge dry-run: would merge PR #{pr} ({branch}) via "
f"`gh pr merge --{policy.auto_merge.merge_method}`")
return EXIT_OK
rc = _gh_pr_merge(
pr, repo,
method=policy.auto_merge.merge_method,
delete_branch=policy.auto_merge.delete_branch,
)
if rc != 0:
print(f"gh pr merge exited {rc}; PR not merged", file=sys.stderr)
return EXIT_EXTERNAL_ERROR
append_state_event(PRStateEvent(
ts=_utcnow(),
pr=pr, repo=repo,
from_state=PRState.MERGE_READY.value,
to_state=PRState.MERGED.value,
watcher_id="auto-merge",
extra={"auto_merge": {"action": "auto", "reason": reason,
"branch": branch,
"method": policy.auto_merge.merge_method}},
))
print(f"auto-merge: PR #{pr} merged ({branch})")
return EXIT_OK
def _gh_pr_branch_and_paths(pr: int, repo: str) -> tuple[str, list[str]]:
"""Return (head_branch, [files_touched]) for a PR via `gh pr view`."""
cmd = ["gh", "pr", "view", str(pr),
"--json", "headRefName,files",
"-q", '{branch: .headRefName, files: [.files[].path]}']
if repo:
cmd += ["--repo", repo]
out = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False)
if out.returncode != 0:
raise P9Error(f"gh pr view failed: {out.stderr.strip()[:200]}")
try:
data = json.loads(out.stdout)
except json.JSONDecodeError as e:
raise P9Error(f"gh pr view returned non-JSON: {e}") from e
return str(data.get("branch", "")), list(data.get("files") or [])
def _gh_pr_merge(pr: int, repo: str, *, method: str, delete_branch: bool) -> int:
"""Invoke `gh pr merge` with the configured method. Returns exit code."""
cmd = ["gh", "pr", "merge", str(pr), f"--{method}"]
if delete_branch:
cmd.append("--delete-branch")
if repo:
cmd += ["--repo", repo]
return subprocess.run(cmd, check=False).returncode
def cmd_conformance(args: argparse.Namespace) -> int:
"""Run the full pytest battery (unit + integration + chaos).
Used as the CI-lane validator and as a local pre-merge check. Honors
BROOMVA_P9_PYTEST env var (default: `python3 -m pytest`) so callers can
pin a specific interpreter or test runner.
"""
runner = os.environ.get("BROOMVA_P9_PYTEST", f"{sys.executable} -m pytest")
tests_dir = Path(__file__).resolve().parent.parent / "tests"
if not tests_dir.exists():
print(f"p9 conformance: tests directory not found at {tests_dir}",
file=sys.stderr)
return EXIT_DEGRADED
cmd = runner.split() + [str(tests_dir)]
if args.verbose:
cmd.append("-v")
if args.k:
cmd += ["-k", args.k]
print(f"p9 conformance: running {' '.join(cmd)}")
rc = subprocess.run(cmd, check=False).returncode
if rc == 0:
print("p9 conformance: ok")
return EXIT_OK
print(f"p9 conformance: failed (pytest exit {rc})", file=sys.stderr)
return EXIT_DEGRADED
def cmd_watch(args: argparse.Namespace) -> int:
"""Watch CI on a PR.
Default behavior (PR E onwards): foreground — block on
`gh pr checks --watch`, then fold the subprocess exit code into a state
transition (WATCHING → GREEN on exit 0, WATCHING → RED_UNCLASSIFIED
otherwise). Callers (the agent) wrap this in `run_in_background` so the
bg-task notification fires when the *whole* watch+fold has finished —
which is what the cardinal protocol actually wants.
--detach reverts to the old fire-and-forget behavior (no fold; the
caller is responsible for polling state). --background and --block are
aliases for the default; they exist so historic AGENTS.md guidance
using `p9 watch <pr> --background` keeps working.
"""
policy = load_policy()
if not policy.ci_watch.enabled:
print("ci_watch.enabled=false in policy; refusing to watch", file=sys.stderr)
return EXIT_POLICY_ERROR
enforce_concurrency_ceiling(policy)
pr = int(args.pr)
repo = args.repo or _detect_repo()
watcher_id = uuid.uuid4().hex[:12]
proc = spawn_watcher(pr, repo, dry_run=args.dry_run)
pid = proc.pid if proc else 0
append_state_event(PRStateEvent(
ts=_utcnow(),
pr=pr,
repo=repo or "",
from_state=PRState.PUSHED.value,
to_state=PRState.WATCHING.value,
watcher_id=watcher_id,
attempt=0,
extra={"pid": pid, "dry_run": args.dry_run, "detach": args.detach},
))
if args.json:
print(json.dumps({
"watcher_id": watcher_id,
"pid": pid,
"pr": pr,
"repo": repo,
"mode": "detach" if args.detach else "foreground",
}))
else:
mode = "detach" if args.detach else "foreground"
print(f"watcher_id={watcher_id} pid={pid} pr={pr} repo={repo} mode={mode}")
# Detach / dry-run: do NOT block; caller polls state.jsonl.
if args.detach or args.dry_run or proc is None:
return EXIT_OK
# Foreground: block on subprocess, then fold result into a state event.
rc = proc.wait()
next_state = PRState.GREEN if rc == 0 else PRState.RED_UNCLASSIFIED
append_state_event(PRStateEvent(
ts=_utcnow(),
pr=pr,
repo=repo or "",
from_state=PRState.WATCHING.value,
to_state=next_state.value,
watcher_id=watcher_id,
attempt=0,
extra={"gh_exit_code": rc, "folded_by": "p9 watch"},
))
if args.json:
print(json.dumps({"watcher_id": watcher_id, "result": next_state.value, "gh_exit_code": rc}))
else:
print(f"folded: {next_state.value} (gh exit {rc})")
return EXIT_OK
def cmd_status(args: argparse.Namespace) -> int:
rows = open_prs()
if args.pr is not None:
rows = [r for r in rows if r["pr"] == int(args.pr)]
if args.json:
print(json.dumps({"open_prs": rows}, indent=2))
else:
if not rows:
print("no PRs in flight")
return EXIT_OK
for r in rows:
print(
f"#{r['pr']:<5} {r['to_state']:<18} "
f"watcher={r['watcher_id']} attempt={r.get('attempt', 0)}"
)
return EXIT_OK
def cmd_wait_queue(args: argparse.Namespace) -> int:
sub = args.action
if sub == "push":
item = queue_push(
args.item, args.source, pr=args.pr, isolation_tier=args.tier or "none",
)
print(item.id)
return EXIT_OK
if sub == "pop":
head = queue_pop()
if not head:
print("(empty)")
return EXIT_OK
print(json.dumps(dataclasses.asdict(head)))
return EXIT_OK
if sub == "list":
items = queue_list()
if args.json:
print(json.dumps([dataclasses.asdict(it) for it in items], indent=2))
else:
for it in items:
print(f"[{it.source:<7}] {it.id} {it.item}")
return EXIT_OK
if sub == "clear":
n = queue_clear()
print(f"cleared {n} item(s)")
return EXIT_OK
print(f"unknown wait-queue action: {sub}", file=sys.stderr)
return EXIT_USAGE
def cmd_heal(args: argparse.Namespace) -> int:
if not args.classify:
print("p9 heal currently only supports --classify (read-only)", file=sys.stderr)
return EXIT_USAGE
if args.log_file:
log = Path(args.log_file).read_text(encoding="utf-8")
elif not sys.stdin.isatty():
log = sys.stdin.read()
else:
# Live mode: pull from gh
log = _gh_log_failed(args.pr, args.repo)
result = classify(log)
print(json.dumps(dataclasses.asdict(result), indent=2))
return EXIT_OK
def cmd_events_tail(args: argparse.Namespace) -> int:
rows, _ = jsonl_read_all(state_jsonl())
if args.since:
cutoff = _parse_duration_ago(args.since)
rows = [r for r in rows if r["ts"] >= cutoff]
for r in rows:
print(json.dumps(r))
return EXIT_OK
def cmd_merge_ready(args: argparse.Namespace) -> int:
pr = int(args.pr)
state = current_pr_state(pr)
if state != PRState.GREEN:
print(
f"PR #{pr} not GREEN (current={state.value if state else 'UNKNOWN'})",
file=sys.stderr,
)
return EXIT_DEGRADED
repo = args.repo or _detect_repo()
event = PRStateEvent(
ts=_utcnow(),
pr=pr,
repo=repo or "",
from_state=PRState.GREEN.value,
to_state=PRState.MERGE_READY.value,
watcher_id="merge-ready",
attempt=0,
)
append_state_event(event)
print(f"PR #{pr} marked MERGE_READY (control metalayer authorizes merge)")
return EXIT_OK
# ─────────────────────────────────────────────────────────────────────────────
# Helpers (gh integration, cwd repo detection, time parsing)
# ─────────────────────────────────────────────────────────────────────────────
def _detect_repo() -> str | None:
try:
out = subprocess.run(
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
capture_output=True, text=True, timeout=10, check=False,
)
if out.returncode == 0:
return out.stdout.strip() or None
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
return None
def _gh_log_failed(pr: int, repo: str | None) -> str:
cmd = ["gh", "run", "view", "--log-failed"]
if repo:
cmd += ["--repo", repo]
out = subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False)
return out.stdout or out.stderr or ""
_DURATION_RE = re.compile(r"^(\d+)([smhd])$")
def _parse_duration_ago(spec: str) -> str:
m = _DURATION_RE.match(spec)
if not m:
return spec # treat as ISO timestamp passthrough
n, unit = int(m.group(1)), m.group(2)
secs = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
return (
_dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(seconds=secs)
).isoformat(timespec="seconds")
# ─────────────────────────────────────────────────────────────────────────────
# CLI dispatch
# ─────────────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="p9",
description=(
"Broomva CI watcher + productive-wait primitive. "
"See docs/superpowers/specs/2026-05-04-p9-ci-watcher-design.md"
),
)
sub = p.add_subparsers(dest="cmd", required=True)
pw = sub.add_parser("watch", help="Watch CI on a PR (foreground; folds result into state)")
pw.add_argument("pr", help="PR number")
pw.add_argument("--repo", help="OWNER/REPO (auto-detected if omitted)")
pw.add_argument("--dry-run", action="store_true",
help="Do not actually spawn `gh pr checks --watch` (test mode)")
pw.add_argument("--detach", action="store_true",
help="Fire-and-forget: spawn the watcher but do not block "
"or fold its exit into a state event. The caller is "
"responsible for finalizing state. Default is "
"foreground (block + fold).")
# `--background` and `--block` are aliases for the default foreground
# behavior. They exist so historic AGENTS.md guidance using
# `p9 watch <pr> --background` keeps working without surprise errors.
pw.add_argument("--background", action="store_true",
help="Alias for default foreground behavior (kept for "
"backwards compatibility with reflexive-rule guidance)")
pw.add_argument("--block", action="store_true",
help="Alias for default foreground behavior")
pw.add_argument("--json", action="store_true")
pw.set_defaults(func=cmd_watch)
ps = sub.add_parser("status", help="Show in-flight PRs")
ps.add_argument("--pr", help="filter by PR number")
ps.add_argument("--json", action="store_true")
ps.set_defaults(func=cmd_status)
pq = sub.add_parser("wait-queue", help="Manage the productive-wait queue")
pq.add_argument("action", choices=["push", "pop", "list", "clear"])
pq.add_argument("--source", default="session",
choices=list(_QUEUE_PRIORITY))
pq.add_argument("--item", default="", help="(push only) free-text item")
pq.add_argument("--pr", type=int, default=None,
help="(push only) tag for this PR")
pq.add_argument("--tier", default=None,
help="(push only) isolation tier")
pq.add_argument("--json", action="store_true")
pq.set_defaults(func=cmd_wait_queue)
ph = sub.add_parser("heal", help="Classify a CI failure (read-only)")
ph.add_argument("pr", help="PR number")
ph.add_argument("--repo", default=None)
ph.add_argument("--classify", action="store_true",
help="Required: pure classifier read-out (no heal action)")
ph.add_argument("--log-file", default=None,
help="Read failure log from file instead of `gh run view`")
ph.set_defaults(func=cmd_heal)
pe = sub.add_parser("events", help="Stream P9 events")
pe_sub = pe.add_subparsers(dest="events_cmd", required=True)
pet = pe_sub.add_parser("tail")
pet.add_argument("--since", default=None,
help="Filter events newer than DURATION (e.g. 6h, 30m)")
pet.set_defaults(func=cmd_events_tail)
pm = sub.add_parser("merge-ready", help="Mark PR as ready for metalayer-authorized merge")
pm.add_argument("pr")
pm.add_argument("--repo", default=None)
pm.set_defaults(func=cmd_merge_ready)
pab = sub.add_parser("abandon",
help="Mark a PR as ABANDONED (frees concurrency slot)")
pab.add_argument("pr")
pab.add_argument("--repo", default=None)
pab.add_argument("--reason", default=None,
help="Free-text reason recorded in extra.reason")
pab.set_defaults(func=cmd_abandon)
pcu = sub.add_parser("cleanup",
help="Drain orphan WATCHING/HEALING rows by polling "
"GitHub for each open PR's true state")
pcu.set_defaults(func=cmd_cleanup)
pa = sub.add_parser("auto-merge",
help="Run policy-gated auto-merge on a MERGE_READY PR")
pa.add_argument("pr")
pa.add_argument("--repo", default=None)
pa.add_argument("--dry-run", action="store_true",
help="Print the planned merge instead of executing it")
pa.set_defaults(func=cmd_auto_merge)
pd = sub.add_parser("doctor", help="Health-check P9 dependencies")
pd.set_defaults(func=cmd_doctor)
pc = sub.add_parser("conformance",
help="Run the full pytest battery (unit + integration + chaos)")
pc.add_argument("-v", "--verbose", action="store_true",
help="Verbose pytest output (-v)")
pc.add_argument("-k", default=None,
help="Filter expression passed through to pytest -k")
pc.set_defaults(func=cmd_conformance)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except P9Error as e:
print(f"p9: {e}", file=sys.stderr)
return e.code
if __name__ == "__main__":
sys.exit(main())
"""Pytest configuration for skills/p9 tests.
Ensures `scripts/` is importable as `p9` regardless of how pytest is invoked.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
==> Run: bun run codegen:check
[graphql codegen] schema mismatch detected
[graphql codegen] generated/schema.ts is out of date with respect to schema.graphql
[graphql codegen] diff:
- type Query { hello: String }
+ type Query { hello: String, world: String }
Run `bun run codegen` and commit the result.
Error: Process completed with exit code 1.
==> Run: bunx prettier --check "**/*.{ts,tsx,js,jsx}"
Checking formatting...
[warn] apps/web/src/components/Card.tsx
[warn] packages/ui/src/Button.tsx
[warn] Code style issues found in 2 files. Run prettier --write to fix.
prettier --check would reformat 2 files.
Error: Process completed with exit code 1.
==> Run: bun run build
error: Cannot find module '@broomva/missing-package' from '/work/apps/web/src/index.ts'
1 | import { foo } from "@broomva/missing-package";
^
Error: Process completed with exit code 1.
==> Run: bun run lint
$ biome check .
./apps/web/src/foo.tsx:42:3 lint/style/useTemplate
✖ Prefer template literals over string concatenation.
40 │ const greeting = "hello " + name;
^^^^^^^^^^^^^^^
biome check found 3 errors and 1 warning across 142 files.
::error::biome check found 3 errors
Error: Process completed with exit code 1.
==> Run: vitest run
FAIL packages/ui/src/Button.test.ts > Button > debounces clicks
AssertionError: expected 1 to be 2
at packages/ui/src/Button.test.ts:42:18
Test Files 1 failed | 18 passed (19)
Tests 1 failed | 87 passed (88)
Duration 3.21s
Error: Process completed with exit code 1.
==> Run: tsc --noEmit
apps/web/src/lib/api.ts:18:34 - error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
18 const result = await fetcher(input);
~~~~~
apps/web/src/components/Form.tsx:55:12 - error TS2322: Type 'number' is not assignable to type 'string'.
Found 2 errors in 2 files.
Error: Process completed with exit code 1.
==> Run: bun run weird-custom-task
[custom-task] something went wrong but in a way no rubric anticipates
panic: runtime fault at 0xDEADBEEF — please contact your system administrator
The author of this fixture intentionally made the message un-rubric-able.
Error: Process completed with exit code 1.
version: "1.0"
profile: governed
workspace: broomva-test
ci_watch:
enabled: true
max_concurrent_prs: 1
isolation_tier_map:
research: none
docs: none
code_independent: worktree
code_dependent: stacked_branch
governance: blocked
ci_heal:
enabled: true
max_attempts: 5
stability_floor: 0.3
classified_failure_types: [lint, format, test_flaky, codegen_drift, import_missing]
escalation_channel:
linear_team: BRO
linear_label: ci-heal-escalation
notify_hook: skills/p9/scripts/p9-escalate-notify.sh
version: "1.0"
profile: governed
workspace: broomva-test
ci_heal:
enabled: true
max_attempts: 5
stability_floor: 0.3
classified_failure_types: [lint]
escalation_channel:
linear_team: BRO
linear_label: ci-heal-escalation
notify_hook: skills/p9/scripts/p9-escalate-notify.sh
version: "1.0"
profile: governed
workspace: broomva-test
ci_watch:
enabled: true
max_concurrent_prs: 1
isolation_tier_map:
research: none
docs: none
code_independent: worktree
code_dependent: stacked_branch
governance: blocked
ci_heal:
enabled: true
max_attempts: 5
stability_floor: 0.3
classified_failure_types: [lint, format, test_flaky, codegen_drift, import_missing]
escalation_channel:
linear_team: BRO
linear_label: ci-heal-escalation
notify_hook: skills/p9/scripts/p9-escalate-notify.sh
auto_merge:
enabled: true
require_no_requested_changes: true
require_branch_up_to_date: true
merge_method: squash
delete_branch: true
rules:
# Governance paths ALWAYS block (enforced by pass-1 in matcher)
- path_touched: CLAUDE.md
action: require_human
- path_touched: AGENTS.md
action: require_human
- path_touched: .control/policy.yaml
action: require_human
# Auto-merge classes
- branch_pattern: "docs/*"
action: auto
- branch_pattern: "research/*"
action: auto
- branch_pattern: "feat/p9-*"
action: auto
default_action: notify
pytest>=8.0
# vcrpy is added in PR 4 alongside the integration battery.
"""Tests for the auto-merge actuator (PR A)."""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
import pytest
_HERE = Path(__file__).resolve().parent
_SCRIPTS = _HERE.parent / "scripts"
_FIXTURES = _HERE / "fixtures"
sys.path.insert(0, str(_SCRIPTS))
@pytest.fixture()
def p9_am(tmp_path, monkeypatch):
"""Fresh p9 import with auto-merge policy enabled."""
monkeypatch.setenv("BROOMVA_P9_HOME", str(tmp_path))
monkeypatch.setenv("BROOMVA_P9_POLICY", str(_FIXTURES / "policy-with-auto-merge.yaml"))
if "p9" in sys.modules:
del sys.modules["p9"]
return importlib.import_module("p9")
# ─────────────────────────────────────────────────────────────────────────────
# Policy parser
# ─────────────────────────────────────────────────────────────────────────────
class TestPolicyParse:
def test_loads_auto_merge_block(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
assert cfg.auto_merge.enabled is True
assert cfg.auto_merge.merge_method == "squash"
assert cfg.auto_merge.delete_branch is True
assert cfg.auto_merge.default_action == "notify"
assert len(cfg.auto_merge.rules) == 6
def test_missing_auto_merge_block_disables_safely(self, p9_am):
# Default policy fixture has no auto_merge block — should default disabled
cfg = p9_am.load_policy(_FIXTURES / "policy-good.yaml")
assert cfg.auto_merge.enabled is False
assert cfg.auto_merge.rules == ()
def test_invalid_action_rejected(self, p9_am, tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text(
"ci_watch:\n enabled: true\n max_concurrent_prs: 1\n"
" isolation_tier_map:\n research: none\n docs: none\n"
" code_independent: worktree\n code_dependent: stacked_branch\n"
" governance: blocked\n"
"ci_heal:\n enabled: true\n max_attempts: 5\n"
" stability_floor: 0.3\n classified_failure_types: [lint]\n"
" escalation_channel:\n linear_team: BRO\n"
" linear_label: ci-heal-escalation\n"
" notify_hook: x.sh\n"
"auto_merge:\n enabled: true\n rules:\n"
" - branch_pattern: \"x/*\"\n action: yolo\n",
encoding="utf-8",
)
with pytest.raises(p9_am.PolicyError):
p9_am.load_policy(bad)
def test_rule_without_branch_or_path_rejected(self, p9_am, tmp_path):
bad = tmp_path / "bad2.yaml"
bad.write_text(
"ci_watch:\n enabled: true\n max_concurrent_prs: 1\n"
" isolation_tier_map:\n research: none\n docs: none\n"
" code_independent: worktree\n code_dependent: stacked_branch\n"
" governance: blocked\n"
"ci_heal:\n enabled: true\n max_attempts: 5\n"
" stability_floor: 0.3\n classified_failure_types: [lint]\n"
" escalation_channel:\n linear_team: BRO\n"
" linear_label: ci-heal-escalation\n"
" notify_hook: x.sh\n"
"auto_merge:\n enabled: true\n rules:\n"
" - action: auto\n",
encoding="utf-8",
)
with pytest.raises(p9_am.PolicyError):
p9_am.load_policy(bad)
# ─────────────────────────────────────────────────────────────────────────────
# Matcher
# ─────────────────────────────────────────────────────────────────────────────
class TestMatcher:
def test_governance_path_always_blocks(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
# Branch matches an auto rule, but PR touches CLAUDE.md → blocks
action, reason = p9_am.match_auto_merge_action(
cfg.auto_merge,
branch="docs/some-update",
paths_touched=["docs/foo.md", "CLAUDE.md"],
)
assert action == "require_human"
assert "CLAUDE.md" in reason
def test_docs_branch_auto_merges(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
action, _ = p9_am.match_auto_merge_action(
cfg.auto_merge,
branch="docs/typo-fix",
paths_touched=["README.md", "docs/foo.md"],
)
assert action == "auto"
def test_research_branch_auto_merges(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
action, _ = p9_am.match_auto_merge_action(
cfg.auto_merge,
branch="research/new-entity",
paths_touched=["research/entities/concept/foo.md"],
)
assert action == "auto"
def test_feat_p9_branch_auto_merges(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
action, _ = p9_am.match_auto_merge_action(
cfg.auto_merge,
branch="feat/p9-spec",
paths_touched=["docs/foo.md"],
)
assert action == "auto"
def test_unknown_branch_falls_to_default_notify(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
action, reason = p9_am.match_auto_merge_action(
cfg.auto_merge,
branch="feat/some-other-thing",
paths_touched=["src/foo.ts"],
)
assert action == "notify"
assert "default" in reason.lower()
def test_path_rule_first_match_wins(self, p9_am):
cfg = p9_am.load_policy(_FIXTURES / "policy-with-auto-merge.yaml")
# AGENTS.md is governance-class blocked; should beat docs/* auto
action, _ = p9_am.match_auto_merge_action(
cfg.auto_merge,
branch="docs/cleanup",
paths_touched=["AGENTS.md"],
)
assert action == "require_human"
# ─────────────────────────────────────────────────────────────────────────────
# Subcommand integration (with subprocess mocked)
# ─────────────────────────────────────────────────────────────────────────────
class _FakeRun:
def __init__(self, *, stdout="", stderr="", returncode=0):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
def _seed_merge_ready(p9, pr: int):
for prev, curr in [
(p9.PRState.PUSHED, p9.PRState.WATCHING),
(p9.PRState.WATCHING, p9.PRState.GREEN),
(p9.PRState.GREEN, p9.PRState.MERGE_READY),
]:
p9.append_state_event(p9.PRStateEvent(
ts="2026-05-05T00:00:00+00:00",
pr=pr, repo="broomva/test",
from_state=prev.value, to_state=curr.value,
watcher_id="seed",
))
class TestCommand:
def test_blocks_when_pr_not_merge_ready(self, p9_am, capsys):
rc = p9_am.main(["auto-merge", "999", "--repo", "broomva/test"])
assert rc == p9_am.EXIT_DEGRADED
def test_dry_run_for_auto_branch(self, p9_am, monkeypatch, capsys):
_seed_merge_ready(p9_am, 100)
def fake_view(cmd, *args, **kwargs):
assert cmd[:3] == ["gh", "pr", "view"]
return _FakeRun(stdout=json.dumps(
{"branch": "docs/typo", "files": ["README.md"]}
))
monkeypatch.setattr(p9_am.subprocess, "run", fake_view)
rc = p9_am.main(["auto-merge", "100", "--repo", "broomva/test", "--dry-run"])
out = capsys.readouterr().out
assert rc == 0
assert "would merge PR #100" in out
# Did NOT transition to MERGED in dry-run
assert p9_am.current_pr_state(100) == p9_am.PRState.MERGE_READY
def test_blocks_governance_path(self, p9_am, monkeypatch, capsys):
_seed_merge_ready(p9_am, 200)
def fake_view(cmd, *args, **kwargs):
return _FakeRun(stdout=json.dumps(
{"branch": "docs/cleanup", "files": ["docs/x.md", "CLAUDE.md"]}
))
monkeypatch.setattr(p9_am.subprocess, "run", fake_view)
rc = p9_am.main(["auto-merge", "200", "--repo", "broomva/test"])
assert rc == p9_am.EXIT_AUTO_MERGE_BLOCKED
# Idempotent self-transition recorded with reason
rows, _ = p9_am.jsonl_read_all(p9_am.state_jsonl())
last = [r for r in rows if r["pr"] == 200][-1]
assert last["to_state"] == "MERGE_READY"
assert last["extra"]["auto_merge"]["action"] == "require_human"
def test_auto_executes_gh_merge(self, p9_am, monkeypatch, capsys):
_seed_merge_ready(p9_am, 300)
calls = []
def fake_run(cmd, *args, **kwargs):
calls.append(cmd)
if cmd[:3] == ["gh", "pr", "view"]:
return _FakeRun(stdout=json.dumps(
{"branch": "docs/something", "files": ["docs/y.md"]}
))
if cmd[:3] == ["gh", "pr", "merge"]:
return _FakeRun(returncode=0)
return _FakeRun(returncode=1)
monkeypatch.setattr(p9_am.subprocess, "run", fake_run)
rc = p9_am.main(["auto-merge", "300", "--repo", "broomva/test"])
assert rc == 0
# Real merge call happened
merge_calls = [c for c in calls if c[:3] == ["gh", "pr", "merge"]]
assert len(merge_calls) == 1
assert "--squash" in merge_calls[0]
assert "--delete-branch" in merge_calls[0]
# State transitioned to MERGED
assert p9_am.current_pr_state(300) == p9_am.PRState.MERGED
def test_disabled_policy_refuses(self, tmp_path, monkeypatch, capsys):
# Use the default good policy (no auto_merge block → disabled)
monkeypatch.setenv("BROOMVA_P9_HOME", str(tmp_path))
monkeypatch.setenv("BROOMVA_P9_POLICY", str(_FIXTURES / "policy-good.yaml"))
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
# seed MERGE_READY anyway
_seed_merge_ready(mod, 400)
rc = mod.main(["auto-merge", "400", "--repo", "broomva/test"])
assert rc == mod.EXIT_POLICY_ERROR
def test_external_merge_failure_reports_clean_error(self, p9_am, monkeypatch, capsys):
_seed_merge_ready(p9_am, 500)
def fake_run(cmd, *args, **kwargs):
if cmd[:3] == ["gh", "pr", "view"]:
return _FakeRun(stdout=json.dumps(
{"branch": "docs/whatever", "files": ["docs/z.md"]}
))
if cmd[:3] == ["gh", "pr", "merge"]:
return _FakeRun(returncode=1)
return _FakeRun(returncode=1)
monkeypatch.setattr(p9_am.subprocess, "run", fake_run)
rc = p9_am.main(["auto-merge", "500", "--repo", "broomva/test"])
assert rc == p9_am.EXIT_EXTERNAL_ERROR
# State did NOT transition to MERGED (external failure must not lie)
assert p9_am.current_pr_state(500) == p9_am.PRState.MERGE_READY
"""Chaos tests for p9 — fault injection battery.
Each test injects a specific failure mode and asserts the system stays
consistent (no silent drops, fails closed where required, recoverable
otherwise). Mirrors the M7-FINAL chaos pattern used elsewhere in the stack.
"""
from __future__ import annotations
import importlib
import json
import multiprocessing
import os
import signal
import sys
import time
from pathlib import Path
import pytest
_HERE = Path(__file__).resolve().parent
_SCRIPTS = _HERE.parent / "scripts"
_FIXTURES = _HERE / "fixtures"
sys.path.insert(0, str(_SCRIPTS))
@pytest.fixture()
def p9(tmp_path, monkeypatch):
monkeypatch.setenv("BROOMVA_P9_HOME", str(tmp_path))
monkeypatch.setenv("BROOMVA_P9_POLICY", str(_FIXTURES / "policy-good.yaml"))
if "p9" in sys.modules:
del sys.modules["p9"]
return importlib.import_module("p9")
# ─────────────────────────────────────────────────────────────────────────────
# Chaos #1 — state.jsonl partial write (truncated mid-flush)
# ─────────────────────────────────────────────────────────────────────────────
def test_chaos_state_jsonl_partial_last_line_recovered(p9):
"""Simulates a process crash mid-write: last line is truncated.
JSONL append-only design must lose at most one event.
"""
# First, write 3 valid events
for pr, state in [(1, "WATCHING"), (2, "WATCHING"), (3, "WATCHING")]:
p9.append_state_event(p9.PRStateEvent(
ts="2026-05-04T20:00:00+00:00",
pr=pr, repo="broomva/x",
from_state=p9.PRState.PUSHED.value,
to_state=state,
watcher_id=f"w{pr}",
))
# Now manually corrupt the last line
raw = p9.state_jsonl().read_text(encoding="utf-8")
truncated = raw[:-30] + '{"ts":"par' # broken trailing JSON
p9.state_jsonl().write_text(truncated, encoding="utf-8")
rows, dropped = p9.jsonl_read_all(p9.state_jsonl())
# We lost the third event entirely (its line was overwritten by the
# corrupt one), so only 2 valid rows remain + 1 dropped from corruption
assert dropped == 1
assert len(rows) >= 2 # at least the first two events survived
# ─────────────────────────────────────────────────────────────────────────────
# Chaos #2 — state.jsonl mid-file corruption (invariant violation)
# ─────────────────────────────────────────────────────────────────────────────
def test_chaos_state_jsonl_mid_file_corruption_raises(p9):
"""Corruption that's NOT on the last line is an invariant violation.
JSONL append-only contract: only the last line can be partial. Anything
else means data was tampered with.
"""
p9.state_jsonl().parent.mkdir(parents=True, exist_ok=True)
p9.state_jsonl().write_text(
'{"ts":"a","pr":1}\n'
'{"corrupted":\n'
'{"ts":"c","pr":3}\n',
encoding="utf-8",
)
with pytest.raises(p9.IllegalTransitionError):
p9.jsonl_read_all(p9.state_jsonl())
# ─────────────────────────────────────────────────────────────────────────────
# Chaos #3 — concurrent state writers (flock correctness)
# ─────────────────────────────────────────────────────────────────────────────
def _writer(home: str, policy: str, pr_start: int, count: int):
"""Worker function — appends `count` events with PRs starting at pr_start."""
os.environ["BROOMVA_P9_HOME"] = home
os.environ["BROOMVA_P9_POLICY"] = policy
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
for i in range(count):
mod.append_state_event(mod.PRStateEvent(
ts="2026-05-04T20:00:00+00:00",
pr=pr_start + i, repo="broomva/x",
from_state=mod.PRState.PUSHED.value,
to_state=mod.PRState.WATCHING.value,
watcher_id=f"w{pr_start + i}",
))
def test_chaos_concurrent_writers_no_corruption(p9, tmp_path):
"""5 concurrent processes each appending 10 events. flock must serialize."""
workers = []
for i in range(5):
proc = multiprocessing.Process(
target=_writer,
args=(str(tmp_path), str(_FIXTURES / "policy-good.yaml"), 1000 + i * 100, 10),
)
workers.append(proc)
proc.start()
for w in workers:
w.join(timeout=30)
assert w.exitcode == 0, "writer process failed"
rows, dropped = p9.jsonl_read_all(p9.state_jsonl())
assert dropped == 0 # no corruption from interleaving
assert len(rows) == 50 # 5 × 10 events all preserved
# All distinct PRs
prs = sorted(r["pr"] for r in rows)
assert prs == sorted(set(prs))
# ─────────────────────────────────────────────────────────────────────────────
# Chaos #4 — policy.yaml missing required block (fail closed)
# ─────────────────────────────────────────────────────────────────────────────
def test_chaos_policy_missing_block_fails_closed(tmp_path, monkeypatch, capsys):
"""Missing ci_watch: block → exit 2 with no side effects."""
monkeypatch.setenv("BROOMVA_P9_HOME", str(tmp_path))
monkeypatch.setenv(
"BROOMVA_P9_POLICY",
str(_FIXTURES / "policy-missing-ci-watch.yaml"),
)
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
rc = mod.main(["watch", "999", "--repo", "broomva/test", "--dry-run"])
assert rc == mod.EXIT_POLICY_ERROR
# No state written
if mod.state_jsonl().exists():
assert mod.state_jsonl().read_text(encoding="utf-8") == ""
# ─────────────────────────────────────────────────────────────────────────────
# Chaos #5 — heal.lock contention timeout
# ─────────────────────────────────────────────────────────────────────────────
def _lock_holder(home: str, policy: str, ready, hold_seconds: float):
"""Acquire the heal lock and hold it for `hold_seconds`."""
os.environ["BROOMVA_P9_HOME"] = home
os.environ["BROOMVA_P9_POLICY"] = policy
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
with mod.file_lock(mod.heal_lock_path(), timeout_s=10.0):
ready.set()
time.sleep(hold_seconds)
def _lock_challenger(home: str, policy: str, ready, out_q, timeout_s: float):
"""Wait for the holder, then try to grab the lock with a tight timeout."""
os.environ["BROOMVA_P9_HOME"] = home
os.environ["BROOMVA_P9_POLICY"] = policy
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
ready.wait(timeout=10)
try:
with mod.file_lock(mod.heal_lock_path(), timeout_s=timeout_s):
out_q.put("acquired")
except mod.P9Error as e:
out_q.put(f"timeout: {e}")
def test_chaos_heal_lock_timeout(p9, tmp_path):
"""Two competing flock holders. Second should time out cleanly."""
lock_path = p9.heal_lock_path()
lock_path.parent.mkdir(parents=True, exist_ok=True)
ready = multiprocessing.Event()
out_q = multiprocessing.Queue()
home = str(tmp_path)
policy = str(_FIXTURES / "policy-good.yaml")
h = multiprocessing.Process(
target=_lock_holder, args=(home, policy, ready, 2.0),
)
c = multiprocessing.Process(
target=_lock_challenger, args=(home, policy, ready, out_q, 0.5),
)
h.start()
c.start()
c.join(timeout=10)
h.join(timeout=10)
result = out_q.get(timeout=2)
assert result.startswith("timeout"), f"expected timeout, got: {result}"
# ─────────────────────────────────────────────────────────────────────────────
# Chaos #6 — wait-queue write durability under concurrent push
# ─────────────────────────────────────────────────────────────────────────────
def _queue_pusher(home: str, policy: str, source: str, count: int):
os.environ["BROOMVA_P9_HOME"] = home
os.environ["BROOMVA_P9_POLICY"] = policy
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
for i in range(count):
mod.queue_push(f"item-{i}", source)
def test_chaos_concurrent_queue_pushes_no_loss(p9, tmp_path):
"""5 concurrent pushers × 10 items = 50 items, no loss."""
sources = ["session", "memory", "graph", "docs", "linear"]
workers = []
for src in sources:
proc = multiprocessing.Process(
target=_queue_pusher,
args=(str(tmp_path), str(_FIXTURES / "policy-good.yaml"), src, 10),
)
workers.append(proc)
proc.start()
for w in workers:
w.join(timeout=30)
assert w.exitcode == 0
items = p9.queue_list()
assert len(items) == 50
# All sources represented
assert set(it.source for it in items) == set(sources)
"""Integration tests for p9 — full lifecycle scenarios.
These tests exercise the complete state machine through realistic flows:
push → watch → green/red → heal → merge-ready. External `gh` calls are
mocked at the subprocess level (tests do not hit GitHub).
"""
from __future__ import annotations
import importlib
import json
import os
import subprocess
import sys
import threading
from pathlib import Path
import pytest
_HERE = Path(__file__).resolve().parent
_SCRIPTS = _HERE.parent / "scripts"
_FIXTURES = _HERE / "fixtures"
sys.path.insert(0, str(_SCRIPTS))
@pytest.fixture()
def p9(tmp_path, monkeypatch):
"""Fresh p9 import with tmpdir state and good policy fixture."""
monkeypatch.setenv("BROOMVA_P9_HOME", str(tmp_path))
monkeypatch.setenv("BROOMVA_P9_POLICY", str(_FIXTURES / "policy-good.yaml"))
if "p9" in sys.modules:
del sys.modules["p9"]
return importlib.import_module("p9")
class _FakePopen:
"""Minimal Popen stand-in that exits with a configured code."""
def __init__(self, returncode: int = 0):
self._returncode = returncode
self.pid = 99999 # always alive from kill(0)'s perspective
def poll(self):
return self._returncode
def wait(self):
return self._returncode
class _FakeRun:
"""Configurable stand-in for subprocess.run."""
def __init__(self, *, stdout: str = "", stderr: str = "", returncode: int = 0):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
# ─────────────────────────────────────────────────────────────────────────────
# Full happy-path lifecycle
# ─────────────────────────────────────────────────────────────────────────────
class TestHappyPath:
def test_full_lifecycle_via_state_events(self, p9):
"""PUSHED → WATCHING → GREEN → MERGE_READY → MERGED, end-to-end."""
# 1. watch (dry-run avoids real subprocess)
rc = p9.main(["watch", "100", "--repo", "broomva/test", "--dry-run", "--json"])
assert rc == 0
assert p9.current_pr_state(100) == p9.PRState.WATCHING
# 2. simulate green
p9.append_state_event(p9.PRStateEvent(
ts="2026-05-04T19:00:00+00:00",
pr=100, repo="broomva/test",
from_state=p9.PRState.WATCHING.value,
to_state=p9.PRState.GREEN.value,
watcher_id="w100",
))
# 3. merge-ready (CLI command)
rc = p9.main(["merge-ready", "100", "--repo", "broomva/test"])
assert rc == 0
assert p9.current_pr_state(100) == p9.PRState.MERGE_READY
# 4. simulate metalayer merge
p9.append_state_event(p9.PRStateEvent(
ts="2026-05-04T19:01:00+00:00",
pr=100, repo="broomva/test",
from_state=p9.PRState.MERGE_READY.value,
to_state=p9.PRState.MERGED.value,
watcher_id="w100",
))
# 5. PR is no longer in flight
assert all(r["pr"] != 100 for r in p9.open_prs())
def test_merge_ready_rejects_non_green(self, p9, capsys):
"""merge-ready requires the PR to be in GREEN state."""
# PR in WATCHING state, not GREEN
p9.append_state_event(p9.PRStateEvent(
ts="2026-05-04T19:00:00+00:00",
pr=200, repo="broomva/test",
from_state=p9.PRState.PUSHED.value,
to_state=p9.PRState.WATCHING.value,
watcher_id="w200",
))
rc = p9.main(["merge-ready", "200", "--repo", "broomva/test"])
assert rc == p9.EXIT_DEGRADED
# No transition to MERGE_READY
assert p9.current_pr_state(200) == p9.PRState.WATCHING
# ─────────────────────────────────────────────────────────────────────────────
# Self-heal flow
# ─────────────────────────────────────────────────────────────────────────────
class TestHealFlow:
def test_classify_lint_log_via_cli(self, p9, capsys):
rc = p9.main([
"heal", "300", "--classify",
"--log-file", str(_FIXTURES / "failures" / "lint.txt"),
])
assert rc == 0
out = capsys.readouterr().out
result = json.loads(out)
assert result["failure_type"] == "lint"
assert result["classified"] is True
assert result["heal_command"] is not None
def test_classify_unclassified_returns_no_heal(self, p9, capsys):
rc = p9.main([
"heal", "301", "--classify",
"--log-file", str(_FIXTURES / "failures" / "unclassified.txt"),
])
assert rc == 0
out = capsys.readouterr().out
result = json.loads(out)
assert result["failure_type"] == "unclassified"
assert result["heal_command"] is None
def test_heal_attempt_counter_via_state_events(self, p9):
"""After multiple failed heal cycles, evaluator stalls and forces ESCALATED."""
scores = []
prev_sig = None
for attempt in range(1, 4):
curr_sig = "stuck-signature" # not changing → no progress
ev = p9.evaluate(
attempt=attempt,
max_attempts=5,
classifier_confidence=0.7,
prev_signature=prev_sig,
curr_signature=curr_sig,
prev_failure_count=3,
curr_failure_count=3, # not decreasing
stability_floor=0.3,
)
scores.append(ev.progress_score)
prev_sig = curr_sig
# After 3 attempts with no signature change and no failure decrease,
# last two scores should be below floor → stall trigger.
assert p9.stalled_for_two_cycles(scores, stability_floor=0.3)
# ─────────────────────────────────────────────────────────────────────────────
# Multi-PR concurrency
# ─────────────────────────────────────────────────────────────────────────────
class TestMultiPR:
def test_max_one_blocks_second_watch(self, p9, capsys, tmp_path, monkeypatch):
# Default policy fixture has max_concurrent_prs=1
rc = p9.main(["watch", "400", "--repo", "broomva/test", "--dry-run"])
assert rc == 0
rc = p9.main(["watch", "401", "--repo", "broomva/test", "--dry-run"])
assert rc == p9.EXIT_CONCURRENCY_CEILING
def test_max_two_allows_pair(self, p9, tmp_path, monkeypatch):
# Write a per-test policy with max_concurrent_prs=2
pol = tmp_path / "policy-2.yaml"
pol.write_text((_FIXTURES / "policy-good.yaml").read_text(encoding="utf-8")
.replace("max_concurrent_prs: 1",
"max_concurrent_prs: 2"), encoding="utf-8")
monkeypatch.setenv("BROOMVA_P9_POLICY", str(pol))
# Re-import to pick up env
if "p9" in sys.modules:
del sys.modules["p9"]
p9b = importlib.import_module("p9")
assert p9b.main(["watch", "500", "--repo", "broomva/test", "--dry-run"]) == 0
assert p9b.main(["watch", "501", "--repo", "broomva/test", "--dry-run"]) == 0
# Third blocked
assert p9b.main(
["watch", "502", "--repo", "broomva/test", "--dry-run"]
) == p9b.EXIT_CONCURRENCY_CEILING
# ─────────────────────────────────────────────────────────────────────────────
# Wait-queue end-to-end
# ─────────────────────────────────────────────────────────────────────────────
class TestWaitQueueLifecycle:
def test_full_drain_cycle(self, p9, capsys):
# Push from each source
for src in ["session", "memory", "graph", "docs", "linear"]:
rc = p9.main(["wait-queue", "push", "--source", src, "--item", f"task-{src}"])
assert rc == 0
capsys.readouterr() # drain output
# List in priority order
rc = p9.main(["wait-queue", "list", "--json"])
assert rc == 0
items = json.loads(capsys.readouterr().out)
sources = [it["source"] for it in items]
assert sources == ["session", "memory", "graph", "docs", "linear"]
# Drain all via pop
for expected in sources:
rc = p9.main(["wait-queue", "pop"])
assert rc == 0
head = json.loads(capsys.readouterr().out)
assert head["source"] == expected
# Empty
rc = p9.main(["wait-queue", "pop"])
assert rc == 0
assert "(empty)" in capsys.readouterr().out
def test_clear_drops_all(self, p9, capsys):
for src in ["session", "memory", "graph"]:
p9.main(["wait-queue", "push", "--source", src, "--item", "x"])
capsys.readouterr()
rc = p9.main(["wait-queue", "clear"])
assert rc == 0
assert "cleared 3 item" in capsys.readouterr().out
# ─────────────────────────────────────────────────────────────────────────────
# Events tail with --since filter
# ─────────────────────────────────────────────────────────────────────────────
class TestEventsTail:
def test_tail_returns_state_jsonl_rows(self, p9, capsys):
p9.main(["watch", "600", "--repo", "broomva/test", "--dry-run"])
capsys.readouterr()
rc = p9.main(["events", "tail"])
assert rc == 0
out = capsys.readouterr().out.strip().splitlines()
assert any("PUSHED" in line for line in out)
assert any("WATCHING" in line for line in out)
# ─────────────────────────────────────────────────────────────────────────────
# Doctor degraded states
# ─────────────────────────────────────────────────────────────────────────────
class TestDoctor:
def test_doctor_passes_with_good_setup(self, p9, capsys):
rc = p9.main(["doctor"])
# gh may or may not be authed in test env — we accept either ok or
# degraded for non-policy reasons. Policy MUST be ok.
out = capsys.readouterr().out
assert "policy:" not in out # no policy issues
assert rc in (p9.EXIT_OK, p9.EXIT_DEGRADED)
def test_doctor_fails_closed_on_missing_policy_block(
self, tmp_path, monkeypatch, capsys,
):
monkeypatch.setenv("BROOMVA_P9_HOME", str(tmp_path))
monkeypatch.setenv("BROOMVA_P9_POLICY", str(_FIXTURES / "policy-missing-ci-watch.yaml"))
if "p9" in sys.modules:
del sys.modules["p9"]
mod = importlib.import_module("p9")
rc = mod.main(["doctor"])
out = capsys.readouterr().out
assert "policy:" in out
assert rc == mod.EXIT_POLICY_ERROR
# ─────────────────────────────────────────────────────────────────────────────
# Subprocess-level mock of `gh` for spawn_watcher
# ─────────────────────────────────────────────────────────────────────────────
class TestSubprocessIntegration:
def test_spawn_watcher_dry_run_returns_none(self, p9):
proc = p9.spawn_watcher(700, "broomva/test", dry_run=True)
assert proc is None
def test_spawn_watcher_real_call_uses_gh_pr_checks_watch(self, p9, monkeypatch):
captured = {}
def fake_popen(cmd, *args, **kwargs):
captured["cmd"] = cmd
captured["new_session"] = kwargs.get("start_new_session")
return _FakePopen(returncode=0)
monkeypatch.setattr(p9.subprocess, "Popen", fake_popen)
proc = p9.spawn_watcher(800, "broomva/test")
assert proc is not None
assert captured["cmd"][:5] == ["gh", "pr", "checks", "800", "--watch"]
assert "--repo" in captured["cmd"] and "broomva/test" in captured["cmd"]
assert captured["new_session"] is True
def test_doctor_handles_gh_missing(self, p9, monkeypatch, capsys):
def fake_run(cmd, *args, **kwargs):
raise FileNotFoundError("gh not found")
monkeypatch.setattr(p9.subprocess, "run", fake_run)
rc = p9.main(["doctor"])
out = capsys.readouterr().out
assert "gh CLI not installed" in out
assert rc == p9.EXIT_DEGRADED # not ok, but not policy fail-closed
Related skills
FAQ
What is the cardinal rule?
Never sleep on a blocking wait; convert the wait into productive work on the next priority, and for PR CI use p9 watch in the background.
How does it handle failures it cannot classify?
It does not attempt to heal; it creates a Linear escalation ticket and keeps the watcher running in case a human pushes a fix.
Does it authorize merges?
No. Merge authorization stays with the existing control metalayer (.control/policy.yaml); p9 only marks a PR merge-ready.