From a99ba994e8e779cbbf2619b91eba491a147398bd Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 12:08:39 +0200 Subject: [PATCH] feat(session): move a finding to where its code went Findings now survive commits, so their line numbers go stale as soon as the code under them shifts. anchor_content already recorded the lines a comment was attached to, but nothing used it, and the agent CLI never filled it in - only comments written in the browser had one. `agent comment` now records the anchor, and carrying a session forward re-anchors every open thread on the new side: the exact lines are located in the working tree and the thread moves to them. The match is exact. A line that was edited is a different line, and guessing at similarity would move a comment onto code it was not written about - such a thread keeps its old position instead. When the same lines appear more than once, the occurrence nearest to where the comment used to be wins. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- packages/cli/src/agent.ts | 3 + packages/cli/src/anchor.ts | 67 +++++++++++++++++++ packages/cli/src/session.ts | 30 ++++++++- packages/cli/src/threads.ts | 9 +++ packages/cli/tests/anchor.test.ts | 50 ++++++++++++++ packages/cli/tests/session-continuity.test.ts | 63 +++++++++++++++++ 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/anchor.ts create mode 100644 packages/cli/tests/anchor.test.ts diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index c07cfd1..47cbf10 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -14,6 +14,7 @@ import { type Thread, } from './threads.js'; import { createTour, addTourStep, updateTourStatus } from './tours.js'; +import { readAnchor } from './anchor.js'; function requireSession() { if (!isGitRepo()) { @@ -164,6 +165,8 @@ Examples: endLine, opts.body, { name: 'Agent', type: 'agent' }, + // Recorded so the finding can follow its code when a later commit moves it. + opts.side === 'new' ? readAnchor(opts.file, opts.line, endLine) : undefined, ); console.log(pc.green(`Created thread ${thread.id.slice(0, 8)}`)); }); diff --git a/packages/cli/src/anchor.ts b/packages/cli/src/anchor.ts new file mode 100644 index 0000000..1c0850f --- /dev/null +++ b/packages/cli/src/anchor.ts @@ -0,0 +1,67 @@ +import { getWorkingTreeFileContent } from '@diffity/git'; + +export interface AnchorRange { + startLine: number; + endLine: number; +} + +/** + * The source lines a comment is attached to, in the same shape the browser stores: the lines + * themselves, joined, with no line numbers. + */ +export function readAnchor(filePath: string, startLine: number, endLine: number): string | undefined { + try { + const lines = getWorkingTreeFileContent(filePath).split('\n'); + const anchor = lines.slice(startLine - 1, endLine).join('\n'); + return anchor || undefined; + } catch { + return undefined; + } +} + +/** + * Finds where a comment's lines went after the file changed under it. + * + * The match is exact: a line that was edited is a different line, and guessing at similarity + * would move a comment onto code it was not written about. When the same lines appear more than + * once, the occurrence nearest to where the comment used to be wins. + */ +export function reanchor( + anchorContent: string, + fileLines: string[], + originalStartLine: number, +): AnchorRange | null { + const anchorLines = anchorContent.split('\n'); + if (anchorContent === '' || anchorLines.length === 0) { + return null; + } + + const matches: number[] = []; + for (let i = 0; i + anchorLines.length <= fileLines.length; i++) { + if (anchorLines.every((line, offset) => fileLines[i + offset] === line)) { + matches.push(i + 1); + } + } + + if (matches.length === 0) { + return null; + } + + const startLine = matches.reduce((best, candidate) => + Math.abs(candidate - originalStartLine) < Math.abs(best - originalStartLine) ? candidate : best, + ); + + return { startLine, endLine: startLine + anchorLines.length - 1 }; +} + +export function reanchorInWorkingTree( + filePath: string, + anchorContent: string, + originalStartLine: number, +): AnchorRange | null { + try { + return reanchor(anchorContent, getWorkingTreeFileContent(filePath).split('\n'), originalStartLine); + } catch { + return null; + } +} diff --git a/packages/cli/src/session.ts b/packages/cli/src/session.ts index fa05100..a0933ed 100644 --- a/packages/cli/src/session.ts +++ b/packages/cli/src/session.ts @@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { getHeadHash, getDiffityDir } from '@diffity/git'; -import { getDb, queryOne } from './db.js'; +import { getDb, queryAll, queryOne } from './db.js'; +import { reanchorInWorkingTree } from './anchor.js'; +import { updateThreadLines } from './threads.js'; export interface Session { id: string; @@ -71,6 +73,32 @@ export function carryForward(fromSessionId: string, toSessionId: string): void { toSessionId, fromSessionId, ); + + reanchorThreads(toSessionId); +} + +/** + * A finding that outlives the commit it was written against points at a line that has since + * moved. Only the new side is re-anchored: a comment on a removed line has nothing to follow. + */ +function reanchorThreads(sessionId: string): void { + const threads = queryAll<{ + id: string; + file_path: string; + side: string; + start_line: number; + anchor_content: string | null; + }>( + "SELECT id, file_path, side, start_line, anchor_content FROM comment_threads WHERE session_id = ? AND status = 'open' AND side = 'new' AND anchor_content IS NOT NULL", + sessionId, + ); + + for (const thread of threads) { + const moved = reanchorInWorkingTree(thread.file_path, thread.anchor_content!, thread.start_line); + if (moved && moved.startLine !== thread.start_line) { + updateThreadLines(thread.id, moved.startLine, moved.endLine); + } + } } export function getCurrentSession(): Session | null { diff --git a/packages/cli/src/threads.ts b/packages/cli/src/threads.ts index d292a1e..94a291f 100644 --- a/packages/cli/src/threads.ts +++ b/packages/cli/src/threads.ts @@ -101,6 +101,15 @@ function getCommentsForThread(threadId: string): ThreadComment[] { return map.get(threadId) ?? []; } +export function updateThreadLines(threadId: string, startLine: number, endLine: number): void { + const db = getDb(); + db.prepare('UPDATE comment_threads SET start_line = ?, end_line = ? WHERE id = ?').run( + startLine, + endLine, + threadId, + ); +} + export function createThread( sessionId: string, filePath: string, diff --git a/packages/cli/tests/anchor.test.ts b/packages/cli/tests/anchor.test.ts new file mode 100644 index 0000000..1e20ab3 --- /dev/null +++ b/packages/cli/tests/anchor.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { reanchor } from '../src/anchor.js'; + +const file = [ + 'import { a } from "a";', + '', + 'function first() {', + ' return 1;', + '}', + '', + 'function second() {', + ' return 2;', + '}', +]; + +describe('reanchor', () => { + it('finds the lines where they now are', () => { + const moved = reanchor('function second() {\n return 2;', ['// new header', ...file], 7); + + expect(moved).toEqual({ startLine: 8, endLine: 9 }); + }); + + it('leaves a line that has not moved alone', () => { + expect(reanchor(' return 1;', file, 4)).toEqual({ startLine: 4, endLine: 4 }); + }); + + it('gives up when the line was edited rather than moved', () => { + expect(reanchor(' return 1; // changed', file, 4)).toBeNull(); + }); + + it('gives up when the code is gone', () => { + expect(reanchor('function third() {', file, 4)).toBeNull(); + }); + + it('takes the occurrence nearest to where the comment was', () => { + const duplicated = [' return 1;', 'x', ' return 1;', 'y', ' return 1;']; + + expect(reanchor(' return 1;', duplicated, 3)?.startLine).toBe(3); + expect(reanchor(' return 1;', duplicated, 5)?.startLine).toBe(5); + expect(reanchor(' return 1;', duplicated, 1)?.startLine).toBe(1); + }); + + it('refuses an empty anchor rather than matching everywhere', () => { + expect(reanchor('', file, 1)).toBeNull(); + }); + + it('handles an anchor longer than the file', () => { + expect(reanchor(file.join('\n') + '\nextra', file, 1)).toBeNull(); + }); +}); diff --git a/packages/cli/tests/session-continuity.test.ts b/packages/cli/tests/session-continuity.test.ts index 30f09c3..6ad6dba 100644 --- a/packages/cli/tests/session-continuity.test.ts +++ b/packages/cli/tests/session-continuity.test.ts @@ -71,6 +71,69 @@ describe('a session when HEAD moves', () => { expect(left.map(thread => thread.id)).toEqual([dealtWith.id]); }); + it('follows its code when a later commit moves it', async () => { + const { findOrCreateSession } = await import('../src/session.js'); + const { createThread, getThreadsForSession } = await import('../src/threads.js'); + + writeFileSync(join(repoDir, 'moving.ts'), 'one\ntwo\nthree\n'); + git(['add', '.']); + git(['commit', '-m', 'moving.ts']); + + const before = findOrCreateSession('work'); + const finding = createThread( + before.id, + 'moving.ts', + 'new', + 2, + 2, + 'P2: about the second line', + { name: 'Agent', type: 'agent' }, + 'two', + ); + + // Two lines land above it, so the code it points at is now on line 4. + writeFileSync(join(repoDir, 'moving.ts'), 'inserted\nalso inserted\none\ntwo\nthree\n'); + git(['add', '.']); + git(['commit', '-m', 'insert above']); + + const after = findOrCreateSession('work'); + const carried = getThreadsForSession(after.id).find(thread => thread.id === finding.id); + + expect(carried?.startLine).toBe(4); + expect(carried?.endLine).toBe(4); + }); + + it('leaves a finding where it is when its code was edited rather than moved', async () => { + const { findOrCreateSession } = await import('../src/session.js'); + const { createThread, getThreadsForSession } = await import('../src/threads.js'); + + writeFileSync(join(repoDir, 'edited.ts'), 'keep\ntarget\nkeep\n'); + git(['add', '.']); + git(['commit', '-m', 'edited.ts']); + + const before = findOrCreateSession('work'); + const finding = createThread( + before.id, + 'edited.ts', + 'new', + 2, + 2, + 'P3: about the target', + { name: 'Agent', type: 'agent' }, + 'target', + ); + + writeFileSync(join(repoDir, 'edited.ts'), 'keep\ntarget changed\nkeep\n'); + git(['add', '.']); + git(['commit', '-m', 'edit the target']); + + const after = findOrCreateSession('work'); + const carried = getThreadsForSession(after.id).find(thread => thread.id === finding.id); + + expect(carried).toBeDefined(); + expect(carried?.startLine).toBe(2); + }); + it('returns the same session while HEAD stays put', async () => { const { findOrCreateSession } = await import('../src/session.js');