From db2e7446002f7fd0fe5afaa36a2640673b4b2eac Mon Sep 17 00:00:00 2001 From: brooksc Date: Sun, 3 May 2026 14:23:29 -0700 Subject: [PATCH] BACK-465 - Detect and warn about duplicate task IDs in web, TUI, and MCP interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two git branches independently create tasks with the same numeric ID and are then merged, the duplicate is silently dropped. This adds detection and user-facing warnings across all three surfaces: - Web browser: amber dismissible banner listing duplicate groups with a "Copy AI fix prompt" button that provides a ready-to-use prompt - TUI board: 15-second startup warning in the footer listing affected IDs - MCP task_list: prepends a ⚠️ warning block with the AI cleanup prompt Detection runs against the raw filesystem task list (before Map dedup) so duplicates aren't lost before they can be reported. Co-Authored-By: Claude Sonnet 4.6 --- ...-task-IDs-in-web-TUI-and-MCP-interfaces.md | 51 +++++++++++ src/mcp/tools/tasks/handlers.ts | 18 ++++ src/server/index.ts | 14 +++ src/test/duplicate-detection.test.ts | 90 +++++++++++++++++++ src/ui/board.ts | 5 ++ src/ui/unified-view.ts | 10 +++ src/utils/duplicate-detection.ts | 34 +++++++ src/web/App.tsx | 7 +- src/web/components/DuplicateIdWarning.tsx | 60 +++++++++++++ src/web/components/Layout.tsx | 23 +++-- src/web/lib/api.ts | 9 ++ 11 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 backlog/tasks/back-465 - Detect-and-warn-about-duplicate-task-IDs-in-web-TUI-and-MCP-interfaces.md create mode 100644 src/test/duplicate-detection.test.ts create mode 100644 src/utils/duplicate-detection.ts create mode 100644 src/web/components/DuplicateIdWarning.tsx diff --git a/backlog/tasks/back-465 - Detect-and-warn-about-duplicate-task-IDs-in-web-TUI-and-MCP-interfaces.md b/backlog/tasks/back-465 - Detect-and-warn-about-duplicate-task-IDs-in-web-TUI-and-MCP-interfaces.md new file mode 100644 index 000000000..9ac298217 --- /dev/null +++ b/backlog/tasks/back-465 - Detect-and-warn-about-duplicate-task-IDs-in-web-TUI-and-MCP-interfaces.md @@ -0,0 +1,51 @@ +--- +id: BACK-465 +title: 'Detect and warn about duplicate task IDs in web, TUI, and MCP interfaces' +status: In Progress +assignee: + - claude +created_date: '2026-05-03 20:54' +labels: + - enhancement + - ux +dependencies: [] +priority: medium +--- + +## Description + + +After a git merge between two branches that independently created tasks with the same numeric ID, Backlog.md silently drops one of the duplicates. Users have no visibility into this data loss. + +## Problem + +Two task files can share the same numeric ID prefix (e.g., `task-123 - Foo.md` and `task-123 - Bar.md`) after a git merge. `Core.loadTasks()` deduplicates them silently via a Map. + +## Solution + +Add duplicate task ID detection across all three UI surfaces: + +1. **Web browser**: Yellow dismissible warning banner listing duplicate groups + copyable AI cleanup prompt +2. **TUI board**: Startup warning in the footer when duplicates are detected +3. **MCP `task_list`**: Prepend a warning block to the output when duplicates exist + +## Implementation + +- `src/utils/duplicate-detection.ts` — pure `detectDuplicateTaskIds(tasks)` utility + `buildDuplicateCleanupPrompt(groups)` +- `src/server/index.ts` — `GET /api/tasks/duplicates` endpoint (reads raw filesystem, bypasses Map dedup) +- `src/web/lib/api.ts` — `fetchDuplicateTasks()` client method +- `src/web/App.tsx` — fetch duplicates on load, store in state +- `src/web/components/Layout.tsx` — pass `duplicateGroups` to warning component +- `src/web/components/DuplicateIdWarning.tsx` — amber banner with task list + copy prompt button +- `src/ui/board.ts` — add `startupWarning?` option to `renderBoardTui` +- `src/ui/unified-view.ts` — detect duplicates after loading tasks, pass warning to board +- `src/mcp/tools/tasks/handlers.ts` — prepend warning text to `listTasks()` output +- `src/test/duplicate-detection.test.ts` — unit tests + + +## Definition of Done + +- [ ] #1 bunx tsc --noEmit passes when TypeScript touched +- [ ] #2 bun run check . passes when formatting/linting touched +- [ ] #3 bun test (or scoped test) passes + diff --git a/src/mcp/tools/tasks/handlers.ts b/src/mcp/tools/tasks/handlers.ts index bf2929171..22a86a729 100644 --- a/src/mcp/tools/tasks/handlers.ts +++ b/src/mcp/tools/tasks/handlers.ts @@ -7,6 +7,7 @@ import { type TaskListFilter, } from "../../../types/index.ts"; import type { TaskEditArgs, TaskEditRequest } from "../../../types/task-edit-args.ts"; +import { buildDuplicateCleanupPrompt, detectDuplicateTaskIds } from "../../../utils/duplicate-detection.ts"; import { createMilestoneFilterValueResolver, normalizeMilestoneFilterValue, @@ -298,6 +299,23 @@ export class TaskHandlers { }); } + try { + const allLocalTasks = await this.core.filesystem.listTasks(); + const duplicateGroups = detectDuplicateTaskIds(allLocalTasks); + if (duplicateGroups.length > 0) { + const warningLines = [ + "⚠️ WARNING: Duplicate task IDs detected. One task per duplicate ID is hidden.", + buildDuplicateCleanupPrompt(duplicateGroups), + ]; + contentItems.unshift({ + type: "text", + text: warningLines.join("\n\n"), + }); + } + } catch { + // Duplicate detection is best-effort; skip if filesystem is unavailable + } + return { content: contentItems, }; diff --git a/src/server/index.ts b/src/server/index.ts index c7879e636..a717de5be 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -18,6 +18,7 @@ import { type TaskUpdateInput, } from "../types/index.ts"; import { watchConfig } from "../utils/config-watcher.ts"; +import { detectDuplicateTaskIds } from "../utils/duplicate-detection.ts"; import { resolveMilestoneInputForStorage } from "../utils/milestone-storage.ts"; import { getVersion } from "../utils/version.ts"; @@ -387,6 +388,9 @@ export class BacklogServer { "/api/tasks/cleanup": { GET: async (req: Request) => await this.handleCleanupPreview(req), }, + "/api/tasks/duplicates": { + GET: async () => await this.handleGetDuplicateTasks(), + }, "/api/tasks/cleanup/execute": { POST: async (req: Request) => await this.handleCleanupExecute(req), }, @@ -1533,6 +1537,16 @@ export class BacklogServer { } } + private async handleGetDuplicateTasks(): Promise { + try { + const tasks = await this.core.filesystem.listTasks(); + const groups = detectDuplicateTaskIds(tasks); + return Response.json(groups); + } catch (error) { + return Response.json({ error: String(error) }, { status: 500 }); + } + } + private async handleCleanupPreview(req: Request): Promise { try { const url = new URL(req.url); diff --git a/src/test/duplicate-detection.test.ts b/src/test/duplicate-detection.test.ts new file mode 100644 index 000000000..83229f558 --- /dev/null +++ b/src/test/duplicate-detection.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import type { Task } from "../types/index.ts"; +import { buildDuplicateCleanupPrompt, detectDuplicateTaskIds } from "../utils/duplicate-detection.ts"; + +function makeTask(id: string, title: string): Task { + return { + id, + title, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: [], + }; +} + +describe("detectDuplicateTaskIds", () => { + it("returns empty array when no tasks", () => { + expect(detectDuplicateTaskIds([])).toEqual([]); + }); + + it("returns empty array when all IDs are unique", () => { + const tasks = [makeTask("TASK-1", "A"), makeTask("TASK-2", "B"), makeTask("TASK-3", "C")]; + expect(detectDuplicateTaskIds(tasks)).toEqual([]); + }); + + it("detects two tasks with the same ID", () => { + const tasks = [makeTask("TASK-123", "Fix the thing"), makeTask("TASK-123", "Add new feature")]; + const groups = detectDuplicateTaskIds(tasks); + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe("TASK-123"); + expect(groups[0]?.tasks).toHaveLength(2); + }); + + it("detects multiple duplicate groups", () => { + const tasks = [ + makeTask("TASK-81", "Task A"), + makeTask("TASK-81", "Task B"), + makeTask("TASK-123", "Task C"), + makeTask("TASK-123", "Task D"), + makeTask("TASK-200", "Task E"), + ]; + const groups = detectDuplicateTaskIds(tasks); + expect(groups).toHaveLength(2); + const ids = groups.map((g) => g.id.toLowerCase()); + expect(ids).toContain("task-81"); + expect(ids).toContain("task-123"); + }); + + it("treats IDs case-insensitively", () => { + const tasks = [makeTask("TASK-1", "Uppercase"), makeTask("task-1", "Lowercase")]; + const groups = detectDuplicateTaskIds(tasks); + expect(groups).toHaveLength(1); + expect(groups[0]?.tasks).toHaveLength(2); + }); + + it("does not flag three unique tasks as duplicates", () => { + const tasks = [makeTask("TASK-1", "A"), makeTask("TASK-2", "B"), makeTask("TASK-3", "C")]; + expect(detectDuplicateTaskIds(tasks)).toHaveLength(0); + }); + + it("handles three tasks sharing the same ID", () => { + const tasks = [makeTask("TASK-5", "A"), makeTask("TASK-5", "B"), makeTask("TASK-5", "C")]; + const groups = detectDuplicateTaskIds(tasks); + expect(groups).toHaveLength(1); + expect(groups[0]?.tasks).toHaveLength(3); + }); +}); + +describe("buildDuplicateCleanupPrompt", () => { + it("includes all duplicate group IDs and titles in the prompt", () => { + const groups = [ + { id: "TASK-81", tasks: [makeTask("TASK-81", "Foo"), makeTask("TASK-81", "Bar")] }, + { id: "TASK-123", tasks: [makeTask("TASK-123", "Baz"), makeTask("TASK-123", "Qux")] }, + ]; + const prompt = buildDuplicateCleanupPrompt(groups); + expect(prompt).toContain("TASK-81"); + expect(prompt).toContain("Foo"); + expect(prompt).toContain("Bar"); + expect(prompt).toContain("TASK-123"); + expect(prompt).toContain("Baz"); + expect(prompt).toContain("Qux"); + }); + + it("mentions renumbering in the prompt", () => { + const groups = [{ id: "TASK-1", tasks: [makeTask("TASK-1", "A"), makeTask("TASK-1", "B")] }]; + const prompt = buildDuplicateCleanupPrompt(groups); + expect(prompt.toLowerCase()).toContain("renumber"); + }); +}); diff --git a/src/ui/board.ts b/src/ui/board.ts index 1b2e018da..519fc5812 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -182,6 +182,7 @@ export async function renderBoardTui( }) => void; milestoneMode?: boolean; milestoneEntities?: Milestone[]; + startupWarning?: string; }, ): Promise { if (!process.stdout.isTTY) { @@ -713,6 +714,10 @@ export async function renderBoardTui( firstColumn.list.focus(); } + if (options?.startupWarning) { + showTransientFooter(` {yellow-fg}${options.startupWarning}{/}`, 15000); + } + const updateBoard = (nextTasks: Task[], nextStatuses: string[]) => { // Update source of truth currentTasks = nextTasks; diff --git a/src/ui/unified-view.ts b/src/ui/unified-view.ts index ce2968ded..adb273765 100644 --- a/src/ui/unified-view.ts +++ b/src/ui/unified-view.ts @@ -5,6 +5,7 @@ import type { Core } from "../core/backlog.ts"; import type { Milestone, Task } from "../types/index.ts"; import { watchConfig } from "../utils/config-watcher.ts"; +import { detectDuplicateTaskIds } from "../utils/duplicate-detection.ts"; import { collectAvailableLabels } from "../utils/label-filter.ts"; import { hasAnyPrefix } from "../utils/prefix-config.ts"; import { applySharedTaskFilters, createTaskSearchIndex } from "../utils/task-search.ts"; @@ -178,6 +179,14 @@ export async function runUnifiedView(options: UnifiedViewOptions): Promise loadingScreenFactory: options.loadingScreenFactory, }); + const rawLocalTasks = await options.core.filesystem.listTasks(); + const duplicateGroups = detectDuplicateTaskIds(rawLocalTasks); + let startupWarning: string | undefined; + if (duplicateGroups.length > 0) { + const ids = duplicateGroups.map((g) => g.id).join(", "); + startupWarning = `⚠ Duplicate task IDs detected: ${ids} — use web UI for AI fix prompt`; + } + const baseTasks = (loadedTasks || []).filter((t) => t.id && t.id.trim() !== "" && hasAnyPrefix(t.id)); if (baseTasks.length === 0) { if (options.filter?.parentTaskId) { @@ -390,6 +399,7 @@ export async function runUnifiedView(options: UnifiedViewOptions): Promise }, milestoneMode: options.milestoneMode, milestoneEntities, + startupWarning, }).then(() => { // If user wants to exit, do it immediately if (result === "exit") { diff --git a/src/utils/duplicate-detection.ts b/src/utils/duplicate-detection.ts new file mode 100644 index 000000000..519b3ed35 --- /dev/null +++ b/src/utils/duplicate-detection.ts @@ -0,0 +1,34 @@ +import type { Task } from "../types/index.ts"; + +export type DuplicateGroup = { + id: string; + tasks: Task[]; +}; + +export function detectDuplicateTaskIds(tasks: Task[]): DuplicateGroup[] { + const byId = new Map(); + for (const task of tasks) { + const key = task.id.toLowerCase(); + const group = byId.get(key) ?? []; + group.push(task); + byId.set(key, group); + } + return Array.from(byId.entries()) + .filter(([, group]) => group.length > 1) + .map(([, group]) => ({ id: group[0]?.id ?? "", tasks: group })) + .filter((g) => g.id !== ""); +} + +export function buildDuplicateCleanupPrompt(groups: DuplicateGroup[]): string { + const lines = [ + "I have duplicate task IDs in my backlog, caused by two git branches independently creating tasks with the same ID before being merged.", + "Please renumber the duplicates: keep one task at its original number and assign the next available IDs to the others. Update any cross-references between tasks as needed.", + "", + "Duplicate groups:", + ]; + for (const group of groups) { + const titles = group.tasks.map((t) => `"${t.title}"`).join(" and "); + lines.push(`- ID ${group.id}: ${titles}`); + } + return lines.join("\n"); +} diff --git a/src/web/App.tsx b/src/web/App.tsx index 05a21c499..aacbe2aa8 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -25,6 +25,7 @@ import { type TaskSearchResult, } from '../types'; import { apiClient } from './lib/api'; +import type { DuplicateGroup } from '../utils/duplicate-detection'; import { useHealthCheckContext } from './contexts/HealthCheckContext'; import { getWebVersion } from './utils/version'; import { collectArchivedMilestoneKeys, collectMilestoneIds, milestoneKey } from './utils/milestones'; @@ -182,6 +183,7 @@ function App() { const [docs, setDocs] = useState([]); const [decisions, setDecisions] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [duplicateGroups, setDuplicateGroups] = useState([]); const { isOnline } = useHealthCheckContext(); const previousOnlineRef = useRef(null); @@ -258,18 +260,20 @@ function App() { const loadAllData = useCallback(async () => { try { setIsLoading(true); - const [statusesData, configData, searchResults, milestonesData, archivedMilestonesData] = await Promise.all([ + const [statusesData, configData, searchResults, milestonesData, archivedMilestonesData, duplicates] = await Promise.all([ apiClient.fetchStatuses(), apiClient.fetchConfig(), apiClient.search(), apiClient.fetchMilestones(), apiClient.fetchArchivedMilestones(), + apiClient.fetchDuplicateTasks(), ]); const archivedKeys = new Set(collectArchivedMilestoneKeys(archivedMilestonesData, milestonesData)); const milestoneAliases = buildMilestoneAliasMap(milestonesData, archivedMilestonesData); const { tasks: tasksList } = applySearchResults(searchResults, archivedKeys, milestoneAliases); + setDuplicateGroups(duplicates); setStatuses(statusesData); setProjectName(configData.projectName); setAvailableLabels(configData.labels || []); @@ -485,6 +489,7 @@ function App() { decisions={decisions} isLoading={isLoading} onRefreshData={refreshData} + duplicateGroups={duplicateGroups} /> } > diff --git a/src/web/components/DuplicateIdWarning.tsx b/src/web/components/DuplicateIdWarning.tsx new file mode 100644 index 000000000..4c88665fc --- /dev/null +++ b/src/web/components/DuplicateIdWarning.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { DuplicateGroup } from "../../utils/duplicate-detection"; +import { buildDuplicateCleanupPrompt } from "../../utils/duplicate-detection"; + +interface DuplicateIdWarningProps { + groups: DuplicateGroup[]; +} + +export function DuplicateIdWarning({ groups }: DuplicateIdWarningProps) { + const [dismissed, setDismissed] = useState(false); + const [copied, setCopied] = useState(false); + + if (dismissed || groups.length === 0) return null; + + const handleCopyPrompt = () => { + const prompt = buildDuplicateCleanupPrompt(groups); + navigator.clipboard.writeText(prompt).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+
+
+
+ ⚠️ + Duplicate task IDs detected ({groups.length} {groups.length === 1 ? "group" : "groups"}) +
+
    + {groups.map((group) => ( +
  • + {group.id} + {": "} + {group.tasks.map((t) => `"${t.title}"`).join(" · ")} +
  • + ))} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/web/components/Layout.tsx b/src/web/components/Layout.tsx index 685d12557..c1f02f152 100644 --- a/src/web/components/Layout.tsx +++ b/src/web/components/Layout.tsx @@ -2,7 +2,9 @@ import { Outlet } from 'react-router-dom'; import SideNavigation from './SideNavigation'; import Navigation from './Navigation'; import { HealthIndicator, HealthSuccessToast } from './HealthIndicator'; +import { DuplicateIdWarning } from './DuplicateIdWarning'; import { type Task, type Document, type Decision } from '../../types'; +import type { DuplicateGroup } from '../../utils/duplicate-detection'; interface LayoutProps { projectName: string; @@ -13,21 +15,24 @@ interface LayoutProps { decisions: Decision[]; isLoading: boolean; onRefreshData: () => Promise; + duplicateGroups?: DuplicateGroup[]; } -export default function Layout({ - projectName, - showSuccessToast, - onDismissToast, - tasks, - docs, - decisions, - isLoading, - onRefreshData +export default function Layout({ + projectName, + showSuccessToast, + onDismissToast, + tasks, + docs, + decisions, + isLoading, + onRefreshData, + duplicateGroups = [], }: LayoutProps) { return (
+ { + try { + return await this.fetchJson(`${API_BASE}/tasks/duplicates`); + } catch { + return []; + } + } + async fetchStatuses(): Promise { const response = await fetch(`${API_BASE}/statuses`); if (!response.ok) {