scoped
by OrenSegal
README.md
# scoped
[](https://github.com/OrenSegal/scoped/actions/workflows/ci.yml) [](LICENSE)
Part of [sous](https://github.com/OrenSegal/sous): tools for checking what coding agents actually do.
scoped stops two Claude Code sessions from editing the same file at the same time: file claims between concurrent sessions, enforced by a `PreToolUse` hook, with an MCP server for `claim`, `release`, `check` and `status`.
## Why
Several Claude Code sessions against one codebase is normal now: one per Linear issue, one per worktree, several in the same repo. Nothing stops two of them from editing the same file at once.
## How it works
**Enforcement.** A `PreToolUse` hook runs before every `Edit`, `MultiEdit`, `Write` and `NotebookEdit`. If another session holds the file, the edit is denied with a reason that names the holder. If the file is free, the hook claims it for the calling session, so enforcement does not depend on the model calling anything first.
**Tools.** For planning and visibility:
- `claim(issue_id, file_paths, session_id)`: reserve files before a multi-file change, so a conflict shows up before you start.
- `release(issue_id, session_id)`: free claims on completion or handoff instead of waiting for them to expire.
- `check(file_path)`: is this file claimed, and by whom.
- `status()`: every active claim, by session and issue.
**Locking is local.** Claims live in SQLite (`node:sqlite`, no extra dependency) at `~/.scoped/claims.db`. A unique key on the canonical file path makes claiming atomic: two sessions racing for one file get one winner.
**One identity for hook and server.** Claude Code gives an MCP server no session id that matches the one a hook receives. A `SessionStart` hook puts the real `session_id` into the session's context, and `claim`/`release` require it, so an explicit claim and the hook's enforcement agree on the owner and a session is never blocked by its own claim.
**Stale claims expire.** Every read reaps dead claims first. A claim made through the MCP server records the server's pid and dies with it (same host). The hook exits after every call, so its claims have no live pid; they expire by TTL instead, and each edit by the owning session restarts that TTL.
**Linear is visibility, not the lock.** With `LINEAR_API_KEY` set, explicit `claim`/`release` calls post a comment on the issue. The hook's automatic claims stay local and silent. See [SECURITY.md](SECURITY.md) for what is sent.
## Install
### As a plugin
```bash
claude plugin marketplace add OrenSegal/scoped
claude plugin install scoped@scoped
```
The plugin registers both hooks, the MCP server, the `scoped` CLI (the plugin's `bin/` is on the Bash tool's `PATH`) and the `/scoped:*` slash commands. On first start the MCP launcher installs the server's runtime dependencies into `${CLAUDE_PLUGIN_DATA}/deps/<lockfile hash>/` with `npm ci --omit=dev --ignore-scripts`, in a temp dir that is renamed into place, so it survives plugin updates, works on a read-only plugin directory, and two sessions starting together don't corrupt it. That needs `npm` and the network once. Without them the server does not start and says why on stderr (`/mcp` shows it); the hooks and the CLI need no dependencies and keep enforcing.
### From a clone
Requires Node.js 22.13.0 or later (23.4 or later on the odd-numbered line), where `node:sqlite` needs no flag, and the `claude` CLI on your `PATH`.
```bash
git clone https://github.com/OrenSegal/scoped.git
cd scoped
npm install
npm run setup
```
`npm run setup` registers the MCP server at user scope (`claude mcp add scoped --scope user -- node <repo>/bin/scoped-mcp`) and adds the `SessionStart` and `PreToolUse` hooks to the global `~/.claude/settings.json`, written atomically. It is safe to re-run: anything already registered, at any scope or from any checkout path, is left alone. It refuses, changing nothing, when scoped is already installed as a plugin, because every hook would then run twice (`--force` overrides). Restart running sessions afterwards. `npm run uninstall` removes only what setup added.
From a clone the CLI is `node bin/scoped`, or `npm link` to put `scoped` on your `PATH`.
<details>
<summary>Manual setup, without the setup script</summary>
MCP server, in your MCP config:
```json
{
"mcpServers": {
"scoped": {
"command": "node",
"args": ["/absolute/path/to/scoped/src/index.mjs"],
"env": { "LINEAR_API_KEY": "optional, enables the visibility comments" }
}
}
}
```
Hooks, in the global `~/.claude/settings.json` (append to existing `SessionStart`/`PreToolUse` arrays rather than replacing them):
```json
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "node \"/absolute/path/to/scoped/hooks/session-start.mjs\"", "timeout": 15 }] }
],
"PreToolUse": [
{
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
"hooks": [{ "type": "command", "command": "node \"/absolute/path/to/scoped/hooks/pretool-enforce.mjs\"", "timeout": 15 }]
}
]
}
}
```
</details>
## CLI and slash commands
| Command | Slash command | What it does |
| :- | :- | :- |
| `scoped status [--json]` | `/scoped:status` | Every active claim: file, issue, session (8-char prefix), age, time left |
| `scoped check <file>` | | Exit 1 and name the holder if `<file>` is claimed, exit 0 if it is free |
| `scoped release <session or prefix>` | `/scoped:release` | Free every claim a session holds. A prefix needs at least 4 characters and must match one session; deny messages print the 8-character one |
| `scoped gc` | | Reap expired and dead-pid claims now (every read does this anyway) |
| `scoped report [--days=N]` | `/scoped:report` | Blocked edits from the block log (default 30 days) by file, session pair and issue, with what to change if one file keeps colliding |
| `scoped doctor` | `/scoped:doctor` | Node and `node:sqlite`; the claims db and its schema; each hook and the MCP server registered exactly once across the plugin and user/project/local settings; an MCP `initialize` + `tools/list` round trip; hook latency; the fail policy. Exit 1 on any FAIL |
`/scoped:release` with another session's id shows that session's claims and asks before releasing them.
## Configuration
All optional. Set them in the environment Claude Code starts with; hooks inherit it.
| Variable | Default | Effect |
| :- | :- | :- |
| `SCOPED_HOME` | `~/.scoped` | Directory for the claims db, block log and (outside a plugin) the MCP server's dependencies |
| `SCOPED_DB` | `$SCOPED_HOME/claims.db` | The claims db alone. Sessions that should see each other must share it |
| `SCOPED_LOG` | `$SCOPED_HOME/blocks.tsv` | Block log path, or `off`. One line per denied edit: time, tool, file, both session ids, issue. No file contents |
| `SCOPED_ISSUE_ID` | `adhoc:<session>` | Issue the hook files its automatic claims under, e.g. `ENG-123` |
| `SCOPED_FAIL_CLOSED` | unset | `1` makes an internal hook error deny the edit instead of allowing it |
| `SCOPED_HOOK_MS` | `1000` | `scoped doctor`'s hook latency budget; over it is a warning |
| `SCOPED_MCP_TIMEOUT_MS` | `60000` | How long `scoped doctor` waits for the MCP server, including a first-start install |
| `SCOPED_DEBUG` | unset | Stack traces instead of one-line errors on stderr |
| `LINEAR_API_KEY` | unset | Linear comments on explicit `claim`/`release` (MCP server environment) |
**Fail open or closed.** By default an internal hook error (no `node:sqlite`, an unwritable or corrupt db, a malformed payload) lets the edit through, because a coordination bug should not stop real work. It is not silent: the first such error in a session shows the user "edits in this session are NOT enforced", with the reason, and every one goes to stderr. With `SCOPED_FAIL_CLOSED=1` the same error denies the edit. SQLite waits at most 5 s for a locked db, well inside the hooks' 15 s timeout, so a stuck db becomes one of these errors rather than a killed hook.
## Troubleshooting
Start with `scoped doctor` (or `/scoped:doctor`): it names the broken piece and exits 1.
- **Edits aren't blocked, or claims never show up.** Doctor says whether each hook is registered, and where. Hooks apply only to sessions started after registration: restart the session.
- **The `scoped` MCP server shows as failed in `/mcp`.** Its stderr says why: Node too old for `node:sqlite`, no `npm` on `PATH`, or the dependency install failed (offline). The hooks keep enforcing meanwhile.
- **`claim`/`release` fail for a missing `session_id`.** The `SessionStart` hook supplies it; restart a session that was already running when scoped was installed.
- **A hook runs twice.** scoped is registered both as a plugin and through `npm run setup`. Run `npm run uninstall` in the clone, or remove the plugin.
The hook costs one Node process start per edit, tens of milliseconds; the claim itself is well under a millisecond. Doctor measures it on your machine.
## Limitations
- **Only Claude Code's edit tools.** A `Bash` command that writes a file (`sed -i`, `>`, a codegen script), or a process outside Claude Code, is not blocked.
- **Fails open by default.** See [Configuration](#configuration).
- **One machine.** Claims are in a local SQLite file, and pid-based reaping only works on the same host.
- **Worktrees are separate files.** Claims key on the canonical absolute path (symlinks and `..` resolved), so `src/app.js` in two worktrees is two files; that collision surfaces at merge.
- **Case-insensitive file systems, partly.** On macOS an existing file resolves to its on-disk case, so `app.js` and `App.js` are one claim. Two spellings of a file that does not exist yet are two claims until it is created.
- **Windows is untested.** CI runs Linux and macOS. Claim keys fold drive letter and case on Windows (tested with `path.win32`), and the launcher uses `npm.cmd`, but nobody has run the hooks there. Reports welcome.
- **Per file, not per region.** Two sessions cannot edit different functions of one file at the same time.
- **Hook claims expire by TTL.** A session that stops editing a file but keeps running holds it until the TTL runs out or it calls `release`.
- **Not yet measured on a real fleet.** The claim race is tested with real processes sharing one db (`test/concurrency.test.mjs`, `test/hook.test.mjs`); a count of collisions prevented in real use will be published once there is one.
## Development
```bash
npm install
npm test
```
Tests run the store directly against a temp SQLite file, and the hooks, MCP server, launcher, CLI and setup script as real subprocesses with `HOME`, `SCOPED_HOME` and `CLAUDE_CONFIG_DIR` pointed at temp dirs. `evals/` is a `claude plugin eval` suite, not run in CI because runs are paid. See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues