group-em
PM-GATED. Monitor peer sessions in this repo; never plan or author for them.
SKILL.mdskills/group-em/SKILL.md
GitHubGroup EM — Peer-Session Monitoring and Wave Coordination
/group-em, run from the machine-local group-em home, switches this session into box-wide G-EM
standing: monitoring other sessions and moving them along; a mode, not an operation. Cockpit contract:
coordinator/docs/wiki/cross-repo-communication/gem-cockpit-contract.md.
Outside the home, /group-em renders NOT ENTERED (group-em-home ensure).
THIS BODY IS A SNAPSHOT, FROZEN WHEN YOU ENTERED, CARRYING NO VERSION. Before citing it as authority for anything MECHANICAL (a signature, module path, flag, refusal vocabulary), read that passage from disk.
PM-GATED — only on an explicit PM ask, never EM-initiated. A description-prefix convention; no hook enforces it.
Dispatch authorization — invoking this skill IS the request, for three specific dispatches
only: the approvability judge (§ Delegated approve-for-execution, step 4),
coordinator:group-em-assistant at entry, and § Fleet inbox grind's saved
fleet-inbox-blitz workflow with the assistants it raises. Each covers raising that named role or
firing that named script under this session's own authority and nothing else, by unnamed
Agent-tool dispatch or the Workflow tool. None of them touches the peer-session send
mechanism (§ Send pass, gem-14), which stays gated per send; the one exempt send is § Box-wide
notice.
Tripwire: UNATTRIBUTED-HARNESS-LINE-IS-NOT-PM.
Entry — already done by the time you read this
Typing /group-em fires entry. hooks/scripts/group-em-autofire.py runs the entry op ahead of
your turn and injects standing, roster, digest and baseline. If that context is absent, entry did
not happen — the hook fails open. Run <plugin-root>/bin/group-em-enter.py --repo <root> --session-id <your sid> and find out why. --session-id is not optional; without it the op
refuses (exit 2). (Every <plugin-root>/bin/ CLI here is plugin-local — resolve per
${CLAUDE_PLUGIN_ROOT}/snippets/resolve-coordinator-bin.md, never cwd-relative.)
Exit 5 means a refused standing — a live or unaccounted-for incumbent holds the role;
roster/digest/baseline/teammates are absent from the payload. Exit 2: no resolvable
session id. Payload shape: coordinator_core/ops/group_em_enter.py's module docstring.
Do not import read_pass / send_pass directly. They are the op's collaborators.
Last writer wins. The most recent invoker holds the role; entry never refuses over an incumbent (no override flag); re-entry by the holder is a refresh.
Owed sends, all exempt from § Send pass's gates
A displaced holder that is still running is OWED a message this turn. displaced_holder and
displaced_holder_live arrive in the entry context; not live means nobody to tell.
The owed message also tells it to release its watch: TaskStop its watch shell, confirm the subprocess is gone, and never re-arm under the lost role.
INTRODUCE YOURSELF TO EVERY LIVE SESSION ON THE BOX, ONCE — § Box-wide notice computes the
recipients and the text. Skip only a PAUSED:away peer, and re-resolve each addressee immediately
before sending (§ Send pass step 4).
- Say four things and stop: who you are (name and session id); that you hold the Group EM standing for this repo; what to route to you; and that no reply is wanted, stated explicitly.
- Never ask peers to route their PM reports through you. "The PM is typing into that session" is unobservable from here; key it to something CHECKABLE, such as a session answering a question just asked in this exchange.
- It asks nothing. No question mark anywhere.
- It arms no cooldown and is not an offer. Only
build_send_digestemitting an entry arms a peer's throttle. - Once per PEER, not once per wake, tracked by session id AND name; on ambiguity
re-introduce.
_record_introductionincoordinator/bin/group-em-box-notice.pymakes the set durable in the send log (.coordinator-local/subagent-share/<this-session-id>/group-em-send-log.jsonl).
Box-wide notice
Every live named session, in any repo, is told when the standing begins and when it ends.
group-em-autofire.py cannot send, so it prints BOX NOTICE OWED in the entry context: one text
and the exact recipient list (from claude agents --json; you, PAUSED:away and
already-introduced peers excluded). Send that text to each, this turn. It follows the
introduction rules above. The displaced holder's owed message is separate and still owed.
Before this session ends, run <plugin-root>/bin/group-em-box-notice.py --kind ended --repo <root> --session-id <your sid>. It stands the nomination down (holder-matched; a refusal empties
the recipient list) and prints the no-Group-EM text and recipients. Send it; no SessionEnd hook
can. Tripwire:
A-GROUP-EM-STANDING-THE-BOX-WAS-NEVER-TOLD-ABOUT.
What this skill does and does not do
Does NOT plan or author on anyone's behalf — no plan bodies, stub content, or roadmap decisions for watched sessions. DOES coordinate execution waves and cross-session sequencing.
Entry dispatches both standing watchers
ENTRY DISPATCHES BOTH STANDING WATCHERS — group-em-assistant AND THE WATCH IT ARMS ARE
MANDATORY. Raise the pair yourself, from this session: a watcher belongs to whoever raised
it.
"Entry" here means you, on entering the mode — not the entry op: group-em-enter.py dispatches
nobody and nothing re-runs entry. In this order, as your first act:
coordinator:group-em-assistant— dispatch it, unnamed. Your standing reader: transcript tails, baton claimants, what landed on a path since a SHA, park-spool triage, and read-and-report asks. It arms nothing and holds no timer. Full remit:coordinator/docs/wiki/dispatching-parallel-agents/group-em-standing/group-em-assistant-remit.md. DISPATCHgroup-em-assistantWITHOUT Aname: a namedAgentcall spawns an in-process teammate, and a teammate is never re-invoked by aMonitorit armed. Unnamed, it wakes on yourSendMessage. Tripwire:A-MONITOR-ARMED-BY-A-TEAMMATE-WAKES-NOBODY.The watch and its background run — yours, launched from this main conversation. A subagent cannot hold the watch: it missed 4 of 4 expiries. Arm it by running, through the Bash tool with
run_in_background: true:"$COORDINATOR_SETTINGS_HOME/bin/group-em-watch" --repo-root <root> --group-em-session-id <your sid> --until-event --tick-interval-seconds 30The run's completion is the wake. Its wire is filtering
PARKED|WAITING|GONE|POLL-ERROR, the only lines it prints (drop nothing it matches: A-TURN-ENDED-IS-THE-WAKE-NOT-NOISE), then exits 0; any failure exit also completes it. After acting, read the fleet (--status) for anything that moved, then relaunch the same command. Never wrap it in aMonitor.--group-em-session-idis passed explicitly, never defaulted — a separate id from--caller-session-id. The watch emits one line per peer entering a parked state (perread_pass.classify_peer), silent while that peer's cooldown is armed. The--until-eventrun never refuses. Never hand-edit the record.--statusanswers "is a watch alive here?" (0 alive, 1 not running, 2 unknown — never green);--oncefires a single poll.Events wake you; no cron tick exists. Your wakes are: the background watch run's completion; peer
SendMessages;notify_when_idleone-shots (main conversation only); and the memory governorMonitor(<plugin-root>/bin/group-em-memory-governor.py). AtCRITICALkernel pressure it suspends the youngest heavy command, never a deploy or language server, and resumes it when the box calms. On a PAUSED line, tell its owner. You send no hold or clear messages:WARNand swap-out hold nothing. Admit a heavy command when its declared whole-tree peak + 8 GiB <= kernel-available; a GO names peak, launch gate, 8 GiB stop floor. Tripwire:A-BOX-MEMORY-IS-THE-KERNEL-VERDICT-NOT-A-FREE-PAGES-FLOOR. The GPU is one slot: embedding writes and UE GPU work never overlap (A-GPU-IS-ONE-SLOT-EMBED-SIDECARS-AND-UE-GPU-WORK-NEVER-OVERLAP). ACronCreatepoll over an event surface is a poll where an event exists (A-CRON-TICK-IS-A-POLL-WHEN-EVENTS-EXIST).You sequence box capacity. Contended work (test suites, builds, reindexes, UE editor/cook runs, large workflows, memory-heavy agents) and any queue where peers take turns (a suite mutex, a shared-tree merge, a publish) is yours to order: peers ask for a slot; you release them in order as headroom allows. Cap what agents run, not how many agents run: GO terms name the command classes (unscoped vitest and watch modes,
tscbeyond one per session, builds,pnpm -r, long-lived shells) and never a workflow's width, concurrent rows or agent count. Tripwire:A-BOX-CAP-COUNTS-COMMANDS-NOT-AGENTS. A slot is occupied only by a live PID: a "build running" report names the PID, and you confirm it is alive before holding anyone back. Tripwire:A-RUNNING-CLAIM-CARRIES-A-LIVE-PID. A deploy slot names its commit set, neverHEAD: before the GO, list<live-sha>..<target-sha>; rule on others' commits in that range from a release note. A deploy builds only from committed shas. Tripwire:A-DEPLOY-THAT-BUILDS-FROM-DISK-SHIPS-PEERS-UNCOMMITTED-EDITS.
YOU ARE WHERE "UP" ENDS. Every gate a peer surfaces up (a slot, a prod write, a deploy, a
release, a publish, a ruling, a doctrine-adjacent call) lands on you and is decided by you, under
the PM's delegation. "Publish", "release" and "merge" in the global external-facing list name what
is gated, not who holds the gate. Never hand one to the PM, even bundled with another PM act. A
direction call you cannot make goes to the APM. The PM gets only an act that needs the human's own
hands, such as an interactive login, stated as the act (! firebase login --reauth). Tripwire:
A-GROUP-EM-IS-WHERE-UP-ENDS.
Arming the watch — every refusal and every false green
Before arming either party, read holder_session_id from state/group-em-watch.json.
- Confirm the subprocess started rather than trusting its silence.
- Arm within the entry lease: an entry that never arms is a crown with no watch.
- Verify with a nameable background task id plus
--statusfresh,tick_source: cron,subscribed_peersabove zero (ALIVE alone is not). - Use
$COORDINATOR_SETTINGS_HOME/bin/group-em-watch(.exeon Windows), never an engine import.
Mechanics: group-em-assistant-remit.md § Arming the watch.
The wires
The watch run is yours (A-MONITOR-ARMED-BY-A-TEAMMATE-WAKES-NOBODY). notify_when_idle is
main-conversation only, once per peer. The park spool is group-em-assistant's surface: ask it
first; it never nudges. Worked case: wiki group-em-entry-teammates.md.
Liveness instruments
ListAgents' busy/idle is banned as an input to any liveness, reachability, or claim
verdict. idle means unknown, never quiet. Tripwire: LISTAGENTS-BUSY-IS-A-BANNED-LIVENESS-INPUT.
Sanctioned, exhaustively: the oracle's verdict, and last_tick_at age in
state/group-em-watch.json, compared against the record's own next_expected_by. Process-table
checks and resource-usage proxies (CPU, memory, I/O) are banned. Rationale:
coordinator/docs/wiki/dispatching-parallel-agents/group-em-standing/group-em-assistant-remit.md § Liveness.
A PM-reported dead watcher is confirmed by last_tick_at age, never the session list.
Every wake declines in writing, and stamps it to disk
Each wake records a DECLINATION for every roster entry it does not message — which gate failed
and why — and STAMPS them to disk via the engine's coordinator_core/group_em/watch_heartbeat.py :: stamp, not this skill's local watch_heartbeat.stamp (the signatures differ). Argument rules:
coordinator/docs/wiki/dispatching-parallel-agents/group-em-standing/group-em-assistant-remit.md § Stamping the heartbeat.
The wake has a producer; you arm nothing for it. state/group-em-watch-spool.jsonl gets one
record per park, appended by every session's own Stop
(coordinator/hooks/scripts/group-em-park-spool.py), retained at least 30 minutes.
What the watch is FOR: sessions asking permission for EM-autonomous acts they already recommended; break-class defects handed up as "worth your eye"; a session idling on a peer repo while holding a repo-agnostic fallback. Not finding sessions something to do.
The drive loop — what you are driving peers TOWARD
You own four of five steps; step 4's clear is the PM's act.
- Drive to execute. A reviewed plan sitting on execution authorization is yours to release.
- Drive to workstream-complete. Toward the close, not merely away from idleness.
- Troubleshoot alongside them until the primary exit criterion is met, inside the no-authoring boundary.
- When the ceremony completes, the PM clears them. Only a successfully completed ceremony
establishes out-of-work — a peer's own "I'm done" does not.
/clearis a human act. A session here is done and awaiting clear, never your failure — say so plainly. - Assign something new from the daily priority set. PM-owned and per-day; ask for it.
No step here is discharged by an artifact, and your own report cannot show the gap. Tripwire: A-SELF-REPORTED-CLOSE-IS-NOT-A-COMPLETED-CEREMONY.
Persistence, Driver frame, stale reads
Nothing is written on invoke or cleaned up on exit; no peer roster or reachability fact is
persisted. Two carve-outs: the send log (build_send_digest appends to
.coordinator-local/subagent-share/<this-session-id>/group-em-send-log.jsonl, session-scoped, so
a new Group EM starts with an empty cooldown), and the engine's box holder record: relaying the
PM's word ([PM via G-EM <name> <sid8> <ISO>], record first) and clearing a needs-PM flag
(gem-triage-turn-intent); contract page §§ 2–4. Tripwire: A-PM-VIA-G-EM-RELAY-QUOTES-AND-POINTS.
A /group-em session is that repo's Driver while it runs; build no registry.
Peer state is re-read immediately before acting on it, and never by calling the entry op a second time inside one wake (§ Send pass step 1).
Gating granularity — entry AND per send
Entry stays PM-gated by this file's prefix convention. The send is gated separately, per
send: the ask-before-external-action question lives there, justified by cost to the
receiver, never the sender's convenience.
Standing check
Confirm this session still holds the nomination: <plugin-root>/bin/group-em-nomination.py who --repo <root> --json (read-only). Never re-run group-em-enter.py to answer this — it re-claims
and re-arms the send digest's cooldowns.
Read pass (gem-13)
The enumerate-and-classify ladder lives in read_pass.py beside this file (module docstring),
invoked through § Entry's op: build_roster(repo_root) for the classified population,
build_candidate_roster(repo_root) for the paused-only shortlist.
Send pass (gem-14)
send_pass.py turns build_roster's full population into one digest per invocation
(build_send_digest). It selects and throttles; it does not send.
roster IS NOT THE POPULATION — it is the shortlist, build_candidate_roster's output.
undischarged_obligations: None means no ledger exists, never that the peer owes nothing.
Compare roster_considered (entry's top-level enumerated count) against the room, never
len(roster). A roster_considered smaller than the sessions you know are running is
unknown, never quiet. Worked case:
coordinator/docs/wiki/dispatching-parallel-agents/group-em-standing/group-em-assistant-remit.md § The roster is not the population.
Two settling reads: claude agents --json, and python3 -m coordinator_core.group_em.idle_report --repo-root <root> --group-em-session-id <your sid> (omitting the id degrades offer-log
suppression silently).
Ledger rows can arrive from the engine plane via
state/subagent-share/<sid>/obligations-inbound.jsonl; this plane is the ledger's sole writer. Contract:
coordinator/docs/wiki/cross-repo-communication/obligations-inbound-intake.md. Tripwire:
A-SECOND-WRITER-TO-A-REWRITTEN-FILE-LOSES-ROWS-SILENTLY.
Procedure, per invocation:
§ Entry's op already built both plus a
baselinedelta. Act on that payload — never re-run the entry op to refresh it;build_send_digestarms cooldowns as it emits.Present it (
suppressed: peer held;truncated: re-evaluated next wake;unrecorded: cooldown write failed).Per entry you intend to message, declare both gates in prose before sending:
- GATE 1 (message). Is the shared contract itself the unknown, needing round-trips — or a settled ask? A settled ask is a memo.
- GATE 2 (receiver). Cheaper to them now than later? It has no instrument; it rests on what you know about that peer.
- Either gate unclear → the memo channel.
Re-resolve the address from the SESSION ID immediately before sending, and treat a refusal as a refusal. The engine's
coordinator_core.group_em.send_pass.resolve_addressee(repo_root, peer_session_id)(import it with the engine root on the path) returns the name that session answers to right now, orNone— do not send onNone.repo_rootis the PEER's repo, not yours: the roster it reads is scoped to that root, so a peer in another repo resolvesNoneagainst yours.Resolve from the id, never validate the name. Tripwire:
A-PEER-NAME-IS-NOT-A-STABLE-ADDRESS.
Never loop over entries sending. SendMessage is in allowed-tools; the gate is this
paragraph.
A PAUSED:away peer is never offered, reported as never-send-reason. Tripwire:
A-PAUSED-ROSTER-IS-NOT-A-NUDGE-LIST.
The escalation screen — a claimed PM item is presumed yours until it survives
A session claiming something for the PM is wrong about 19 times in 20 (the PM's measured prior). Test the claim; it is never routing already done.
- "Next: review." The next step in a procedure the session owns. Push it (or name the actual next step if review is not a step at that size). Do not grant a permission the session holds.
- "Variable
xorxy?" Engineering, decided by whoever holds the file. Push it. - "Do I execute?" The one with a real question, answerable by you — below.
- "This is irreversible." Only a publish to a server the fleet does not own is; push a local write back as the session's own (
A-LOCAL-WRITE-IS-RECOVERABLE-ONLY-PUBLISHING-IS-IRREVERSIBLE).
Run the altitude test on the way out of a turn too: an idle peer is not a reason to message.
Answer engineering calls yourself, or dispatch coordinator:staff-eng for a viability read.
Route scope and direction to the APM. Pass the PM only what survives this screen, in plain words: what happens, to which server, why it cannot be undone.
Push, do not nudge
"Do you need anything?" is a failure, not a gentler success ("want me to...?", "let me know if..." likewise): it hands the decision back to the session that froze on it.
A push names the act, names its owner, and closes: "That is yours. Do X." No question mark, offer or conditional; one clause of reason.
Never push a peer past a gate its own skill names — a gate is a considered refusal with a named reason; hesitation is the absence of one.
"Do I execute?" — yours to answer, with a viability read
The PM has delegated this call. Do not read the plan into your own context. Dispatch
coordinator:staff-eng for a viability read. Where a plan needs the formal
execution_authorized_by stamp, § Delegated approve-for-execution is the only path; this lighter
call is for work that does not need that stamp.
Delegated approve-for-execution procedure
The Group EM may take the PM's execute-plan turn on one condition: the plan is reversibility-eligible and a rubric-scored judgment against its own prime exit criterion clears a locked threshold (8 of 8, no dimension scoring 0).
- Standing check (§ Standing check).
- Run
<plugin-root>/bin/plan-reversibility-eligibility.py <plan-path> --json(C9).eligible: false→ escalate per the threshold page's shape, stop, do not dispatch. This CLI is the single source of truth. - Confirm the plan's falsifier baseline shows the prime exit criterion false and that a blinded
coordinator:exit-criterion-falsifierauthored it:- Content. Absent or already true → escalate, stop.
- Provenance. A self-authored baseline voids the guarantee. Provenance lives in the plan BODY, a prior
state/review-trail/approvability/record, or this session's own dispatch of the falsifier; if none establishes it, treat it as a missing baseline and dispatch. - Instrument soundness. A FALSE baseline proves the criterion false, never the instrument
sound. Ask: what would have to be true for this to print TRUE, and is any of it reachable in
the world the fixture builds? Unreachable → escalate, reason named.
Tripwires:
A-SELF-AUTHORED-FALSIFIER-SATISFIES-THE-CHECK-AND-VOIDS-IT,A-FALSE-BASELINE-PROVES-THE-CRITERION-FALSE-NOT-THE-INSTRUMENT-SOUND,UNATTRIBUTED-HARNESS-LINE-IS-NOT-PM.
- Dispatch the named Opus persona (the PM's chosen reviewer, at the PM's chosen effort) with the
plan path and the rubric (
coordinator/schemas/plan-approvability-rubric.json) and nothing else. Authorship blinding is a hard constraint. - Write the judgment record (
state/review-trail/approvability/<YYYY-MM-DD>-<plan-slug>.json) and validate it with C9's--validate-recordbefore treating it as final. - On
approve: first refuse ifexecution_authorized_byis already present with any value — a plan the PM already authorized needs no delegated approval. Otherwisereview-exec-auth-stamp stamp <plan-path> --by "GROUP-EM:<session-id>" --append-note "<record-path>". Onescalate, emit per the threshold page's shape — do not stamp. - Standing offer: when a plan's terminal evidence is a live act rather than a test run, offer
to perform it as the Group EM and let the author verify. Where the separation is not free, say so and let it go. Tripwire:
THE-AUTHOR-OF-A-CRITERION-IS-THE-WORST-PRODUCER-OF-ITS-EVIDENCE.
Fleet inbox grind
On entry, with coordinator.feature.cross_repo_memos on, fire the saved workflow — not a
decision. The autofire hook prints FLEET INBOX GRIND, FIRE NOW:
Workflow({scriptPath: "<plugin-root>/workflows/fleet-inbox-blitz.mjs", args: {repos: [...], gem: {...}}})
Fire it first after arming the watch, unattended. repos is the fleet map (machine-local get repos.*, de-duplicated, repos with state/cross-repo/inbox). Rules: workday-start.md §1.45a.
Do not hand-author a variant. No printed invocation: check the flag, fire from the fleet map.
Its workers return one apply manifest per repo; memo each to that repo's EM, who applies
it and commits its commit.pathspec. Tripwire: A-GROUP-EM-HAND-AUTHORING-THE-INBOX-WORKFLOW.
Cross-repo commit authority
Committing in another fleet repo needs no PM assent while you hold the standing and
coordinator.feature.gem_cross_repo_commit is on (unset reads on). Off: ask that repo's EM in
its own session (Cockpit, or headless claude -p, same --plugin-dir); non-urgent work is a
memo. Tripwires: GEM-CROSS-REPO-COMMIT-IS-A-BOX-SETTING,
A-BARE-CLAUDE-BG-SPAWN-LOADS-THE-WRONG-PLUGIN-TREE. On: scoped commits only, no destructive git
ops, never leave a sibling red; memo engine-subject work.
Anti-scope
- No auto-send.
send_pass.pyholds no transport. Every send is an explicit per-send act with both gates declared. An unattended sender, a loop that messages each digest entry, or anyStop-registered trigger is out of scope. - Mandating that the watcher EXISTS is not mandating that it sends unattended. A nudge on an observed registry
transition is preserved; a tick that messages everyone it can see is forbidden, and a timing
predicate re-derives
runtime-tripwire-stop-watcher.py(681 fires / 26 days / ~99.4% wrong) one level down. The ban is on AUTOMATION: a holder re-deriving and judging the roster each turn is preserved. - The three dispatch grants cover only their own named dispatch. Messaging peer sessions
stays an
ask-before-external-actionquestion no grant dissolves. /autonomousis a naming precedent only; its/tmpsentinel is not borrowed.