Skip to content

feat(import): reconcile a newer copy instead of duplicating it - #92

Merged
tanglearncode merged 5 commits into
mainfrom
feat/import-reconcile
Aug 16, 2026
Merged

feat(import): reconcile a newer copy instead of duplicating it#92
tanglearncode merged 5 commits into
mainfrom
feat/import-reconcile

Conversation

@tanglearncode

@tanglearncode tanglearncode commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What changes

Importing a bundle you already have no longer builds a second context beside the first. Import resolves what the bundle is before it acts, and answers with one of:

  • current — nothing new upstream. Says so, writes nothing.
  • replace — your copy is untouched since it arrived, so the newer one can be taken whole. Previews first; applies on --yes.
  • merge — both copies moved. Prints the targets, the model reconciles the two, and the result is previewed and confirmed before anything is written.
  • choose — a context of that name is here but nothing records a shared origin. Asks whether it is the same context (--into "<name>") or a collision (--name "<new name>"), and waits.

A bundle new to the machine still imports immediately, exactly as before.

A context is never deleted, and a replacement keeps the id — same context, same name, so a session already connected to it reads the updated material immediately.

Why

import only ever created. That was correct exactly once, the first time a bundle arrived. Every import after that was a newer copy of something already here — someone updated the shared copy and you wanted their work — and creating gave you two contexts about one subject, competing every time a session routed itself, while the connected session went on reading the stale one.

What the user actually saw was a dead end: "A context named X already exists. Pick another name or delete that one first." Both suggestions are wrong. Renaming is the duplicate. Deleting loses the id that the connected session, the routing card, and the selection state all point at — so even when "take theirs whole" is the right outcome, delete-and-reimport is the wrong mechanic.

Identity and content are separate questions, and this separates them.

How

Lineage. Import stamps importedFrom on the local manifest: the exporter's context id, their revision/updatedAt, and two fingerprints. The id is the key — it survives a rename on either side and cannot confuse two teams who picked the same name. Export strips it again, since it describes only this machine's copy.

Two fingerprints, two questions. fingerprint is this copy at import time and answers did you change?; bundleFingerprint is the bundle's content and answers did they change? Both are needed, and the order matters: a merged context differs from the bundle permanently by construction, so asking only "did you change?" reads a merge as "behind" and offers to overwrite it with the material it was built from — every time it is seen again. "Did they move?" is asked first.

A baseline left by a different origin never licenses a replacement, which is what makes --into safe: adopting a context asserts identity, not that its contents are disposable.

Merge reuses the save pipeline. No textual merge algorithm was added — knowledge is prose, and the model is the merge engine, as in save.md. The merged capture is a save capture with targetId and baseHash, applied through updateCapturedContext. --merged-from exists only so the lineage is re-stamped in the same breath; without that, the same divergence would be re-offered against a stale baseline forever.

One shared module. shared/core/import-commands.mjs holds the orchestration and rendering, synced into all five packaged cores, so every host resolves identically and the CLIs stay thin. Hosts differ only in how a connect command is spelled.

Wired through Claude Code, Copilot, Kimi, and Codex CLIs, and pi — whose import is a registered command in the extension rather than a skill, and which gained an argument parser so the new flags are reachable there too. Command docs and skills rewritten for all four documented hosts; the root, Codex, Copilot, Kimi, and pi READMEs updated.

Review rounds

Round one — four correctness findings, all confirmed and fixed in a7df5a4:

  • A merged capture was bound to neither its target nor the bundle version. It proved only that it was built against some local context at a known base hash, so an unrelated context could be updated and then stamped with this lineage, and a draft could be applied after upstream moved — marking as taken material the merge had never seen. Drafts now carry a bundleHash beside baseHash, and both the target's lineage and that hash are verified before anything is written.
  • Forking made the lineage ambiguous. --name left two contexts holding one lineage id, and find over a name-sorted list picked the alphabetically first — so a fork could quietly become the thing that got updated. Several copies is now an outcome of its own: it lists them and requires --into.
  • A no-op adoption recorded nothing, so the same question returned on every import and no answer to it could ever fast-forward. Adoption is now persisted when asserted, as identity alone — claiming the contents had been taken would make the next import report current over a copy that never received them.
  • A routing description written with describe was silently replaced. It lives only in the routing card, invisible to both the manifest and the fingerprint. It is now read before the write and kept after it, on both paths, and reported. Deliberately not treated as divergence: a routing tweak is not knowledge and does not need a merge to settle.

Round two — three identity-resolution gaps the first round's fixes opened, fixed in f50c10a:

  • --into conflated selecting with adopting. Naming a context that already carried this lineage restamped it identity-only, discarding a valid, earned baseline — so an untouched copy that qualified for a fast-forward was pushed into a merge. Only naming a context that is not already this bundle's copy now counts as an assertion, and only that case writes.
  • Resolution and application disagreed about identity. Resolution counted a context whose own id matched the bundle (your own export, brought back); application required a recorded lineage, which an original context has no reason to have. Every merge offered for one was refused on every attempt. Both now go through one isBundleCopy predicate.
  • An id-less bundle was offered reconciliations it could not keep. The lineage write had no key to store, so current claimed an identity it discarded and merge produced a draft that applying would always reject. Such a bundle now resolves to a new unlinkable action and can only arrive as its own context.

Behavior change worth knowing

--name no longer means "rename on the way in" as an escape from a collision. It means keep both as separate contexts, and when the two really are copies of one bundle it says what that costs. A bare name collision between unrelated contexts is not reported as a duplicate.

Verification

  • npm run check — clean
  • node --test — 576/576 pass (was 546; 30 new)
  • pi suite — 55/55 pass
  • npm run coverage — all 840 changed lines covered
  • e2e:commands 58/58, e2e:extensions 84/84, e2e:no-nudge — pass

New tests cover the four outcomes, lineage recording, adoption, forking, an upstream rename, lineage stripped on export, and the two seams the command line cannot stage (a target deleted mid-import, a lineage stamp that fails after the import landed). tests/import-hosts.test.mjs pins that every host resolves rather than always creating — a host left on the old path would silently duplicate.

🤖 Generated with Claude Code

Import always created. That was right exactly once — the first time a
bundle arrived. Every import after it was a newer copy of a context
already here, and creating produced a second one that competed during
routing while the connected session went on reading the stale copy. The
name collision was reported as a dead end: pick another name, or delete
the context first.

Import now resolves before it acts. It records where a copy came from, so
a later bundle from the same origin is recognised, and it answers with
one of: nothing new, take it whole, reconcile the two first, or ask.

A context is never deleted, and a replacement keeps the id — so a session
connected to it reads the updated material immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/import-hosts.test.mjs Fixed
tanglearncode and others added 2 commits August 14, 2026 04:02
CI caught these; the local run could not. The new module was untracked
when coverage ran here, so it was absent from the diff and its lines were
never inspected — the four refusals a merged capture can meet, and the
draft that reproduces what is already stored, all went unexercised.

Each is checked to leave the context byte-for-byte untouched. A merge
carrying no targetId is the one worth naming: it would apply as a create
and produce exactly the duplicate this command exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex spells its connect command `$neatcontext:use`, and the host sweep
escaped that `$` with a single-occurrence replace before building a
RegExp from it. It happened to be correct for one leading symbol and
would silently stop being correct for anything else — CodeQL flagged the
incomplete escaping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tanglearncode tanglearncode left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found several correctness issues in the reconciliation paths; inline details below.

Comment thread shared/core/context-store.mjs
Comment thread shared/core/context-store.mjs Outdated
Comment thread shared/core/context-store.mjs
Comment thread shared/core/import-commands.mjs Outdated
…lace

All four were review findings, and each is a case where import wrote
somewhere plausible rather than somewhere proven.

A merged capture proved only that it was built against some local context
at a known base hash. It never proved it was built for the copy this
bundle belongs to, nor from the bundle in front of it — so an unrelated
context could be updated and then have this lineage stamped over its own,
and a draft could be applied after upstream moved, marking material as
taken that the merge had never seen. The draft now carries a bundle hash
beside the base hash, and both the target's lineage and that hash are
checked before anything is written.

Forking with --name left two contexts holding one lineage id, and the
next import picked between them by list order — alphabetically, so a
fork could quietly become the thing that got updated. Several copies is
now an answer of its own: it lists them and asks for --into.

Adopting a context whose contents already matched the bundle recorded
nothing, so the same question came back on the next import and no answer
to it could ever fast-forward. Adoption is now persisted the moment it is
asserted, as identity alone: claiming the contents had been taken would
make the next import report `current` over a copy that never received
them.

And a routing description written with `describe` lives only in the
routing card, where neither the fingerprint nor the manifest can see it —
so a fast-forward silently put the bundle's line back. It is now read
before the write and kept after it, and the import says so. Deliberately
not treated as divergence: a routing tweak is not knowledge and does not
need a merge to settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tanglearncode tanglearncode left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four earlier findings are addressed, but the fixes leave three identity-resolution gaps; inline details below.

Comment thread shared/core/context-store.mjs
Comment thread shared/core/context-store.mjs Outdated
Comment thread shared/core/context-store.mjs Outdated
Three follow-up review findings, all in the seams the last fix opened.

`--into` was treated as an assertion of identity even when it was only
picking between copies. Naming a context that already carried this
lineage restamped it identity-only, discarding a baseline that was valid
and earned — so an untouched copy that qualified for a fast-forward was
pushed into a merge instead. Selecting and adopting are now different
things, and only adopting writes.

Resolution counted a context whose own id matched the bundle — your own
export, brought back — while application accepted only a recorded
lineage. An original context has neither, so every merge the command
offered for one was refused on every attempt. Both now ask the same
question through one predicate.

And a bundle with no id was being offered reconciliations it could never
keep: the lineage write had no key to store, so `current` claimed to
record an identity it discarded, and `merge` produced a draft that
applying would always reject. Such a bundle is now told apart up front
and can only arrive as its own context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tanglearncode
tanglearncode merged commit 1b391cb into main Aug 16, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants