Skip to content

Drift and conflicts

Two people edit the same page. ctx handles that without ever overwriting either side: it keeps a copy of the version you last synced, compares your file and the server’s version against it, and when both moved it records a conflict and waits for you to decide. Nothing is merged behind your back, and nothing is lost.

This guide covers the model (base, local, server and the nine page states), what ctx does when it finds a conflict, and the ways out: by hand with ctx show and ctx resolve, through an agent, or in VS Code.

For every page it has synced, ctx keeps a base: the exact bytes of the version you last pushed or pulled. It lives next to your knowledge, under .contexo/.sync/:

Path Holds
.sync/state.json page_shas: per page, the server commit sha of its base
.sync/bases.json per page, the base’s sha, the hash of its content and synced_at
.sync/base/<path> the base bytes themselves
.sync/conflicts.json and .sync/conflicts/<path> conflict sidecars (see below)
.sync/backup/<path>.<UTC time> your previous file, saved by ctx resolve --theirs
.sync/lock held while a push, pull, resolve or repair runs

Editing a page never touches any of this. The base only moves when a page is pushed, pulled, resolved or repaired, which is exactly the point: it records the version you started from.

.contexo/.sync/ contains its own .gitignore with *, so these copies are never committed, even by teams that commit .contexo/. ctx writes everything there itself; never edit it by hand.

Each page then has three sides:

  • base: the version you last synced;
  • local: your working file under .contexo/;
  • server: the version the server has now.

ctx compares local against base by content (normalized, so formatting-only rewrites do not count) and server against base by sha. The answer is one of nine states:

State Base, local, server What to do
unchanged local equals base, server still at the base nothing
modified you edited it, server still at the base push
new your page, never synced, not on the server push
incoming you did not edit it, the server moved pull
incoming_new on the server, not on disk, never synced pull
conflict (both_modified) you edited it and the server moved resolve
conflict (both_added) your new page, and the server already has the same path resolve
deleted_local synced before, the working file is gone deletes are not synced; ctx resolve <path> --theirs restores it
invalid the file does not parse, or its path does not match its slug and type fix the file
unverified ctx cannot confirm the base (missing, or for another sha) ctx repair, or the next online status, pull or push

Online, the server side comes from one listing of the repo’s pages per run. There is no remote cache: offline (ctx status --offline), ctx knows the server side of a page only from a conflict sidecar, so it can still show conflicts but not incoming pages.

ctx status groups the pages that need an action, and prints the command for each group. Here you (the user dev) edited two pages while a teammate, bob, pushed his own edits to the same two pages, plus a new page:

ctx status
contexo  ● connected

── REMOTE
server     https://contexo.example.com  (from credentials)
repo       acme-api
user       dev <dev@example.com>
auth       authenticated (legacy)

── KNOWLEDGE
pages      4 · 2 conflict · 1 incoming · 1 unchanged
last pull  a4b2c1d3
captures   none pending

── CONFLICTS
    wiki/concepts/stripe-subscription.md   both_modified
    wiki/entities/billing-worker.md        both_modified
keep yours   ctx resolve <path> --ours
take theirs  ctx resolve <path> --theirs

── TO PULL
    wiki/concepts/webhook-retries.md   new on server
run  ctx pull

CONFLICTS names each page and its kind; TO PULL lists what the server has that you do not. TO PUSH, INVALID, UNVERIFIED and DELETED LOCALLY sections appear the same way when they have pages. With --json the same facts come as the ctx.status.v1 document, with each page’s state, its conflict_kind, and the base, local and remote sides it has.

A conflict sidecar is an entry in .sync/conflicts.json with the server’s sha, the base sha, the kind and where it was found (push, pull or repair), plus the server’s version of the page (sanitized) under .sync/conflicts/<path>. Three operations write one:

  • a push that the server refused with a 409 (source: push);
  • a pull that skipped a page as a conflict (source: pull);
  • a base repair that found an old agent’s sha bump over your edit (source: repair; see upgrading below).

The sidecar is cleared by a successful push of the page, by a pull that writes or adopts it, and by ctx resolve. While it exists, the page is a conflict even offline, and even when a later listing on its own would make your file look like a plain edit, so it can never be pushed over the teammate’s version by accident.

Recording a conflict never moves the page’s base or its recorded sha. Here is a push that lost a race: bob’s push landed between your push’s listing and its send, so two pages got a 409 and the third was created. ctx push --json writes the push document, then the error envelope, and exits 6:

ctx push --json
{
  "schema": "ctx.push.v1",
  "dry_run": false,
  "message": "ctx push (3 pages)",
  "server_url": "https://contexo.example.com",
  "repo_id": "acme-api",
  "new_head": "e5a3b9c807162534435261708f9e0d1c2b3a4958",
  "pushed": [
    {
      "path": "wiki/entities/invoice-service.md",
      "kind": "create",
      "sha": "e5a3b9c807162534435261708f9e0d1c2b3a4958"
    }
  ],
  "conflicts": [
    {
      "path": "wiki/concepts/stripe-subscription.md",
      "kind": "both_modified",
      "base_sha": "d2f0e617a3b5c7d9e1f2031425364758697a8b9c",
      "server_sha": "f0c47d2196385a7b4c3d2e1f0a9b8c7d6e5f4a3b"
    },
    {
      "path": "wiki/entities/billing-worker.md",
      "kind": "both_modified",
      "base_sha": "c1d8a3f50e2b4c6d8f90a1b2c3d4e5f607182930",
      "server_sha": "3b8e1a0947c2f6d85e90a1b2c3d4e5f60718293a"
    }
  ],
  "skipped": [],
  "distill": {
    "required": false,
    "bypassed": false
  },
  "warnings": []
}
{"schema":"ctx.error.v1","exit_code":6,"error":{"code":"conflict","message":"push: 2 conflict(s) remain; other pages were pushed","advice":["ctx resolve wiki/concepts/stripe-subscription.md --ours","ctx resolve wiki/concepts/stripe-subscription.md --theirs","ctx resolve wiki/entities/billing-worker.md --ours","ctx resolve wiki/entities/billing-worker.md --theirs"],"retryable":false,"http_status":409,"details":{"paths":["wiki/concepts/stripe-subscription.md","wiki/entities/billing-worker.md"]}}}
(exit 6)

ctx pull never overwrites a page you edited:

  • an incoming page is overwritten with the server version, and an incoming_new page is created;
  • a modified page is skipped; you push it later;
  • a conflict is skipped and gets a sidecar;
  • a conflict whose file already equals the server version is adopted: the server version becomes its base, nothing is rewritten;
  • invalid, deleted_local pages, pages whose paths differ only by letter case, and pages that changed on disk while the pull ran are skipped and left alone.

Skips are normal results: ctx pull exits 0. Here the JSON document is followed by the human run of the same pull, with the resolve advice:

ctx pull --json, then ctx pull
{
  "schema": "ctx.pull.v1",
  "server_url": "https://contexo.example.com",
  "repo_id": "acme-api",
  "head": "e5a3b9c807162534435261708f9e0d1c2b3a4958",
  "written": [
    {
      "path": "wiki/concepts/webhook-retries.md",
      "sha": "7d25c6bf018a3e4d5c6b7a8990f1e2d3c4b5a697",
      "kind": "create"
    }
  ],
  "adopted": [],
  "skipped": [
    {
      "path": "wiki/concepts/stripe-subscription.md",
      "state": "conflict",
      "reason": "conflict",
      "server_sha": "f0c47d2196385a7b4c3d2e1f0a9b8c7d6e5f4a3b"
    },
    {
      "path": "wiki/entities/billing-worker.md",
      "state": "conflict",
      "reason": "conflict",
      "server_sha": "3b8e1a0947c2f6d85e90a1b2c3d4e5f60718293a"
    }
  ],
  "reindexed": true,
  "warnings": []
}

$ ctx pull
Pulled 0 page(s), adopted 0; HEAD=e5a3b9c8
Skipped 2 page(s):
  wiki/concepts/stripe-subscription.md: conflicts with the server version; run ctx resolve wiki/concepts/stripe-subscription.md --theirs|--ours
  wiki/entities/billing-worker.md: conflicts with the server version; run ctx resolve wiki/entities/billing-worker.md --theirs|--ours

No agent needed. Take one conflicting page at a time.

1. Find the conflicts. ctx status lists them under CONFLICTS (above).

2. Look at both sides. Your version is the working file. Print the other two with ctx show:

Terminal window
ctx show wiki/entities/billing-worker.md --at base # what you both started from
ctx show wiki/entities/billing-worker.md --at server # what the teammate pushed

ctx show writes the raw page bytes and nothing else, so you can redirect them to a file or feed them to diff. --at base needs no network; --at server reads the server’s current version, sanitized exactly as a pull would write it.

ctx show wiki/entities/billing-worker.md --at base
---
schema: ctx.page.v1
slug: billing-worker
type: entity
author: dev
agent: claude
created: 2026-07-21T09:30:00Z
updated: 2026-07-25T11:20:00Z
parent_sha: ""
tags:
  - billing
---

## What it is

`worker` consumes Stripe webhook events from SQS and retries failed
invoices on the schedule in stripe-subscription.

## Rationale

Considered retrying inside the webhook handler; rejected because Stripe
expects a fast 2xx and redelivers slow webhooks.
ctx show wiki/entities/billing-worker.md --at server
---
schema: ctx.page.v1
slug: billing-worker
type: entity
author: dev
agent: claude
created: 2026-07-21T09:30:00Z
updated: 2026-07-25T11:20:00Z
parent_sha: ""
tags:
  - billing
---

## What it is

`worker` consumes Stripe webhook events from SQS and retries failed
invoices on the schedule in stripe-subscription. It runs as one ECS task per region.

## Rationale

Considered retrying inside the webhook handler; rejected because Stripe
expects a fast 2xx and redelivers slow webhooks.

Bob added one sentence to ## What it is. Compare that with what you changed in your file.

3. Decide. Either keep yours or take theirs.

Keep yours. Merge the teammate’s change into your working file by hand if you want both, then run ctx resolve with --ours. It keeps your file exactly as it is and re-bases it onto the server’s current version, so the page becomes modified. Then push:

Terminal window
ctx resolve wiki/concepts/stripe-subscription.md --ours
ctx push
ctx resolve wiki/concepts/stripe-subscription.md --ours
✔ Kept your version of wiki/concepts/stripe-subscription.md on top of server f0c47d2; now run ctx push
ctx push --yes
About to push 1 page(s) to acme-api:
  [EDIT]  wiki/concepts/stripe-subscription.md  (~ Concepts)
Pushed 1 page(s); HEAD=a4b2c1d3

The push sends your version with the teammate’s version as its parent, so it supersedes theirs on purpose. That is why it matters what you merged in step 2.

Take theirs. --theirs replaces your working file with the server version. If your file differed, it is saved first under .contexo/.sync/backup/, with a UTC timestamp:

Terminal window
ctx resolve wiki/entities/billing-worker.md --theirs
ctx resolve wiki/entities/billing-worker.md --theirs
✔ Took the server version of wiki/entities/billing-worker.md (3b8e1a0); your previous file is saved at ~/projects/acme-api/.contexo/.sync/backup/wiki/entities/billing-worker.md.20260728T091407Z

The page ends unchanged, and the backup keeps your old content in case you want parts of it back.

--ours re-bases onto whatever the server has at the moment you run it. If a teammate could push again while you are reviewing, pass the server sha you reviewed:

Terminal window
ctx resolve wiki/concepts/stripe-subscription.md --ours --if-server-sha f0c47d2196385a7b4c3d2e1f0a9b8c7d6e5f4a3b

ctx then refuses with stale (exit 6 with --json) unless the server is still at exactly that sha, so you never supersede a version you have not seen. The sha is compared in full; a shortened sha counts as stale. The VS Code extension always passes the sha the user looked at.

Two more rules:

  • --ours applies only to a conflict (or an unverified page with a valid file). Any other state is refused with not_resolvable (exit 9 with --json) and nothing is written.
  • --theirs works on any state the server has: it also restores a deleted_local page, and on an unchanged page it only drops a leftover sidecar.

Without --json, a failed ctx resolve prints a human message and exits 1.

Agents get the same guarantees through the MCP tools, which run the same sync engine:

  • ctx_push sends only changed pages. A page that is a conflict is not sent; the agent gets a <MERGE_REQUIRED origin=push> brief with the ancestor, its own version and the server’s version, plus the sections both sides changed.
  • ctx_pull keeps conflicting pages and returns the same brief with origin=pull.
  • The agent writes the merged page with ctx_write_page, then calls ctx_push with resolved: ["<path>"]. That is ctx resolve --ours guarded by the server sha the agent was shown, followed by a push. If the server moved again, the agent gets a fresh brief and nothing is overwritten.

To keep merges rare, the MCP server also warns before the edit. When an agent reads a page through ctx://wiki/<slug> or ctx://raw/<session-id> and the server has moved past the page’s recorded sha, the read is prefixed with a <DRIFT_NOTICE> listing the changed sections and pointing the agent at ctx_pull. The check needs a recorded sha (pages never pushed or pulled are skipped), is cached for 60 seconds per page, and degrades to no notice when the server cannot be reached. CONTEXO_DRIFT_DISABLE=1 turns it off. Agent directives has the exact text.

Agents run ctx mcp as a long-lived process, and a process started before ctx update keeps running the old code until the agent restarts. Old versions answered a 409 by recording the teammate’s sha as the page’s base without merging anything, so the next push would overwrite the teammate’s change.

ctx repairs that when it can. If bases.json still holds the older base for the page, ctx keeps it, so the page shows as a conflict (or as incoming when you had not edited it), never as a plain edit; when you had edited it, the repair also writes a sidecar.

When there is no older base record, that cannot work: on the first run after upgrading from a version without base snapshots, or after bases.json was lost, ctx can only take the server version as the base. A page an old agent bumped before the upgrade can then show up as modified, and pushing it would overwrite the teammate’s change.

So, after every ctx update:

  • restart every agent session (Claude Code, Cursor, Codex, VS Code), so their MCP servers run the new ctx. The VS Code extension reminds you when it sees a new ctx version;
  • if an old agent was working in the project, check modified pages with ctx show <path> --at server before you push them.
  • Pull at the start of a session. The ctx_pull tool description tells agents to do exactly this.
  • Leave drift detection on. The notice is cheap, and it is the only warning that arrives before the edit.
  • Push soon after you write. The window between writing and pushing is the window in which someone else’s push turns yours into a merge.
  • Run ctx status when you come back to a project after a few days.
Variable Effect
CONTEXO_DRIFT_DISABLE=1 No <DRIFT_NOTICE> on MCP page reads.
CONTEXO_DISTILL_DISABLE=1 Bypasses the distill guard for ctx push and ctx_push; the push document records it as distill.bypassed. The VS Code extension removes it from the environment it gives ctx.
CONTEXO_CAPTURE_DISABLE=1 ctx capture turn no-ops, so no buffer is written for the session.
CONTEXO_LOCK_TIMEOUT How long push, pull, resolve and repair wait for .sync/lock before failing with busy (exit 12). A Go duration (3s) or whole seconds; default 10 seconds.