feat(KB-034): add task archive/unarchive functionality

- Add 'archived' column to task store with archiveTask and unarchiveTask methods
- Add CLI commands: kb task archive <id> and kb task unarchive <id>
- Add pi extension tools for archive and unarchive operations
- Add dashboard API endpoints POST /api/tasks/:id/archive and /unarchive
- Add Archived column to board UI with archive/unarchive buttons
- Prevent drag-drop into archived column, add visual distinction
- Include duplicateTask from concurrent branch in merge resolution
This commit is contained in:
gsxdsm
2026-03-29 20:03:43 -07:00
parent 6c151b6591
commit 37afb24d60
22 changed files with 793 additions and 58 deletions

View File

@@ -1563,4 +1563,230 @@ describe("TaskStore", () => {
expect(duplicated.baseBranch).toBeUndefined();
});
});
// ── Archive/Unarchive Tests ──────────────────────────────────────
describe("archiveTask", () => {
it("archives a done task (moves done → archived)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id);
expect(archived.column).toBe("archived");
});
it("adds log entry 'Task archived'", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id);
expect(archived.log.some((l) => l.action === "Task archived")).toBe(true);
});
it("emits task:moved event with correct from/to columns", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
await store.archiveTask(task.id);
expect(events).toHaveLength(1);
expect(events[0].from).toBe("done");
expect(events[0].to).toBe("archived");
});
it("persists to disk and round-trips correctly", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const fetched = await store.getTask(task.id);
expect(fetched.column).toBe("archived");
});
it("throws error when task is not in 'done' column", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage, not done
await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'");
});
it("updates columnMovedAt timestamp", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const beforeArchive = (await store.getTask(task.id)).columnMovedAt;
await new Promise((r) => setTimeout(r, 10));
const archived = await store.archiveTask(task.id);
expect(archived.columnMovedAt).not.toBe(beforeArchive);
expect(new Date(archived.columnMovedAt!).getTime()).toBeGreaterThan(new Date(beforeArchive!).getTime());
});
});
describe("unarchiveTask", () => {
it("unarchives an archived task (moves archived → done)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
});
it("adds log entry 'Task unarchived'", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
});
it("emits task:moved event with correct from/to columns", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
await store.unarchiveTask(task.id);
expect(events).toHaveLength(1);
expect(events[0].from).toBe("archived");
expect(events[0].to).toBe("done");
});
it("persists to disk and round-trips correctly", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await store.unarchiveTask(task.id);
const fetched = await store.getTask(task.id);
expect(fetched.column).toBe("done");
});
it("throws error when task is not in 'archived' column", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage, not archived
await expect(store.unarchiveTask(task.id)).rejects.toThrow("must be in 'archived'");
});
});
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
it("moveTask from archived → in-progress should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await expect(store.moveTask(task.id, "in-progress")).rejects.toThrow("Invalid transition");
});
it("moveTask from archived → triage should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await expect(store.moveTask(task.id, "triage")).rejects.toThrow("Invalid transition");
});
it("moveTask from archived → todo should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await expect(store.moveTask(task.id, "todo")).rejects.toThrow("Invalid transition");
});
it("moveTask from archived → in-review should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await expect(store.moveTask(task.id, "in-review")).rejects.toThrow("Invalid transition");
});
it("moveTask from triage → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
it("moveTask from todo → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
it("moveTask from in-progress → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
it("moveTask from in-review → archived should fail", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
});
});
});

View File

@@ -742,6 +742,72 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
/**
* Archive a done task (move from done → archived).
* Logs the action and emits `task:moved` event.
*/
async archiveTask(id: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
if (task.column !== "done") {
throw new Error(
`Cannot archive ${id}: task is in '${task.column}', must be in 'done'`,
);
}
task.column = "archived";
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
task.log.push({
timestamp: task.columnMovedAt,
action: "Task archived",
});
await this.atomicWriteTaskJson(dir, task);
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
return task;
});
}
/**
* Unarchive an archived task (move from archived → done).
* Logs the action and emits `task:moved` event.
*/
async unarchiveTask(id: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
if (task.column !== "archived") {
throw new Error(
`Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`,
);
}
task.column = "done";
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
task.log.push({
timestamp: task.columnMovedAt,
action: "Task unarchived",
});
await this.atomicWriteTaskJson(dir, task);
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column });
return task;
});
}
private async moveToDone(task: Task, dir: string): Promise<void> {
task.column = "done";
task.worktree = undefined;

View File

@@ -2,7 +2,7 @@
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
export type Column = (typeof COLUMNS)[number];
export type PrStatus = "open" | "closed" | "merged";
@@ -267,6 +267,7 @@ export const COLUMN_LABELS: Record<Column, string> = {
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
};
export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
@@ -275,6 +276,7 @@ export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
"in-progress": "AI is working on this in a worktree",
"in-review": "Complete — ready to merge",
done: "Merged and closed",
archived: "Completed and archived",
};
export const VALID_TRANSITIONS: Record<Column, Column[]> = {
@@ -282,5 +284,6 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
todo: ["in-progress", "triage"],
"in-progress": ["in-review", "todo", "triage"],
"in-review": ["done", "in-progress"],
done: [],
done: ["archived"],
archived: ["done"],
};