npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 whose origin is 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 login is one way to give groupie a GitHub token), and ANTHROPIC_API_KEY for 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;
  • fly on your PATH: curl -L https://fly.io/install.sh | sh installs it in ~/.fly/bin (add that to PATH as 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
claude

init 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.net

The 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 init

groupie 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 the node that ran init, so they work even when Claude Code's PATH has no node), so they go in Claude Code's per-machine settings file, not the shared .claude/settings.json. If an earlier init put the hooks in settings.json, init moves 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's init rewrites the groupie entry 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, including hub.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 repo scope (needed for a private repository).
  • gh auth login: groupie then uses gh 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 env

Every 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.ts

It 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_decisions

A 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 out

It 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 acme

It 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=false

Then 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-ips

Each 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 7420 exposes http://127.0.0.1:7420 to your tailnet only, as https://<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_BYPASS set, whatever the bind;
  • refuses to start with --tls-cert/--tls-key as well (one TLS terminator);
  • still requires --allowed-owner and 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 here

join 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 remote

With 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 repository

Non-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 blocked and is promoted automatically when the holder releases or expires.
  • Local first. State lives in ~/.groupie/state.db and 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 machine

Actor 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_* env

Run 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:

  1. .claude/settings.local.json: remove the hook entries whose command runs groupie's hooks/entry.js, and "groupie" from enabledMcpjsonServers.
  2. .mcp.json: remove mcpServers.groupie (or the file, if nothing else is in it).
  3. .git/info/exclude (the main checkout's, shared by every worktree): remove the block that starts # groupie (machine-specific, do not commit).
  4. .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> --force on 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.