feat(import): reconcile a newer copy instead of duplicating it - #92
Merged
Conversation
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>
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
commented
Aug 13, 2026
tanglearncode
left a comment
Contributor
Author
There was a problem hiding this comment.
Found several correctness issues in the reconciliation paths; inline details below.
…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
commented
Aug 14, 2026
tanglearncode
left a comment
Contributor
Author
There was a problem hiding this comment.
The four earlier findings are addressed, but the fixes leave three identity-resolution gaps; inline details below.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
importonly 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
importedFromon the local manifest: the exporter's context id, theirrevision/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.
fingerprintis this copy at import time and answers did you change?;bundleFingerprintis 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
--intosafe: 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 withtargetIdandbaseHash, applied throughupdateCapturedContext.--merged-fromexists 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.mjsholds 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:
bundleHashbesidebaseHash, and both the target's lineage and that hash are verified before anything is written.--nameleft two contexts holding one lineage id, andfindover 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.currentover a copy that never received them.describewas 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:
--intoconflated 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.isBundleCopypredicate.currentclaimed an identity it discarded andmergeproduced a draft that applying would always reject. Such a bundle now resolves to a newunlinkableaction and can only arrive as its own context.Behavior change worth knowing
--nameno 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— cleannode --test— 576/576 pass (was 546; 30 new)npm run coverage— all 840 changed lines coverede2e:commands58/58,e2e:extensions84/84,e2e:no-nudge— passNew 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.mjspins that every host resolves rather than always creating — a host left on the old path would silently duplicate.🤖 Generated with Claude Code