Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

<!-- SECTION:DESCRIPTION:BEGIN -->
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
<!-- SECTION:DESCRIPTION:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 bunx tsc --noEmit passes when TypeScript touched
- [ ] #2 bun run check . passes when formatting/linting touched
- [ ] #3 bun test (or scoped test) passes
<!-- DOD:END -->
18 changes: 18 additions & 0 deletions src/mcp/tools/tasks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down
14 changes: 14 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -1533,6 +1537,16 @@ export class BacklogServer {
}
}

private async handleGetDuplicateTasks(): Promise<Response> {
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<Response> {
try {
const url = new URL(req.url);
Expand Down
90 changes: 90 additions & 0 deletions src/test/duplicate-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 5 additions & 0 deletions src/ui/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export async function renderBoardTui(
}) => void;
milestoneMode?: boolean;
milestoneEntities?: Milestone[];
startupWarning?: string;
},
): Promise<void> {
if (!process.stdout.isTTY) {
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/ui/unified-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -178,6 +179,14 @@ export async function runUnifiedView(options: UnifiedViewOptions): Promise<void>
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) {
Expand Down Expand Up @@ -390,6 +399,7 @@ export async function runUnifiedView(options: UnifiedViewOptions): Promise<void>
},
milestoneMode: options.milestoneMode,
milestoneEntities,
startupWarning,
}).then(() => {
// If user wants to exit, do it immediately
if (result === "exit") {
Expand Down
34 changes: 34 additions & 0 deletions src/utils/duplicate-detection.ts
Original file line number Diff line number Diff line change
@@ -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<string, Task[]>();
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");
}
7 changes: 6 additions & 1 deletion src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -182,6 +183,7 @@ function App() {
const [docs, setDocs] = useState<Document[]>([]);
const [decisions, setDecisions] = useState<Decision[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [duplicateGroups, setDuplicateGroups] = useState<DuplicateGroup[]>([]);

const { isOnline } = useHealthCheckContext();
const previousOnlineRef = useRef<boolean | null>(null);
Expand Down Expand Up @@ -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 || []);
Expand Down Expand Up @@ -485,6 +489,7 @@ function App() {
decisions={decisions}
isLoading={isLoading}
onRefreshData={refreshData}
duplicateGroups={duplicateGroups}
/>
}
>
Expand Down
Loading
Loading