Skip to content

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

Open
tanglearncode wants to merge 4 commits into
mainfrom
feat/import-reconcile
Open

feat(import): reconcile a newer copy instead of duplicating it#92
tanglearncode wants to merge 4 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 round

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.

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 — 573/573 pass (was 546; 27 new)
  • pi suite — 55/55 pass
  • npm run coverage — all 791 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.

...captureFromBundle(bundle, record),
baseHash
});
const matchedBy = adopted ? "adopted" : lineage ? "lineage" : "name";

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.

[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) {

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.

[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

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.

[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.

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