# Operating a project

## For humans

**Read this if** you run the `interlock` CLI. **Skip it if** you only use Studio in the browser.

### Do this

1. **Check which project you are pointed at** before your first sync in a tree.

```bash
interlock whoami
```

2. **Confirm the key file is beside `code/`.** Parent-directory keys do not authorize a code tree.

```bash
ls -la .interlock-key
```

3. **Dry-run anything destructive.**

```bash
interlock sync code --dry
```

### What will bite you

- **A tree with the wrong key syncs into someone else's project.** It reads as a clean success. It
  can delete your working copy and pull a stranger's units in.
- **`yolo` is a listener, not a reconciliation command.** It refuses to start unless the tree and
  target already match byte for byte. Use explicit `sync` to reconcile and review differences first.
- **Two identities, one binary.** `interlock` is PRODUCTION. `interlock-local` is your dev server.
- **Conflicts are yours to resolve.** They appear as `<file>.conflict-server`. If you are an agent,
  do not resolve one on a human's behalf.

### Then read

- [.interlock-key binds a tree to a project](#interlock-key-binds-a-tree-to-a-project).
- [Verified command reference](#verified-command-reference) for flags that actually exist.

## For robots

How a git tree, a project, and a server stay bound to each other, and what breaks when they come
apart. Every command below was checked against `code/assets/interlock-cli.js`; nothing here is
invented surface.

The CLI is Node 18+ with zero dependencies. The API base resolves in this order: `--api`, then
`INTERLOCK_URL`, then the profile config, then `http://localhost:8090`.

## .interlock-key binds a tree to a project

**The rule.** A code tree declares which Interlock project it belongs to by carrying a
`.interlock-key` in the exact directory that owns `code/`. `sync`, `yolo`, `status`, `pull`, and `push`
require that exact binding before they read the tree or reach the network. They do not inherit a
parent-directory key and do not fall back to the home login. Ordinary non-tree commands may still
walk upward for the nearest key.

**Why it exists.** Credential resolution for tree commands is the exact tree owner's
`.interlock-key`, then stop. `INTERLOCK_SDK_KEY`, a parent key, and the home profile do not authorize
a tree. A home profile proves who the operator is;
it does not prove which project owns this tree. An SDK key is bound to exactly one project
server-side, and exact placement makes the filesystem boundary equally explicit.

**The failure it prevents.** This one is destructive and it looks fine while it happens. The old
behavior let `yolo` waive the missing-key warning and use a personal home token. It then diffed THIS
tree against THAT token's project: server-only units appeared in the working copy and local-only
units published to the wrong environment. A key inherited from a monorepo root creates the same
cross-project ambiguity for sibling APIs. Both cases now fail locally:

```
interlock: yolo refuses an unbound code tree.
        Expected /Users/you/workspace/yourapp/.interlock-key
        (the exact owner of /Users/you/workspace/yourapp/code).
        Parent keys and the home login are intentionally ignored for tree synchronization.
```

There is no confirmation, environment-variable, or `--project` escape hatch: rejection happens
before network, sync-index, or lock activity. CI must provision `.interlock-key` in the project
workspace with mode 0600.

**Minimal example.** Bind the tree once, from its root:

```bash
interlock connect --dir .
```

It signs you in, lets you pick or create a project, mints a key for this directory, and writes the
file with mode 0600:

```
interlock: wrote /Users/you/workspace/yourapp/.interlock-key (project yourapp, key il_9f3c2a…, mode 0600)
interlock: every command run at or below /Users/you/workspace/yourapp now uses this project.
```

The established filename stays `.interlock-key`, but new files are versioned JSON so project and
server metadata can evolve without adding more credential files:

```json
{
  "version": 1,
  "project": "your-project-id",
  "api": "https://interlock.sh",
  "key": "<secret>"
}
```

The CLI still reads legacy one-line keys. Treat the entire JSON document as secret and keep
`**/.interlock-key` in `.gitignore`.

For non-tree commands, `INTERLOCK_SDK_KEY` still overrides the file. Tree commands always use the
exact owner file. Tooling only ever CREATES `.interlock-key` and never rewrites it.

**Verify it.**

```bash
interlock whoami
```

It prints the active profile, the API base, which credential is in play, and the project directory
the key governs. A home-profile credential remains valid for account commands, but tree commands
will reject it.

## Commit the key file deliberately

**The rule.** Decide, once and in writing, whether `.interlock-key` is tracked. Tracking it is a
legitimate choice for a small team; drifting into it is not. Whatever you choose, never print more
than the file's first 8 characters.

**Why it exists.** The key is a credential AND a binding. Untracked, a fresh clone has no project
and the first `sync` in it is the destructive case above. Tracked, `git diff .interlock-key` catches
a stray key that some tool wrote, and a new machine works immediately. Note that the CLI's own
`connect` output advises the opposite default:

```
interlock: add .interlock-key to .gitignore — it is a credential.
```

That is the safe default for a public repo. A team that tracks it on purpose should say so where a
reader will look, because `.gitignore` has no effect on an already-tracked file and listing it there
would tell a reader the key is absent when it is not.

**The failure it prevents.** Two, symmetrically. A gitignored key means every teammate's fresh clone
silently uses their personal token. A key that is tracked by accident, with nobody having decided it,
is an undocumented secret in the history that no rotation plan covers.

**Minimal example.** The comment that makes the decision reviewable, at the top of `.gitignore`:

```
# .interlock-key is deliberately TRACKED, not ignored — the same call made for the DB password.
# It is NOT listed below, because gitignore has no effect on an already-tracked file and listing
# it would tell a reader the key is absent from the repo when it is not.
# When the team grows: `git rm --cached .interlock-key`, add it back here, rotate the key.
```

Never echo the file. When you must show which key is in play, show a prefix:

```bash
cut -c1-8 .interlock-key
```

**Verify it.**

```bash
git ls-files --error-unmatch .interlock-key
```

Exit 0 means tracked, exit 1 means ignored. Either is fine; not knowing is not.

## One key, one project, one server

**The rule.** An SDK key is bound to one project ON ONE SERVER, and `.interlock-key` is a single
slot. A host project develops against RELEASED Interlock. Do not point a tree at a dev Interlock
server "just to try something".

**Why it exists.** The sync index is per server (`code/.interlock-index/<server>/<env>/…`), so a
tree can legitimately sync to several servers and keep separate bases for each. The KEY is not
per server. There is one slot, so repointing the tree overwrites the credential that identified it.

**The failure it prevents.** Pointing a host tree at a local Interlock overwrote the production key
and deleted entries from the committed sync base for the production server, which then read as mass
deletion on the next production sync. There is no error text for this: it is a clean, successful
sync against the wrong server.

**Minimal example.** When the host project needs an Interlock change, review that code and cut a
release; do not aim the tree at a development server. If you genuinely need a second server from the
same tree, keep the key file for the primary and pass the other explicitly for that one command:

```bash
INTERLOCK_SDK_KEY=$OTHER_KEY interlock sync code --api http://localhost:8090
```

**Verify it.** Before any sync you are unsure about, print the plan and change nothing:

```bash
interlock sync code --dry
```

The first line names the profile and the API base it will talk to, for example
`sync (DRY RUN) [prod] → https://api.interlock.sh  (dev; 41 unit(s) in git)`.

## Two CLI identities

**The rule.** `interlock` targets PRODUCTION (`~/.interlock/config.json`). `interlock-local` targets
a local dev server (`~/.interlock/local.json`). Same binary, chosen by the invoked name, by `--local`,
or by `INTERLOCK_PROFILE=local`.

**Why it exists.** Two configs make the target unambiguous at the call site, in the shell history,
and in a script that somebody reads six months later. A flag you can forget would not.

**The failure it prevents.** Syncing a work-in-progress tree to production because the config
happened to point there. Production syncs also prompt:

```
interlock: this targets PRODUCTION. Continue? [y/N]
```

Pass `--yes` in CI, deliberately. Note again that `yolo` implies `--yes`, so a `yolo` against the
production profile announces itself and then just runs:

```
YOLO: two-way live mirror with PRODUCTION — saves deploy, Studio edits land in your tree. No prompts. Godspeed.
```

**Minimal example.** Day-to-day loop against the local server, release against production:

```bash
interlock-local sync code
```

```bash
interlock sync code --yes
```

**Verify it.**

```bash
interlock version
```

It prints the CLI version, the active profile in brackets, and the API base, for example
`interlock 0.1.0 [prod] · node v22.4.0 · api https://api.interlock.sh`.

## code/.interlock-index/ is the sync base

**The rule.** `code/.interlock-index/<server>/<env>/<id>.<ext>.interlock` records, per unit, the
server version and the content hash as of the last sync. Commit it in the SAME commit as the code
change it describes. Indexes for `localhost-*` servers are gitignored, as is
`code/.interlock-index/yolo.lock`.

**Why it exists.** The index is what makes every sync decision three-way. The version says whether
the SERVER moved; the hash says whether the LOCAL file moved. Without it, sync can only compare two
sides and has to guess which one wins.

| local vs index | server vs index | sync does |
|---|---|---|
| unchanged | unchanged | nothing |
| changed | unchanged | push, compare-and-swap on the version (a 409 becomes a conflict, never a lost update) |
| unchanged | changed | pull into the tree |
| changed | changed | CONFLICT, nothing overwritten |

**The failure it prevents.** A commit that changes a unit but not its index entry makes the next
sync see a local change against a stale base, which at best is a needless push and at worst is a
conflict manufactured out of nothing. Committing the index with the code keeps the base honest for
everyone who pulls. The per-server layout prevents a worse one: a single shared index read an empty
second server as mass remote deletion and wiped a working tree (git restored it, because git is the
master). If you are upgrading from a single-server layout, the CLI migrates it in place and says so:

```
  (index migrated to per-server layout: .interlock-index/interlock.sh/dev)
```

**Minimal example.**

```bash
interlock-local sync code && git add code && git status --short
```

`code/caller/UseIt.java` and `code/.interlock-index/<server>/dev/caller/UseIt.java.interlock` should
appear in the same commit.

**Verify it.** A clean tree that has just been synced must be a no-op on the next sync:

```bash
interlock sync code --dry
```

Expect `0 pushed, 0 pulled` and a nonzero `unchanged`. Anything else means the committed base does
not match what was actually synced.

## Conflicts surface as <file>.conflict-server

**The rule.** When both sides moved, nothing is overwritten. The server's copy is written next to
your file as `<file>.conflict-server` (gitignored), and you resolve explicitly with `--ours <id>` to
keep the local copy or `--theirs <id>` to take the server's.

**Why it exists.** A three-way sync can detect the collision but cannot know which side is right.
Writing the server copy beside yours makes the comparison a plain `diff` instead of a fetch, and
leaves both versions on disk while you decide.

**The failure it prevents.** Silent clobbering in either direction. The conflict is printed with the
exact commands that resolve it:

```
  !! CONFLICT caller/UseIt (dev) — changed locally AND on the server (server v7)
     server copy: code/caller/UseIt.java.conflict-server
     keep yours:  interlock sync code --ours caller/UseIt    ·    take server: --theirs caller/UseIt
```

Deletions collide the same way and say so:

```
  !! CONFLICT caller/UseIt (dev) — you deleted it, but the server changed it (v7). Restore it: interlock sync code --theirs caller/UseIt  (or delete it in Studio)
```

A non-yolo `sync` exits 1 when anything conflicted or failed, so a pre-flight script notices.

**Minimal example.** Look before choosing:

```bash
diff code/caller/UseIt.java code/caller/UseIt.java.conflict-server
```

```bash
interlock sync code --theirs caller/UseIt
```

Resolution removes the `.conflict-server` file and rewrites the index entry. If the id matches
nothing on either side, the CLI says so and changes nothing:

```
interlock: could not resolve 'caller/UseIt' — no such unit locally or on the server
```

**Verify it.**

```bash
find code -name '*.conflict-server'
```

Empty output means nothing is outstanding. Add that check to your pre-flight; a stale
`.conflict-server` file is also a `*.java` file that a naive compile gate would try to build, which
is why [the gate script](/testing#the-gate-script) excludes it by name.

## An agent must not resolve a conflict on a human's behalf

**The rule.** An AI agent surfaces a conflict, shows both sides, and STOPS. It does not run
`--ours` or `--theirs` unless the human asked for that specific resolution.

**Why it exists.** A conflict means two people made a decision about the same unit. Only they can
say which decision survives. Every other part of sync is recoverable from git; a resolution is the
one step that deliberately discards one side.

**The failure it prevents.** An agent picking `--ours` because the local tree is the one it can see
throws away a colleague's Studio edit that exists nowhere else, with a green summary line reporting
success. There is no error text, because nothing failed.

**Minimal example.** What an agent should produce instead of a resolution:

```
CONFLICT caller/UseIt (dev), server v7.
  local:  code/caller/UseIt.java
  server: code/caller/UseIt.java.conflict-server
  diff:   23 lines differ, both changed the cooldown branch
Resolve with `interlock sync code --ours caller/UseIt` (keep local) or `--theirs caller/UseIt` (take server).
Which one do you want?
```

**Verify it.** After any agent-run sync, the conflicts must still be there:

```bash
find code -name '*.conflict-server'
```

If the agent reported conflicts and this comes back empty without you having chosen, the rule was
broken.

## sync versus yolo

**The rule.** `sync` runs once and stops. `yolo` first computes that same plan without changing
anything. Byte-identical stale or missing sync records are refreshed automatically because there is
no ownership decision to make. If authored content differs in a terminal, it lists the affected units and offers `[G]` make the
target match Git, `[S]` import the target into the tree, `[D]` show a syntax-colored target → local
Git diff, `[R]` review the complete plan, or `[C]`
cancel. A direction requires a typed second confirmation; YOLO runs the normal three-way/CAS sync,
rechecks byte-for-byte equality, and attaches only when that recheck is clean. Non-interactive
callers still change nothing unless they explicitly pass `--git-wins --yes` or
`--server-wins --yes`. Once attached, local saves upload, server and Studio edits arrive in the
tree, and deletes propagate both ways. Run `yolo` only when a human explicitly asked for it, and
only in a tree whose exact owner directory has `.interlock-key`.

**Why it exists.** The mirror is genuinely useful when someone is editing in Studio and you want
those edits in git as they happen. It is also a process that deletes files in your working copy
without asking after startup, which is fine when the tree is bound to the right project and
catastrophic when it is not. Startup therefore makes ownership explicit instead of sending the
operator away to understand sync metadata and run a second command. Importing the server is refused
while authored files are dirty in Git, because “take theirs” without a recovery path is data loss.

**The failure it prevents.** Two mirrors fighting over the same tree, which the lock refuses:

```
interlock: another yolo is already mirroring this tree (pid 4711) — stop it first
```

And a non-interactive caller that did not choose ownership:

```
interlock: YOLO startup refused: dev and this tree are not already in sync. Nothing was changed. Run interactively, or pass --git-wins --yes / --server-wins --yes explicitly.
```

The larger failure remains a mirror running in an unbound tree, which is the wipe described in
[.interlock-key binds a tree to a project](#interlock-key-binds-a-tree-to-a-project). The CLI now
rejects that state before opening the mirror.

**Minimal example.** Safe daily loop:

```bash
interlock-local sync code
```

Mirror, when a human asked for it and `whoami` shows the right key:

```bash
interlock-local yolo code
```

If the preflight differs, choose a side in that same command. For scripts and CI, spell it out:

```bash
interlock yolo code dev --git-wins --yes
```

Once mirroring, it reports live feed state (`⚡ live feed connected`, or a fall back to 2.5s
polling), and every download prints `↓ <id> v<n> (<env>) → tree  (review with git)`. Review those
with `git diff` and commit what you keep.

**Verify it.** Before starting a mirror, prove the target and the plan:

```bash
interlock whoami && interlock sync code --dry
```

## Verified command reference

Everything below exists in `code/assets/interlock-cli.js`. Flags not listed here were not verified.

| command | what it does |
|---|---|
| `interlock connect [--dir <d>] [--project <id>] [--label <l>]` | sign in, pick or create a project, mint a key, write `.interlock-key` (mode 0600) |
| `interlock init` | sign in if needed, then install the Interlock skills into Claude Code, Codex, and Cursor for this project; skips sign-in when a key already exists |
| `interlock whoami` | profile, API base, which credential is in play, which directory the key governs |
| `interlock version` | CLI version, active profile, API base |
| `interlock sync [dir] [--dry] [--prune] [--git-wins\|--server-wins] [--env <e>] [--yes] [--project <id>]` | one-shot three-way sync; ownership policies require `--yes`, and server-wins refuses a dirty authored tree |
| `interlock sync [dir] --ours <id>` / `--theirs <id>` | resolve one conflict, keeping local or taking the server copy |
| `interlock yolo [dir] [--git-wins\|--server-wins] [--yes]` | live two-way mirror; a terminal guides drift reconciliation, scripts must state ownership, and attach still requires a clean recheck; one per tree |
| `interlock push <file> [--promote]` | upsert one unit, id and env inferred from the `code/…` path |
| `interlock pull [--env <e>] [--dir <d>]` | download an env's units into the tree, then review with git |
| `interlock run <id> [--env <e>] [--save <path>]` | execute a unit; exit 4 when no such unit, exit 1 on other failures |
| `interlock get <id> [--env <e>] [--save <path>]` | fetch a unit's source; binary units require `--save` |
| `interlock list [--env <e>]` | list units with language and version |
| `interlock search <query> [target] [--in <prefix>] [--limit <n>] [-C <n> \| -B <n> -A <n>]` | ranked content search across units, with optional asymmetric line context |
| `interlock tasks list [--limit n]` | list durable tasks in the selected project |
| `interlock tasks submit <type> [--input JSON] [--schema n] [--idempotency-key k]` | submit one task; input is JSON and schema version is a separate integer |
| `interlock tasks get\|watch\|cancel <taskId>` | inspect, follow, or cooperatively cancel one task |
| `interlock tasks workers` | show ready, full, draining and offline task workers |
| `interlock tasks doctor [--spool <dir>]` | verify Tasks authorization, clock drift, queue/outbox health, and optional spool access |
| `interlock check [dir]` | author-time syntax gate for `.js` units; `.jsx` and `.java` are reported as skipped and guarded server-side |
| `interlock promote <id> [--from <env>]` | dev to staging to prod |
| `interlock projects [new <id> --name <n>]` | list your projects, or create one |
| `interlock keys <list\|new\|roll\|revoke> --project <id>` | manage project keys; `new --kind sdk\|task-producer\|task-worker` chooses authority and the plaintext is shown once |
| `interlock members <list\|add\|remove> --project <id>` | manage project members |
| `interlock login [--api <url>] [--token <t>]` | set this profile's API base and token |

Environment: `INTERLOCK_SDK_KEY` (overrides the key file), `INTERLOCK_URL` (API base),
`INTERLOCK_PROFILE=local` (same as `--local`).
