feat(import): reconcile a newer copy instead of duplicating it - #92
feat(import): reconcile a newer copy instead of duplicating it#92tanglearncode wants to merge 4 commits into
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
left a comment
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
left a comment
There was a problem hiding this comment.
The four earlier findings are addressed, but the fixes leave three identity-resolution gaps; inline details below.
| ...captureFromBundle(bundle, record), | ||
| baseHash | ||
| }); | ||
| const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name"; |
There was a problem hiding this comment.
[P2] Distinguish selection from a new adoption
adopted is non-null for every --into, even when the chosen record already has this bundle's full lineage. That makes matchedBy become adopted, so the current branch rewrites importedFrom with identityOnly: true and nulls a valid fingerprint/bundleFingerprint. Repro: import the bundle, fork it, select the untouched original with --into while the bundle is current, then advance the bundle; the original now resolves to merge instead of the safe replace it previously qualified for. Please track whether --into actually established a new identity separately from merely selecting one of several existing lineage matches, and only write the identity-only stamp in the former case.
| if (!record) { | ||
| throw new ContextError("The context this merge was prepared for no longer exists."); | ||
| } | ||
| if (!bundleId || record.importedFrom?.id !== bundleId) { |
There was a problem hiding this comment.
[P1] Accept the direct-ID lineage case emitted by the resolver
resolveImportTarget explicitly treats context.id === bundleId as a lineage match, but this application check accepts only record.importedFrom?.id === bundleId. An original context importing its own exported bundle after both the local context and bundle changed therefore gets Import action: merge, produces all three valid tokens, and is then rejected here every time because an original context naturally has no importedFrom. Please use the same identity predicate as resolution (or stamp that proven direct-ID relation before drafting) so every merge the command offers can actually be applied.
| } | ||
|
|
||
| const bundleHash = fingerprintImportBundle(bundle); | ||
| const lineageMatches = bundleId |
There was a problem hiding this comment.
[P2] Do not offer reconciliation when the bundle has no lineage key
Bundles without an id are intentionally accepted and tested, but the new adoption flow cannot complete for them. If the adopted contents match, the command reports current and says the identity was recorded even though recordImportLineage immediately returns without writing; if they differ, it emits merge, but applyImportMerge unconditionally rejects !bundleId. Either establish a stable identity for this case or keep ID-less bundles on create/fork-only behavior with an explanatory result instead of offering current/merge actions that cannot persist or apply.
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 round
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.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— 573/573 pass (was 546; 27 new)npm run coverage— all 791 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