diff --git a/README.md b/README.md index c3887ee..7661780 100644 --- a/README.md +++ b/README.md @@ -199,19 +199,22 @@ Import a context bundle shared by someone else. The shared folder is only ever read: importing makes your own local copy and never writes back to it. A bundle you already imported can be imported again — that is how you pick up a -teammate's newer work. Import recognises the copy it gave you and says what -taking the update would cost, rather than building a second context beside it: +teammate's newer work. Import recognises the copy it gave you, rather than +building a second context beside it: - Nothing new in the bundle, and it says so. -- Your copy untouched since it arrived, so the newer one replaces it whole once - you confirm. It stays the same context, so a session connected to it picks the +- The same context with newer material upstream, so it shows you what changed, + tells you what a replacement would cost here, and asks which you want: leave + your copy alone, take theirs whole, or reconcile the two into one. Whichever + you pick, it stays the same context, so a session connected to it picks the material up immediately. -- Both copies changed, so the two are reconciled into one and previewed before - anything is written. Your work is never dropped in favour of theirs. - A name already taken by a context with no shared origin, which import will not guess about: it asks whether the two are the same context or a collision, and waits. +Import decides who a bundle is; it never decides what your work is worth. +Nothing is written until you say which answer you want. + A context is never deleted to make room for an imported one. After importing, connect it with `/neatcontext:use `. diff --git a/codex-marketplace/README.md b/codex-marketplace/README.md index f4f45c3..266be99 100644 --- a/codex-marketplace/README.md +++ b/codex-marketplace/README.md @@ -164,19 +164,22 @@ Import a context bundle shared by someone else. The shared folder is only ever read: importing makes your own local copy and never writes back to it. A bundle you already imported can be imported again — that is how you pick up a -teammate's newer work. Import recognises the copy it gave you and says what -taking the update would cost, rather than building a second context beside it: +teammate's newer work. Import recognises the copy it gave you, rather than +building a second context beside it: - Nothing new in the bundle, and it says so. -- Your copy untouched since it arrived, so the newer one replaces it whole once - you confirm. It stays the same context, so a session connected to it picks the +- The same context with newer material upstream, so it shows you what changed, + tells you what a replacement would cost here, and asks which you want: leave + your copy alone, take theirs whole, or reconcile the two into one. Whichever + you pick, it stays the same context, so a session connected to it picks the material up immediately. -- Both copies changed, so the two are reconciled into one and previewed before - anything is written. Your work is never dropped in favour of theirs. - A name already taken by a context with no shared origin, which import will not guess about: it asks whether the two are the same context or a collision, and waits. +Import decides who a bundle is; it never decides what your work is worth. +Nothing is written until you say which answer you want. + A context is never deleted to make room for an imported one. After importing, connect it with `$neatcontext:use `. diff --git a/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md index bb34201..f5f035f 100644 --- a/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/import/SKILL.md @@ -22,16 +22,20 @@ The source bundle is read-only throughout. Never modify, move, or delete it. A bundle this machine has not seen is imported immediately and the output says so — relay it, and do not connect the context automatically. Otherwise follow the printed `Import action`: - `current` — the context here already holds everything in the bundle. Relay that and stop. -- `replace` — the local copy came from this bundle and has not been edited since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. -- `merge` — both copies have changed. Reconcile them yourself, below. +- `reconcile` — the bundle is a newer copy of a context already here. Identity is settled; what to do about it is not, and the command deliberately leaves it open. Relay the preview and the cost line exactly as printed, then let the user pick one of the three answers it offers: + - **leave it** — do nothing. Stop; nothing has been written. + - **replace** — take the bundle whole, discarding whatever only the local copy holds. Rerun the same command with `--replace --yes`. + - **merge** — keep both sides. Reconcile them yourself, below. - `unlinkable` — the bundle carries no context id, so it cannot be tied to anything already here. Relay that, and offer `--name ""` to bring it in as its own context. - `choose` — the target is not decidable. Either a context of the same name is here but nothing records a shared origin, or several contexts are copies of this bundle because one was forked. Relay the options and stop until the user picks: rerun with `--into ""` to name the context they mean, or with `--name ""` to keep a separate copy. -Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not recoverable from each other. +Never answer `choose` or `reconcile` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and only the user knows whether the material a replacement would discard was worth keeping. None of these answers is recoverable from the others. + +Relay the cost line rather than summarising it, because the three it can print mean different things. "Nothing here has been edited" means replacing is lossless and a merge could only reproduce the bundle — say so, and still let the user choose. "This copy has been edited here" means replacing destroys that work. "Nothing here records what the two copies once had in common" means the copy may be untouched and cannot prove it; do not report that one as edits. ## Merging -Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. The three hashes are what prove the merge is for this context, was built on its current contents, and consumed this version of the bundle; a merge that gets any of them wrong is refused rather than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. +Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values printed under `Merge inputs`. The three hashes are what prove the merge is for this context, was built on its current contents, and consumed this version of the bundle; a merge that gets any of them wrong is refused rather than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. Merge them the way a save merges a conversation into an existing context: diff --git a/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs b/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs index abbf01e..e2eaab2 100644 --- a/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs +++ b/codex-marketplace/plugins/neatcontext/src/codex/neatcontext-cli.mjs @@ -678,6 +678,7 @@ async function commandImport(flags) { name: typeof flags.name === "string" ? flags.name : "", into: typeof flags.into === "string" ? flags.into : "", mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + replace: flags.replace === true || flags.replace === "true", confirmed: flags.yes === true || flags.yes === "true", consume: flags.consume === true || flags.consume === "true", useCommand: "$neatcontext:use" diff --git a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs index d6e6c17..b804fe4 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/context-store.mjs @@ -1156,18 +1156,34 @@ export async function resolveImportTarget({ bundleFolder, into }) { return { ...base, action: "current" }; } - // They have moved, so this is about what taking it would cost here. No - // baseline means no way to prove this copy is untouched, and replacing an - // edited copy loses the edits — merge is the answer whenever it cannot be - // ruled out. + // They have moved, so this is about what taking the bundle would cost here — + // and the cost is reported rather than acted on. Which way to take it is not + // a fact about the two copies but a judgement about whose material matters, + // and only the person who wrote the local half can make it. Deciding here + // means either overwriting work nobody offered to sacrifice or demanding a + // merge over edits the user would have thrown away without a second thought. // - // A baseline left by a different origin does not count, which is what makes - // adoption safe: saying two contexts are the same does not make this copy's - // material disposable, and it came from somewhere else entirely. + // What the evidence does settle is the price of each answer, and that travels + // with the resolution. No baseline means no way to prove this copy is + // untouched, so it is reported as diverged: unprovable and edited cost the + // same to replace. + // + // A baseline left by a different origin does not count either, which is what + // keeps adoption honest. Saying two contexts are the same does not establish + // that they ever shared contents, so an adopted copy's material is never + // assumed disposable. + // + // Why it diverged is carried out separately from whether it did, because the + // two reasons cost the same and read nothing alike. A copy with edits in it + // has work to lose; a copy with no usable baseline may be byte-identical to + // the bundle and simply unable to prove it. Saying the second one has been + // edited would be inventing a history, which is the one thing this whole path + // exists to avoid. const baseline = record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; - const diverged = typeof baseline !== "string" || baseline !== baseHash; - return { ...base, action: diverged ? "merge" : "replace" }; + const divergedBy = + typeof baseline !== "string" ? "unproven" : baseline === baseHash ? null : "edits"; + return { ...base, action: "reconcile", diverged: divergedBy !== null, divergedBy }; } // The fast-forward: this copy has not been touched since it arrived, so the diff --git a/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs b/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs index 240c919..047fbc3 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/import-commands.mjs @@ -13,6 +13,14 @@ // diverged. Whenever the safe answer cannot be established, the command reports // and stops rather than writing. // +// Identity is the only question it answers by itself. Once two copies are known +// to be one context, what to do about that is the user's call — leave it, +// replace it, or merge — because the evidence can price each answer and cannot +// say which price is worth paying. Divergence therefore phrases the choice +// rather than making it: it is the difference between a replacement that loses +// nothing and one that discards a week of work, and both are legitimate things +// to want. +// // Rendering lives here too. The four hosts differ only in how a slash command // is spelled, and that arrives as `useCommand`. @@ -90,6 +98,39 @@ function describeDistance(lines, record, bundle) { lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); } +// What replacing would cost, which is the whole of what divergence decides now +// that it no longer decides the action. Stated on both the offer and the +// confirmation, because those are separate invocations and the second may be +// the only one a hurried reader looks at. +// +// Three sentences for three states, and the middle one is why they are not two. +// A copy with no usable baseline cannot be called edited: it may hold nothing +// the bundle does not, and only its receipt is missing. Telling that user their +// work is at risk teaches them to stop reading the warning that matters. +// +// The untouched case says outright that merging can only reproduce the bundle. +// Offering an answer while concealing that it is busywork would be a worse kind +// of withholding than not offering it at all. +function describeCost(lines, resolved, record) { + if (resolved.divergedBy === "edits") { + lines.push( + "This copy has been edited here since it arrived, so replacing discards that work." + ); + return; + } + if (resolved.divergedBy === "unproven") { + lines.push( + "Nothing here records what the two copies once had in common, so replacing " + + `discards whatever only "${record.name}" holds.` + ); + return; + } + lines.push( + "Nothing here has been edited since this copy arrived, so replacing discards " + + "nothing and a merge could only reproduce the bundle." + ); +} + function describeImported(lines, result, source, useCommand) { lines.push(`Imported the "${result.record.name}" conversation context.`); lines.push(` Domain profile: ${result.record.profilePath}`); @@ -180,6 +221,7 @@ export async function runImport({ name = "", into = "", mergedFrom = "", + replace = false, confirmed = false, consume = false, useCommand @@ -253,10 +295,10 @@ export async function runImport({ } // A name in common is not evidence of a common origin, and the two cases - // want opposite handling, so this is the one outcome that asks. `--into` - // adopts the local context as this bundle's copy; since no baseline against - // this bundle exists for it, adopting leads to a merge and never to a - // replacement. + // want opposite handling, so this is the outcome that asks about identity. + // `--into` adopts the local context as this bundle's copy, which settles who + // it is and nothing about what to do next: it arrives at the same choice any + // other copy does, carrying no baseline to prove it untouched. // No id in the bundle means no key to recognise it by, now or later. It can // still be brought in — as its own context — but it cannot be tied to one // already here, so the reconciling answers are not offered rather than @@ -308,67 +350,100 @@ export async function runImport({ return lines.join("\n"); } - if (resolved.action === "merge") { - lines.push("Import action: merge"); - lines.push( - resolved.matchedBy === "adopted" - ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + - "records what the two once had in common. Taking the bundle whole would " + - "discard whatever only this copy holds, so the two have to be reconciled first." - : `"${record.name}" came from this bundle, and both copies have changed since. ` + - "Taking the bundle whole would discard the work saved here, so the two have " + - "to be reconciled first." - ); - describeDistance(lines, record, bundle); - // Adoption is recorded now rather than at apply time, so the merge that - // follows can be checked against a target this bundle is known to belong - // to. Identity only: nothing has been taken from the bundle yet. - if (resolved.matchedBy === "adopted") { - await recordImportLineage(record, bundle, { identityOnly: true }); - } - lines.push(`Context name: ${record.name}`); - lines.push(`Context id: ${record.id}`); - lines.push(`Base hash: ${resolved.baseHash}`); - lines.push(`Bundle hash: ${resolved.bundleHash}`); - lines.push(`Profile path: ${record.profilePath}`); - lines.push(`Knowledge folder: ${record.knowledgeFolder}`); - lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); - lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push( - "Merge both sides, then apply the result with --merged-from. Carry the context " + - "id, base hash, and bundle hash into the draft exactly as printed: they are what " + - "prove the merge is for this context and was built from this bundle." - ); - return lines.join("\n"); + // What is left is `reconcile`: the same context, with the bundle holding + // something this copy does not. Reached by falling through rather than by a + // guard of its own, because every other resolution has returned by now and a + // branch here would need an unreachable else to sit beside it. + // + // All three answers are things a user might genuinely want, so all three are + // offered and none is taken: leaving it, replacing it, and merging differ in + // what they cost, not in whether they are correct. + // + // Merge is offered even when this copy is provably untouched, where it can + // only reproduce the bundle. Withholding it there would be answering the + // question again one step further in, so it is offered and its + // pointlessness is stated instead. + + // Adoption is recorded before any answer is given, so a merge drafted from + // this output can be checked against a target this bundle is known to + // belong to. Identity only: nothing has been taken from the bundle yet, + // and this stands whichever answer follows — including none of them. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); } - if (!confirmed) { - lines.push("Import action: replace"); - lines.push( - `"${record.name}" came from this bundle and has not been edited here since, so ` + - "the newer copy can be taken whole." + // Replacing is the one answer that can destroy work, so it is spelled out + // rather than reachable by confirming. `--yes` alone means "the action you + // resolved", and there is no longer such a thing: naming the answer is + // what keeps a merge drafted for hours from being overwritten by a `--yes` + // that forgot its `--merged-from`. + if (replace) { + if (!confirmed) { + lines.push(`Replace "${record.name}" with this bundle?`); + describeCost(lines, resolved, record); + describeChanges(lines, preview); + lines.push("Re-run this import with --replace --yes to take it."); + return lines.join("\n"); + } + const authored = await authoredUseWhen(record); + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` ); - describeDistance(lines, record, bundle); - describeChanges(lines, preview); - lines.push("Re-run this import with --yes to take it."); return lines.join("\n"); } - const authored = await authoredUseWhen(record); - const result = await replaceContextFromBundle({ - bundleFolder, - targetId: record.id, - baseHash: resolved.baseHash - }); - await refreshCard(result, authored); - if (authored) { - lines.push(`Kept the routing description you set here: ${authored}`); - } - describeUpdated( - lines, - result, - source, - `Updated the "${result.record.name}" context from the bundle.` + lines.push("Import action: reconcile"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and the bundle ` + + "holds material this copy does not." + : `"${record.name}" came from this bundle, and the bundle has moved on since ` + + "this copy was taken." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + describeCost(lines, resolved, record); + lines.push("Choose one — nothing here is written until you do:"); + lines.push(" leave it"); + lines.push(" Stop here. This copy stays exactly as it is."); + lines.push(" replace"); + lines.push(" Take the bundle whole: re-run this import with --replace --yes."); + lines.push(" merge"); + lines.push( + " Keep both sides: reconcile them into one copy, then apply it with " + + "--merged-from." + ); + // Printed on every reconcile rather than behind a second round trip, so + // choosing merge costs nothing beyond the drafting. They are inert until + // used, and whoever leaves it or replaces it simply never reads them. + // + // Flush left, unlike the options above: these are read back by whatever + // drafts the merge, and a label is a stabler anchor than an indent. + lines.push("Merge inputs, needed only if you choose merge:"); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push( + "Carry the context id, base hash, and bundle hash into the draft exactly as " + + "printed: they are what prove the merge is for this context and was built from " + + "this bundle." ); return lines.join("\n"); } catch (error) { diff --git a/plugins/claude-code/neatcontext/commands/import.md b/plugins/claude-code/neatcontext/commands/import.md index 4db8b8d..ea2c59a 100644 --- a/plugins/claude-code/neatcontext/commands/import.md +++ b/plugins/claude-code/neatcontext/commands/import.md @@ -33,10 +33,14 @@ the printed `Import action`: - `current` — the context here already holds everything in the bundle. Relay that and stop. -- `replace` — the local copy came from this bundle and has not been edited - since, so the newer copy can be taken whole. Relay the preview, ask the user - to confirm, and only then rerun the same command with `--yes`. -- `merge` — both copies have changed. Reconcile them yourself, below. +- `reconcile` — the bundle is a newer copy of a context already here. Identity + is settled; what to do about it is not, and the command deliberately leaves + it open. Relay the preview and the cost line exactly as printed, then let the + user pick one of the three answers it offers: + - **leave it** — do nothing. Stop; nothing has been written. + - **replace** — take the bundle whole, discarding whatever only the local + copy holds. Rerun the same command with `--replace --yes`. + - **merge** — keep both sides. Reconcile them yourself, below. - `unlinkable` — the bundle carries no context id, so it cannot be tied to anything already here. Relay that, and offer `--name ""` to bring it in as its own context. @@ -46,17 +50,26 @@ the printed `Import action`: picks: rerun with `--into ""` to name the context they mean, or with `--name ""` to keep a separate copy. -Never answer `choose` on the user's behalf. Two people naming a context the same -thing is not evidence that it is the same context, and the two answers are not -recoverable from each other. +Never answer `choose` or `reconcile` on the user's behalf. Two people naming a +context the same thing is not evidence that it is the same context, and only +the user knows whether the material a replacement would discard was worth +keeping. None of these answers is recoverable from the others. + +Relay the cost line rather than summarising it, because the three it can print +mean different things. "Nothing here has been edited" means replacing is +lossless and a merge could only reproduce the bundle — say so, and still let +the user choose. "This copy has been edited here" means replacing destroys +that work. "Nothing here records what the two copies once had in common" means +the copy may be untouched and cannot prove it; do not report that one as edits. ## Merging Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` -values the command printed. The three hashes are what prove the merge is for -this context, was built on its current contents, and consumed this version of -the bundle; a merge that gets any of them wrong is refused rather than applied. +values printed under `Merge inputs`. The three hashes are what prove the merge +is for this context, was built on its current contents, and consumed this +version of the bundle; a merge that gets any of them wrong is refused rather +than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. diff --git a/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs b/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs index 875e667..5c78935 100644 --- a/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs +++ b/plugins/claude-code/neatcontext/src/claude/neatcontext-cli.mjs @@ -814,6 +814,7 @@ async function commandImport(flags) { name: typeof flags.name === "string" ? flags.name : "", into: typeof flags.into === "string" ? flags.into : "", mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + replace: flags.replace === true || flags.replace === "true", confirmed: flags.yes === true || flags.yes === "true", consume: flags.consume === true || flags.consume === "true", useCommand: "/neatcontext:use" diff --git a/plugins/claude-code/neatcontext/src/core/context-store.mjs b/plugins/claude-code/neatcontext/src/core/context-store.mjs index d6e6c17..b804fe4 100644 --- a/plugins/claude-code/neatcontext/src/core/context-store.mjs +++ b/plugins/claude-code/neatcontext/src/core/context-store.mjs @@ -1156,18 +1156,34 @@ export async function resolveImportTarget({ bundleFolder, into }) { return { ...base, action: "current" }; } - // They have moved, so this is about what taking it would cost here. No - // baseline means no way to prove this copy is untouched, and replacing an - // edited copy loses the edits — merge is the answer whenever it cannot be - // ruled out. + // They have moved, so this is about what taking the bundle would cost here — + // and the cost is reported rather than acted on. Which way to take it is not + // a fact about the two copies but a judgement about whose material matters, + // and only the person who wrote the local half can make it. Deciding here + // means either overwriting work nobody offered to sacrifice or demanding a + // merge over edits the user would have thrown away without a second thought. // - // A baseline left by a different origin does not count, which is what makes - // adoption safe: saying two contexts are the same does not make this copy's - // material disposable, and it came from somewhere else entirely. + // What the evidence does settle is the price of each answer, and that travels + // with the resolution. No baseline means no way to prove this copy is + // untouched, so it is reported as diverged: unprovable and edited cost the + // same to replace. + // + // A baseline left by a different origin does not count either, which is what + // keeps adoption honest. Saying two contexts are the same does not establish + // that they ever shared contents, so an adopted copy's material is never + // assumed disposable. + // + // Why it diverged is carried out separately from whether it did, because the + // two reasons cost the same and read nothing alike. A copy with edits in it + // has work to lose; a copy with no usable baseline may be byte-identical to + // the bundle and simply unable to prove it. Saying the second one has been + // edited would be inventing a history, which is the one thing this whole path + // exists to avoid. const baseline = record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; - const diverged = typeof baseline !== "string" || baseline !== baseHash; - return { ...base, action: diverged ? "merge" : "replace" }; + const divergedBy = + typeof baseline !== "string" ? "unproven" : baseline === baseHash ? null : "edits"; + return { ...base, action: "reconcile", diverged: divergedBy !== null, divergedBy }; } // The fast-forward: this copy has not been touched since it arrived, so the diff --git a/plugins/claude-code/neatcontext/src/core/import-commands.mjs b/plugins/claude-code/neatcontext/src/core/import-commands.mjs index 240c919..047fbc3 100644 --- a/plugins/claude-code/neatcontext/src/core/import-commands.mjs +++ b/plugins/claude-code/neatcontext/src/core/import-commands.mjs @@ -13,6 +13,14 @@ // diverged. Whenever the safe answer cannot be established, the command reports // and stops rather than writing. // +// Identity is the only question it answers by itself. Once two copies are known +// to be one context, what to do about that is the user's call — leave it, +// replace it, or merge — because the evidence can price each answer and cannot +// say which price is worth paying. Divergence therefore phrases the choice +// rather than making it: it is the difference between a replacement that loses +// nothing and one that discards a week of work, and both are legitimate things +// to want. +// // Rendering lives here too. The four hosts differ only in how a slash command // is spelled, and that arrives as `useCommand`. @@ -90,6 +98,39 @@ function describeDistance(lines, record, bundle) { lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); } +// What replacing would cost, which is the whole of what divergence decides now +// that it no longer decides the action. Stated on both the offer and the +// confirmation, because those are separate invocations and the second may be +// the only one a hurried reader looks at. +// +// Three sentences for three states, and the middle one is why they are not two. +// A copy with no usable baseline cannot be called edited: it may hold nothing +// the bundle does not, and only its receipt is missing. Telling that user their +// work is at risk teaches them to stop reading the warning that matters. +// +// The untouched case says outright that merging can only reproduce the bundle. +// Offering an answer while concealing that it is busywork would be a worse kind +// of withholding than not offering it at all. +function describeCost(lines, resolved, record) { + if (resolved.divergedBy === "edits") { + lines.push( + "This copy has been edited here since it arrived, so replacing discards that work." + ); + return; + } + if (resolved.divergedBy === "unproven") { + lines.push( + "Nothing here records what the two copies once had in common, so replacing " + + `discards whatever only "${record.name}" holds.` + ); + return; + } + lines.push( + "Nothing here has been edited since this copy arrived, so replacing discards " + + "nothing and a merge could only reproduce the bundle." + ); +} + function describeImported(lines, result, source, useCommand) { lines.push(`Imported the "${result.record.name}" conversation context.`); lines.push(` Domain profile: ${result.record.profilePath}`); @@ -180,6 +221,7 @@ export async function runImport({ name = "", into = "", mergedFrom = "", + replace = false, confirmed = false, consume = false, useCommand @@ -253,10 +295,10 @@ export async function runImport({ } // A name in common is not evidence of a common origin, and the two cases - // want opposite handling, so this is the one outcome that asks. `--into` - // adopts the local context as this bundle's copy; since no baseline against - // this bundle exists for it, adopting leads to a merge and never to a - // replacement. + // want opposite handling, so this is the outcome that asks about identity. + // `--into` adopts the local context as this bundle's copy, which settles who + // it is and nothing about what to do next: it arrives at the same choice any + // other copy does, carrying no baseline to prove it untouched. // No id in the bundle means no key to recognise it by, now or later. It can // still be brought in — as its own context — but it cannot be tied to one // already here, so the reconciling answers are not offered rather than @@ -308,67 +350,100 @@ export async function runImport({ return lines.join("\n"); } - if (resolved.action === "merge") { - lines.push("Import action: merge"); - lines.push( - resolved.matchedBy === "adopted" - ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + - "records what the two once had in common. Taking the bundle whole would " + - "discard whatever only this copy holds, so the two have to be reconciled first." - : `"${record.name}" came from this bundle, and both copies have changed since. ` + - "Taking the bundle whole would discard the work saved here, so the two have " + - "to be reconciled first." - ); - describeDistance(lines, record, bundle); - // Adoption is recorded now rather than at apply time, so the merge that - // follows can be checked against a target this bundle is known to belong - // to. Identity only: nothing has been taken from the bundle yet. - if (resolved.matchedBy === "adopted") { - await recordImportLineage(record, bundle, { identityOnly: true }); - } - lines.push(`Context name: ${record.name}`); - lines.push(`Context id: ${record.id}`); - lines.push(`Base hash: ${resolved.baseHash}`); - lines.push(`Bundle hash: ${resolved.bundleHash}`); - lines.push(`Profile path: ${record.profilePath}`); - lines.push(`Knowledge folder: ${record.knowledgeFolder}`); - lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); - lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push( - "Merge both sides, then apply the result with --merged-from. Carry the context " + - "id, base hash, and bundle hash into the draft exactly as printed: they are what " + - "prove the merge is for this context and was built from this bundle." - ); - return lines.join("\n"); + // What is left is `reconcile`: the same context, with the bundle holding + // something this copy does not. Reached by falling through rather than by a + // guard of its own, because every other resolution has returned by now and a + // branch here would need an unreachable else to sit beside it. + // + // All three answers are things a user might genuinely want, so all three are + // offered and none is taken: leaving it, replacing it, and merging differ in + // what they cost, not in whether they are correct. + // + // Merge is offered even when this copy is provably untouched, where it can + // only reproduce the bundle. Withholding it there would be answering the + // question again one step further in, so it is offered and its + // pointlessness is stated instead. + + // Adoption is recorded before any answer is given, so a merge drafted from + // this output can be checked against a target this bundle is known to + // belong to. Identity only: nothing has been taken from the bundle yet, + // and this stands whichever answer follows — including none of them. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); } - if (!confirmed) { - lines.push("Import action: replace"); - lines.push( - `"${record.name}" came from this bundle and has not been edited here since, so ` + - "the newer copy can be taken whole." + // Replacing is the one answer that can destroy work, so it is spelled out + // rather than reachable by confirming. `--yes` alone means "the action you + // resolved", and there is no longer such a thing: naming the answer is + // what keeps a merge drafted for hours from being overwritten by a `--yes` + // that forgot its `--merged-from`. + if (replace) { + if (!confirmed) { + lines.push(`Replace "${record.name}" with this bundle?`); + describeCost(lines, resolved, record); + describeChanges(lines, preview); + lines.push("Re-run this import with --replace --yes to take it."); + return lines.join("\n"); + } + const authored = await authoredUseWhen(record); + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` ); - describeDistance(lines, record, bundle); - describeChanges(lines, preview); - lines.push("Re-run this import with --yes to take it."); return lines.join("\n"); } - const authored = await authoredUseWhen(record); - const result = await replaceContextFromBundle({ - bundleFolder, - targetId: record.id, - baseHash: resolved.baseHash - }); - await refreshCard(result, authored); - if (authored) { - lines.push(`Kept the routing description you set here: ${authored}`); - } - describeUpdated( - lines, - result, - source, - `Updated the "${result.record.name}" context from the bundle.` + lines.push("Import action: reconcile"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and the bundle ` + + "holds material this copy does not." + : `"${record.name}" came from this bundle, and the bundle has moved on since ` + + "this copy was taken." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + describeCost(lines, resolved, record); + lines.push("Choose one — nothing here is written until you do:"); + lines.push(" leave it"); + lines.push(" Stop here. This copy stays exactly as it is."); + lines.push(" replace"); + lines.push(" Take the bundle whole: re-run this import with --replace --yes."); + lines.push(" merge"); + lines.push( + " Keep both sides: reconcile them into one copy, then apply it with " + + "--merged-from." + ); + // Printed on every reconcile rather than behind a second round trip, so + // choosing merge costs nothing beyond the drafting. They are inert until + // used, and whoever leaves it or replaces it simply never reads them. + // + // Flush left, unlike the options above: these are read back by whatever + // drafts the merge, and a label is a stabler anchor than an indent. + lines.push("Merge inputs, needed only if you choose merge:"); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push( + "Carry the context id, base hash, and bundle hash into the draft exactly as " + + "printed: they are what prove the merge is for this context and was built from " + + "this bundle." ); return lines.join("\n"); } catch (error) { diff --git a/plugins/copilot/neatcontext/commands/import.md b/plugins/copilot/neatcontext/commands/import.md index 84fc91b..a808acb 100644 --- a/plugins/copilot/neatcontext/commands/import.md +++ b/plugins/copilot/neatcontext/commands/import.md @@ -33,10 +33,14 @@ the printed `Import action`: - `current` — the context here already holds everything in the bundle. Relay that and stop. -- `replace` — the local copy came from this bundle and has not been edited - since, so the newer copy can be taken whole. Relay the preview, ask the user - to confirm, and only then rerun the same command with `--yes`. -- `merge` — both copies have changed. Reconcile them yourself, below. +- `reconcile` — the bundle is a newer copy of a context already here. Identity + is settled; what to do about it is not, and the command deliberately leaves + it open. Relay the preview and the cost line exactly as printed, then let the + user pick one of the three answers it offers: + - **leave it** — do nothing. Stop; nothing has been written. + - **replace** — take the bundle whole, discarding whatever only the local + copy holds. Rerun the same command with `--replace --yes`. + - **merge** — keep both sides. Reconcile them yourself, below. - `unlinkable` — the bundle carries no context id, so it cannot be tied to anything already here. Relay that, and offer `--name ""` to bring it in as its own context. @@ -46,17 +50,26 @@ the printed `Import action`: picks: rerun with `--into ""` to name the context they mean, or with `--name ""` to keep a separate copy. -Never answer `choose` on the user's behalf. Two people naming a context the same -thing is not evidence that it is the same context, and the two answers are not -recoverable from each other. +Never answer `choose` or `reconcile` on the user's behalf. Two people naming a +context the same thing is not evidence that it is the same context, and only +the user knows whether the material a replacement would discard was worth +keeping. None of these answers is recoverable from the others. + +Relay the cost line rather than summarising it, because the three it can print +mean different things. "Nothing here has been edited" means replacing is +lossless and a merge could only reproduce the bundle — say so, and still let +the user choose. "This copy has been edited here" means replacing destroys +that work. "Nothing here records what the two copies once had in common" means +the copy may be untouched and cannot prove it; do not report that one as edits. ## Merging Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` -values the command printed. The three hashes are what prove the merge is for -this context, was built on its current contents, and consumed this version of -the bundle; a merge that gets any of them wrong is refused rather than applied. +values printed under `Merge inputs`. The three hashes are what prove the merge +is for this context, was built on its current contents, and consumed this +version of the bundle; a merge that gets any of them wrong is refused rather +than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. diff --git a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs index b8763d3..8de607a 100644 --- a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs +++ b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs @@ -710,6 +710,7 @@ async function commandImport(flags) { name: typeof flags.name === "string" ? flags.name : "", into: typeof flags.into === "string" ? flags.into : "", mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + replace: flags.replace === true || flags.replace === "true", confirmed: flags.yes === true || flags.yes === "true", consume: flags.consume === true || flags.consume === "true", useCommand: "/neatcontext:use" diff --git a/plugins/copilot/neatcontext/src/core/context-store.mjs b/plugins/copilot/neatcontext/src/core/context-store.mjs index d6e6c17..b804fe4 100644 --- a/plugins/copilot/neatcontext/src/core/context-store.mjs +++ b/plugins/copilot/neatcontext/src/core/context-store.mjs @@ -1156,18 +1156,34 @@ export async function resolveImportTarget({ bundleFolder, into }) { return { ...base, action: "current" }; } - // They have moved, so this is about what taking it would cost here. No - // baseline means no way to prove this copy is untouched, and replacing an - // edited copy loses the edits — merge is the answer whenever it cannot be - // ruled out. + // They have moved, so this is about what taking the bundle would cost here — + // and the cost is reported rather than acted on. Which way to take it is not + // a fact about the two copies but a judgement about whose material matters, + // and only the person who wrote the local half can make it. Deciding here + // means either overwriting work nobody offered to sacrifice or demanding a + // merge over edits the user would have thrown away without a second thought. // - // A baseline left by a different origin does not count, which is what makes - // adoption safe: saying two contexts are the same does not make this copy's - // material disposable, and it came from somewhere else entirely. + // What the evidence does settle is the price of each answer, and that travels + // with the resolution. No baseline means no way to prove this copy is + // untouched, so it is reported as diverged: unprovable and edited cost the + // same to replace. + // + // A baseline left by a different origin does not count either, which is what + // keeps adoption honest. Saying two contexts are the same does not establish + // that they ever shared contents, so an adopted copy's material is never + // assumed disposable. + // + // Why it diverged is carried out separately from whether it did, because the + // two reasons cost the same and read nothing alike. A copy with edits in it + // has work to lose; a copy with no usable baseline may be byte-identical to + // the bundle and simply unable to prove it. Saying the second one has been + // edited would be inventing a history, which is the one thing this whole path + // exists to avoid. const baseline = record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; - const diverged = typeof baseline !== "string" || baseline !== baseHash; - return { ...base, action: diverged ? "merge" : "replace" }; + const divergedBy = + typeof baseline !== "string" ? "unproven" : baseline === baseHash ? null : "edits"; + return { ...base, action: "reconcile", diverged: divergedBy !== null, divergedBy }; } // The fast-forward: this copy has not been touched since it arrived, so the diff --git a/plugins/copilot/neatcontext/src/core/import-commands.mjs b/plugins/copilot/neatcontext/src/core/import-commands.mjs index 240c919..047fbc3 100644 --- a/plugins/copilot/neatcontext/src/core/import-commands.mjs +++ b/plugins/copilot/neatcontext/src/core/import-commands.mjs @@ -13,6 +13,14 @@ // diverged. Whenever the safe answer cannot be established, the command reports // and stops rather than writing. // +// Identity is the only question it answers by itself. Once two copies are known +// to be one context, what to do about that is the user's call — leave it, +// replace it, or merge — because the evidence can price each answer and cannot +// say which price is worth paying. Divergence therefore phrases the choice +// rather than making it: it is the difference between a replacement that loses +// nothing and one that discards a week of work, and both are legitimate things +// to want. +// // Rendering lives here too. The four hosts differ only in how a slash command // is spelled, and that arrives as `useCommand`. @@ -90,6 +98,39 @@ function describeDistance(lines, record, bundle) { lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); } +// What replacing would cost, which is the whole of what divergence decides now +// that it no longer decides the action. Stated on both the offer and the +// confirmation, because those are separate invocations and the second may be +// the only one a hurried reader looks at. +// +// Three sentences for three states, and the middle one is why they are not two. +// A copy with no usable baseline cannot be called edited: it may hold nothing +// the bundle does not, and only its receipt is missing. Telling that user their +// work is at risk teaches them to stop reading the warning that matters. +// +// The untouched case says outright that merging can only reproduce the bundle. +// Offering an answer while concealing that it is busywork would be a worse kind +// of withholding than not offering it at all. +function describeCost(lines, resolved, record) { + if (resolved.divergedBy === "edits") { + lines.push( + "This copy has been edited here since it arrived, so replacing discards that work." + ); + return; + } + if (resolved.divergedBy === "unproven") { + lines.push( + "Nothing here records what the two copies once had in common, so replacing " + + `discards whatever only "${record.name}" holds.` + ); + return; + } + lines.push( + "Nothing here has been edited since this copy arrived, so replacing discards " + + "nothing and a merge could only reproduce the bundle." + ); +} + function describeImported(lines, result, source, useCommand) { lines.push(`Imported the "${result.record.name}" conversation context.`); lines.push(` Domain profile: ${result.record.profilePath}`); @@ -180,6 +221,7 @@ export async function runImport({ name = "", into = "", mergedFrom = "", + replace = false, confirmed = false, consume = false, useCommand @@ -253,10 +295,10 @@ export async function runImport({ } // A name in common is not evidence of a common origin, and the two cases - // want opposite handling, so this is the one outcome that asks. `--into` - // adopts the local context as this bundle's copy; since no baseline against - // this bundle exists for it, adopting leads to a merge and never to a - // replacement. + // want opposite handling, so this is the outcome that asks about identity. + // `--into` adopts the local context as this bundle's copy, which settles who + // it is and nothing about what to do next: it arrives at the same choice any + // other copy does, carrying no baseline to prove it untouched. // No id in the bundle means no key to recognise it by, now or later. It can // still be brought in — as its own context — but it cannot be tied to one // already here, so the reconciling answers are not offered rather than @@ -308,67 +350,100 @@ export async function runImport({ return lines.join("\n"); } - if (resolved.action === "merge") { - lines.push("Import action: merge"); - lines.push( - resolved.matchedBy === "adopted" - ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + - "records what the two once had in common. Taking the bundle whole would " + - "discard whatever only this copy holds, so the two have to be reconciled first." - : `"${record.name}" came from this bundle, and both copies have changed since. ` + - "Taking the bundle whole would discard the work saved here, so the two have " + - "to be reconciled first." - ); - describeDistance(lines, record, bundle); - // Adoption is recorded now rather than at apply time, so the merge that - // follows can be checked against a target this bundle is known to belong - // to. Identity only: nothing has been taken from the bundle yet. - if (resolved.matchedBy === "adopted") { - await recordImportLineage(record, bundle, { identityOnly: true }); - } - lines.push(`Context name: ${record.name}`); - lines.push(`Context id: ${record.id}`); - lines.push(`Base hash: ${resolved.baseHash}`); - lines.push(`Bundle hash: ${resolved.bundleHash}`); - lines.push(`Profile path: ${record.profilePath}`); - lines.push(`Knowledge folder: ${record.knowledgeFolder}`); - lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); - lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push( - "Merge both sides, then apply the result with --merged-from. Carry the context " + - "id, base hash, and bundle hash into the draft exactly as printed: they are what " + - "prove the merge is for this context and was built from this bundle." - ); - return lines.join("\n"); + // What is left is `reconcile`: the same context, with the bundle holding + // something this copy does not. Reached by falling through rather than by a + // guard of its own, because every other resolution has returned by now and a + // branch here would need an unreachable else to sit beside it. + // + // All three answers are things a user might genuinely want, so all three are + // offered and none is taken: leaving it, replacing it, and merging differ in + // what they cost, not in whether they are correct. + // + // Merge is offered even when this copy is provably untouched, where it can + // only reproduce the bundle. Withholding it there would be answering the + // question again one step further in, so it is offered and its + // pointlessness is stated instead. + + // Adoption is recorded before any answer is given, so a merge drafted from + // this output can be checked against a target this bundle is known to + // belong to. Identity only: nothing has been taken from the bundle yet, + // and this stands whichever answer follows — including none of them. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); } - if (!confirmed) { - lines.push("Import action: replace"); - lines.push( - `"${record.name}" came from this bundle and has not been edited here since, so ` + - "the newer copy can be taken whole." + // Replacing is the one answer that can destroy work, so it is spelled out + // rather than reachable by confirming. `--yes` alone means "the action you + // resolved", and there is no longer such a thing: naming the answer is + // what keeps a merge drafted for hours from being overwritten by a `--yes` + // that forgot its `--merged-from`. + if (replace) { + if (!confirmed) { + lines.push(`Replace "${record.name}" with this bundle?`); + describeCost(lines, resolved, record); + describeChanges(lines, preview); + lines.push("Re-run this import with --replace --yes to take it."); + return lines.join("\n"); + } + const authored = await authoredUseWhen(record); + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` ); - describeDistance(lines, record, bundle); - describeChanges(lines, preview); - lines.push("Re-run this import with --yes to take it."); return lines.join("\n"); } - const authored = await authoredUseWhen(record); - const result = await replaceContextFromBundle({ - bundleFolder, - targetId: record.id, - baseHash: resolved.baseHash - }); - await refreshCard(result, authored); - if (authored) { - lines.push(`Kept the routing description you set here: ${authored}`); - } - describeUpdated( - lines, - result, - source, - `Updated the "${result.record.name}" context from the bundle.` + lines.push("Import action: reconcile"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and the bundle ` + + "holds material this copy does not." + : `"${record.name}" came from this bundle, and the bundle has moved on since ` + + "this copy was taken." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + describeCost(lines, resolved, record); + lines.push("Choose one — nothing here is written until you do:"); + lines.push(" leave it"); + lines.push(" Stop here. This copy stays exactly as it is."); + lines.push(" replace"); + lines.push(" Take the bundle whole: re-run this import with --replace --yes."); + lines.push(" merge"); + lines.push( + " Keep both sides: reconcile them into one copy, then apply it with " + + "--merged-from." + ); + // Printed on every reconcile rather than behind a second round trip, so + // choosing merge costs nothing beyond the drafting. They are inert until + // used, and whoever leaves it or replaces it simply never reads them. + // + // Flush left, unlike the options above: these are read back by whatever + // drafts the merge, and a label is a stabler anchor than an indent. + lines.push("Merge inputs, needed only if you choose merge:"); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push( + "Carry the context id, base hash, and bundle hash into the draft exactly as " + + "printed: they are what prove the merge is for this context and was built from " + + "this bundle." ); return lines.join("\n"); } catch (error) { diff --git a/plugins/kimi-code/neatcontext/skills/import/SKILL.md b/plugins/kimi-code/neatcontext/skills/import/SKILL.md index 2f8fbc1..2b26f29 100644 --- a/plugins/kimi-code/neatcontext/skills/import/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/import/SKILL.md @@ -22,16 +22,20 @@ The source bundle is read-only throughout. Never modify, move, or delete it. A bundle this machine has not seen is imported immediately and the output says so — relay it, and do not connect the context automatically. Otherwise follow the printed `Import action`: - `current` — the context here already holds everything in the bundle. Relay that and stop. -- `replace` — the local copy came from this bundle and has not been edited since, so the newer copy can be taken whole. Relay the preview, ask the user to confirm, and only then rerun the same command with `--yes`. -- `merge` — both copies have changed. Reconcile them yourself, below. +- `reconcile` — the bundle is a newer copy of a context already here. Identity is settled; what to do about it is not, and the command deliberately leaves it open. Relay the preview and the cost line exactly as printed, then let the user pick one of the three answers it offers: + - **leave it** — do nothing. Stop; nothing has been written. + - **replace** — take the bundle whole, discarding whatever only the local copy holds. Rerun the same command with `--replace --yes`. + - **merge** — keep both sides. Reconcile them yourself, below. - `unlinkable` — the bundle carries no context id, so it cannot be tied to anything already here. Relay that, and offer `--name ""` to bring it in as its own context. - `choose` — the target is not decidable. Either a context of the same name is here but nothing records a shared origin, or several contexts are copies of this bundle because one was forked. Relay the options and stop until the user picks: rerun with `--into ""` to name the context they mean, or with `--name ""` to keep a separate copy. -Never answer `choose` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and the two answers are not recoverable from each other. +Never answer `choose` or `reconcile` on the user's behalf. Two people naming a context the same thing is not evidence that it is the same context, and only the user knows whether the material a replacement would discard was worth keeping. None of these answers is recoverable from the others. + +Relay the cost line rather than summarising it, because the three it can print mean different things. "Nothing here has been edited" means replacing is lossless and a merge could only reproduce the bundle — say so, and still let the user choose. "This copy has been edited here" means replacing destroys that work. "Nothing here records what the two copies once had in common" means the copy may be untouched and cannot prove it; do not report that one as edits. ## Merging -Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values the command printed. The three hashes are what prove the merge is for this context, was built on its current contents, and consumed this version of the bundle; a merge that gets any of them wrong is refused rather than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. +Use the exact `Context name`, `Context id`, `Base hash`, `Bundle hash`, `Profile path`, `Knowledge folder`, `Bundle profile`, and `Bundle knowledge` values printed under `Merge inputs`. The three hashes are what prove the merge is for this context, was built on its current contents, and consumed this version of the bundle; a merge that gets any of them wrong is refused rather than applied. Read the local profile and every file in the local knowledge folder, then read the bundle's profile and every file in its knowledge folder. Merge them the way a save merges a conversation into an existing context: diff --git a/plugins/kimi-code/neatcontext/src/core/context-store.mjs b/plugins/kimi-code/neatcontext/src/core/context-store.mjs index d6e6c17..b804fe4 100644 --- a/plugins/kimi-code/neatcontext/src/core/context-store.mjs +++ b/plugins/kimi-code/neatcontext/src/core/context-store.mjs @@ -1156,18 +1156,34 @@ export async function resolveImportTarget({ bundleFolder, into }) { return { ...base, action: "current" }; } - // They have moved, so this is about what taking it would cost here. No - // baseline means no way to prove this copy is untouched, and replacing an - // edited copy loses the edits — merge is the answer whenever it cannot be - // ruled out. + // They have moved, so this is about what taking the bundle would cost here — + // and the cost is reported rather than acted on. Which way to take it is not + // a fact about the two copies but a judgement about whose material matters, + // and only the person who wrote the local half can make it. Deciding here + // means either overwriting work nobody offered to sacrifice or demanding a + // merge over edits the user would have thrown away without a second thought. // - // A baseline left by a different origin does not count, which is what makes - // adoption safe: saying two contexts are the same does not make this copy's - // material disposable, and it came from somewhere else entirely. + // What the evidence does settle is the price of each answer, and that travels + // with the resolution. No baseline means no way to prove this copy is + // untouched, so it is reported as diverged: unprovable and edited cost the + // same to replace. + // + // A baseline left by a different origin does not count either, which is what + // keeps adoption honest. Saying two contexts are the same does not establish + // that they ever shared contents, so an adopted copy's material is never + // assumed disposable. + // + // Why it diverged is carried out separately from whether it did, because the + // two reasons cost the same and read nothing alike. A copy with edits in it + // has work to lose; a copy with no usable baseline may be byte-identical to + // the bundle and simply unable to prove it. Saying the second one has been + // edited would be inventing a history, which is the one thing this whole path + // exists to avoid. const baseline = record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; - const diverged = typeof baseline !== "string" || baseline !== baseHash; - return { ...base, action: diverged ? "merge" : "replace" }; + const divergedBy = + typeof baseline !== "string" ? "unproven" : baseline === baseHash ? null : "edits"; + return { ...base, action: "reconcile", diverged: divergedBy !== null, divergedBy }; } // The fast-forward: this copy has not been touched since it arrived, so the diff --git a/plugins/kimi-code/neatcontext/src/core/import-commands.mjs b/plugins/kimi-code/neatcontext/src/core/import-commands.mjs index 240c919..047fbc3 100644 --- a/plugins/kimi-code/neatcontext/src/core/import-commands.mjs +++ b/plugins/kimi-code/neatcontext/src/core/import-commands.mjs @@ -13,6 +13,14 @@ // diverged. Whenever the safe answer cannot be established, the command reports // and stops rather than writing. // +// Identity is the only question it answers by itself. Once two copies are known +// to be one context, what to do about that is the user's call — leave it, +// replace it, or merge — because the evidence can price each answer and cannot +// say which price is worth paying. Divergence therefore phrases the choice +// rather than making it: it is the difference between a replacement that loses +// nothing and one that discards a week of work, and both are legitimate things +// to want. +// // Rendering lives here too. The four hosts differ only in how a slash command // is spelled, and that arrives as `useCommand`. @@ -90,6 +98,39 @@ function describeDistance(lines, record, bundle) { lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); } +// What replacing would cost, which is the whole of what divergence decides now +// that it no longer decides the action. Stated on both the offer and the +// confirmation, because those are separate invocations and the second may be +// the only one a hurried reader looks at. +// +// Three sentences for three states, and the middle one is why they are not two. +// A copy with no usable baseline cannot be called edited: it may hold nothing +// the bundle does not, and only its receipt is missing. Telling that user their +// work is at risk teaches them to stop reading the warning that matters. +// +// The untouched case says outright that merging can only reproduce the bundle. +// Offering an answer while concealing that it is busywork would be a worse kind +// of withholding than not offering it at all. +function describeCost(lines, resolved, record) { + if (resolved.divergedBy === "edits") { + lines.push( + "This copy has been edited here since it arrived, so replacing discards that work." + ); + return; + } + if (resolved.divergedBy === "unproven") { + lines.push( + "Nothing here records what the two copies once had in common, so replacing " + + `discards whatever only "${record.name}" holds.` + ); + return; + } + lines.push( + "Nothing here has been edited since this copy arrived, so replacing discards " + + "nothing and a merge could only reproduce the bundle." + ); +} + function describeImported(lines, result, source, useCommand) { lines.push(`Imported the "${result.record.name}" conversation context.`); lines.push(` Domain profile: ${result.record.profilePath}`); @@ -180,6 +221,7 @@ export async function runImport({ name = "", into = "", mergedFrom = "", + replace = false, confirmed = false, consume = false, useCommand @@ -253,10 +295,10 @@ export async function runImport({ } // A name in common is not evidence of a common origin, and the two cases - // want opposite handling, so this is the one outcome that asks. `--into` - // adopts the local context as this bundle's copy; since no baseline against - // this bundle exists for it, adopting leads to a merge and never to a - // replacement. + // want opposite handling, so this is the outcome that asks about identity. + // `--into` adopts the local context as this bundle's copy, which settles who + // it is and nothing about what to do next: it arrives at the same choice any + // other copy does, carrying no baseline to prove it untouched. // No id in the bundle means no key to recognise it by, now or later. It can // still be brought in — as its own context — but it cannot be tied to one // already here, so the reconciling answers are not offered rather than @@ -308,67 +350,100 @@ export async function runImport({ return lines.join("\n"); } - if (resolved.action === "merge") { - lines.push("Import action: merge"); - lines.push( - resolved.matchedBy === "adopted" - ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + - "records what the two once had in common. Taking the bundle whole would " + - "discard whatever only this copy holds, so the two have to be reconciled first." - : `"${record.name}" came from this bundle, and both copies have changed since. ` + - "Taking the bundle whole would discard the work saved here, so the two have " + - "to be reconciled first." - ); - describeDistance(lines, record, bundle); - // Adoption is recorded now rather than at apply time, so the merge that - // follows can be checked against a target this bundle is known to belong - // to. Identity only: nothing has been taken from the bundle yet. - if (resolved.matchedBy === "adopted") { - await recordImportLineage(record, bundle, { identityOnly: true }); - } - lines.push(`Context name: ${record.name}`); - lines.push(`Context id: ${record.id}`); - lines.push(`Base hash: ${resolved.baseHash}`); - lines.push(`Bundle hash: ${resolved.bundleHash}`); - lines.push(`Profile path: ${record.profilePath}`); - lines.push(`Knowledge folder: ${record.knowledgeFolder}`); - lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); - lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push( - "Merge both sides, then apply the result with --merged-from. Carry the context " + - "id, base hash, and bundle hash into the draft exactly as printed: they are what " + - "prove the merge is for this context and was built from this bundle." - ); - return lines.join("\n"); + // What is left is `reconcile`: the same context, with the bundle holding + // something this copy does not. Reached by falling through rather than by a + // guard of its own, because every other resolution has returned by now and a + // branch here would need an unreachable else to sit beside it. + // + // All three answers are things a user might genuinely want, so all three are + // offered and none is taken: leaving it, replacing it, and merging differ in + // what they cost, not in whether they are correct. + // + // Merge is offered even when this copy is provably untouched, where it can + // only reproduce the bundle. Withholding it there would be answering the + // question again one step further in, so it is offered and its + // pointlessness is stated instead. + + // Adoption is recorded before any answer is given, so a merge drafted from + // this output can be checked against a target this bundle is known to + // belong to. Identity only: nothing has been taken from the bundle yet, + // and this stands whichever answer follows — including none of them. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); } - if (!confirmed) { - lines.push("Import action: replace"); - lines.push( - `"${record.name}" came from this bundle and has not been edited here since, so ` + - "the newer copy can be taken whole." + // Replacing is the one answer that can destroy work, so it is spelled out + // rather than reachable by confirming. `--yes` alone means "the action you + // resolved", and there is no longer such a thing: naming the answer is + // what keeps a merge drafted for hours from being overwritten by a `--yes` + // that forgot its `--merged-from`. + if (replace) { + if (!confirmed) { + lines.push(`Replace "${record.name}" with this bundle?`); + describeCost(lines, resolved, record); + describeChanges(lines, preview); + lines.push("Re-run this import with --replace --yes to take it."); + return lines.join("\n"); + } + const authored = await authoredUseWhen(record); + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` ); - describeDistance(lines, record, bundle); - describeChanges(lines, preview); - lines.push("Re-run this import with --yes to take it."); return lines.join("\n"); } - const authored = await authoredUseWhen(record); - const result = await replaceContextFromBundle({ - bundleFolder, - targetId: record.id, - baseHash: resolved.baseHash - }); - await refreshCard(result, authored); - if (authored) { - lines.push(`Kept the routing description you set here: ${authored}`); - } - describeUpdated( - lines, - result, - source, - `Updated the "${result.record.name}" context from the bundle.` + lines.push("Import action: reconcile"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and the bundle ` + + "holds material this copy does not." + : `"${record.name}" came from this bundle, and the bundle has moved on since ` + + "this copy was taken." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + describeCost(lines, resolved, record); + lines.push("Choose one — nothing here is written until you do:"); + lines.push(" leave it"); + lines.push(" Stop here. This copy stays exactly as it is."); + lines.push(" replace"); + lines.push(" Take the bundle whole: re-run this import with --replace --yes."); + lines.push(" merge"); + lines.push( + " Keep both sides: reconcile them into one copy, then apply it with " + + "--merged-from." + ); + // Printed on every reconcile rather than behind a second round trip, so + // choosing merge costs nothing beyond the drafting. They are inert until + // used, and whoever leaves it or replaces it simply never reads them. + // + // Flush left, unlike the options above: these are read back by whatever + // drafts the merge, and a label is a stabler anchor than an indent. + lines.push("Merge inputs, needed only if you choose merge:"); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push( + "Carry the context id, base hash, and bundle hash into the draft exactly as " + + "printed: they are what prove the merge is for this context and was built from " + + "this bundle." ); return lines.join("\n"); } catch (error) { diff --git a/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs b/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs index 1384272..7db15c0 100644 --- a/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs +++ b/plugins/kimi-code/neatcontext/src/kimi/neatcontext-cli.mjs @@ -704,6 +704,7 @@ async function commandImport(flags) { name: typeof flags.name === "string" ? flags.name : "", into: typeof flags.into === "string" ? flags.into : "", mergedFrom: typeof flags["merged-from"] === "string" ? flags["merged-from"] : "", + replace: flags.replace === true || flags.replace === "true", confirmed: flags.yes === true || flags.yes === "true", consume: flags.consume === true || flags.consume === "true", useCommand: "/neatcontext:use" diff --git a/plugins/pi/neatcontext/src/core/context-store.mjs b/plugins/pi/neatcontext/src/core/context-store.mjs index d6e6c17..b804fe4 100644 --- a/plugins/pi/neatcontext/src/core/context-store.mjs +++ b/plugins/pi/neatcontext/src/core/context-store.mjs @@ -1156,18 +1156,34 @@ export async function resolveImportTarget({ bundleFolder, into }) { return { ...base, action: "current" }; } - // They have moved, so this is about what taking it would cost here. No - // baseline means no way to prove this copy is untouched, and replacing an - // edited copy loses the edits — merge is the answer whenever it cannot be - // ruled out. + // They have moved, so this is about what taking the bundle would cost here — + // and the cost is reported rather than acted on. Which way to take it is not + // a fact about the two copies but a judgement about whose material matters, + // and only the person who wrote the local half can make it. Deciding here + // means either overwriting work nobody offered to sacrifice or demanding a + // merge over edits the user would have thrown away without a second thought. // - // A baseline left by a different origin does not count, which is what makes - // adoption safe: saying two contexts are the same does not make this copy's - // material disposable, and it came from somewhere else entirely. + // What the evidence does settle is the price of each answer, and that travels + // with the resolution. No baseline means no way to prove this copy is + // untouched, so it is reported as diverged: unprovable and edited cost the + // same to replace. + // + // A baseline left by a different origin does not count either, which is what + // keeps adoption honest. Saying two contexts are the same does not establish + // that they ever shared contents, so an adopted copy's material is never + // assumed disposable. + // + // Why it diverged is carried out separately from whether it did, because the + // two reasons cost the same and read nothing alike. A copy with edits in it + // has work to lose; a copy with no usable baseline may be byte-identical to + // the bundle and simply unable to prove it. Saying the second one has been + // edited would be inventing a history, which is the one thing this whole path + // exists to avoid. const baseline = record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; - const diverged = typeof baseline !== "string" || baseline !== baseHash; - return { ...base, action: diverged ? "merge" : "replace" }; + const divergedBy = + typeof baseline !== "string" ? "unproven" : baseline === baseHash ? null : "edits"; + return { ...base, action: "reconcile", diverged: divergedBy !== null, divergedBy }; } // The fast-forward: this copy has not been touched since it arrived, so the diff --git a/plugins/pi/neatcontext/src/core/import-commands.mjs b/plugins/pi/neatcontext/src/core/import-commands.mjs index 240c919..047fbc3 100644 --- a/plugins/pi/neatcontext/src/core/import-commands.mjs +++ b/plugins/pi/neatcontext/src/core/import-commands.mjs @@ -13,6 +13,14 @@ // diverged. Whenever the safe answer cannot be established, the command reports // and stops rather than writing. // +// Identity is the only question it answers by itself. Once two copies are known +// to be one context, what to do about that is the user's call — leave it, +// replace it, or merge — because the evidence can price each answer and cannot +// say which price is worth paying. Divergence therefore phrases the choice +// rather than making it: it is the difference between a replacement that loses +// nothing and one that discards a week of work, and both are legitimate things +// to want. +// // Rendering lives here too. The four hosts differ only in how a slash command // is spelled, and that arrives as `useCommand`. @@ -90,6 +98,39 @@ function describeDistance(lines, record, bundle) { lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); } +// What replacing would cost, which is the whole of what divergence decides now +// that it no longer decides the action. Stated on both the offer and the +// confirmation, because those are separate invocations and the second may be +// the only one a hurried reader looks at. +// +// Three sentences for three states, and the middle one is why they are not two. +// A copy with no usable baseline cannot be called edited: it may hold nothing +// the bundle does not, and only its receipt is missing. Telling that user their +// work is at risk teaches them to stop reading the warning that matters. +// +// The untouched case says outright that merging can only reproduce the bundle. +// Offering an answer while concealing that it is busywork would be a worse kind +// of withholding than not offering it at all. +function describeCost(lines, resolved, record) { + if (resolved.divergedBy === "edits") { + lines.push( + "This copy has been edited here since it arrived, so replacing discards that work." + ); + return; + } + if (resolved.divergedBy === "unproven") { + lines.push( + "Nothing here records what the two copies once had in common, so replacing " + + `discards whatever only "${record.name}" holds.` + ); + return; + } + lines.push( + "Nothing here has been edited since this copy arrived, so replacing discards " + + "nothing and a merge could only reproduce the bundle." + ); +} + function describeImported(lines, result, source, useCommand) { lines.push(`Imported the "${result.record.name}" conversation context.`); lines.push(` Domain profile: ${result.record.profilePath}`); @@ -180,6 +221,7 @@ export async function runImport({ name = "", into = "", mergedFrom = "", + replace = false, confirmed = false, consume = false, useCommand @@ -253,10 +295,10 @@ export async function runImport({ } // A name in common is not evidence of a common origin, and the two cases - // want opposite handling, so this is the one outcome that asks. `--into` - // adopts the local context as this bundle's copy; since no baseline against - // this bundle exists for it, adopting leads to a merge and never to a - // replacement. + // want opposite handling, so this is the outcome that asks about identity. + // `--into` adopts the local context as this bundle's copy, which settles who + // it is and nothing about what to do next: it arrives at the same choice any + // other copy does, carrying no baseline to prove it untouched. // No id in the bundle means no key to recognise it by, now or later. It can // still be brought in — as its own context — but it cannot be tied to one // already here, so the reconciling answers are not offered rather than @@ -308,67 +350,100 @@ export async function runImport({ return lines.join("\n"); } - if (resolved.action === "merge") { - lines.push("Import action: merge"); - lines.push( - resolved.matchedBy === "adopted" - ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + - "records what the two once had in common. Taking the bundle whole would " + - "discard whatever only this copy holds, so the two have to be reconciled first." - : `"${record.name}" came from this bundle, and both copies have changed since. ` + - "Taking the bundle whole would discard the work saved here, so the two have " + - "to be reconciled first." - ); - describeDistance(lines, record, bundle); - // Adoption is recorded now rather than at apply time, so the merge that - // follows can be checked against a target this bundle is known to belong - // to. Identity only: nothing has been taken from the bundle yet. - if (resolved.matchedBy === "adopted") { - await recordImportLineage(record, bundle, { identityOnly: true }); - } - lines.push(`Context name: ${record.name}`); - lines.push(`Context id: ${record.id}`); - lines.push(`Base hash: ${resolved.baseHash}`); - lines.push(`Bundle hash: ${resolved.bundleHash}`); - lines.push(`Profile path: ${record.profilePath}`); - lines.push(`Knowledge folder: ${record.knowledgeFolder}`); - lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); - lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push( - "Merge both sides, then apply the result with --merged-from. Carry the context " + - "id, base hash, and bundle hash into the draft exactly as printed: they are what " + - "prove the merge is for this context and was built from this bundle." - ); - return lines.join("\n"); + // What is left is `reconcile`: the same context, with the bundle holding + // something this copy does not. Reached by falling through rather than by a + // guard of its own, because every other resolution has returned by now and a + // branch here would need an unreachable else to sit beside it. + // + // All three answers are things a user might genuinely want, so all three are + // offered and none is taken: leaving it, replacing it, and merging differ in + // what they cost, not in whether they are correct. + // + // Merge is offered even when this copy is provably untouched, where it can + // only reproduce the bundle. Withholding it there would be answering the + // question again one step further in, so it is offered and its + // pointlessness is stated instead. + + // Adoption is recorded before any answer is given, so a merge drafted from + // this output can be checked against a target this bundle is known to + // belong to. Identity only: nothing has been taken from the bundle yet, + // and this stands whichever answer follows — including none of them. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); } - if (!confirmed) { - lines.push("Import action: replace"); - lines.push( - `"${record.name}" came from this bundle and has not been edited here since, so ` + - "the newer copy can be taken whole." + // Replacing is the one answer that can destroy work, so it is spelled out + // rather than reachable by confirming. `--yes` alone means "the action you + // resolved", and there is no longer such a thing: naming the answer is + // what keeps a merge drafted for hours from being overwritten by a `--yes` + // that forgot its `--merged-from`. + if (replace) { + if (!confirmed) { + lines.push(`Replace "${record.name}" with this bundle?`); + describeCost(lines, resolved, record); + describeChanges(lines, preview); + lines.push("Re-run this import with --replace --yes to take it."); + return lines.join("\n"); + } + const authored = await authoredUseWhen(record); + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` ); - describeDistance(lines, record, bundle); - describeChanges(lines, preview); - lines.push("Re-run this import with --yes to take it."); return lines.join("\n"); } - const authored = await authoredUseWhen(record); - const result = await replaceContextFromBundle({ - bundleFolder, - targetId: record.id, - baseHash: resolved.baseHash - }); - await refreshCard(result, authored); - if (authored) { - lines.push(`Kept the routing description you set here: ${authored}`); - } - describeUpdated( - lines, - result, - source, - `Updated the "${result.record.name}" context from the bundle.` + lines.push("Import action: reconcile"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and the bundle ` + + "holds material this copy does not." + : `"${record.name}" came from this bundle, and the bundle has moved on since ` + + "this copy was taken." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + describeCost(lines, resolved, record); + lines.push("Choose one — nothing here is written until you do:"); + lines.push(" leave it"); + lines.push(" Stop here. This copy stays exactly as it is."); + lines.push(" replace"); + lines.push(" Take the bundle whole: re-run this import with --replace --yes."); + lines.push(" merge"); + lines.push( + " Keep both sides: reconcile them into one copy, then apply it with " + + "--merged-from." + ); + // Printed on every reconcile rather than behind a second round trip, so + // choosing merge costs nothing beyond the drafting. They are inert until + // used, and whoever leaves it or replaces it simply never reads them. + // + // Flush left, unlike the options above: these are read back by whatever + // drafts the merge, and a label is a stabler anchor than an indent. + lines.push("Merge inputs, needed only if you choose merge:"); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push( + "Carry the context id, base hash, and bundle hash into the draft exactly as " + + "printed: they are what prove the merge is for this context and was built from " + + "this bundle." ); return lines.join("\n"); } catch (error) { diff --git a/shared/core/context-store.mjs b/shared/core/context-store.mjs index d6e6c17..b804fe4 100644 --- a/shared/core/context-store.mjs +++ b/shared/core/context-store.mjs @@ -1156,18 +1156,34 @@ export async function resolveImportTarget({ bundleFolder, into }) { return { ...base, action: "current" }; } - // They have moved, so this is about what taking it would cost here. No - // baseline means no way to prove this copy is untouched, and replacing an - // edited copy loses the edits — merge is the answer whenever it cannot be - // ruled out. + // They have moved, so this is about what taking the bundle would cost here — + // and the cost is reported rather than acted on. Which way to take it is not + // a fact about the two copies but a judgement about whose material matters, + // and only the person who wrote the local half can make it. Deciding here + // means either overwriting work nobody offered to sacrifice or demanding a + // merge over edits the user would have thrown away without a second thought. // - // A baseline left by a different origin does not count, which is what makes - // adoption safe: saying two contexts are the same does not make this copy's - // material disposable, and it came from somewhere else entirely. + // What the evidence does settle is the price of each answer, and that travels + // with the resolution. No baseline means no way to prove this copy is + // untouched, so it is reported as diverged: unprovable and edited cost the + // same to replace. + // + // A baseline left by a different origin does not count either, which is what + // keeps adoption honest. Saying two contexts are the same does not establish + // that they ever shared contents, so an adopted copy's material is never + // assumed disposable. + // + // Why it diverged is carried out separately from whether it did, because the + // two reasons cost the same and read nothing alike. A copy with edits in it + // has work to lose; a copy with no usable baseline may be byte-identical to + // the bundle and simply unable to prove it. Saying the second one has been + // edited would be inventing a history, which is the one thing this whole path + // exists to avoid. const baseline = record.importedFrom?.id === bundleId ? record.importedFrom.fingerprint : null; - const diverged = typeof baseline !== "string" || baseline !== baseHash; - return { ...base, action: diverged ? "merge" : "replace" }; + const divergedBy = + typeof baseline !== "string" ? "unproven" : baseline === baseHash ? null : "edits"; + return { ...base, action: "reconcile", diverged: divergedBy !== null, divergedBy }; } // The fast-forward: this copy has not been touched since it arrived, so the diff --git a/shared/core/import-commands.mjs b/shared/core/import-commands.mjs index 240c919..047fbc3 100644 --- a/shared/core/import-commands.mjs +++ b/shared/core/import-commands.mjs @@ -13,6 +13,14 @@ // diverged. Whenever the safe answer cannot be established, the command reports // and stops rather than writing. // +// Identity is the only question it answers by itself. Once two copies are known +// to be one context, what to do about that is the user's call — leave it, +// replace it, or merge — because the evidence can price each answer and cannot +// say which price is worth paying. Divergence therefore phrases the choice +// rather than making it: it is the difference between a replacement that loses +// nothing and one that discards a week of work, and both are legitimate things +// to want. +// // Rendering lives here too. The four hosts differ only in how a slash command // is spelled, and that arrives as `useCommand`. @@ -90,6 +98,39 @@ function describeDistance(lines, record, bundle) { lines.push(` Their revision: ${theirs} (you last took revision ${taken})`); } +// What replacing would cost, which is the whole of what divergence decides now +// that it no longer decides the action. Stated on both the offer and the +// confirmation, because those are separate invocations and the second may be +// the only one a hurried reader looks at. +// +// Three sentences for three states, and the middle one is why they are not two. +// A copy with no usable baseline cannot be called edited: it may hold nothing +// the bundle does not, and only its receipt is missing. Telling that user their +// work is at risk teaches them to stop reading the warning that matters. +// +// The untouched case says outright that merging can only reproduce the bundle. +// Offering an answer while concealing that it is busywork would be a worse kind +// of withholding than not offering it at all. +function describeCost(lines, resolved, record) { + if (resolved.divergedBy === "edits") { + lines.push( + "This copy has been edited here since it arrived, so replacing discards that work." + ); + return; + } + if (resolved.divergedBy === "unproven") { + lines.push( + "Nothing here records what the two copies once had in common, so replacing " + + `discards whatever only "${record.name}" holds.` + ); + return; + } + lines.push( + "Nothing here has been edited since this copy arrived, so replacing discards " + + "nothing and a merge could only reproduce the bundle." + ); +} + function describeImported(lines, result, source, useCommand) { lines.push(`Imported the "${result.record.name}" conversation context.`); lines.push(` Domain profile: ${result.record.profilePath}`); @@ -180,6 +221,7 @@ export async function runImport({ name = "", into = "", mergedFrom = "", + replace = false, confirmed = false, consume = false, useCommand @@ -253,10 +295,10 @@ export async function runImport({ } // A name in common is not evidence of a common origin, and the two cases - // want opposite handling, so this is the one outcome that asks. `--into` - // adopts the local context as this bundle's copy; since no baseline against - // this bundle exists for it, adopting leads to a merge and never to a - // replacement. + // want opposite handling, so this is the outcome that asks about identity. + // `--into` adopts the local context as this bundle's copy, which settles who + // it is and nothing about what to do next: it arrives at the same choice any + // other copy does, carrying no baseline to prove it untouched. // No id in the bundle means no key to recognise it by, now or later. It can // still be brought in — as its own context — but it cannot be tied to one // already here, so the reconciling answers are not offered rather than @@ -308,67 +350,100 @@ export async function runImport({ return lines.join("\n"); } - if (resolved.action === "merge") { - lines.push("Import action: merge"); - lines.push( - resolved.matchedBy === "adopted" - ? `"${record.name}" is being treated as this bundle's copy, and nothing here ` + - "records what the two once had in common. Taking the bundle whole would " + - "discard whatever only this copy holds, so the two have to be reconciled first." - : `"${record.name}" came from this bundle, and both copies have changed since. ` + - "Taking the bundle whole would discard the work saved here, so the two have " + - "to be reconciled first." - ); - describeDistance(lines, record, bundle); - // Adoption is recorded now rather than at apply time, so the merge that - // follows can be checked against a target this bundle is known to belong - // to. Identity only: nothing has been taken from the bundle yet. - if (resolved.matchedBy === "adopted") { - await recordImportLineage(record, bundle, { identityOnly: true }); - } - lines.push(`Context name: ${record.name}`); - lines.push(`Context id: ${record.id}`); - lines.push(`Base hash: ${resolved.baseHash}`); - lines.push(`Bundle hash: ${resolved.bundleHash}`); - lines.push(`Profile path: ${record.profilePath}`); - lines.push(`Knowledge folder: ${record.knowledgeFolder}`); - lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); - lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); - lines.push( - "Merge both sides, then apply the result with --merged-from. Carry the context " + - "id, base hash, and bundle hash into the draft exactly as printed: they are what " + - "prove the merge is for this context and was built from this bundle." - ); - return lines.join("\n"); + // What is left is `reconcile`: the same context, with the bundle holding + // something this copy does not. Reached by falling through rather than by a + // guard of its own, because every other resolution has returned by now and a + // branch here would need an unreachable else to sit beside it. + // + // All three answers are things a user might genuinely want, so all three are + // offered and none is taken: leaving it, replacing it, and merging differ in + // what they cost, not in whether they are correct. + // + // Merge is offered even when this copy is provably untouched, where it can + // only reproduce the bundle. Withholding it there would be answering the + // question again one step further in, so it is offered and its + // pointlessness is stated instead. + + // Adoption is recorded before any answer is given, so a merge drafted from + // this output can be checked against a target this bundle is known to + // belong to. Identity only: nothing has been taken from the bundle yet, + // and this stands whichever answer follows — including none of them. + if (resolved.matchedBy === "adopted") { + await recordImportLineage(record, bundle, { identityOnly: true }); } - if (!confirmed) { - lines.push("Import action: replace"); - lines.push( - `"${record.name}" came from this bundle and has not been edited here since, so ` + - "the newer copy can be taken whole." + // Replacing is the one answer that can destroy work, so it is spelled out + // rather than reachable by confirming. `--yes` alone means "the action you + // resolved", and there is no longer such a thing: naming the answer is + // what keeps a merge drafted for hours from being overwritten by a `--yes` + // that forgot its `--merged-from`. + if (replace) { + if (!confirmed) { + lines.push(`Replace "${record.name}" with this bundle?`); + describeCost(lines, resolved, record); + describeChanges(lines, preview); + lines.push("Re-run this import with --replace --yes to take it."); + return lines.join("\n"); + } + const authored = await authoredUseWhen(record); + const result = await replaceContextFromBundle({ + bundleFolder, + targetId: record.id, + baseHash: resolved.baseHash + }); + await refreshCard(result, authored); + if (authored) { + lines.push(`Kept the routing description you set here: ${authored}`); + } + describeUpdated( + lines, + result, + source, + `Updated the "${result.record.name}" context from the bundle.` ); - describeDistance(lines, record, bundle); - describeChanges(lines, preview); - lines.push("Re-run this import with --yes to take it."); return lines.join("\n"); } - const authored = await authoredUseWhen(record); - const result = await replaceContextFromBundle({ - bundleFolder, - targetId: record.id, - baseHash: resolved.baseHash - }); - await refreshCard(result, authored); - if (authored) { - lines.push(`Kept the routing description you set here: ${authored}`); - } - describeUpdated( - lines, - result, - source, - `Updated the "${result.record.name}" context from the bundle.` + lines.push("Import action: reconcile"); + lines.push( + resolved.matchedBy === "adopted" + ? `"${record.name}" is being treated as this bundle's copy, and the bundle ` + + "holds material this copy does not." + : `"${record.name}" came from this bundle, and the bundle has moved on since ` + + "this copy was taken." + ); + describeDistance(lines, record, bundle); + describeChanges(lines, preview); + describeCost(lines, resolved, record); + lines.push("Choose one — nothing here is written until you do:"); + lines.push(" leave it"); + lines.push(" Stop here. This copy stays exactly as it is."); + lines.push(" replace"); + lines.push(" Take the bundle whole: re-run this import with --replace --yes."); + lines.push(" merge"); + lines.push( + " Keep both sides: reconcile them into one copy, then apply it with " + + "--merged-from." + ); + // Printed on every reconcile rather than behind a second round trip, so + // choosing merge costs nothing beyond the drafting. They are inert until + // used, and whoever leaves it or replaces it simply never reads them. + // + // Flush left, unlike the options above: these are read back by whatever + // drafts the merge, and a label is a stabler anchor than an indent. + lines.push("Merge inputs, needed only if you choose merge:"); + lines.push(`Context name: ${record.name}`); + lines.push(`Context id: ${record.id}`); + lines.push(`Base hash: ${resolved.baseHash}`); + lines.push(`Bundle hash: ${resolved.bundleHash}`); + lines.push(`Profile path: ${record.profilePath}`); + lines.push(`Knowledge folder: ${record.knowledgeFolder}`); + lines.push(`Bundle profile: ${path.join(source, "profile.md")}`); + lines.push(`Bundle knowledge: ${path.join(source, "knowledge")}`); + lines.push( + "Carry the context id, base hash, and bundle hash into the draft exactly as " + + "printed: they are what prove the merge is for this context and was built from " + + "this bundle." ); return lines.join("\n"); } catch (error) { diff --git a/tests/import-reconcile.test.mjs b/tests/import-reconcile.test.mjs index a44d4d2..60eb772 100644 --- a/tests/import-reconcile.test.mjs +++ b/tests/import-reconcile.test.mjs @@ -2,11 +2,12 @@ // // The first import is a create and always was. Every import after it is the // interesting one: the bundle is a newer copy of something already here, and -// the plugin has to work out whether taking it would cost anything before it -// takes it. These protect the four answers — nothing to do, take it whole, -// reconcile first, or ask — and the two things that must never happen: losing -// local work to a replacement, and losing the context's identity to a -// re-create. +// the plugin has to work out who it is before offering to do anything with it. +// These protect the answers it gives on its own — nothing to do, ask which +// context, cannot be linked — and the one it refuses to give: which of leave, +// replace, or merge the user wants. They also protect the two things that must +// never happen: losing local work to a replacement nobody chose, and losing the +// context's identity to a re-create. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; @@ -158,15 +159,26 @@ describe("importing a bundle this machine already has", () => { }); const preview = await cli("import", "--from", bundle); - assert.match(preview, /Import action: replace/); - assert.match(preview, /has not been edited here since/); + assert.match(preview, /Import action: reconcile/); assert.match(preview, /Their revision: 2 \(you last took revision 1\)/); assert.match(preview, /Add: runbook\.md/); - assert.match(preview, /Re-run this import with --yes/); + // All three answers, and the honest note that here one of them is busywork. + assert.match(preview, /Nothing here has been edited since this copy arrived/); + assert.match(preview, /a merge could only reproduce the bundle/); + assert.match(preview, /^ {2}leave it$/m); + assert.match(preview, /re-run this import with --replace --yes/); + assert.match(preview, /apply it with --merged-from/); // A preview writes nothing. assert.deepEqual(await manifestAt(directory), before); - const applied = await cli("import", "--from", bundle, "--yes"); + // Confirmation alone is not an answer: there is no resolved action for it + // to confirm, so it re-offers the choice rather than picking the one that + // happens to be safe here. + const bare = await cli("import", "--from", bundle, "--yes"); + assert.match(bare, /Import action: reconcile/); + assert.deepEqual(await manifestAt(directory), before, "--yes alone must not write"); + + const applied = await cli("import", "--from", bundle, "--replace", "--yes"); assert.match(applied, /Updated the "Team Checkout" context from the bundle/); const after = await manifestAt(directory); @@ -182,24 +194,47 @@ describe("importing a bundle this machine already has", () => { assert.match(await cli("list"), /Team Checkout\s+\(connected\)/); }); - it("offers a merge instead of a replacement once local work exists", async () => { + it("prices the choice once local work exists, and takes neither side", async () => { const bundle = await sharedBundle(); const directory = localBundle(await cli("import", "--from", bundle)); await localWork("Team Checkout", "# Session summary\n\nLocal work happened here."); await upstreamUpdate(bundle, { profileNote: "Upstream: provider raised the limit." }); + const summary = path.join(directory, "knowledge", "session-summary.md"); const resolved = await cli("import", "--from", bundle); - assert.match(resolved, /Import action: merge/); - assert.match(resolved, /both copies have changed since/); - assert.match(resolved, /would discard the work saved here/); + assert.match(resolved, /Import action: reconcile/); + assert.match(resolved, /the bundle has moved on since this copy was taken/); + assert.match(resolved, /This copy has been edited here since it arrived/); + assert.match(resolved, /replacing discards that work/); assert.equal(field(resolved, "Context id"), (await manifestAt(directory)).id); assert.equal(field(resolved, "Bundle profile"), path.join(bundle, "profile.md")); - assert.doesNotMatch(resolved, /--yes/, "a merge is never offered as a one-key overwrite"); + // Discarding that work is the user's to choose, so it is offered — and it + // is spelled, because `--yes` on its own is what a half-finished merge run + // looks like and it must never be read as "overwrite". + assert.match(resolved, /re-run this import with --replace --yes/); + assert.doesNotMatch( + await cli("import", "--from", bundle, "--yes"), + /Updated the "Team Checkout" context/, + "a diverged copy is never overwritten by --yes alone" + ); // Nothing was taken: the local work is still the only thing there. + assert.match(await readFile(summary, "utf8"), /Local work happened here/); + + // Named, the same overwrite is confirmed once and then done. + const offered = await cli("import", "--from", bundle, "--replace"); + assert.match(offered, /Replace "Team Checkout" with this bundle\?/); + assert.match(offered, /replacing discards that work/); + assert.match(await readFile(summary, "utf8"), /Local work happened here/); + + const applied = await cli("import", "--from", bundle, "--replace", "--yes"); + assert.match(applied, /Updated the "Team Checkout" context from the bundle/); assert.match( - await readFile(path.join(directory, "knowledge", "session-summary.md"), "utf8"), - /Local work happened here/ + await readFile(summary, "utf8"), + /Original work/, + "choosing replace takes the bundle's copy of a file both sides changed" ); + assert.equal((await manifestAt(directory)).importedFrom.revision, 2); + assert.match(await cli("import", "--from", bundle), /Import action: current/); }); it("applies a merged capture, then treats that bundle as consumed", async () => { @@ -368,8 +403,8 @@ describe("importing a bundle this machine already has", () => { await writeFile(path.join(bundle, "context.json"), `${JSON.stringify(manifest, null, 2)}\n`); await upstreamUpdate(bundle, { profileNote: "Upstream: renamed and revised." }); - assert.match(await cli("import", "--from", bundle), /Import action: replace/); - await cli("import", "--from", bundle, "--yes"); + assert.match(await cli("import", "--from", bundle), /Import action: reconcile/); + await cli("import", "--from", bundle, "--replace", "--yes"); assert.equal( (await manifestAt(directory)).name, "Team Checkout", @@ -431,13 +466,20 @@ describe("importing a bundle whose identity cannot be proved", () => { ); }); - it("reconciles rather than overwrites when a context is adopted into a lineage", async () => { + it("never calls an adopted copy untouched, whatever it looks like", async () => { const stranger = await strangerBundle(); const adopted = await cli("import", "--from", stranger, "--into", "Team Checkout"); // The local copy is untouched since its own import, but that baseline was - // left by a different origin and cannot license replacing it. - assert.match(adopted, /Import action: merge/); + // left by a different origin. It cannot price a replacement against this + // bundle, so the offer says what it actually knows. + assert.match(adopted, /Import action: reconcile/); assert.match(adopted, /is being treated as this bundle's copy/); + assert.match(adopted, /Nothing here records what the two copies once had in common/); + assert.doesNotMatch( + adopted, + /Nothing here has been edited/, + "an unprovable baseline must never read as a free replacement" + ); assert.doesNotMatch(adopted, /Their revision/, "no shared history to count from"); }); @@ -494,12 +536,12 @@ describe("what import must not overwrite or forget", () => { assert.match(ambiguous, /2 contexts here are copies of this bundle/); assert.match(ambiguous, /AAA Fork/); assert.match(ambiguous, /Team Checkout/); - assert.doesNotMatch(ambiguous, /Import action: replace/); + assert.doesNotMatch(ambiguous, /Import action: reconcile/); // Named, it resolves to exactly the one asked for. assert.match( await cli("import", "--from", bundle, "--into", "Team Checkout"), - /Import action: replace/ + /Import action: reconcile/ ); }); @@ -526,7 +568,8 @@ describe("what import must not overwrite or forget", () => { // contents — it reconciles rather than overwriting. await upstreamUpdate(bundle, { profileNote: "Upstream: later work." }); const next = await cli("import", "--from", bundle); - assert.match(next, /Import action: merge/); + assert.match(next, /Import action: reconcile/); + assert.match(next, /Nothing here records what the two copies once had in common/); assert.doesNotMatch(next, /Import action: choose/); }); @@ -537,9 +580,9 @@ describe("what import must not overwrite or forget", () => { await cli("describe", "Team Checkout", "--use-when", mine); await upstreamUpdate(bundle, { profileNote: "Upstream: retries are capped." }); - // `describe` writes to the routing card and never to the manifest, so this - // is deliberately still a fast-forward rather than a merge. - const applied = await cli("import", "--from", bundle, "--yes"); + // `describe` writes to the routing card and never to the manifest, so the + // copy is still provably untouched and replacing it is still lossless. + const applied = await cli("import", "--from", bundle, "--replace", "--yes"); assert.match(applied, /Updated the "Team Checkout" context from the bundle/); assert.match(applied, new RegExp(`Kept the routing description you set here: ${mine}`)); @@ -575,13 +618,13 @@ describe("what naming a context with --into does and does not assert", () => { "selecting a copy must not restamp what it already proved" ); - // And because the baseline survived, an untouched copy still qualifies for - // the fast-forward rather than being pushed into a merge. + // And because the baseline survived, the copy is still reported as + // untouched — which is the whole difference between a replacement that + // costs nothing and one that silently spends a week of work. await upstreamUpdate(bundle, { profileNote: "Upstream: later work." }); - assert.match( - await cli("import", "--from", bundle, "--into", "Team Checkout"), - /Import action: replace/ - ); + const offered = await cli("import", "--from", bundle, "--into", "Team Checkout"); + assert.match(offered, /Import action: reconcile/); + assert.match(offered, /Nothing here has been edited since this copy arrived/); }); it("can apply a merge into the very context the bundle was exported from", async () => { @@ -597,7 +640,7 @@ describe("what naming a context with --into does and does not assert", () => { await upstreamUpdate(bundle, { profileNote: "Upstream: edited the shared copy." }); const resolved = await cli("import", "--from", bundle); - assert.match(resolved, /Import action: merge/); + assert.match(resolved, /Import action: reconcile/); const merged = path.join(home, `own-merge-${serial++}.json`); await writeFile( @@ -628,14 +671,14 @@ describe("what naming a context with --into does and does not assert", () => { delete manifest.id; await writeFile(path.join(bundle, "context.json"), `${JSON.stringify(manifest, null, 2)}\n`); - // Nothing can be recorded about such a bundle, so `current` and `merge` + // Nothing can be recorded about such a bundle, so `current` and `reconcile` // would both be answers the command could not keep. for (const args of [[], ["--into", "Team Checkout"]]) { const resolved = await cli("import", "--from", bundle, ...args); assert.match(resolved, /Import action: unlinkable/); assert.match(resolved, /carries no context id/); assert.match(resolved, /--name ""/); - assert.doesNotMatch(resolved, /Import action: (current|merge|replace|choose)/); + assert.doesNotMatch(resolved, /Import action: (current|reconcile|choose)/); } // Forking is still open, and is the only thing this bundle can honestly do.