feat(pawl): present review as the user front door; warm verbs grouped operator-only (age-hk5zg.2)

'ao pawl --help' listed the warm standing-service verbs as flat peers of review,
pushing users toward operator machinery (NTM, projects_base) they cannot run.
Cobra groups now split the surface: review under 'Use the membrane (the front
door — needs no NTM, no setup)', up/down/reap/health/doctor/smoke/route/metrics
under 'Operate the standing warm service (operator-only; requires the NTM swarm
substrate)'. Grouping/labeling only — every verb keeps its name and behavior.
docs/contracts/pawls.md carries the same operator-only note.

Fix swept in: scripts/lib/sku_extract.py's cobra help parser only recognized
'*Commands:' section titles, so EVERY grouped command's subcommands were
invisible to the SKU catalog ('ao goals' was already affected; this change made
'ao pawl' the second victim and surfaced the bug). The parser now enters any
non-reserved column-0 title block and requires the 2+-space name/short gap, so
grouped subcommands resolve precisely (registry.json reflects the corrected
projection for ao goals too). Locked by TestPawlHelpPresentsReviewAsFrontDoor.
This commit is contained in:
boshu
2026-07-10 20:34:58 -04:00
parent f9976105f4
commit 0514f82ec3
7 changed files with 157 additions and 43 deletions
+38 -11
View File
@@ -20,12 +20,28 @@ var pawlCmd = &cobra.Command{
Use: "pawl",
Short: "Cross-family membrane review and verdict tooling (the in-repo acceptance pawl)",
Long: `The pawl is AgentOps's acceptance gate: a change reaches "done" only with an
INDEPENDENT cross-family verdict (never the author, never the same model). 'ao pawl
review' runs that review and, on CONFIRMED, writes the commit-bound verdict the
push-to-main gate enforces.`,
INDEPENDENT cross-family verdict (never the author, never the same model).
'ao pawl review' is the FRONT DOOR — it works in any git repo with just this binary
plus one reviewer CLI (codex or agy) on PATH: no NTM, no tmux, no config. On
CONFIRMED it writes the commit-bound verdict the push-to-main gate enforces.
The remaining verbs (up/down/reap/health/doctor/smoke/route/metrics) operate the
OPTIONAL standing warm service — operator machinery that requires the NTM swarm
substrate. You never need them to use the membrane.`,
Args: cobra.NoArgs,
}
// pawl help groups (age-hk5zg.2 / S2): `review` is the user front door; the warm
// standing-service verbs are operator-only and require NTM. The group split keeps a
// user from reading `up` as a prerequisite for using the membrane.
const (
pawlUserGroupID = "pawl-user"
pawlUserGroupTitle = "Use the membrane (the front door — needs no NTM, no setup):"
pawlOperatorGroupID = "pawl-operator"
pawlOperatorGroupTitle = "Operate the standing warm service (operator-only; requires the NTM swarm substrate):"
)
// pawlReviewExitError carries scripts/pawl-review.sh's exit code so it propagates
// VERBATIM through ao (the exit code IS the verdict, like ao plan-pawl / ao validate):
// 0 CONFIRMED+written · 3 REFUTED · 4 --converge advisory-only (no lineage) · 2 usage · 1 hard error.
@@ -70,17 +86,28 @@ turns real strict on. See 'ao verify --help' for the full posture.`,
func init() {
rootCmd.AddCommand(pawlCmd)
// age-hk5zg.2: group the surface so `review` reads as the front door and the warm
// verbs read as operator-only (requires NTM) — grouping/labeling only, every verb
// keeps its name and behavior.
pawlCmd.AddGroup(&cobra.Group{ID: pawlUserGroupID, Title: pawlUserGroupTitle})
pawlCmd.AddGroup(&cobra.Group{ID: pawlOperatorGroupID, Title: pawlOperatorGroupTitle})
pawlReviewCmd.GroupID = pawlUserGroupID
pawlCmd.AddCommand(pawlReviewCmd)
// ml8: surface the standing pawl-service (scripts/pawl.sh) on the ao CLI so the bead
// intent ("ao pawl up/route/metrics") is the real command, not "bash scripts/pawl.sh".
pawlCmd.AddCommand(pawlServiceCmd("up", "up [--dual|--tri|--models a,b,c]", "Stand up the standing pawl-service — adaptive: probe installed families (claude/codex/agy) and form the strongest membrane; pin with --dual/--tri/--models. Readiness-gated, idempotent"))
pawlCmd.AddCommand(pawlServiceCmd("down", "down", "Tear down the standing pawl-service (no orphan panes)"))
pawlCmd.AddCommand(pawlServiceCmd("reap", "reap", "Tear down the standing pawl-service iff idle > PAWL_IDLE_TTL (substrate/cron schedules it; no-op otherwise)"))
pawlCmd.AddCommand(pawlServiceCmd("health", "health [--json]", "Per-pane liveness/readiness of the standing pawl-service + the membrane tier"))
pawlCmd.AddCommand(pawlServiceCmd("doctor", "doctor [--json] [--expected-cwd PATH] [--expected-claude-model MODEL] [--expected-codex-model MODEL]", "Read-only standing pawl preflight: assert atm alias, session, pane cwd/model, trust prompts, readiness, and evidence policy"))
pawlCmd.AddCommand(pawlServiceCmd("smoke", "smoke [--json] [--expected-cwd PATH] [--expected-claude-model MODEL] [--expected-codex-model MODEL]", "Alias for pawl doctor: non-mutating readiness smoke before routing real reviews"))
pawlCmd.AddCommand(pawlServiceCmd("route", "route <bead> <packet> [pr]", "Route a review packet to the warm cross-family panel; require tier-appropriate agreement, record the verdict"))
pawlCmd.AddCommand(pawlServiceCmd("metrics", "metrics [--json]", "p50/p95 route latency + agreement-rate SLOs over the recorded routes"))
addPawlOperatorCmd := func(sub, use, short string) {
c := pawlServiceCmd(sub, use, short)
c.GroupID = pawlOperatorGroupID
pawlCmd.AddCommand(c)
}
addPawlOperatorCmd("up", "up [--dual|--tri|--models a,b,c]", "Stand up the standing pawl-service — adaptive: probe installed families (claude/codex/agy) and form the strongest membrane; pin with --dual/--tri/--models. Readiness-gated, idempotent")
addPawlOperatorCmd("down", "down", "Tear down the standing pawl-service (no orphan panes)")
addPawlOperatorCmd("reap", "reap", "Tear down the standing pawl-service iff idle > PAWL_IDLE_TTL (substrate/cron schedules it; no-op otherwise)")
addPawlOperatorCmd("health", "health [--json]", "Per-pane liveness/readiness of the standing pawl-service + the membrane tier")
addPawlOperatorCmd("doctor", "doctor [--json] [--expected-cwd PATH] [--expected-claude-model MODEL] [--expected-codex-model MODEL]", "Read-only standing pawl preflight: assert swarm binary (ntm-first), session, pane cwd/model, trust prompts, readiness, and evidence policy")
addPawlOperatorCmd("smoke", "smoke [--json] [--expected-cwd PATH] [--expected-claude-model MODEL] [--expected-codex-model MODEL]", "Alias for pawl doctor: non-mutating readiness smoke before routing real reviews")
addPawlOperatorCmd("route", "route <bead> <packet> [pr]", "Route a review packet to the warm cross-family panel; require tier-appropriate agreement, record the verdict")
addPawlOperatorCmd("metrics", "metrics [--json]", "p50/p95 route latency + agreement-rate SLOs over the recorded routes")
}
// defaultPawlLabel mirrors scripts/pawl.sh's LABEL default.
+53
View File
@@ -748,3 +748,56 @@ func TestPawlService_ReadOnlyCmdsInspectUnderDryRun(t *testing.T) {
})
}
}
// TestPawlHelpPresentsReviewAsFrontDoor (age-hk5zg.2 / S2 of the pawl-user-front-door
// packet): `ao pawl --help` must present `review` as the primary USER path and group the
// warm standing-service verbs (up/down/reap/health/doctor/smoke/route/metrics) as
// operator-only with a note that they require NTM — so a user is never led to believe
// they must run `ao pawl up` to use the membrane. Locks group membership, render order
// (user group before operator group), and the NTM note in the operator title.
func TestPawlHelpPresentsReviewAsFrontDoor(t *testing.T) {
if pawlReviewCmd.GroupID != pawlUserGroupID {
t.Fatalf("pawl review must sit in the user (front door) group; got GroupID=%q", pawlReviewCmd.GroupID)
}
warm := map[string]bool{"up": true, "down": true, "reap": true, "health": true,
"doctor": true, "smoke": true, "route": true, "metrics": true}
seen := 0
for _, c := range pawlCmd.Commands() {
if !warm[c.Name()] {
continue
}
seen++
if c.GroupID != pawlOperatorGroupID {
t.Errorf("warm verb %q must sit in the operator group; got GroupID=%q", c.Name(), c.GroupID)
}
}
if seen != len(warm) {
t.Fatalf("expected all %d warm verbs registered on pawl; saw %d", len(warm), seen)
}
var buf strings.Builder
pawlCmd.SetOut(&buf)
t.Cleanup(func() { pawlCmd.SetOut(nil) })
if err := pawlCmd.Help(); err != nil {
t.Fatalf("rendering pawl help: %v", err)
}
help := buf.String()
userIdx := strings.Index(help, pawlUserGroupTitle)
opIdx := strings.Index(help, pawlOperatorGroupTitle)
if userIdx < 0 {
t.Fatalf("help must render the user group title %q; got:\n%s", pawlUserGroupTitle, help)
}
if opIdx < 0 {
t.Fatalf("help must render the operator group title %q; got:\n%s", pawlOperatorGroupTitle, help)
}
if userIdx > opIdx {
t.Fatalf("the user (front door) group must render BEFORE the operator group (review-first); user@%d operator@%d", userIdx, opIdx)
}
if !strings.Contains(pawlOperatorGroupTitle, "NTM") {
t.Fatalf("the operator group title must name the NTM requirement; got %q", pawlOperatorGroupTitle)
}
reviewIdx := strings.Index(help, "\n review ")
if reviewIdx < 0 || reviewIdx > opIdx {
t.Fatalf("review must be listed before the operator group; review@%d operator@%d\n%s", reviewIdx, opIdx, help)
}
}
+9 -9
View File
@@ -1856,9 +1856,17 @@ ao pawl [command]
**Subcommands:**
#### `ao pawl review`
Wrap scripts/pawl-review.sh and surface it on the ao CLI. Dispatches the codex
```
ao pawl review <bead-id> [--scope head|staged|upstream] [--base <sha>] [--converge] [--strict] [--author-family <fam>] [--context <s>] [--smoke <cmd>] [flags]
```
#### `ao pawl doctor`
Read-only standing pawl preflight: assert atm alias, session, pane cwd/model, trust prompts, readiness, and evidence policy
Read-only standing pawl preflight: assert swarm binary (ntm-first), session, pane cwd/model, trust prompts, readiness, and evidence policy
```
ao pawl doctor [--json] [--expected-cwd PATH] [--expected-claude-model MODEL] [--expected-codex-model MODEL] [flags]
@@ -1896,14 +1904,6 @@ Tear down the standing pawl-service iff idle > PAWL_IDLE_TTL (substrate/cron sch
ao pawl reap [flags]
```
#### `ao pawl review`
Wrap scripts/pawl-review.sh and surface it on the ao CLI. Dispatches the codex
```
ao pawl review <bead-id> [--scope head|staged|upstream] [--base <sha>] [--converge] [--strict] [--author-family <fam>] [--context <s>] [--smoke <cmd>] [flags]
```
#### `ao pawl route`
Route a review packet to the warm cross-family panel; require tier-appropriate agreement, record the verdict
+8 -1
View File
@@ -217,6 +217,13 @@ A new pawl earns its place **only** if the action is genuinely irreversible —
## Operating the warm pawl-service: idle reaping
> **Operator-only (requires NTM).** Everything in this section is OPTIONAL operator
> machinery: the warm verbs (`up`/`down`/`reap`/`health`/`doctor`/`smoke`/`route`/`metrics`)
> drive tmux panes through the NTM swarm substrate and expect the repo under a
> `projects_base`. **A user never needs any of it** — the front door is plain
> `ao pawl review`, which runs cold (codex/agy one-shot) from any git repo with zero
> NTM, zero `projects_base`, zero config. `ao pawl --help` groups the surface the same way.
The cross-family pawl can run as a **standing warm service** (`ao pawl up` — capability-adaptive over the installed families; see [`scripts/pawl.sh`](../../scripts/pawl.sh)) so reviews route to warm panes instead of spinning a cold `codex exec` each time. Warm panes hold a model-account slot, so the service has an idle reaper:
- **`ao pawl reap`** tears the session down **iff** it has been idle longer than `PAWL_IDLE_TTL` (default 1800s); otherwise it is a no-op. The next review's lazy-auto-up brings the service back.
@@ -234,7 +241,7 @@ Every `ao pawl` service verb (`up`/`down`/`reap`/`health`/`doctor`/`smoke`/`rout
- **Installed binary, any git repo**: the **embedded** `pawl.sh` bundle runs against that repo, with a sanitized environment (trusted PATH, `BASH_ENV`/`ENV`/`GIT_EXTERNAL_DIFF` neutralized, `PAWL_UNTRUSTED_REPO=1`). A repo-planted `scripts/pawl.sh` is **never executed**. The session's family/pane **layout** is a property of the (global) tmux session, so it lives in a **session-scoped shared** file (`${TMPDIR:-/tmp}/pawl-session-<session>.json`) — a second repo routing to one existing `PAWL_SESSION` reads the same layout `up` wrote, not a wrong default. The per-repo `metrics.jsonl` stays under **that repo's** `.agents/pawl/`; a symlink anywhere in the state path (ancestor or leaf) is refused/neutralized so writes never escape the repo.
- **Outside any git repo**: fail closed before mutation, naming the requirement.
**`ao pawl up` (spawn).** `atm spawn <project>` roots its panes at `projects_base/<project>` — there is no cwd flag — so `up` can only spawn correctly when the repo is a **direct child of the ATM `projects_base`**. The project defaults to the `basename` of the git toplevel (`PAWL_PROJECT` overrides). Before spawning, `up` **verifies** that `projects_base/<project>` resolves back to this repo; if it does not (a nested worktree, or a repo outside `projects_base`), it **fails closed before any mutation** with an actionable message — never spawning into the wrong directory. When the target session already exists, `up` is idempotent (no spawn, no verification). The read-only verbs and `route` are fully cross-repo regardless.
**`ao pawl up` (spawn).** The scripts resolve the swarm binary through one ntm-first seam (`PAWL_SWARM_BIN` override → the public `ntm` → the operator's `atm` alias; `doctor` reports which resolved as `swarm-bin`). `ntm spawn <project>` roots its panes at `projects_base/<project>` — there is no cwd flag — so `up` can only spawn correctly when the repo is a **direct child of the NTM `projects_base`**. The project defaults to the `basename` of the git toplevel (`PAWL_PROJECT` overrides). Before spawning, `up` **verifies** that `projects_base/<project>` resolves back to this repo; if it does not (a nested worktree, or a repo outside `projects_base`), it **fails closed before any mutation** with an actionable message — never spawning into the wrong directory. When the target session already exists, `up` is idempotent (no spawn, no verification). The read-only verbs and `route` are fully cross-repo regardless.
**Dry-run.** Global `--dry-run` on a mutating verb (`up`/`down`/`reap`/`route`/`review`) — in either `--dry-run` or `--dry-run=true` form — executes **nothing** (no tmux/NTM spawn/kill/send, no state/verdict/metric/lock write) and reports the exact planned action; with `--json` it emits exactly one JSON object (`action`, `dry_run`, `mutated`, `session`, `families`, `tier`, `planned_steps`). The planned `session` is derived exactly as a real run would (`${PROJECT}--${LABEL}`). Read-only verbs (`health`/`doctor`/`smoke`/`metrics`) may inspect real state under `--dry-run` but run with `PAWL_DRY_RUN=1`, which suppresses even prompt-clearing key sends.
+29 -18
View File
@@ -699,12 +699,7 @@
"purpose": "Fitness goal measurement and validation",
"bounded_context": "BC3",
"driven_by_skills": [
"evolve",
"goals",
"post-mortem",
"release",
"scaffold",
"validate"
"goals"
]
},
{
@@ -1666,7 +1661,7 @@
"drives_commands": [
"ao beads exec",
"ao gate check",
"ao goals",
"ao goals measure",
"ao pawl",
"ao skills edit digest",
"ao skills edit seal"
@@ -1761,7 +1756,26 @@
],
"drives_commands": [
"ao eval scenario",
"ao goals"
"ao goals",
"ao goals add",
"ao goals drift",
"ao goals export",
"ao goals history",
"ao goals init",
"ao goals measure",
"ao goals meta",
"ao goals migrate",
"ao goals prune",
"ao goals render",
"ao goals scenarios",
"ao goals steer",
"ao goals steer add",
"ao goals steer apply",
"ao goals steer prioritize",
"ao goals steer recommend",
"ao goals steer remove",
"ao goals trace",
"ao goals validate"
],
"path": "skills/goals/",
"references": 3
@@ -2112,7 +2126,9 @@
],
"drives_commands": [
"ao beads exec",
"ao goals",
"ao goals history",
"ao goals measure",
"ao goals steer apply",
"ao membrane digest",
"ao membrane triage",
"ao session close"
@@ -2358,7 +2374,7 @@
"result.json"
],
"drives_commands": [
"ao goals"
"ao goals init"
],
"path": "skills/release/",
"references": 8
@@ -2511,7 +2527,7 @@
"converted-skill"
],
"drives_commands": [
"ao goals"
"ao goals scenarios"
],
"path": "skills/scaffold/",
"references": 3
@@ -2841,7 +2857,7 @@
"drives_commands": [
"ao beads exec",
"ao doctor",
"ao goals",
"ao goals measure",
"ao pawl"
],
"path": "skills/validate/",
@@ -3079,12 +3095,7 @@
"purpose": "Fitness goal measurement and validation",
"status": "active",
"driven_by_skills": [
"evolve",
"goals",
"post-mortem",
"release",
"scaffold",
"validate"
"goals"
],
"flags": [
"--config",
+19 -3
View File
@@ -141,13 +141,27 @@ def _run_help(ao_bin: str, command: List[str]) -> subprocess.CompletedProcess:
)
# Cobra sections that head NON-command blocks. Any other column-0 line ending with
# ":" is a command block: either the default "Available/Additional Commands:" or a
# custom cobra.Group title (e.g. `ao pawl`'s front-door/operator split), whose titles
# are arbitrary prose and need not contain the word "Commands".
_NON_COMMAND_SECTIONS = {
"Usage:",
"Aliases:",
"Examples:",
"Flags:",
"Global Flags:",
"Additional help topics:",
}
def _parse_subcommands(help_text: str) -> List[str]:
"""Parse the ``Available Commands:`` block of cobra help into command names."""
"""Parse the command blocks of cobra help (default + group-titled) into names."""
subs: List[str] = []
in_block = False
for line in help_text.splitlines():
stripped = line.rstrip()
if re.match(r"^[A-Za-z].*Commands:\s*$", stripped):
if re.match(r"^[A-Za-z].*:\s*$", stripped) and stripped not in _NON_COMMAND_SECTIONS:
in_block = True
continue
if in_block:
@@ -157,7 +171,9 @@ def _parse_subcommands(help_text: str) -> List[str]:
if not line.startswith((" ", "\t")):
in_block = False
continue
m = re.match(r"^\s+([a-z][a-z0-9-]*)\b", line)
# Command rows render as " <name><2+ spaces><short>"; the >=2-space gap
# keeps Examples-style " ao pawl ..." lines from parsing as commands.
m = re.match(r"^\s+([a-z][a-z0-9-]*)(\s{2,}|\s*$)", line)
if m and m.group(1) not in {"help", "completion"}:
subs.append(m.group(1))
return subs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"schema_version": "2",
"generated_at": "2026-07-10T13:07:36Z",
"generated_at": "2026-07-11T00:32:53Z",
"skill_count": 62,
"skills": [
{