mygroupie
v0.6.0-rc.3
Published
Coordination layer for AI-assisted engineering — work registry and collision detection
Readme
groupie
Coordination layer for AI-assisted engineering. Groupie tracks which agents and developers are editing which parts of a repository right now, and tells them about collisions before they land.
This is slice one of a larger product: a work registry with collision detection.
Requirements
- Node.js 22 or later, with npm.
- git, and a git repository to work in.
- A GitHub
origin, for syncing with teammates. A repository whoseoriginis not on GitHub, or that has none, still works on one machine (local-only): the hub authorizes by GitHub repository access, so it serves GitHub repositories only. - Claude Code: groupie installs into its hooks and MCP config. Other agents: see Non-Claude-Code agents.
- macOS or Linux. Windows is not supported.
- Optional: the GitHub CLI
gh(gh auth loginis one way to give groupie a GitHub token), andANTHROPIC_API_KEYfor intent comparison.
To run a team hub on Fly.io with groupie hub deploy, also:
- a Fly.io account with billing set up: the hub is one always-on machine plus a 1 GB volume;
flyon yourPATH:curl -L https://fly.io/install.sh | shinstalls it in~/.fly/bin(add that toPATHas the installer says), then open a new terminal;- an app name nobody else has taken: Fly app names are global.
Install
A teammate joining a project that already uses groupie needs three steps:
git clone https://github.com/acme/app.git && cd app
npx mygroupie init
claudeinit installs the hooks and the MCP server, and, when the project's
.groupie/config.json names a hub, offers to join it: it first prints what joining
shares, then asks (on a terminal; --join is your consent in advance, and without a
terminal and without --join it never joins, only prints the groupie hub join command).
If no GitHub token is available it then offers, on a terminal, to run gh auth login
for you or to take a pasted token (typed with echo off); see Which GitHub
token for the one to create. init stores a pasted token in the
macOS Keychain (service groupie-github-token), or elsewhere (and whenever
GROUPIE_HOME is set) in a 0600 file github-token in the groupie home
(~/.groupie); never in the repository, and never printed. The daemon uses
GROUPIE_GITHUB_TOKEN first, then the stored token, then gh auth token. It also says which
repository the token must reach: the hub needs push (write) access to share your work,
and to show you teammates' work it needs read access on a private repository, push on a
public or internal one. groupie token clear removes the stored token. Run through
npx, every command init prints is spelled npx mygroupie …. init ends with one
line each for the hooks, the daemon and the hub, with the fix for anything that is not
working (a missing token is reported there, once):
✓ hooks hooks and MCP server installed in this worktree
✓ daemon running
✓ hub connected to https://hub.example.ts.netThe first person on a team names the hub, then commits and pushes the config:
npx mygroupie init --hub https://hub.example.ts.net
git add .groupie/config.json && git commit -m "groupie: team hub" && git push--hub sets hub.url in .groupie/config.json (creating it, or keeping everything
else in it), then offers to join as above. It refuses a URL that is not an allowed hub
URL, and refuses to replace a different hub.url already there unless you pass --force.
npx runs groupie from npm's cache, which npm may clear at any time, and the hooks then
point at nothing. init warns about this, and groupie doctor reports it as
entry-paths. For a lasting install, install it once per machine:
npm i -g mygroupie
groupie initgroupie has one native module, better-sqlite3, built by its install script. On current
npm (11.21) npm i -g mygroupie only warns about install scripts and builds it. If npm
says better-sqlite3's install script was skipped, reinstall with
npm i -g mygroupie --allow-scripts=better-sqlite3; add the flag only then, since some npm
versions (11.9, for one) reject --allow-scripts as an unknown option. If the module was
not built, the daemon cannot start: groupie doctor reports sqlite with this fix, and
the daemon writes the cause to ~/.groupie/daemon.log.
The package is mygroupie on npm; the command it installs is groupie.
init writes these files:
.claude/settings.local.json: Groupie's hooks, and approval of its MCP server for this worktree. The hooks hold this machine's absolute paths (to groupie and to thenodethat raninit, so they work even when Claude Code'sPATHhas no node), so they go in Claude Code's per-machine settings file, not the shared.claude/settings.json. If an earlierinitput the hooks insettings.json,initmoves them and leaves the rest of that file alone. Commit that change..mcp.json: registers the MCP server, also with this machine's absolute paths. Claude Code has no per-machine MCP file that other tools may edit (its local scope lives in its own~/.claude.json), so it stays here, and each developer'sinitrewrites thegroupieentry for their own machine..groupie/config.json: written if it does not exist yet. This one is meant to be committed: it holds the team's settings, includinghub.url.
init keeps the first two out of git by adding them to the repository's
.git/info/exclude, in a block marked # groupie (machine-specific, do not commit)
(shared by every worktree; .gitignore is never touched). If one of them is already
committed, init leaves it and says to remove it from the repository.
.groupie/config.json and .groupie/decisions/ should be committed. groupie doctor
warns (hooks-shared) when groupie hooks are still in the committed
.claude/settings.json.
Nothing else in those files is removed, and an MCP server you explicitly disabled stays disabled. Start a new Claude Code session to pick up the hooks.
Run groupie init in each worktree agents work in. Claude Code keeps MCP approval
and settings.local.json per worktree, so a worktree that was never initialised has
neither the hooks nor the groupie tools. groupie doctor reports this as hooks and
mcp-approved.
Which GitHub token
Only needed with a hub. The hub checks every request with GitHub: it asks /user (who
you are) and /repos/<owner>/<repo> (your permission on the repository, and whether it
is private). Any of these works:
- A fine-grained personal access token limited to this one repository (recommended): github.com/settings/personal-access-tokens/new, Repository access "Only select repositories" with just this repository, and under Repository permissions only Metadata: read-only, which GitHub adds by itself. For a repository owned by an organization, the organization must allow fine-grained tokens and may have to approve yours. (This minimum follows from the two calls above; it has not yet been checked against GitHub with a real token.)
- A classic token with the
reposcope (needed for a private repository). gh auth login: groupie then usesgh auth token. The simplest, but that token reaches everything your GitHub CLI can.
Whoever runs the hub receives the token with every request. An unmodified hub keeps
only a hash of it, but its operator could log it, so give it the least access that
works. Paste it when groupie init asks (it stores it, as above), or set
GROUPIE_GITHUB_TOKEN, which overrides the stored one.
Set ANTHROPIC_API_KEY to enable intent comparison. Without it Groupie runs
mechanical detection only, which is a supported mode.
Enabling intent comparison
Intent comparison runs in the daemon, and the daemon reads ANTHROPIC_API_KEY
from the environment of the process that started it — usually the first hook
of the first session, not your current shell. groupie doctor reports the
running daemon's state (semantic). If it says the daemon has no key, restart
it from a shell where the key is exported:
export ANTHROPIC_API_KEY=...
kill $(cat ~/.groupie/daemon.pid) # it restarts on demand, inheriting this shell's envEvery verdict the judge returns is recorded — related and unrelated too, not
only the duplicates and incompatibilities that become conflicts — so you can see
that it ran and what it concluded. groupie verdicts [--limit N] lists recent
ones (time, relation, confidence, both intents, the judge's rationale);
groupie status shows Intent comparison: N pairs judged in the last 24h (x
duplicate, y incompatible), or off (daemon has no API key). The daemon logs a
one-line summary per judged claim to ~/.groupie/daemon.log, and a judge error
(at most one line per semanticErrorLogIntervalMs, default a minute) — a failed
call records no verdicts.
What it detects
| Detector | Trigger | Severity |
|---|---|---|
| claim_violation | A write to a path under another actor's exclusive claim | blocking |
| hunk_overlap | Two actors editing overlapping or adjacent lines in one file | high |
| path_overlap | Two actors touching the same file, or one working inside another's claim | medium |
| intent_duplicate | Two declared intents describing the same work | high |
| intent_incompatible | Two declared intents making contradictory assumptions | high |
The first three are deterministic. The last two come from one batched LLM call made when a claim is filed, never on a write.
An agent hears about a given collision once per collision episode, and only at
its strongest severity — a path overlap is suppressed once the same pair collides
on lines. An episode continues while the same pair keeps colliding on the same
file, and ends after activityWindowMs without being re-detected; the next
collision after that is a new episode and is reported again, to both sides. An
episode that has gone quiet is never delivered late. An intent conflict lasts as
long as both claims it names are still enforceable. Acknowledging a collision
silences the current episode only.
Shell writes are observed too, after the fact. Agents change files through
Bash as often as through Edit/Write (python3 - <<EOF … EOF, sed -i,
codemods). After every Bash command the read hook asks the daemon which files the
actor changed since its last check — including changes committed in the same
command — and records them through the same detection as a write hook, so
collisions, notices, and decisions follow. Up to observation.maxShellFiles
(default 50) files are recorded per command; the rest are picked up by later
checks while they remain uncommitted. Changes already present when the session
started are not attributed to it.
The limit: a shell command cannot be inspected for what it will write before it
runs, so a shell write is never blocked. Exclusive claims block write tools;
against the shell they are advisory — a shell write into another actor's
exclusive claim is reported to both sides as a claim_violation, after it has
happened. Shell observation needs Bash in hooks.readMatcher (the default).
Overlapping claims are reported to both sides. The agent filing a claim
sees who is already on those paths in the groupie_claim response; the agents
who claimed them earlier get a path_overlap notice naming the new intent on
their next hook.
Working inside someone's claim is one notice per pair and claim, not one per
file. A broad claim (src/stores) used to produce a notice for every file
written inside it. Now the overlap is one episode per actor pair and claim: each
side hears about it once, later files in the scope are added to the episode's
file list (groupie status shows them, up to the 20 most recent) without new
notices, and the claim-time overlap notice and the writes that follow are the
same episode. A hunk overlap on one of those files is still its own [high]
notice — a scope notice never hides it, and a file with its own notice is not
repeated in the scope notice. Once the episode goes quiet for activityWindowMs,
the next write inside the claim is a new episode and is reported again.
[groupie] Working inside chunk-b's claim — "add profiles to the stores"
chunk-b (claude-code, 1m ago)
files so far: src/stores/useLockStore.ts, src/stores/useUserStore.ts [medium]
→ coordinate or pick a different seam. groupie_status for detail.The claim's holder sees [groupie] chunk-a is working inside your claim, with
chunk-a's intent.
Overlapping work, for whoever merges. groupie status (and groupie_status)
ends with a merge-readiness view: for each pair of actors, the files both changed
within status.overlapWindowMs (default 24h) and which of those have overlapping
or adjacent lines — where their branches will likely conflict:
Overlapping work (last 24h)
chunk-a ↔ chunk-b 5 shared files, 2 with overlapping lines (likely merge conflicts): src/pages/LockPage.tsx, src/stores/useLockStore.tsIt uses each actor's latest observed change per file and the same
hunkAdjacency as collision detection, and leaves out actors that ended longer
ago than the window. It reports overlap; it does not prevent or resolve it.
What a notice looks like in an agent's context:
[groupie] Collision — session.ts
gr-auth (claude-code, 4m ago) intent: "add refresh-token rotation to sessions"
overlap: their lines 120 vs your 121 [high]
→ coordinate or pick a different seam. groupie_status for detail.When several people collide on one file at once you get one notice listing them all, strongest first; with one other person the notice is unchanged. This applies to line and file overlaps delivered together, on one machine with several worktrees as well as through a hub:
[groupie] Collision — src/session.ts (3 others)
app (cy@mac3) (claude-code, 0s ago) — their lines 125 vs your 121 [high]
chunk-b (claude-code, 2m ago) intent: "add refresh-token rotation" — their lines 120 vs your 121 [high]
app (bo@mac2) (claude-code, 1m ago) — both changed src/session.ts [medium]
→ coordinate or pick a different seam. groupie_status for detail.Teammates whose labels collide (every worktree folder called app, say) are shown as
label (user@host), here and in the SessionStart peer list, groupie status and
groupie_status; unique labels are shown as they are.
Decision memory
Agents record choices that others would otherwise remake or reverse, and Groupie puts them in front of whoever next touches the same paths.
Decisions are files in .groupie/decisions/, committed with the code they
govern. A decision is proposed while it exists only on a branch, accepted
once it is merged to the default branch, and superseded once an accepted
decision replaces it. Groupie indexes every worktree, so an agent sees a
proposed decision from a sibling worktree before it merges.
Delivery happens when an agent reads a file — through the Read tool, or
through a shell command that mentions it (cat session.ts, sed -n '1,40p'
src/a.ts; up to decisions.maxShellPaths existing files per command, in one
block) — and also when it writes one (so a file created without a prior read
still gets its decisions), once per session per decision. groupie_claim also
returns the decisions governing the claimed paths, so an agent that claims
before working has them before its first read or edit; those are not repeated
on later reads in the same session. Hooks serve the current index and never scan in the
request; a stale index (older than decisions.rescanIntervalMs) is refreshed in
the background, so a change made in git reaches readers on the next hook after
that refresh. Matching decisions are ranked by path specificity and capped by
decisions.maxPerDelivery and decisions.deliveryBudgetTokens; the notice says
how many were left out. An agent's own decisions are not echoed back to it.
[groupie] Decisions for src/session.ts
dec_7f3k2q Session fields are appended, never inserted mid-interface [accepted]
New fields go after userId or at the end. Why: parallel agents collided…
dec_p2m81x Session ids are opaque strings [proposed · branch v0.3.0 · gr-auth]
Never parse or construct a session id outside src/session.ts. Why: the format is changing.
→ follow these, or supersede via groupie_decide. +2 more: groupie_decisionsA new decision whose paths overlap an existing one must either supersede it or state why the two stand alongside each other. Paths that match nothing in the repository produce a warning rather than silently protecting nothing.
.groupie/decisions/ must not be gitignored: an ignored decision can never be
merged, so it can never become accepted. groupie_decide warns when the file
it wrote is ignored, and groupie doctor reports malformed decision files and
divergent copies of one id.
Agent tools
| Tool | Purpose |
|---|---|
| groupie_claim | Declare intent and expected paths; optionally request exclusivity. Returns the decisions governing those paths |
| groupie_check | Ask who else is on specific paths before starting |
| groupie_status | Who is active, what they declared, which collisions are open, overlapping work, intent comparison state |
| groupie_release | Release your own claims |
| groupie_acknowledge | Mark a collision handled so the current episode stops being reported |
| groupie_decide | Record a path-scoped decision; delivered to any agent that later reads those paths |
| groupie_decisions | List decisions for paths, or the most recent ones |
An agent cannot release another actor's claim. That is a human action:
groupie release --claim <id> --force. A forced release of a teammate's claim that
came through the hub is local to this machine: it is not sent to the hub, so other
machines keep the claim until its holder releases it or its lease runs out.
Exclusive claims block only while their holder is working. A freshly filed
exclusive claim denies other actors' writes outright. If the holder then goes
exclusiveIdleMs (default 10 minutes) without writing to the claimed paths, the
claim stops blocking and other writers get an ordinary overlap warning instead.
The moment the holder writes there again, blocking resumes. This is computed at
check time, so an idle claim is never lost — it just stops wedging others. A
blocked writer is told when the holder was last active and roughly when the claim
will stop blocking; groupie claims labels idle exclusive claims.
Teams: connecting machines through a hub
Agents on one machine see each other through the local daemon. Agents on different machines see each other through a hub your team runs. Each daemon stays in charge of git, detection and its own hooks; the hub only relays coordination facts between them. It never runs git or detection, and never sees code.
1. Run a hub
On something always on (a small VM, an office machine, or Fly.io, below). A laptop works for a trial, but every time it sleeps the team is local-only until it wakes.
groupie hub start --allowed-owner acme # foreground, for a trial
groupie hub install-service --allowed-owner acme # launchd (macOS) or systemd user unit (Linux)--allowed-owner (required, repeatable) names the GitHub orgs or users whose
repositories the hub serves. The hub listens on 127.0.0.1:7420 and keeps its
state in ~/.groupie-hub (--host, --port, --data-dir change them). It
refuses any non-loopback --host unless --tls-cert and --tls-key are both
given, or it runs in proxy mode. On start it prints who can reach it
(reachable from: this machine only (loopback)), the allowed owners and the data
directory.
hub install-service takes the same flags, plus --print (show the unit, write
nothing) and --force (replace an existing unit). It writes
~/Library/LaunchAgents/dev.groupie.hub.plist on macOS, or
~/.config/systemd/user/groupie-hub.service on Linux, that runs groupie hub
start with your flags and restarts it, logging to <data-dir>/hub.log. It
never starts the service itself: it prints the commands to start, stop,
reload and uninstall it, for you to run:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.groupie.hub.plist # macOS
systemctl --user daemon-reload && systemctl --user enable --now groupie-hub # Linux
loginctl enable-linger "$USER" # Linux, optional: keep it running after you log outIt refuses the same shapes hub start refuses, plus two more, because a unit
outlives the shell that wrote it: the owners must be on the command line, and a
non-loopback --host must name its TLS files there too. It allows
--behind-tls-proxy only on a loopback --host (a TLS proxy on the same machine):
on any other interface, whatever reaches that address would bypass the proxy. Paths
in the unit are absolute, and it must be run from the built CLI. Other platforms:
run groupie hub start under your own service manager.
Docker (Linux only). examples/hub/ has a compose file that builds groupie
from this repository and runs the hub with host networking bound to
127.0.0.1, so it is reachable only from the Docker host, with no ports:
mapping. Put GROUPIE_HUB_OWNER=acme in a .env file there, then
docker compose up -d --build. Host networking does not work on macOS; use
hub start or hub install-service there.
Fly.io (one command, always on). With a Fly.io account with billing and the fly
CLI on your PATH (see Requirements):
groupie hub deploy --app acme-groupie --owner acmeIt writes the app into its own folder (~/.groupie/fly/<app>, because fly uploads the
folder it runs in), says who will be able to reach the hub, shows the fly commands and
asks once. It then runs them attached to your terminal, so you see fly's output and
answer its prompts (and fly auth login if you are not logged in). Last, it checks
that the live hub answers health and refuses a request with no GitHub identity, and
if the hub answered one it says so and prints fly scale count 0 --app <name>. Run it
again to resume after a failed step or to upgrade the hub to the installed version:
steps already done are skipped. Add --private for no public endpoint. It ends with
the next step, groupie init --hub https://<app>.fly.dev in your repository; for
--private it is groupie init --hub http://127.0.0.1:17420, and
fly proxy 17420:7420 --app <name> must be running first (in another terminal), because
joining, which init --hub offers, contacts the hub.
To run the steps by hand instead, groupie hub fly-config --app <name> --owner <org>
writes a fly.toml and a Dockerfile and prints the same fly commands; fly-config
never runs them (hub deploy is the one that does). The Dockerfile installs the published mygroupie, pinned to the version
that generated it (no clone, no build). examples/fly/ is the output for
--app my-team-groupie --owner my-org, with its own README.
Public (the default), by hand:
groupie hub fly-config --app acme-groupie --owner acme --dir hub-on-fly
cd hub-on-fly
fly launch --copy-config --no-deploy --name acme-groupie
fly volumes create groupie_data --size 1 --app acme-groupie
fly deploy --app acme-groupie --ha=falseThen commit {"hub":{"url":"https://acme-groupie.fly.dev"}} in .groupie/config.json.
Fly terminates TLS (force_https) and the hub runs behind it in proxy mode: anyone on
the internet can connect, but every request needs a GitHub token with access to a
repository under --owner, and when GitHub cannot be asked the hub fails closed.
Private (--private, no public endpoint), by hand:
groupie hub fly-config --app acme-groupie --owner acme --private --dir hub-on-fly
cd hub-on-fly
fly launch --copy-config --no-deploy --name acme-groupie
fly volumes create groupie_data --size 1 --app acme-groupie
fly deploy --app acme-groupie --ha=false --no-public-ipsEach teammate keeps fly proxy 17420:7420 --app acme-groupie running (it needs fly
auth login and access to your Fly org), and the team commits
{"hub":{"url":"http://127.0.0.1:17420"}} (with the proxy running, groupie init --hub
http://127.0.0.1:17420 writes it and offers to join). The local port is 17420 so it never clashes
with a hub on the same laptop. The private fly.toml has no [http_service] at all,
so even a public IP allocated later routes nowhere.
| | Public | Private |
|---|---|---|
| Who can connect | anyone; only GitHub-verified repository members get in | only people in your Fly org, through fly proxy |
| Teammate setup | groupie hub join | install flyctl, fly auth login, fly proxy, then groupie hub join |
| Matches a future hosted hub | yes | no |
One machine only (SQLite). The hub keeps its state in SQLite on the Fly volume, so
deploy with --ha=false and never scale above one machine. The container starts as
root only to chown the volume Fly mounts root-owned at /data, then drops to the
node user (setpriv) for the hub itself.
2. Put a private TLS front end in front of it
A Fly deployment already has one (Fly's proxy, or fly proxy for a private app); skip
to step 3. A hub you run yourself is reached through a private network's TLS front
end, for example:
tailscale serve --bg 7420exposeshttp://127.0.0.1:7420to your tailnet only, ashttps://<machine>.<tailnet>.ts.net;- a TLS reverse proxy on the same host (Caddy, nginx) proxying to
http://127.0.0.1:7420, with its listener reachable only from your private network.
On the public internet, only behind a TLS-terminating proxy, with GitHub auth. A hub
you run yourself stays on loopback behind a private network's TLS front end. A Fly
deployment may be public: Fly terminates TLS, the hub runs in proxy mode
(--behind-tls-proxy), and every request needs a GitHub identity with access to a
repository under --allowed-owner. Proxy mode refuses the auth test bypass and refuses
to combine with the hub's own TLS files. If you want no public endpoint, use the private
Fly option or a private network. examples/hub/README.md has more detail.
Proxy mode
--behind-tls-proxy (behindTlsProxy in hub.json) tells the hub that a platform
proxy in front of it terminates TLS, so it serves plain HTTP. It is the one way to bind
beyond loopback without the hub's own --tls-cert/--tls-key, and it is only safe
when nothing but that proxy can reach the hub; groupie hub fly-config sets it up for
you. In proxy mode the hub:
- refuses to start with
GROUPIE_HUB_AUTH_TEST_BYPASSset, whatever the bind; - refuses to start with
--tls-cert/--tls-keyas well (one TLS terminator); - still requires
--allowed-ownerand verifies every request with GitHub; - logs at startup that it serves plain HTTP behind a TLS proxy, and prints
reachable from: whatever reaches <host>:<port>rather than calling itself private.
--client-ip-header <name> (clientIpHeader) names the header the proxy sets to the
caller's address (Fly sets Fly-Client-IP). It is honoured only in proxy mode, and the
hub refuses to start with it otherwise, since anywhere else a caller could forge it.
The hub uses it only to key the per-address auth-failure limit, takes the last value
(the one the proxy appended), and falls back to the socket address unless that value is
a valid IP address. Above every address, maxVerificationsPerMinute caps GitHub
verifications for new tokens hub-wide.
3. Commit the URL
In .groupie/config.json:
{ "hub": { "url": "https://hub.example.ts.net" } }The URL must be https:// with no credentials, query or fragment (http:// is
allowed only for 127.0.0.1 or localhost). groupie init then points out that
the project syncs through a hub and that this machine has not joined it.
4. Each developer joins, once per machine
groupie init offers this itself when the repository names a hub, and groupie init
--hub <url> sets the URL and joins in one step for the first person. To join on its own:
groupie hub join # the committed hub.url
groupie hub join <url> # or a URL given herejoin makes one unauthenticated health request (no token is sent), refuses a hub
that speaks another protocol version, then prints exactly what will be shared and
asks before approving:
hub https://hub.example.ts.net — groupie v0.6.0, protocol 1
Joining shares this with everyone who has access to github.com/acme/app on GitHub:
- your GitHub login, host name, OS user name, worktree path and branch
- the agent kind (for example Claude Code) and an actor label (the worktree folder name)
- claim intents and path patterns, plus claim state and lease times
- observed file paths, with changed line ranges and times
- semantic verdicts (relation, confidence, one-line rationale, and the semantic-judge model name)
- your GitHub token, sent with every request so the hub can check your access to the repository: whoever runs the hub receives it and could use it for anything the token allows (an unmodified hub keeps only a hash of it), so use a fine-grained token limited to this one repository
Never: file contents, diffs, decision files, environment variables, or any API keys other than the GitHub token above.
Approve this hub for this repository on this machine? [y/N] y
approved https://hub.example.ts.net for github.com/acme/app on this machine
GitHub token found; the daemon syncs this project while it's running (it backs off if the hub refuses or is unreachable)The approval is recorded in ~/.groupie/approvals.json, never in the
repository, so a committed URL alone never makes a clone send anything. It
is per repository and per URL: a changed URL needs a new join. --yes
approves without asking; without it, join refuses when not on a terminal.
With no token it says sync stays off until you add one: re-run groupie init, which
offers gh auth login or stores a pasted token (macOS Keychain, else a private file), or
set GROUPIE_GITHUB_TOKEN. When the URL you pass differs from the project's hub.url,
join approves it but says the daemon does not sync with it yet (the daemon syncs only
with hub.url), and gives the two ways to make it: groupie init --hub <url> (with
--force to replace a different hub.url), or the .groupie/config.json line to commit.
Decisions still travel through git, never through the hub.
Identity and authorization
Identity is GitHub: each daemon uses GROUPIE_GITHUB_TOKEN, else the token
groupie init stored (macOS Keychain, or a 0600 file in the groupie home elsewhere or
whenever GROUPIE_HOME is set), else gh auth token, resolved in the background, never
on a hook. groupie token clear removes the stored one. Which GitHub
token says which to create. The hub verifies the token with GitHub
and authorizes by your access to the repository: push to send claims and changes;
to read its state, pull on a private repository but push on a public or internal
one. GitHub gives pull on a public repository to every signed-in user, and the hub's
state names hosts, OS users, worktree paths, branches and intents, so pull alone would
let any GitHub user read it. The visibility comes from the same GitHub lookup as the
permission and is cached with it (authCacheMs); anything GitHub does not plainly
report as private counts as public. A refused reader sees forbidden_public_repo in
groupie hub status and groupie doctor: "this repository is public: reading it needs
push access". A hub operator who wants pull-to-read back for public repositories sets
publicRepoReadPermission to "pull" in hub.json. The hub serves only repositories
owned by an --allowed-owner. GitHub repositories only: a repository whose
origin is not on GitHub stays local-only, and join says so.
Checking on it
groupie status shows a Hub: line (connected, unreachable since …, refused,
not joined, no token). groupie hub status shows the detail for this repository:
Hub: connected to https://hub.example.ts.net, synced 4s ago
url https://hub.example.ts.net
approved yes, on this machine
GitHub token available
last sync 4s ago
cursor yes
clock offset +12ms (hub minus this machine)
outbox empty
claims 0 provisional, 0 hub-limited, 0 stale remoteWith the daemon stopped it still shows the configured URL, approval and token
(never the token itself). A refused hub shows its reason and when it retries. When a
teammate or the hub runs a newer groupie whose facts this version cannot read, a
newer facts line counts the ones skipped on pull, dropped by the hub and held back
in the outbox; sync carries on with everything else.
groupie doctor adds hub checks, all warnings rather than failures, since a
project without a hub, or with a hub that is down, is a supported state:
hub-config (an allowed URL, a GitHub repository), hub-approved, hub-token
(found, and where from), hub-reachable and hub-version (the protocol),
hub-sync (the daemon's sync state), clock-skew (beyond
hub.maxClockSkewMs), and outbox (whether queued changes are older than
hub.claimCacheMaxAgeMs). An unapproved hub is never contacted, not even for
health.
When the hub is down
Every machine keeps working locally. Hooks never call the hub (only filing an
exclusive claim does, and it waits at most hub.requestTimeoutMs). Changes
queue in the outbox and are replayed when the hub returns. Collisions that happened
during the outage are still reported once it is back. A teammate's exclusive
claim stops blocking you once your last sync is older than
hub.claimCacheMaxAgeMs (2 minutes), so a dead hub can never wedge a write. An
exclusive claim filed while the hub is unreachable is granted provisionally and
checked with the hub when it returns; if an earlier claim holds those paths, it
is demoted to blocked and you are told.
Replacing a hub
No recovery is needed: the hub's state is disposable. Start a new one; daemons
re-send their live state to it on the first sync. If its URL changed, commit
the new one and everyone runs groupie hub join again.
Commands
groupie init # install into this repository (--join: also join its hub)
groupie init --hub <url> # first on a team: set the hub URL, join, then commit .groupie/config.json
groupie token clear # remove the GitHub token init stored
groupie doctor # check the install and the daemon
groupie config # every effective setting and where it came from
groupie status # who is active, what collides, overlapping work, intent comparison state
groupie claims # active claims and their leases
groupie release # release your claims (--claim <id> --force: another actor's, on this machine only)
groupie decisions [paths...] # decisions governing paths, or the most recent ones
groupie verdicts [--limit N] # recent intent-comparison verdicts, whatever the relation
groupie watch # register this worktree for a non-Claude-Code agent
groupie hub start # run a team hub in the foreground (loopback by default)
groupie hub install-service # write a launchd/systemd unit for the hub; prints how to start it
groupie hub deploy --app <name> --owner <org> [--private] # deploy (or upgrade) a hub on Fly.io; asks once
groupie hub fly-config --app <name> --owner <org> [--private] # write fly.toml + Dockerfile; prints the fly commands
groupie hub join [url] # approve this repository's hub on this machine
groupie hub status # sync state, outbox and clock offset for this repositoryNon-Claude-Code agents
groupie watch registers a worktree as a generic actor. The daemon then polls
its git state every 10 seconds and synthesizes observations. Lower fidelity —
no pre-write blocking — but the registry and the MCP tools still work.
Cursor loads Claude Code hooks from .claude/settings*.json by default, so in Cursor
groupie's hooks may run, in a degraded and untested way. Groupie does not support
Cursor or other agents' hooks yet.
Design guarantees
- Fail open. Groupie never blocks a write except on an explicit, unexpired, exclusive claim by another actor. A stopped daemon, a locked database, a missing API key, or a malformed payload all resolve to "allow, say nothing".
- Say it once. Each actor is told about a given collision exactly once.
- Leases, not locks. Every claim expires. A crashed session cannot wedge
anyone. A contended exclusive claim waits in
blockedand is promoted automatically when the holder releases or expires. - Local first. State lives in
~/.groupie/state.dband is reached over a unix socket. Teams connect machines through a self-hosted hub: each local daemon stays in charge of git, detection and its own hooks, and syncs coordination facts through the hub. A hub that is down or gone degrades everyone to local-only, never to a blocked write.
Architecture
daemon owns SQLite state, JSON API over ~/.groupie/sock; started on demand
├── groupie-mcp stdio MCP server (bin), one per agent session
├── groupie-hook short-lived (bin), one per hook fire
├── groupie (CLI) init, doctor, status, claims, release, watch, hub
└── sync worker pushes local facts to the hub, pulls remote ones (optional)
│
│ HTTPS, GitHub token per request, through a private TLS front end
│ (or Fly's TLS proxy, with the hub in proxy mode)
▼
groupie hub start team hub: relay with its own SQLite file (~/.groupie-hub/hub.db),
binds 127.0.0.1:7420 (:: on Fly); holds actors, claims, observations and
verdicts, never code; one per team, shared by every machineActor identity is (project, host, user, worktree path). Every worktree of a
repository resolves to one project via git's --git-common-dir, which is how
agents in separate worktrees can see each other at all. Every clone of the same
GitHub origin resolves to the same project id, which is how machines meet at a
hub.
The hub never runs git or detection. Each daemon detects for its own actors, both when they write and when a peer's write arrives from the hub, so each side holds the conflicts it needs and delivers them through its own hooks.
src/core/ is pure and performs no I/O, so all detection logic is testable
without a daemon, a database, or an agent. src/git/exec.ts is the only module
permitted to run a command (git, gh, security for the Keychain, and fly for
hub deploy); it uses an argument array and no shell. The one other spawn is
src/daemon/lifecycle.ts starting the daemon itself: node (the running
process.execPath) on groupie's own daemon entry, detached, also with no shell.
Development
npm install
npm run verify # typecheck + test + build
npm run smoke # drives the built binaries in a scratch repo
npm run demo # two worktrees, one collision, one exclusive denial
npm run eval:semantic # measures the intent detector against the real API (needs a key)
npm run check:pack # what the npm tarball would ship: no stale dist files, no keys or .env (part of verify)
npm run check:install # packs the tarball, installs it into a prefix whose path has a space,
# runs the installed groupie init and hook (needs network)Releasing
npm run verify, npm run smoke and npm run check:install must all pass first.
prepublishOnly runs the build, the tests and check:pack again.
Before publishing (maintainers). Run ANTHROPIC_API_KEY=… npm run eval:semantic
at least three times with the larger hard-negative set and record the results in
BACKLOG; then npm publish — only with the maintainer's explicit go-ahead. A
prerelease is npm publish --tag next, also only with the maintainer's explicit
go-ahead.
Configuration
Settings resolve in layers, each overriding the last:
defaults → ~/.groupie/config.json → <repo>/.groupie/config.json → GROUPIE_* envRun groupie config to see every effective value, which layer it came from, and
the environment variable that overrides it.
Durations accept units: "15m", "2h", "7d", or a plain number of milliseconds.
Project settings (per repository, <repo>/.groupie/config.json):
{
"surfaceAtOrAbove": "high",
"severityOverrides": { "path_overlap": "low" },
"detectors": { "intent_incompatible": false },
"semantic": { "confidenceFloor": 0.75 },
"activityWindowMs": "15m",
"exclusiveIdleMs": "10m",
"extraDenyPatterns": ["**/generated/**"]
}| Key | Default |
|---|---|
| surfaceAtOrAbove | medium (notices below it are recorded, not delivered) |
| severityOverrides, detectors | {} |
| semantic.enabled | true |
| semantic.confidenceFloor | 0.6 (verdicts below it are recorded, never conflicts) |
| activityWindowMs | 15m (a collision episode ends after this long without re-detection) |
| exclusiveIdleMs | 10m |
| defaultLeaseMs | 2h (a claim's lease when none is asked for) |
| hunkAdjacency | 3 (lines apart that still count as a hunk overlap) |
| extraDenyPatterns | [] (added to the built-in deny list) |
| hooks.readMatcher | Read\|Bash (Bash also drives shell-write observation) |
| observation.maxShellFiles | 50 (files one Bash command can record as shell writes) |
surfaceAtOrAbove is the one to reach for first if Groupie is too chatty.
Severities below the floor are still detected, recorded, and visible in
groupie status — they simply stop being injected into agent context, and they
stay undelivered, so lowering the floor later surfaces the history rather than
losing it. Prefer raising the floor over disabling a detector.
Decision settings (project scope, under decisions in <repo>/.groupie/config.json):
| Key | Default |
|---|---|
| decisions.enabled | true |
| decisions.dir | .groupie/decisions (repo-relative) |
| decisions.defaultBranch | auto-detected (origin/HEAD, then the main worktree's branch) |
| decisions.rescanIntervalMs | 10s |
| decisions.maxPerDelivery | 3 |
| decisions.deliveryBudgetTokens | 400 |
| decisions.listLimit | 20 |
| decisions.maxFileBytes | 65536 (larger files are skipped and reported) |
| decisions.maxShellPaths | 8 (files one shell command can deliver decisions for) |
Status and verdict settings (project scope):
| Key | Default |
|---|---|
| status.overlapWindowMs | 24h (how far back "Overlapping work" looks) |
| status.verdictWindowMs | 24h (how far back "Intent comparison" counts judged pairs) |
| semantic.verdictListLimit | 20 (verdicts groupie verdicts shows without --limit) |
| noticeMaxChars | 8000, at least 200 (one hook's notices are cut there, ending "… N more — groupie_status for the rest") |
Daemon settings (per machine, ~/.groupie/config.json only — a repository
cannot change how the shared daemon behaves):
{
"semanticModel": "claude-haiku-5-5",
"idleShutdownMs": "30m",
"fallbackPollMs": "10s",
"hookBudgetMs": 2000,
"gitTimeoutMs": 1000,
"daemonStartBackoffMs": "1m",
"semanticErrorLogIntervalMs": "1m"
}hookBudgetMs is the whole of a hook's time, starting the daemon included. After a
daemon fails to start, hooks wait daemonStartBackoffMs before trying again (0
disables the wait), so a broken install costs nothing per write. CLI commands
(status, init, …) ignore that wait, and also replace a daemon left running from
an older version.
Hub settings, project scope (<repo>/.groupie/config.json, committed):
| Key | Default |
|---|---|
| hub.url | "" (local-only). Like every setting it can also come from ~/.groupie/config.json or GROUPIE_HUB_URL, which overrides the committed one; the daemon syncs with whichever wins (groupie config shows it) |
| hub.claimCacheMaxAgeMs | 2m, at most 10m (a remote exclusive claim stops blocking once your last sync is older) |
Hub settings, daemon scope (~/.groupie/config.json):
| Key | Default |
|---|---|
| hub.requestTimeoutMs | 1500 (one hub request, including filing an exclusive claim) |
| hub.longPollMs | 25s (how long the hub may hold a pull open) |
| hub.pushIntervalMs | 1s (also the first retry delay while the hub is unreachable) |
| hub.outboxMaxItems | 10000 (oldest observations dropped first; claims never) |
| hub.maxClockSkewMs | 30s (groupie doctor warns beyond it) |
| hub.pushBatchSize | 200 |
| hub.maxBackoffMs | 30s (longest retry delay while the hub is unreachable) |
| hub.tokenRetryMs | 1m (how often to retry finding a GitHub token while there is none) |
| hub.ghTimeoutMs | 2s (one background gh auth token) |
| hub.actorPublishIntervalMs | 5s (an unchanged actor is re-sent at most this often) |
| hub.receiveBatchSize | 50 (pulled facts judged before yielding to hooks) |
| hub.receiveSliceMs | 50 (or sooner, after this long) |
| hub.receiveErrorLogIntervalMs | 1m (at most one receive-failure line per interval; 0 logs all) |
| hub.maxConsecutiveDrops | 20 (refused facts dropped in a row before the worker holds as refused) |
| hub.capabilitiesRefreshMs | 10m (how often to re-ask the hub which fact kinds it accepts; others stay queued) |
Hub settings, hub scope. The hub reads <data-dir>/hub.json and its
groupie hub start flags; flags override the file. Unknown keys are refused, and
a hub with any problem in its config does not start.
| Key | Flag | Default |
|---|---|---|
| host | --host | 127.0.0.1 (anything else requires tlsCert and tlsKey, or behindTlsProxy) |
| port | --port | 7420 |
| dataDir | --data-dir (flag only) | ~/.groupie-hub |
| allowedOwners | --allowed-owner (repeatable) | none, required |
| tlsCert, tlsKey | --tls-cert, --tls-key | "" (set both or neither) |
| behindTlsProxy | --behind-tls-proxy | false (plain HTTP behind a TLS-terminating platform proxy; see Proxy mode) |
| clientIpHeader | --client-ip-header | '' (header naming the caller's address; proxy mode only) |
| publicRepoReadPermission | | push (permission needed to read a public or internal repository's state; pull lets any GitHub user read it) |
| authCacheMs | | 10m (how long a verified token, its permissions and the repository's visibility are trusted) |
| negativeAuthCacheMs | | 1m (how long a denial is cached, at most authCacheMs) |
| authCacheMaxEntries | | 1000 |
| authCacheMaxReposPerToken | | 100 |
| verifiedTokenMemoryMs | | 24h (a recently verified token re-verifies without counting against the failure limit) |
| verifiedTokenMemoryMaxEntries | | 1000 |
| githubTimeoutMs | | 5s |
| authFailuresPerMinute | | 30 (per remote address; past it, 429 without asking GitHub) |
| authFailureMaxAddresses | | 10000 |
| maxVerificationsPerMinute | | 120 (hub-wide, all addresses together, for tokens neither cached nor remembered; past it, 429 without asking GitHub) |
| maxRequestBytes | | 1048576 |
| headersTimeoutMs | | 10s (at most requestTimeoutMs) |
| requestTimeoutMs | | 30s (receiving one request; not a held long-poll) |
| maxConnections | | 1024 |
| shutdownGraceMs | | 2s |
| githubApiUrl | | https://api.github.com (https, or http on loopback only) |
| longPollMaxMs | | 30s |
| changesPageSize | | 300 (facts per pull page; a bound on response size, not on how much syncs) |
| maxFutureSkewMs | | 5m |
| maxLeaseMs | | 24h |
| maxClockOffsetMs | | 24h |
| maxExclusiveClaimsPerPush | | 8 (past it, stored blocked with reason limit) |
| maxLiveExclusiveClaimsPerLogin | | 8 (per project) |
| maxContentionCharsPerRequest | | 500000 |
| maxWaitersPerProject | | 256 (held long-polls) |
| maxWaitersPerLogin | | 32 |
| observationRetentionMs | | 7d |
| claimRetentionMs | | 30d |
| verdictRetentionMs | | 30d |
| maintenanceIntervalMs | | 1m |
Durations in hub.json take the same units as above. The hub reads no
GROUPIE_* configuration from the environment; the one exception is the
test-only GROUPIE_HUB_AUTH_TEST_BYPASS, which is honoured only on a loopback
bind and is a startup error in proxy mode.
A malformed setting never stops the daemon: the bad field falls back to its own
default, every other setting still applies, and the problem is reported by
groupie doctor and groupie config. A misspelled key is reported with a
suggestion rather than silently ignored.
Removing groupie
There is no uninstall command yet. By hand, in each worktree you ran groupie init in:
.claude/settings.local.json: remove the hook entries whose command runs groupie'shooks/entry.js, and"groupie"fromenabledMcpjsonServers..mcp.json: removemcpServers.groupie(or the file, if nothing else is in it)..git/info/exclude(the main checkout's, shared by every worktree): remove the block that starts# groupie (machine-specific, do not commit)..groupie/: the team's committed config and decisions. Remove it (and commit that) only if the whole team is leaving groupie.
Then, once per machine: groupie token clear (the token init stored), stop the
daemon (kill $(cat ~/.groupie/daemon.pid)), remove ~/.groupie (local state,
approvals, logs), and npm rm -g mygroupie. A hub installed with groupie hub
install-service was given its uninstall commands when the unit was written.
Known limitations
- One GitHub login on two machines shares that login's hub limits
(
maxLiveExclusiveClaimsPerLogin,maxWaitersPerLogin): the hub counts per login, not per machine. - Clock skew beyond 24 hours is refused by the hub (
maxClockOffsetMs); within it, times are shifted onto the hub's clock. - Acknowledging a collision is per side: the other machine is not told.
- Force-release is local:
groupie release --claim <id> --forceon a hub claim does not reach other machines. - Hooks can go quiet under heavy load: every hook has a time budget
(
hookBudgetMs), and a daemon that does not answer in time means "allow, say nothing" (fail open), so a notice can be missed. - No Windows.
- More, for the hub, in
BACKLOG.md.
Upgrading
The state database carries a schema version. Pending migrations apply in a
transaction on open, after copying state.db to state.db.backup-v<n>. A
database written by a newer build is refused with a version mismatch rather than
operated on.
After upgrading, stop the old daemon so the new one starts on demand — otherwise the previous version keeps serving until it idles out:
kill $(cat ~/.groupie/daemon.pid)(Use $GROUPIE_HOME/daemon.pid if you set GROUPIE_HOME.) Then re-run
groupie init in each worktree (it also approves the MCP server there); for v0.3.0 that installs the Read hook, and since then it also
widens that hook's matcher to Read|Bash so shell reads deliver decisions (a
project that set hooks.readMatcher explicitly keeps its own value).
groupie doctor reports a daemon/CLI version mismatch (daemon-ver) and a
missing Read hook. v0.4.0 adds migration 4 (the semantic_verdict table); it
applies on first open, after the usual backup.
v0.5.0 adds migration 5 (the team-hub tables and columns); it applies on first
open, after the usual backup. A remote spelled ….GIT, or ending in .git/,
now normalizes to the same key as its bare form, so those projects get a new
local project id (any host). Stop the old daemon after upgrading, as above. A
project syncs only once hub.url is set and groupie hub join has approved it.
v0.6.0: no migration. The package is mygroupie on npm; collision notices for one file
are grouped across peers. Re-run groupie init in each worktree: it moves groupie's
hooks from .claude/settings.json to .claude/settings.local.json (commit the
settings.json change) and quotes the hook commands. Mixed versions on one hub keep
syncing: facts a version doesn't understand are skipped, not fatal (the hub's health
lists the fact and observation kinds it accepts, and groupie hub status counts what
was skipped under newer facts).
Status
v0.6.0 — ten minutes to a team: npm i -g mygroupie (or npx mygroupie init), a
one-command Fly.io hub (public behind Fly's TLS proxy with GitHub auth, or private
through fly proxy), init offering to join, and one grouped notice per file.
v0.5.0 — the MVP: two developers on separate machines whose agents see each
other, through a self-hosted hub with GitHub-verified identity, loopback by
default, per-machine approval (groupie hub join), and fail-open when the hub
is down. hub status, hub install-service, a Docker sample and hub checks in
groupie doctor come with it.
Still early. Not yet built: merge checks (paused), dependency and impact
analysis, consistency verification, cross-team agent representatives, a hosted
hub, and the web UI. Known hub limitations, and the one small measurement of
intent-duplicate detection, are in BACKLOG.md.
