companion — be a session peer on an org-team channel
Experimental. This skill is part of org mode (Article X — multi-session coordinated workflows), which is opt-in and off by default (velocity.org_mode.enabled). The single-session workflow is unaffected by anything here.
It turns the current Claude Code session into a session peer on a coordination channel, so a feature can be worked by a small pod of real sessions instead of by one. Instead of pasting register_peer / claim_task / signal_done calls by hand, you type /companion on <channel_id>.
The bounded-peer contract (Article X — non-negotiable)
A peer decides in its own lane and escalates out of it. When acting as a companion you SHALL:
- Stay a peer. You are a peer on this channel for the whole session, even if the same human also runs the lead session elsewhere. You SHALL NOT arbitrate yields, release or re-dispatch lanes, answer other peers, or decline a claimable lane on the belief that you are the lead. If a lane is claimable and yours to take (no assignee, or assigned to you), take it.
- Know who you are. You are the exact
peer_id you registered as, and no other. Never infer identity from a task push — a task-available for a lane assigned to another peer is not yours, and a rejected claim does not mean you should change who you are.
- Decide in-lane. How to structure a function, which existing helper to reuse, where a boundary sits — those are yours to make, in your own main context. That is what makes a peer a peer rather than a worker.
- Escalate out-of-lane. A cross-lane choice (its answer changes another lane) or an un-decidable one (it needs design or product judgment) is not yours. Task-bound →
yield_fork(...) and stop that lane. Free-form → ask_lead(...) and continue if you can. The lead answers via answer_peer; read it back with sprint_status.
- Never send a free-form directive over the channel. The message types are a closed set; coordination travels over it, judgment does not.
Execute a claimed lane within its declared write_set only. Every PreToolUse hook fires on your writes exactly as in a solo session.
Prerequisites
- Org mode is enabled for this repo (
velocity.org_mode.enabled in .claude/project.json). Off → stop; the fence is closed by design.
- The
baseline MCP server is loaded. It ships registered in .mcp.json, so a normally-started session has it. Verify with claude mcp list (expect baseline … Connected). If the tools are missing, the session started before the server was registered — restart it.
- Same repo, same machine as the lead. The channel is a file-locked on-disk directory under
.claude/state/sprint/<channel_id>/. A peer elsewhere cannot see it.
- A git repository. Org mode requires git.
No launcher and no special flags: a peer is an ordinary Claude Code session opened in the same repo.
Subcommands
Parse the argument string. The first token is the subcommand (on | off | status).
/companion on <channel_id> [peer_id]
- Require
<channel_id> (alphanumeric, _/-, at most 128 chars — it must satisfy the channel's isSafeId). If absent, stop and ask.
- Choose
peer_id: the second token if given, else companion-1. Same id rules.
- Call
register_peer with { channel_id, peer_id, pclass: "session", role: "peer", workspace: "." }. Confirm { ok: true, registered: true }.
- Write the local marker
.claude/state/companion/<channel_id>.json (create the dir if missing):
{ "channel_id": "<channel_id>", "peer_id": "<peer_id>", "pclass": "session", "active": true, "registered_at": 0 }
off and status find the active peer through this marker. .claude/state/ is gitignored.
- Enter the claim loop:
- Call
sprint_status({ channel_id }) for authoritative state. Pick a pending lane with no assignee (or assigned to you) whose depends_on are all done, then claim_task({ channel_id, peer_id, task_id }). The handler re-checks atomically, so a lost race returns claimed: false — move to the next lane.
- On a successful claim, execute that lane within its
write_set, applying code-structure discipline.
- On completion call
signal_done({ channel_id, peer_id, task_id }) and report the unblocked list it returns.
- On an out-of-lane fork →
yield_fork(...) (task-bound) or ask_lead(...) (free-form), per the contract above.
- When no claimable lane remains, do not stop — enter watch mode so a lead re-dispatch is picked up with no human turn: run
node .claude/skills/companion/watch.mjs <channel_id> <peer_id> with run_in_background and end the turn.
- Watch-mode re-invocation. When the watcher exits, act on its JSON:
{"wake":true,"task_id":…} (exit 0) — a lane became claimable. Re-enter the claim loop, then re-arm the watcher and end the turn.
{"wake":false,"reason":"companion inactive"} (exit 2) — /companion off ran. Do not re-arm.
{"wake":false,"reason":"heartbeat"} (exit 3) — no work in the cap window. Re-arm and end the turn; this only keeps each turn bounded.
- Report what you claimed, finished, and escalated. Once the watcher is armed the peer is hands-off.
/companion off [channel_id]
- Resolve the marker: the given
<channel_id>, or the single active marker under .claude/state/companion/ if only one exists (otherwise ask which).
- Stop the claim loop. An armed watcher reads the marker's
active flag each tick and exits 2 on the next poll, so it self-stops once step 3 runs — do not re-arm after that.
- Set
"active": false in the marker.
- Call
leave_peer with { channel_id, peer_id }. It removes the peer from peers[] and returns { ok: true, removed: <bool> } (idempotent).
- Report: deregistered from the channel and marked inactive locally.
/companion status
Read any markers under .claude/state/companion/ and report channel_id, peer_id, active, registered_at for each. If none, report "no companion peer registered in this session."
Optional: push dispatch
The shipped path above polls — sprint_status is the authoritative check, and the watcher blocks between ticks. That is deliberate: it depends on nothing outside the project.
A push-dispatch path used to exist via a sibling channel server. It needed a launch flag no consumer install can assume, so it was never part of the shipped path, and it retired in 0.26.0. Polling sprint_status is the whole peer path. A pointer naming a claimable lane may arrive from the host and shorten the wait, but nothing here waits for one.
If you are experimenting with push dispatch, treat it as research-preview tooling with the failure modes that implies. Nothing in the claim loop above changes: sprint_status remains the never-dropped completion check, and a lost push is recoverable by reconciling from it.
Constraints
- Decide in-lane, escalate out-of-lane — re-read the Article X contract above. A peer that guesses a cross-lane answer defeats the model.
off deregisters via leave_peer — it removes the peer from peers[] and marks the local marker inactive.
- Experimental — org mode is opt-in, off by default, and its surface may change. The single-session workflow is unaffected.