diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbca3d..0564234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.1 + +- Repair stale automatic conversation links when the current project has + stronger path or Git evidence. +- Prevent broad parent repositories from claiming conversations whose recorded + Git remote belongs to another project. + ## 0.4.0 - Rename the project and public interfaces to ThreadRelink. diff --git a/package.json b/package.json index 7d9bde8..1065521 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "threadrelink-monorepo", - "version": "0.4.0", + "version": "0.4.1", "private": true, "description": "Path-independent discovery and recovery for local Codex conversations.", "license": "MIT", diff --git a/packages/core/src/matcher.ts b/packages/core/src/matcher.ts index 9b1dd70..38a8af9 100644 --- a/packages/core/src/matcher.ts +++ b/packages/core/src/matcher.ts @@ -33,35 +33,47 @@ export async function matchThreadToProject( context: MatchContext, ): Promise { const { project, existingLink } = context; - if (existingLink) { - if (existingLink.projectId === project.id) { - return decision( - thread, - "linked", - project.id, - [ - { - kind: "stored-link", - confidence: 1, - description: "Conversation was already linked to this ThreadRelink project.", - }, - ], - existingLink.relativeCwd, - ); - } + if (existingLink?.projectId === project.id) { + return decision( + thread, + "linked", + project.id, + [ + { + kind: "stored-link", + confidence: 1, + description: "Conversation was already linked to this ThreadRelink project.", + }, + ], + existingLink.relativeCwd, + ); + } + if (existingLink?.linkedBy === "manual") { return decision(thread, "unlinked", null, [ { kind: "stored-link", confidence: 1, - description: "Conversation is already linked to another ThreadRelink project.", + description: + "Conversation was manually linked to another ThreadRelink project.", }, ]); } + const threadRemote = thread.gitInfo?.originUrl + ? normalizeRemoteUrl(thread.gitInfo.originUrl) + : null; + const remoteMatches = Boolean( + threadRemote && project.remotes.includes(threadRemote), + ); + const remoteConflicts = Boolean( + threadRemote + && project.remotes.length > 0 + && !remoteMatches, + ); const matchingAlias = project.aliases.find((alias) => isPathInside(thread.cwd, alias.path) ); - if (matchingAlias) { + if (matchingAlias && !remoteConflicts) { return decision( thread, "linked", @@ -77,12 +89,6 @@ export async function matchThreadToProject( ); } - const threadRemote = thread.gitInfo?.originUrl - ? normalizeRemoteUrl(thread.gitInfo.originUrl) - : null; - const remoteMatches = Boolean( - threadRemote && project.remotes.includes(threadRemote), - ); const shaMatches = Boolean( context.gitRoot && thread.gitInfo?.sha @@ -130,5 +136,16 @@ export async function matchThreadToProject( ]); } + if (existingLink) { + return decision(thread, "unlinked", null, [ + { + kind: "stored-link", + confidence: 1, + description: + "Conversation has an automatic link to another ThreadRelink project and no stronger evidence was found.", + }, + ]); + } + return decision(thread, "unlinked", null, []); } diff --git a/packages/core/test/matcher.test.ts b/packages/core/test/matcher.test.ts index 85807f4..9e22da4 100644 --- a/packages/core/test/matcher.test.ts +++ b/packages/core/test/matcher.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { matchThreadToProject } from "../src/matcher.js"; import type { ProjectRecord, + ThreadLink, ThreadMetadata, } from "../src/types.js"; @@ -45,6 +46,27 @@ function thread(overrides: Partial = {}): ThreadMetadata { }; } +function link(overrides: Partial = {}): ThreadLink { + return { + provider: "codex", + threadId: "thread-1", + projectId: "project-old", + linkedBy: "automatic", + originalCwd: "/repo/ToolSpec", + relativeCwd: "", + evidence: [ + { + kind: "path-alias", + confidence: 1, + description: "Legacy automatic path match.", + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + describe("conversation matching", () => { it("automatically links a known historical path alias", async () => { const result = await matchThreadToProject(thread(), { @@ -102,4 +124,52 @@ describe("conversation matching", () => { expect(result.status).toBe("suggested"); expect(result.evidence[0]?.confidence).toBeLessThan(0.9); }); + + it("repairs an automatic link to another project when a path alias matches", async () => { + const result = await matchThreadToProject(thread(), { + project, + existingLink: link(), + gitRoot: "/repo/FinSpec", + shaExists: async () => false, + }); + + expect(result.status).toBe("linked"); + expect(result.projectId).toBe(project.id); + expect(result.evidence[0]?.kind).toBe("path-alias"); + }); + + it("does not automatically replace a manual link to another project", async () => { + const result = await matchThreadToProject(thread(), { + project, + existingLink: link({ linkedBy: "manual" }), + gitRoot: "/repo/FinSpec", + shaExists: async () => false, + }); + + expect(result.status).toBe("unlinked"); + expect(result.projectId).toBeNull(); + expect(result.evidence[0]?.description).toContain("manually linked"); + }); + + it("rejects a broad path alias when the recorded Git remote conflicts", async () => { + const result = await matchThreadToProject( + thread({ + cwd: "/repo/ToolSpec/nested-project", + gitInfo: { + branch: "main", + originUrl: "https://github.com/ascendho/other-project.git", + sha: null, + }, + }), + { + project, + existingLink: null, + gitRoot: "/repo/FinSpec", + shaExists: async () => false, + }, + ); + + expect(result.status).toBe("unlinked"); + expect(result.projectId).toBeNull(); + }); }); diff --git a/packages/core/test/service.test.ts b/packages/core/test/service.test.ts index 9165905..711ac03 100644 --- a/packages/core/test/service.test.ts +++ b/packages/core/test/service.test.ts @@ -159,6 +159,81 @@ describe("ThreadRelinkService", () => { .toEqual(expect.arrayContaining([oldRoot, newRoot])); }); + it("moves a stale automatic link when the current project has stronger evidence", async () => { + const base = await mkdtemp(join(tmpdir(), "threadrelink-stale-link-")); + cleanup.push(base); + const root = join(base, "internship-notes"); + const registryHome = join(base, "state"); + await mkdir(root); + await execFileAsync("git", ["init", root]); + + const conversation: ThreadMetadata = { + provider: "codex", + id: "thread-stale-parent", + name: "Internship notes", + preview: "Internship notes", + cwd: root, + createdAt: 1, + updatedAt: 2, + archived: false, + cliVersion: "0.145.0", + modelProvider: "openai", + gitInfo: null, + }; + const service = new ThreadRelinkService({ + registryHome, + historyAdapterFactory: async () => adapterFor([conversation]), + now: () => new Date("2026-07-28T00:00:00.000Z"), + }); + const currentProject = await service.initProject(root); + const originalCreatedAt = "2026-07-27T00:00:00.000Z"; + await service.registry.update((draft) => { + draft.projects.push({ + id: "legacy-parent", + name: "home", + kind: "git", + aliases: [{ + path: base, + key: base, + firstSeenAt: originalCreatedAt, + lastSeenAt: originalCreatedAt, + }], + remotes: [], + createdAt: originalCreatedAt, + updatedAt: originalCreatedAt, + }); + draft.threadLinks.push({ + provider: "codex", + threadId: conversation.id, + projectId: "legacy-parent", + linkedBy: "automatic", + originalCwd: conversation.cwd, + relativeCwd: "internship-notes", + evidence: [{ + kind: "path-alias", + confidence: 1, + description: "Legacy parent path match.", + }], + createdAt: originalCreatedAt, + updatedAt: originalCreatedAt, + }); + }); + + const result = await service.sync(root); + const repairedLink = (await service.registry.read()).threadLinks.find( + (candidate) => candidate.threadId === conversation.id, + ); + + expect(result.linked.map((item) => item.thread.id)) + .toContain(conversation.id); + expect(repairedLink).toMatchObject({ + projectId: currentProject.id, + linkedBy: "automatic", + createdAt: originalCreatedAt, + evidence: [{ kind: "path-alias" }], + }); + }); + it("persists an explicit relink for an otherwise unverifiable move", async () => { const base = await mkdtemp(join(tmpdir(), "threadrelink-relink-")); cleanup.push(base); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 93b715f..6197a70 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.1 + +- Repair stale automatic conversation links when the current project has + stronger path or Git evidence. +- Preserve manual links while preventing broad parent repositories from + claiming conversations with conflicting Git remotes. + ## 0.4.0 - Rename the project and public interfaces to ThreadRelink. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 01df338..0ce3a71 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "threadrelink", "displayName": "ThreadRelink", "description": "A VS Code extension that keeps local Codex conversations discoverable after a repository is renamed or moved.", - "version": "0.4.0", + "version": "0.4.1", "icon": "resources/threadrelink.png", "publisher": "ascendho", "repository": {