F7 (core): the panel's own health was recorded but never surfaced — the audit found the tri
panel (cc cod agy) reached FULL agreement 0/22 times while dual (cc cod) was fine, and nothing
warned. 'ao pawl metrics' now groups routes by the family-set that served them and prints, per
panel, full-agreement rate + p50 latency, flagging AGREEMENT-COLLAPSE when a panel's
full-agreement is <30% over >=5 routes. On this repo's real corpus it correctly surfaces
'cc cod agy: 0/22 agree (0.0) COLLAPSE' vs 'cc cod: 10/19 (0.526)'. --json gains by_families.
Legacy null-family rows tolerated.
Tests: pawl-metrics-by-panel.bats (collapsing tri + healthy dual fixture; asserts the flag fires
on the right panel, not the healthy one). Existing pawl-metrics suite green.
Scoped: the persisted 'ao pawl bench/unbench' verb + doctor bench-source line split to a
follow-up (config.json write) so the high-value metrics legibility lands now.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
F13 item 1. _touch_route_ts updated last_route_ts via python3 ONLY — on a host without python3 it
silently no-op'd, freezing the idle clock at the up-time, so 'ao pawl reap' would tear down an
ACTIVELY-serving session once PAWL_IDLE_TTL elapsed. Now it prefers jq (already a route-path hard
dep; same compact "key":value form the grep readers parse; atomic tmp+mv) with the python3 block
kept as a fallback. And cmd_reap FAILS SAFE: when the clock cannot be written at all (no jq AND no
python3, via new _clock_writable), it keeps the session warm + warns rather than reaping on a
frozen timestamp — never kill on a broken clock.
Tests: tests/scripts/pawl-idle-clock.bats (jq round-trip bumps + reads the clock near-zero; parseable
by the compact reader; _clock_writable true/false). Existing pawl.sh-sourcing suites unchanged.
Scoped: item 2 (bounded pane-death re-probe for cod_dead/agy_dead) touches the live route loop and
is split to a follow-up so this idle-clock fix lands clean and fast.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
F10 UX batch (shell items): (1) 'ao pawl route' with missing args now prints a clean
'usage: ao pawl route <bead> <packet-file> [pr]' and exits 2, instead of leaking a raw
bash ${N:?} trace ('scripts/pawl.sh: line NNNN: 1: route needs <bead>'). (3) the pre-push
mixed-push gate (a full ~40s cockpit gate PER non-trivial commit behind a #trivial tip —
an otherwise-silent multi-minute hang) now prints 'gating commit K of N: <sha> <subject>'
before each and a per-commit elapsed after, turning the stall into a legible progress log.
Existing asserted substrings ('re-targeting the cockpit gate', 'cockpit gate PASSED for
non-trivial commit <sha>') preserved — 18/18 pre-push tests green, +2 usage tests.
Scoped: item 2 (cosmetic pawl.go dry-run 'atm spawn' -> seam-derived string) split off — it
touches the Go layer + its dry-run tests and would drag the ~500s Go gate into this shell
bead; tracked as a follow-up so this lands clean and fast.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
'No verdict = not done' is enforced on the ledger EDGE (ao done / verify_prepush read
the edge, not the verdict file), but emit_verdict_edge_checked was fail-OPEN: a failed
'ao provenance emit-verdict' only WARNed, so a CONFIRMED verdict FILE could exist with no
edge — a silent desync where pawl-review says 'ready to push' but ao done refuses the close.
F2 makes the emit fail-CLOSED: on a genuine emit failure (no trusted ao, or emit exits
non-zero) emit_verdict_edge_checked returns a distinct EDGE-UNBOUND code (7) IN-PROCESS (no
shared status file — race-free per pre-mortem), do_write propagates it, and pawl-review's
cold + strict write sites map it to exit 6 (distinct from a hard write failure = exit 1),
keep the verdict file (the recovery input), and print the exact re-emit command. Governed:
- PAWL_EDGE_FAIL_OPEN=1 restores warn-and-continue (status 0);
- a SUCCESSFUL emit is byte-identical (status 0);
- PREPUSH parking (emit succeeded, autobind deferred) stays a SUCCESS, never misclassified;
- the rebind/restamp paths stay FAIL-OPEN (|| true) — F2 governs the review/write path only.
Tests: pawl-verdict-edge-failclosed.bats (4: fail-closed/knob/happy/parking) +
pawl-review-edge-unbound.bats (2: e2e exit-6 + knob). The 2 autobind emit-failure tests
updated from the old fail-open (status 0) to the new fail-closed (status 7) contract.
Differential: pristine 0 fail, mine 0 fail across 7 suites — zero unintended regression.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
The warm NTM panel is the intended review substrate but was never guaranteed: any
warm failure (auto-up failure, or a routed verdict that fails the real gate) silently
fell through to a cold codex-exec. PAWL_REQUIRE_SERVICE=1 / --require-warm converts that
silent cold fallback into an honest HOLD (exit 5) for warm-ELIGIBLE modes (non-strict,
non-converge, head/upstream, codex reviewer). Strict/converge/staged/non-codex remain
documented COLD exceptions (LAW 0) — the knob prints a notice, never silently ignores
itself. An untrusted/stranger repo requesting the knob HOLDs naming why (warm never runs
there). Default OFF: byte-identical to pre-F1.
The HOLD chokepoint sits BEFORE the cold refuter (no cold spawn on a HOLD) and removes a
NON-gate-valid routed verdict so the HOLD leaves no invalid artifact — but never deletes a
pre-existing gate-valid verdict (gated on the real check; two cross-family refutes hardened
this). Security (pre-mortem): untrusted-repo precedence checked so an injected
PAWL_NO_SERVICE cannot silently neutralize the knob.
Tests: tests/scripts/pawl-require-warm.bats (7, all green); differential confirms zero
regression (46 pre-existing env-dependent failures unchanged on pristine).
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
The three-tier andon hardwired 'any breaker trip' to human: goal-design
routers, the packet scaffold, pawls.md escalation, flywheel doctrine, and
rpi error-handling all sent stuck-N-rounds / oscillation / scope-creep
straight to the operator. This restores the lost rung: a stuck context
consults a fresh one before it consults the operator.
- Tier 2 widens from council to helper (fresh context, cross-family model,
or /council — council is its heaviest form); stuckness is
model-adjudicable too.
- Breaker trips take exactly ONE bounded helper pass per blocker class;
the helper returns UNSTUCK (concrete next action) or ESCALATE. Advisor,
never a second driver — it does not take over the work.
- Human is reserved for: refusal lane, explicit judgment flag, exhausted
budget, and blockers that survive their helper pass. Those classes skip
the helper entirely.
- Scaffold + dispatch prompt now state the auto -> helper -> human ladder;
route_back_rules values carry the helper clause (schema v1 unchanged).
- TDD: extended bats andon/prompt tests red -> green; codex twins regen'd
(goal-design parity) / hand-mirrored (rpi bespoke).
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
The no-epic-close-with-open-child gate was bd-only ('bd dep list'), so on br
repos (age-*) it errored 'no issue found' and was silently inert. Route child
enumeration through 'ao beads exec children <epic> --json' (br: plain id
lines; bd: JSON array — both parsed) and per-child status through
'ao beads exec show <child> --json' (canonical shape on both trackers).
Error contract, exactly as tested: a children-ENUMERATION failure is a hard
exit 4 (a gate that cannot see the children must not pass); a per-child
status read failure is fail-closed as an offender (WARN + exit 1) so one
unreadable child blocks the close without aborting the other offenders'
report. Hermetic bats suite rewritten to stub ao with both tracker shapes
plus both failure modes; proven live against br epics in both directions
(all-closed exit 0, 52-open-children exit 1).
Closes the 3-sweep crash-safety convergence gap: hand-rolled tmp+rename atomic
writes outside cli/internal/storage (24 grandfathered at cutoff, hand-audited;
the full comment stripper dropped one comment-only false entry vs the naive
count. Sanctioned own-rename packages are GRANDFATHERED not exempted, so
shrink pressure applies when they delegate).
Detector (pre-registered corpus, pre-mortem r2-r3 + pawl round): non-comment
os.Rename( AND non-comment (os.CreateTemp( | ".tmp) where non-comment means
AFTER stripping // tails and /* */ blocks (a comment mention never counts —
string-literal limitation pre-declared). Added-hunk guard fires EVEN on
grandfathered files. Growth guard ON. _test.go exempt. ADVISORY PERMANENTLY
at file-level fidelity; blocking requires a precision detector in its own arc.
Twin 15/15 on the pre-registered corpus incl. inline-// and /* block */
comment-strip exercisers. seed.go row go.atomic-write-ratchet (Full, advisory)
+ self-routing paths. NOTE the sweep's pool.atomicMove no-fsync claim was
stale (fixed a8267ddc8) — the payload is adoption convergence.
The lib's promise holds: a new shrink-only gate cost one detector + one
pinned file.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
BLOCKING-gate migration with zero semantic change: allowlist parse ->
ratchet_load_pinned trailing-comment (original strip-#-then-trim on all real
shapes); stale sweep -> ratchet_stale_entries_by with the gate's ORIGINAL
exists-only predicate (full stale-prune upgrade stays a separate decision,
age-scenario-linkage-stale-upgrade-570y). Ambiguity-error path untouched.
Differential (byte stdout/stderr/exit): --json, --warn-only, usage-error all
parity; default mode parity except the pre-declared sanctioned delta (stale
entries now emitted in deterministic allowlist order, was bash hash order —
same set, same count, same rc). Twin 13/13 with skeleton lib copy.
FM3 self-routing REPAIR: the row previously matched only skillPaths — its own
script, allowlist, bats, and the shared lib were un-routed; new
scenarioLinkagePaths closes that.
Fix-forward: pruned 5 stale allowlist entries (compile/curate/perf/recover/
review .feature — retire-wave deletions; shrink-only direction). The gate's
remaining 10 uncovered-scenario errors are PRE-EXISTING on main (old gate
agrees) — routed to age-scenario-linkage-uncovered-backlog-szgyu.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
Second python-seam restructure (sibling of .5): python emits US-delimited
findings (rel/lineno/token/suggestion/snippet); bash owns baseline parse
(strip mode), two-way set arithmetic, and byte-identical messages incl. the
did-you-mean hint and repo-relative baseline path. python3 failure is a loud
rc 2 naming the cause.
Differential evidence (byte stdout/stderr/exit, same injected AO bin): real
repo PASS w/ counts, new-offender FAIL w/ suggestion hint, baselined PASS,
stale FAIL — all parity. Twin 11/11 with fixture lib copy (FM5). seed.go
docsCliSnippetsPaths gains the lib (FM3).
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
Locks S1 of the pawl-user-front-door packet: the built ao binary COPIED OUTSIDE
the checkout (forcing the embedded-bundle stranger path an installed binary
takes) reviews a throwaway repo under mktemp — no projects_base, no operator
config. ntm/atm are POISONED sentinels proving the cold path never reaches for
the swarm binary at all. Stubbed reviewer -> CONFIRMED verdict written into the
user's own state dir; reviewer absent -> honest exit-2 error naming codex with
no artifacts; both without any operator sharp edge (projects_base/spawn).
The standing pawl-service hardcoded the operator's personal 'atm' alias at 8
invocation sites, so the shipped scripts only worked on the operator's machine.
One resolver seam (_pawl_swarm_bin: PAWL_SWARM_BIN override -> ntm -> atm) now
feeds every swarm invocation through "$SWARM"; the doctor 'atm-alias' check
becomes 'swarm-bin' and reports what actually resolved. Embedded copy synced
byte-identical. Locked by tests/scripts/pawl-swarm-bin.bats.
The ORIGINAL grandfather machinery (the jsonl gate's port source) now
delegates to the shared lib: name-status changed-scope collection (incl.
upstream), base-ref map, intersection authority, shrink-only guard
(308 -> 218 lines; message shapes preserved by thin wrappers). Parse mode raw.
Differential evidence (byte stdout/stderr/exit): auto (no args), head,
worktree, usage-error, head+UTF-8 — all parity on the real repo; twin 10/10.
FM5 catch: the twin's SECOND fixture skeleton (initial-snapshot test) needed
the lib copy — exactly the per-copy-site landmine the pre-mortem pre-registered.
seed.go preambleRatchetPaths gains the explicit lib literal (FM3 closure test).
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
The extraction template comes home: grandfather load/base-ref/intersection
authority/shrink-only/changed-scope/added-hunk/regenerate all delegate to the
lib (368 -> 281 lines; message shapes preserved by thin wrappers). Parse mode
raw = original entry parsing. Regenerate now atomic (tmp+mv) with byte-identical
output (golden-compared old vs new in a fixture repo).
Differential evidence (ratchet-difftest, byte stdout/stderr/exit): real-repo
clean head scope (C + UTF-8), usage-error, worktree scope — all parity; twin
7/7. Sanctioned deltas: unreadable grandfather now loud rc 2; head scope gains
--root -m --first-parent (root/merge commits no longer certify empty — lib
handled-shapes contract). seed.go jsonlScannerRatchetPaths gains the lib (FM3).
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
First consumer migration. Baseline parse -> ratchet_load_pinned cr-strip (the
gate's original CR-strip + comment/blank shape); stale computation ->
ratchet_stale_entries. Differential evidence: byte-parity (stdout, stderr,
exit) old-vs-new on clean real repo (C + en_US.UTF-8 locales), new-violation
fixture, stale fixture, CRLF baseline fixture via ratchet-difftest.bash.
Sanctioned deltas (fail-loud class, documented): unreadable baseline is now a
loud rc 2 (was a strict-mode abort rc 1); multi-stale report order is now
LC_ALL=C sorted (was unspecified hash order) — both pinned by new twin cases.
Twin skeleton copies ratchet.sh; seed.go demotedClaimsPaths gains the lib so
lib edits re-run this gate (FM3; self-extending routing test now covers it).
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
scripts/lib/ratchet.sh: the one implementation of the grandfather/baseline
ratchet mechanics 7 gates hand-roll today — per-consumer parse modes
(raw|cr-strip|strip|trailing-comment), scope-aware changed-file collection
incl. upstream, fail-closed growth guard (comm -13 + intersection authority,
RATCHET_GROWTH_GUARD=off escape for migrated gates), parameterized stale
predicates, generic regenerate (header-fn + LC_ALL=C sorted body), added-hunk
guard with ENVIRON-passed ERE.
tests/scripts/lib/ratchet-difftest.bash: byte-parity differential harness
(stdout, stderr, exit code separately) for the migration slices.
tests/scripts/ratchet-lib.bats: 24 cases — real pinned-shape round-trips per
parse mode, growth rejection, self-allowlist refusal, stale by set-diff and
by predicate, regenerate golden, added-hunk guard, harness smoke.
cli/internal/gates/checks/ratchet_routing_test.go: self-extending FM3 closure —
every check script sourcing the lib must route lib edits via its Match globs.
Pre-mortem: .agents/council/2026-07-10-pre-mortem-ratchet-lib.md (3 rounds).
Lib is FROZEN for the rest of the arc per FM6.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u
Twelve defects, each with a regression test written to fail first. Five were the
operator's; SEVEN more were found by the cross-family refuter across THREE REFUTED
rounds on this very change — the membrane repeatedly catching its own hardening,
which is the product working as intended.
D1 dry-run was ignored. pawl leaves set DisableFlagParsing, so cobra never parsed
the inherited persistent flags: `ao --dry-run --json pawl up` reached RunE with
the flags still in args and the globals FALSE, so the service script ran for real
(observed in production: it created agentops--pawl-service). Mutating verbs
(up/down/reap/route/review) now extract the flags from raw args, report the planned
action, and execute NOTHING. Round 2 caught that the fix matched only the BARE
token, so `--dry-run=true` still spawned; both forms handled now, unparseable value
fails closed.
D2 JSON purity. A dry-run with --json emits exactly one parseable object: action,
dry_run, mutated, session, families, tier, planned_steps.
D3 cross-repo contract. `ao pawl up` from an ordinary repo died with "locating
AgentOps repo root". Service verbs now resolve like `ao pawl review`: live scripts
only when the ao binary lives inside the checkout, else the embedded bundle against
the caller's own git repo. Outside any git repo it fails closed. Round 3 caught that
`up`'s atm spawn still hardcoded PROJECT=agentops, so panes came up in the wrong
repo; PROJECT now defaults to the resolved repo (basename of ROOT), PAWL_PROJECT
overrides. Round 7 caught the other half: SESSION was still hardcoded
'agentops--pawl-service' while atm spawn creates '${PROJECT}--${LABEL}', so `up`
from personal-site spawned 'personal-site--pawl-service' but ran readiness and
lifecycle against 'agentops--pawl-service' (an orphaned pane). SESSION now defaults
to '${PROJECT}--${LABEL}' — the exact name atm creates — so spawn, readiness, route,
health, and teardown target one session; from the agentops checkout it stays
'agentops--pawl-service' (the operator's live session), PAWL_SESSION overrides.
Round 8 caught the Go side of that same rename: the dry-run planner still hardcoded
'agentops--pawl-service', so the plan misreported the target from any other repo.
The planner now derives the session exactly as the script does (pawlPlannedSession:
${PROJECT}--${LABEL}, PROJECT from PAWL_PROJECT or the repo basename), proven at
parity with the script live.
D4 installed-binary trust. pawlServiceCmd executed scripts/pawl.sh from the
marker-resolved checkout with no aoBinaryInside guard, so a forged repo's planted
script would run. Now behind the same trust split with pawlServiceColdEnv. Round 1
caught the same RCE one hop deeper (verdict writer invoked via the caller-derived
ROOT = the caller's repo; resolved script-relative via PAWL_SCRIPT_DIR). Round 2
caught that the verdict DATA then defaulted into the deleted temp bundle; both route
write sites pass --dir into the caller's repo.
D5 route safety. Bead ids are containment-validated before any write. The route
lease is an atomic exclusive lock DIRECTORY with owner metadata; down/reap acquire
it before teardown (no check-then-kill TOCTOU); session.json writes are atomic;
metrics reports skipped_corrupt explicitly. Round 2 found the lease was keyed by
repo not session (two repos, one tmux session, both routed), published owner
non-atomically (a publication-window contender declared a fresh lease stale), and
recursively deleted the unvalidated PAWL_ROUTE_LOCK override (data-loss). All fixed:
keyed by session outside any repo; dir mtime as the atomic freshness fallback;
rmdir-only, non-lease paths refused. Round 3 found the stale-reclaim was still not
single-winner (the reclaim returned success on a no-op AND renamed unconditionally,
so two contenders both acquired). Round 5 found the deeper interleaving that STILL
broke a rename-based reclaim: A finishes acquisition and holds a fresh lease, then
B — carrying the OLD generation it observed — renames A's fresh lease aside without
re-checking staleness, and both route. Fixed with a GENERATION-SCOPED break-token:
one breaker acts per stale generation, and it removes the lease only after
re-confirming (while holding the token) that it is STILL that same stale
generation, so a peer's fresh lease (a different generation) is never touched;
mkdir remains the sole ownership barrier. Wedge-free (a crashed breaker's token
ages out). Proven live: 8-way and staggered-entry concurrent races on a stale lease
each yield exactly one winner across repeated trials.
Round 4 caught a rebase hazard, not a logic bug: rebuilding pawl.sh from a stale
base during a hot-main reland had silently reverted a concurrent lane's
_route_emit_catch (age-2yh2), reintroducing the pawl-review-refuted-see-evidence
escape. Reconciled via 3-way merge (my changes + the intervening emitter restored)
and guarded by a standing test that the routed-REFUTE membrane-catch emitter stays
defined and reachable.
Round 24 closed two more: the dry-run planner now VALIDATES args the way the real
command does (route requires bead+packet and a valid id; `up --models` must name
known families), so `--dry-run pawl route` with no args exits 1 instead of
reporting a bogus plan (round 25 refined this to validate route args POSITIONALLY,
exactly as cmd_route does — a leading-dash `route --bogus …` is a bead the real
command rejects, so the dry-run must too, not skip it); and the route's verdict dir (`.agents/pawl-verdicts`) is
now symlink-guarded like the state dir, so a committed symlink can't redirect the
verdict write outside the repo.
Round 23 closed a heartbeat TOCTOU: the background heartbeat's ownership-check and
owner-write are not atomic, and heartbeat_stop killed without waiting — so a delayed
heartbeat could, after the route released and a successor acquired, overwrite the
successor's owner with our PID, dispossessing a live successor. heartbeat_stop now
WAITS for the heartbeat to die before the release runs, so any in-flight write lands
only in our own (about-to-be-removed) lease dir, never the successor's.
Round 22 + Round 26 closed a Python sys.path RCE: `pawl metrics` / `_touch_route_ts`
/ `resolve_engage_deadline` run `python3 - <<PY; import json` with the untrusted
repo as cwd, and Python puts cwd first on sys.path — so a repo-planted json.py would
be imported and executed even for read-only `ao pawl metrics`. ALL THREE invocations
use isolated mode (`python3 -I`), which drops cwd from sys.path and ignores
PYTHONPATH/user-site; a grep-guard test fails any future non-isolated invocation
(round 26 was a third one a merge added after the round-22 fix).
Round 20 extended the state-dir symlink guard to LEAF files: the chain guard
covered ancestors, but a committed `.agents/pawl/metrics.jsonl` (or session.json)
symlink was still followed by the `>>` append / write. Each leaf write now unlinks
a symlink there first (removing only the link, leaving the external target
untouched), so the write creates a real in-repo file.
Round 19 closed a state-dir symlink-escape edge: on the installed-binary path in an
untrusted repo, a committed `.agents` or `.agents/pawl` SYMLINK escaping the repo
would make session.json/metrics writes land outside it and `down` delete the
target's session.json. A symlink anywhere in the ROOT→STATE_DIR chain is now
refused before any state write (up/route die; down skips the rm), so repo-local
state can never escape the repo.
Round 18 + Round 21 resolved the session-state locality tension. The session's
family/pane LAYOUT is a property of the (global) tmux session, not of any repo, so
it now lives in a SESSION-scoped shared file (${TMPDIR}/pawl-session-<session>.json)
rather than repo-local `.agents/pawl/session.json`. This makes cross-repo route to
one existing PAWL_SESSION read the SAME layout `up` wrote (round 21: repo-local
state defaulted a shared `cc agy` session to `cc cod agy`, mislabeling the AGY pane
as codex), and the injective session-slug filename means one session never reads
another's state (round 18). A defensive stored-session match check remains. The
per-repo metrics.jsonl stays repo-local under `.agents/pawl/`.
Round 17 made the session→path map genuinely injective and closed a same-session
cross-repo evidence collision. The round-16 cksum suffix was NOT injective (the
refuter constructed two distinct sessions with the same CRC), so the slug now uses
a reversible HEX encoding — distinct sessions ALWAYS get distinct paths. And
because the cross-repo contract lets two repos target one existing PAWL_SESSION,
evidence is now scoped by session AND repo (hex(ROOT) appended), so two repos never
share `${bead}.packet.md` under one session.
Round 16 closed two more edges: the session→slug map was not injective (`sess+a`
and `sess=a` both sanitized to `sess_a`, colliding on one lease/evidence path for
two distinct sessions) — a suffix now disambiguates; and stale reclaim would
`rm -f` any regular file at ROUTE_LOCK as a "legacy lock" — a legacy lock is now
validated to hold only a numeric epoch, so an unrelated file (e.g. an operator's
/etc/hosts pointed at by PAWL_ROUTE_LOCK) is refused, never deleted.
Round 15 floored the freshness window: the supported PAWL_ROUTE_TIMEOUT=0 tunable
collapsed 2*ROUTE_TIMEOUT to 0, making every live lease instantly stale (age < 0 is
impossible). The window (2*ROUTE_TIMEOUT, floored to 2) is now a single helper
shared by _route_lock_fresh and the heartbeat-interval derivation, so the two can
never disagree.
Round 14 closed a symlink data-loss edge (a symlink at PAWL_ROUTE_LOCK is followed
by [ -d ]/mv/rmdir, so reclaiming it would delete the TARGET's owner — is_lease now
refuses any symlink) and corrected docs/contracts/pawls.md to match the shipped
behavior (session-scoped /tmp lease, projects_base-verified `up`, generation-token
+ 2*ROUTE_TIMEOUT reclaim, heartbeat) rather than the pre-fix description.
Round 13 caught the atm-project-model limit of the cross-repo `up`: `atm spawn
<project>` roots panes at projects_base/<project> (no cwd flag — atm's own help:
"the session name must match an actual directory under projects_base"), so
basename(ROOT) targets the wrong/missing dir for a nested worktree or a repo
outside projects_base. Per the spec's fallback (work when safe, else fail before
mutation, never ambiguous), `up` now VERIFIES that projects_base/<project> resolves
back to this repo before spawning and fails closed otherwise with an actionable
message (run from a repo under projects_base, or set PAWL_PROJECT, or PAWL_SESSION
to an existing session). The dry-run/health/metrics/route paths remain fully
cross-repo; only the spawn — inherently atm-project-bound — fails closed. An
explicit PAWL_PROJECT is trusted as the operator's deliberate choice.
Round 12 caught that the lease is session-keyed but per-route packet/evidence files
were named by bead under a GLOBAL EVID_DIR, so two repos routing the same bead id
wrote identical paths — one overwriting the other's evidence, letting a verdict
validate against the wrong review. EVID_DIR is now session-scoped too (same keying
as the lease); PAWL_EVID_DIR still overrides.
Round 9 caught the release/TTL angle: a long route (send/respawn + polling can run
past ROUTE_TIMEOUT+60) could outlive its own lease's freshness window, letting a
successor reclaim it mid-run, and the original route's RETURN trap then deleted the
successor's lease unconditionally — concurrent routes. Fixed with a HEARTBEAT (a
route refreshes its own lease each poll tick via an atomic owner rewrite, so it
never goes stale while alive) plus OWNERSHIP-CHECKED release (only remove a lease
whose owner pid is ours; a successor's lease is never dispossessed). Owner writes
are atomic (sibling temp + rename) so a concurrent freshness reader never sees a
half-written owner. Proven live: a heartbeat restores freshness past the TTL and a
would-be successor fails closed against the live lease. Round 10 showed a
poll-loop-only heartbeat still left the pre-poll send/respawn phase (~700s+ with
default retries) uncovered, and no static window can bound it — so the heartbeat is
now a BACKGROUND process tied to the route's lifetime: it refreshes the lease every
PAWL_HEARTBEAT_INTERVAL across ALL phases (send AND poll) and self-terminates the
instant the route process dies (kill -0 fails), so a crashed route — however it
died — stops heartbeating and its lease ages out (an orphaned heartbeat keeping a
dead lease alive would be the worse bug). The freshness window stays 2*ROUTE_TIMEOUT
purely as a crashed-route recovery bound. Round 11 caught that a FIXED heartbeat
interval (20s) could exceed a small window (PAWL_ROUTE_TIMEOUT=2 → 4s window), so
the interval is now DERIVED from the window (window/4, capped 20s) with any env
override HARD-CLAMPED to window/2 — the interval < window invariant now holds for
every configuration. Proven with live process tests: a lease held past its TTL by
the heartbeat stays fresh and rejects successors, and a SIGKILL'd route's lease
goes stale and is reclaimable.
Bonus catch: load_session/_session_idle's greps died under set -euo pipefail on a
truncated session.json instead of falling back to the documented default.
scripts/pawl.sh and cli/embedded/pawl/scripts/pawl.sh stay byte-identical. Verdict
semantics, exit codes, no-self-grade, evidence + commit binding, nonce parsing,
reviewer diversity, and readiness checks are untouched; no multi-model door is
degraded.
Verified: 97 Go pawl tests, full cmd/ao suite, focused bats (route-lock 32 incl. a
deterministic concurrent-acquire barrier, route-ids, dry-run, metrics,
session-state), gate 44/44, shellcheck clean. Live acceptance with a fresh binary
from the AgentOps worktree, an ordinary git repo, and a forged repo with
sentinel-planted pawl.sh + pawl-verdict.sh (neither touched); dry-run snapshots
identical; a populated PAWL_ROUTE_LOCK directory survives intact; 4-way stale-lease
race yields one winner.
Residual risk filed as age-l3xj.4-.8 (cross-host advisory lock, session-identity
re-check on kill, atomic metrics append, large-diff review stall, dry-run planner
parity).
Claude-Session: https://claude.ai/code/session_01Jb7V7jgVpUvAsUAd9k2PfL
Two live routes degraded into REFUTED binds on 2026-07-10 purely because
the static 240s engage-deadline sat below the panel's measured latency
(p50 261s) and a mid-run reap burned into a defect-claiming REFUTED.
- resolve_engage_deadline: effective deadline = max(default, recorded
metrics p95) capped at ROUTE_TIMEOUT; an explicit PAWL_ENGAGE_DEADLINE
is an operator override and wins as-is.
- route_outcome: the bound/recorded outcome is REFUTED only on a real
refutation; deadline/stall give-ups with no substantive refuter bind
HOLD:insufficient-reviewers, and a standing session that disappears
mid-route breaks to HOLD:service-unavailable. pawl_decide (the quorum)
and what counts as CONFIRMED are untouched; HOLD is schema-valid and
fail-closed at every door.
- Embedded pair byte-identical in this commit (embedded-sync constraint).
Hermetic bats: tests/scripts/pawl-engage-deadline.bats (12 cases).
close subcommand flips both artifact statuses to closed only when every
candidate bead carries a disposition (closed/dropped/superseded-by),
stamps prior status + per-candidate dispositions in the driver body,
refreshes the intent digest, and re-runs the checker transactionally;
mark-validated now refuses to reopen a closed packet. Additive closed
enum in both v1 schemas is the entire checker-side change.
Two guards for the 2026-07-09 wrong-tree/evidence-loss incident:
wckn: pawl_tip_coherence refuses (exit 3, fix-and-re-run) when the review
target commit cites bead ids sharing the reviewed bead's prefix and none is
the bead or a dotted relative - a verdict can no longer bind a tree that
belongs to a different bead. Positive-mismatch only; PAWL_ALLOW_TIP_MISMATCH=1
opts out. 7 bats.
np1e: evidence transcripts and membrane catches now root at the CANONICAL
checkout (git-common-dir parent) via pawl_evidence_dir/pawl_canonical_root,
so reviews run in disposable land worktrees no longer write artifacts that
die with 'git worktree remove', and ledger evidence_path never dangles.
5 bats. Both libs registered in validate-embedded-sync + embedded copies synced.
Claude-Session: https://claude.ai/code/session_01BkgdP88SiccCSyYdi8GHNa
Closes the audit gap where draft->validated was a hand-edit the checker
could not see: mark-validated requires a PASS/WARN verdict string, flips
both statuses, stamps the driver verdict line, refreshes the digest, and
re-checks in one move. new now scaffolds the canonical class->tier andon
router (with a TODO row for goal-specific doors) instead of leaving the
driver body bare. Bats 11/11 + checker 12/12.
Claude-Session: https://claude.ai/code/session_01BkgdP88SiccCSyYdi8GHNa
Dogfood gap: the skill produced a validated packet but no small copyable
prompt for the codex/claude goal APIs. New 'prompt' subcommand renders a
<=4000-char packet-pointer prompt from intent+driver frontmatter; fails
closed on draft packets and over-budget prompts. SKILL.md step 7 + scenario;
codex twin regenerated; 3 new bats cases (6/6 + 12/12 green).
Claude-Session: https://claude.ai/code/session_01BkgdP88SiccCSyYdi8GHNa
A user whose only installed agent CLI is claude has NO cold reviewer (LAW 0:
no cold claude adapter; the cold path is codex-first by design with
REVIEWER=agy as the sanctioned failover). They previously found out DEEP in
the run — CODEX_EXEC_MISSING=2 from lib/codex-exec.sh after the amend-guard /
deterministic battery / smoke / packet build — with no guidance.
New EARLY user-readiness preflight (after the jq precondition, before any
review work): resolve every usable reviewer for the run (default codex;
REVIEWER / PAWL_REVIEWER_CHAIN honored, roster-validated upstream) and when
NONE has its CLI on PATH, exit 2 immediately with a named, actionable message
(install the named reviewer / REVIEWER=agy Gemini failover / ao pawl up
adaptive service) + the standard MISSING triage block. Non-strict only —
STRICT keeps its own reachability + honest-UNAVAILABLE/HOLD (exit 5). The
in-loop MISSING precondition stays as defense-in-depth and the mid-chain
failover path; a partially-reachable chain passes through untouched.
Bats: tests/scripts/pawl-review-user-preflight.bats — codex-absent sanitized
PATH exits 2 early with the named message and creates NO review artifacts;
REVIEWER=agy absent gives the same shape naming agy; chain codex,agy with agy
present still fails over (preflight never blocks a reachable chain).
Differential vs pristine base: no new failures (fixes the ergonomics MISSING
triage lock on hosts where the ebec.9 battery resolves a host ao).
shellcheck -S warning clean; embedded re-sync green.
The standing-service route path (pawl.sh cmd_route) recorded its REFUTEs only
via the verdict write's generic reason — 'standing-pawl route: no agreement
(tier=…)' — a route pseudo-class no producer can act on, while the refuting
lane's substantive 'REFUTED: <finding>' sat unread in its evidence capture.
- pawl.sh: after the routed-REFUTED verdict write, ALSO emit a structured
membrane catch via the landed Go entry (ao membrane catch --evidence, whose
two-tier salvage skips the routed PAWL-nonce sentinel and extracts the real
finding). _refuting_evidence picks the FIRST refuter's capture in canonical
cc->cod->agy order; no substantive refuter (all-timeout/insufficient) =>
skip — a timeout carries no finding to salvage. _route_ao_bin mirrors the
trusted-checkout resolve order (AO_BIN, repo build, PATH). Non-blocking +
fail-safe (emit_pawl_catch contract): never affects the REFUTED exit.
- pawl-review.sh: double-record guard — the routed-REFUTED branch no longer
calls emit_pawl_catch. Its $evidence (${bead}-pawl-review.txt) is never
written on the routed path (absent => boilerplate placeholder; stale from a
prior cold run => wrong finding). One recorder per routed REFUTE, at the
point that owns the evidence (cmd_route).
- tests/scripts/pawl-route-emit-catch.bats: seam lock (executed red first),
mirroring pawl-review-emit-catch.bats — lane selection, exact flags,
class/detector passthrough, empty-evidence skip, fail-safe, no-ao no-op.
- cli/embedded/pawl: re-synced (make sync-hooks); validate-embedded-sync PASS.
Supersedes/absorbs age-7cws.
Absorb emit_pawl_catch's reason-extraction from scripts/pawl-review.sh into
the ao CLI (shift-left epic age-tc0l): --evidence <file> applies the same
two-tier REFUTED salvage in Go (last 'VERDICT: REFUTED <text>' sentinel, else
the first substantive 'REFUTED: <finding>' prose line — age-9931 parity —
else the placeholder), resolves domain from the first changed file's top dir
and affected paths (first 20) from git by --scope head|staged, all via the
existing buildCatchInput + yieldledger.Writer sink. Explicit --reason /
--domain / --paths win over extraction; all prior flags unchanged.
emit_pawl_catch collapses 53->30 lines to a single fail-safe ao call keeping
the || true non-blocking contract and the PAWL_CATCH_CLASS/DETECTOR
passthrough; embedded copy re-synced (make sync-hooks, validate-embedded-sync
PASS). New seam bats lock the delegated flags + non-blocking contract;
table-driven Go tests cover the salvage tiers and git-derived domain/paths.
Batched 4 strictly-strengthening quick-wins from the membrane land-friction investigation (age-pkrl); each skips no check, adds no fail-open, changes no verdict binding:
- age-jmfl: ScriptRunner injects AO_BIN=self (basename name-guard) so the gate binary is authoritative for sub-checks — kills stale-binary false provenance.chain on the release-authority path.
- age-je8h: deterministic COMMANDS.md — scrub abs paths from generate-cli-reference --help captures + cobra-tree --help-purity test.
- age-jad0: corpus-freshness structural SKIP (exit 75) when its repair tool is absent from the binary — retires the every-land AGENTOPS_CORPUS_FRESHNESS_SKIP ritual.
- age-n8dt: pawl auto-repro cds to the enclosing go.mod (cli/) — kills the wrong-cwd false-REFUTE class.
ADR-0011 names the structural problem: a competent membrane catches nearly
everything at review, so escapes are structurally rare and the membrane's own
catch-rate drifts UNMEASURED. Prior measurements (2026-06-22/23) were one-shots.
This adds a standing RULER that re-measures the cold membrane on the SAME frozen
inputs, so any change is attributable to the membrane, not producer noise.
- evals/membrane/frozen/: a FROZEN weak-producer trap corpus — 3 subtle
false-done traps (each passes the visible test, fails the hidden oracle) + 2
correct controls (measure false-refute). Overlaid onto the task scaffolds by
producers/frozen-trap-producer.sh (deterministic — no model, reproducible
byte-for-byte). Reuses the existing eval-membrane.sh machinery + task oracles.
- scripts/membrane-calibrate.sh: wraps eval-membrane.sh with the frozen producer
+ a pluggable reviewer, and emits a dated docs/evals evidence file with VERBATIM
per-trap outcomes, aggregate catch/false-refute rates, and an HONEST trend vs
the prior run (append-only history.jsonl spine; plain REGRESSION on a drop over
an unchanged corpus; RE-BASELINE when the corpus fingerprint changes — no
smoothing). Budget-bounded (<= #traps+1 reviewer calls; zero producer cost).
- ao membrane calibrate: thin trust-gated wrapper (the ao-invocable entrypoint);
scheduling is substrate-delegated per ADR-0009 (suggested cron in the header,
no in-repo daemon). Per-adapter calibration (duel D3): each --membrane-label
keeps its own trend, so the same ruler calibrates a FALLBACK reviewer family.
- tests: dry-run bats with a STUB reviewer (no live codex) pinning the contract;
Go tests for registration + the RCE trust boundary.
Baseline run (codex reviewer, cross-family): catch_rate=1.0 (3/3 traps caught,
0 escapes), false_refute_rate=0.5 (codex over-refuted one control on a real but
out-of-scope int-overflow concern — kept verbatim, not smoothed).
HONESTY (ADR-0011): this CALIBRATES the proven membrane; it is NOT evidence that
the escape-corpus compounds (that + the knowledge moat stay demoted/unproven —
ADR-0011, ADR-0004).
Kills the operator-error class that fired ~4x on 2026-07-07, each costing a wasted
review round: after the pawl auto-binds a #trivial provenance commit on the feat, a
'git commit --amend' folds the fix INTO the bind — HEAD becomes a #trivial commit
carrying code, the reviewer's trivial-waiver walk steps PAST it to review the STALE
original feat, and the verdict binds the wrong tree (surfacing only as an opaque
'hidden non-trivial commit' gate failure at push).
scripts/lib/pawl-amend-guard.sh (new, sourceable, unit-tested): pawl_amend_guard
returns 2 when HEAD is a trailing-#trivial commit changing non-provenance files,
printing the diagnosis + the rebuild-don't-amend recovery. Wired EARLY into
pawl-review.sh (scope=head, before the review — where it saves the round) and as a
land-time backstop in pawl-land.sh; embedded pawl bundle + Makefile + embed validator
updated. Safe: provenance-only #trivial binds, plain feats, and mid-subject #trivial
mentions all pass; opt out PAWL_NO_AMEND_GUARD=1. bats 5/5; guard proven to fire on
the trap and stay silent on legitimate binds; race suite green.
A gate whose repair instruction points at a removed command fails closed with no
working way out (2026-07-07: corpus-freshness blocked a land with 'fix: ao corpus
snapshot' after 'ao corpus' was archived — the audit's fix-hints-are-CLAIMS pattern
as a gate defect). scripts/check-gate-fixhints-live.sh scans the gate-backing scripts
for fix/remedy/repair directives naming an 'ao <subcommand>' and asserts each resolves
in the live 'ao __complete' command tree; exempts shell comments and lines carrying a
removal/historical marker (restore|archived|retired|...), so documenting what a command
USED to be is not flagged. Warn-only (GOALS row); --strict/AGENTOPS_GATE_FIXHINTS_STRICT=1
blocks. Preamble-sourced (ratchet). bats 5/5; live repo PASS (0 dead hints).
THE bottleneck fix. On 2026-07-07, closing skill-doc changes took 4-7 model-review
rounds each because ~90% of the defects were DETERMINISTIC (format/link/twin/
gate-hint) — but the expensive cross-family verdict was requested BEFORE the local
deterministic checks were green, so the membrane re-discovered them serially, each
round a full re-review + re-land.
scripts/lib/pawl-preflight.sh (new, sourceable, unit-tested): pawl_preflight <scope>
<repo> runs the reviewed repo's deterministic battery before the reviewer and fails
fast (exit 3, same fix-and-re-run disposition as the --smoke/REFUTED path) on a
CONFIRMED-red battery — WITHOUT spending a reviewer round (0 tokens). Command:
PAWL_PREFLIGHT_CMD if set (operator owns exit semantics), else the default
'ao gate check --fast --scope head' (ao resolved AO_BIN -> repo cli/bin/ao -> PATH,
mirroring check-pawl-pre-push.sh). Wired into pawl-review.sh after the HEAD snapshot,
before packet assembly + the meter clock.
SAFETY — pure ACCELERATOR, never a false blocker: a nonzero that cannot be CONFIRMED
as a real gate run (RCE trust-guard, missing/stale ao, build error — distinguished by
the absence of the gate's 'checks —' summary marker) is SKIP-and-proceed, not RED.
Worst case it no-ops and behavior is byte-identical to today (the pre-push gate still
backstops). Disabled for PAWL_UNTRUSTED_REPO=1 (never run a repo's own battery over an
untrusted checkout) and scope != head (no committed HEAD). Opt out: PAWL_NO_PREFLIGHT=1.
Converts the inert prose lesson 'run the battery before the verdict' into MECHANISM
(the skills audit proved prose lessons are inert). bats 9/9; embedded pawl bundle +
Makefile sync + embed-sync validator updated; Go embed test green.
Automates the 2026-06-10 hand count: counts Skill-tool calls (compact
"skill":"name" shape) + slash commands (<command-name>) across
~/.claude/projects transcripts with --since/--dir/--json; segments
library/read-not-invoked skills so their zeros are not misread as death;
caveats (codex-side, other hosts, read-consumption) stated in output.
Feeds disposition refreshes + age-e508 measured tier. bats 4/4 (fixtures
round-trip the real persisted shapes); shellcheck clean.
Harvested FRESH against current main per the bead spec (PR predates the 07-03/07-04
churn; PR flow retired — direct-main + pawl is the landing path).
Item 1 (from 0c9b1582e): skill-builder init.sh/build.sh honor
SKILL_BUILDER_REPO_ROOT (HEAL_REPO_ROOT pattern) so the whole scaffold pipeline
— skills/, dispositions ledger, codex catalog, registry regen — points at a
scratch repo in tests; test_skill_builder.bats scaffolds into a setup_file
scratch copy + isolation test proving no bats-builder-test-* trace lands in the
real tree (14/14 green, 0 traces). append-skill-disposition.sh appends
newline-safely (regression test; 12/12 green).
Item 2 (from 30f7b4f80): regen-codex-hashes.sh keys manifest skills[] by name
— last row wins, one row per name; live run collapsed 102 rows -> 62 (the 40-dup
pathology the PR described), 0 duplicate names. regen-codex-hashes-only.bats
regression suite green. skill-builder twin resynced (codex-sync --force) +
hash bookkeeping refreshed; validate-codex-generated-{manifest,artifacts} green.
Item 3 (compile->curate merge) deliberately NOT harvested here: superseded by
the audit retire wave (l6ic.12) which lands the end state (compile+curate ->
post-mortem) in its own arc. #892 closes as superseded after this lands.
Council-decided (3/3 unanimous, .agents/council/2026-07-06-debate-next-move.md).
A benched family (A7: agy) dispatched by default stalls ~3.5min/land and
contributed zero verdicts on 2/3 of tonight's lands. resolve_default_families
= probe minus PAWL_BENCHED_FAMILIES (default agy; empty = bench nothing), with
a raw-probe fallback so an all-benched host degrades instead of dying.
Explicit pins (--tri/--models agy) still admit benched families; quorum/tier
math untouched (26 pre-existing capability tests green unchanged; 4 new).
Embedded pawl bundle synced in THIS commit (make sync-hooks) so the commit
validates against its own tree (per-commit cockpit rule).
Observational surface for epic age-verification-economics-ebec: structural
verdict edges from the provenance ledger (totals, refute rate, per-family,
per-month), git bind-subject cross-check (SKIP on shallow clones), and cost
columns printed UNMEASURED until the meter (.1) lands. Wired as a warn-only
GOALS.md gate; FAILS only on a dead instrument (zero verdict edges).
bats 5/5 green; shellcheck -S warning clean; live run: 322 verdict edges,
2.8 pct refute rate (311 CONFIRMED / 9 REFUTED).
Reframe the retired-bd stance across the live canon now that gascity is the
adopted substrate. bd/dolt is NOT retired globally — it is the gascity SUBSTRATE
store (first-class, embraced, native to a gas-city factory); br remains
AgentOps' OWN repo work tracker. Different layers, not competitors.
Enforcement gates keep the agentops-tracking protection but gain a documented
substrate carve-out: a line marked 'gascity-substrate' is exempt from the
bd-command check (unmarked bd tracker commands still fail). TDD: carve-out bats
tests added red first, then the gate change to green (20/20 pass).
Left untouched: .agents/, docs/audits/*, release notes / CHANGELOG / UPGRADING /
3.2.md (historical release records), retired/historical runbooks.
docs.no-retired-tech: gascity is the ADOPTED coexisting substrate now, not
retired tech — drop gas[ -]?city|gastown from the retired pattern
(runtime=gc, the severed in-CLI bridge, stays flagged); SUBSTRATE_LANG
carve-out applies to bd-tracker-class hits ONLY (a co-occurring runtime=gc
or other retired token keeps the line flagged — pawl refute closed that
fail-open). docs.control-plane-taxonomy: negation forms ('moved off',
'is **not** the store') are removal language, not a rebinding — narrow
REMOVAL_LANG extension; affirmative rebinding still fails. Both pinned by
bats (6+8 cases). Adopts and hardens the in-flight worktree change from
the crashed morning lane (same epic).