mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
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:
+38
-11
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user