feat(KB-048): add collapsible list sections to dashboard

- Add section expansion state management with localStorage persistence
- Update section headers with chevron toggle controls
- Implement conditional task row rendering based on section state
- Add Expand All / Collapse All toolbar controls
- Add CSS styles for chevron rotation animation and section headers
- Add comprehensive tests for collapsible section behavior
This commit is contained in:
gsxdsm
2026-03-29 19:51:41 -07:00
parent 2ad581d829
commit 3b71d032a6
111 changed files with 23013 additions and 2936 deletions

View File

@@ -22,6 +22,7 @@
},
"devDependencies": {
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^3.1.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel } from "./types.js";
export type { Column, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -345,6 +345,19 @@ describe("TaskStore", () => {
});
});
describe("autoResolveConflicts setting", () => {
it("persists autoResolveConflicts and returns it via getSettings", async () => {
await store.updateSettings({ autoResolveConflicts: false });
const settings = await store.getSettings();
expect(settings.autoResolveConflicts).toBe(false);
});
it("default settings have autoResolveConflicts set to true", async () => {
const settings = await store.getSettings();
expect(settings.autoResolveConflicts).toBe(true);
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {
@@ -602,6 +615,64 @@ describe("TaskStore", () => {
});
});
describe("updateTask — model overrides", () => {
it("sets executor model provider and id via updateTask", async () => {
const task = await createTestTask();
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(updated.modelProvider).toBe("anthropic");
expect(updated.modelId).toBe("claude-sonnet-4-5");
});
it("sets validator model provider and id via updateTask", async () => {
const task = await createTestTask();
const updated = await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
expect(updated.validatorModelProvider).toBe("openai");
expect(updated.validatorModelId).toBe("gpt-4o");
});
it("clears executor model fields via null", async () => {
const task = await createTestTask();
await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
const updated = await store.updateTask(task.id, { modelProvider: null, modelId: null });
expect(updated.modelProvider).toBeUndefined();
expect(updated.modelId).toBeUndefined();
});
it("clears validator model fields via null", async () => {
const task = await createTestTask();
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
const updated = await store.updateTask(task.id, { validatorModelProvider: null, validatorModelId: null });
expect(updated.validatorModelProvider).toBeUndefined();
expect(updated.validatorModelId).toBeUndefined();
});
it("sets only executor model without affecting validator model", async () => {
const task = await createTestTask();
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(updated.modelProvider).toBe("anthropic");
expect(updated.modelId).toBe("claude-sonnet-4-5");
expect(updated.validatorModelProvider).toBe("openai");
expect(updated.validatorModelId).toBe("gpt-4o");
});
it("preserves model fields when updating unrelated fields", async () => {
const task = await createTestTask();
await store.updateTask(task.id, {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
const updated = await store.updateTask(task.id, { title: "Updated title" });
expect(updated.modelProvider).toBe("anthropic");
expect(updated.modelId).toBe("claude-sonnet-4-5");
expect(updated.validatorModelProvider).toBe("openai");
expect(updated.validatorModelId).toBe("gpt-4o");
expect(updated.title).toBe("Updated title");
});
});
describe("agent log persistence", () => {
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
const task = await createTestTask();
@@ -718,6 +789,281 @@ describe("TaskStore", () => {
});
});
describe("addSteeringComment", () => {
it("adds a steering comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Please handle the edge case");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].text).toBe("Please handle the edge case");
expect(updated.steeringComments![0].author).toBe("user");
expect(updated.steeringComments![0].id).toBeDefined();
expect(updated.steeringComments![0].createdAt).toBeDefined();
});
it("accepts agent as author", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Note from agent", "agent");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].author).toBe("agent");
});
it("initializes steeringComments array if undefined", async () => {
const task = await createTestTask();
expect(task.steeringComments).toBeUndefined();
const updated = await store.addSteeringComment(task.id, "First comment");
expect(updated.steeringComments).toBeDefined();
expect(updated.steeringComments).toHaveLength(1);
});
it("appends multiple comments in order", async () => {
const task = await createTestTask();
await store.addSteeringComment(task.id, "First comment");
await store.addSteeringComment(task.id, "Second comment");
await store.addSteeringComment(task.id, "Third comment");
const fetched = await store.getTask(task.id);
expect(fetched.steeringComments).toHaveLength(3);
expect(fetched.steeringComments![0].text).toBe("First comment");
expect(fetched.steeringComments![1].text).toBe("Second comment");
expect(fetched.steeringComments![2].text).toBe("Third comment");
});
it("generates unique IDs for each comment", async () => {
const task = await createTestTask();
const updated1 = await store.addSteeringComment(task.id, "Comment 1");
const updated2 = await store.addSteeringComment(task.id, "Comment 2");
const id1 = updated1.steeringComments![0].id;
const id2 = updated2.steeringComments![1].id;
expect(id1).not.toBe(id2);
});
it("emits task:updated event", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
await store.addSteeringComment(task.id, "Test comment");
expect(events).toHaveLength(1);
expect(events[0].steeringComments).toHaveLength(1);
expect(events[0].steeringComments![0].text).toBe("Test comment");
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
await store.addSteeringComment(task.id, "Persisted comment");
const fetched = await store.getTask(task.id);
expect(fetched.steeringComments).toHaveLength(1);
expect(fetched.steeringComments![0].text).toBe("Persisted comment");
expect(fetched.steeringComments![0].author).toBe("user");
});
it("adds log entry for the action", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Comment with log");
expect(updated.log.some((l) => l.action === "Steering comment added")).toBe(true);
expect(updated.log.some((l) => l.outcome === "by user")).toBe(true);
});
it("updates updatedAt timestamp", async () => {
const task = await createTestTask();
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const updated = await store.addSteeringComment(task.id, "Timestamp test");
expect(updated.updatedAt).not.toBe(before);
});
});
describe("updatePrInfo", () => {
it("adds PR info to a task without existing PR", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
const updated = await store.updatePrInfo(task.id, prInfo);
expect(updated.prInfo).toEqual(prInfo);
expect(updated.log.some((l) => l.action === "PR linked" && l.outcome?.includes("#42"))).toBe(true);
});
it("updates existing PR info with new values", async () => {
const task = await createTestTask();
const prInfo1 = {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open" as const,
title: "Initial PR",
headBranch: "branch-1",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo1);
const prInfo2 = {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "merged" as const,
title: "Initial PR (updated)",
headBranch: "branch-1",
baseBranch: "main",
commentCount: 3,
lastCommentAt: "2026-01-01T00:00:00.000Z",
};
const updated = await store.updatePrInfo(task.id, prInfo2);
expect(updated.prInfo?.status).toBe("merged");
expect(updated.prInfo?.commentCount).toBe(3);
expect(updated.prInfo?.lastCommentAt).toBe("2026-01-01T00:00:00.000Z");
});
it("clears PR info when passed null", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
const updated = await store.updatePrInfo(task.id, null);
expect(updated.prInfo).toBeUndefined();
expect(updated.log.some((l) => l.action === "PR unlinked")).toBe(true);
});
it("emits task:updated event when PR info changes", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
expect(events).toHaveLength(1);
expect(events[0].prInfo?.number).toBe(42);
});
it("does NOT emit task:updated when PR info is unchanged", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
// Update with same values (status and number unchanged)
await store.updatePrInfo(task.id, { ...prInfo });
// Should not emit because number and status are the same
expect(events).toHaveLength(0);
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 5,
lastCommentAt: "2026-03-30T12:00:00.000Z",
};
await store.updatePrInfo(task.id, prInfo);
const fetched = await store.getTask(task.id);
expect(fetched.prInfo).toEqual(prInfo);
});
it("updates updatedAt timestamp", async () => {
const task = await createTestTask();
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
const updated = await store.updatePrInfo(task.id, prInfo);
expect(updated.updatedAt).not.toBe(before);
});
it("serializes concurrent updates correctly", async () => {
const task = await createTestTask();
// Fire 5 concurrent updates
const promises = Array.from({ length: 5 }, (_, i) =>
store.updatePrInfo(task.id, {
url: `https://github.com/owner/repo/pull/${i + 1}`,
number: i + 1,
status: "open" as const,
title: `PR ${i + 1}`,
headBranch: `branch-${i + 1}`,
baseBranch: "main",
commentCount: i,
}),
);
await Promise.all(promises);
// Read back and verify valid JSON
const taskJsonPath = join(rootDir, ".kb", "tasks", task.id, "task.json");
const raw = await readFile(taskJsonPath, "utf-8");
const result = JSON.parse(raw) as Task;
// Should have exactly one of the PRs set (last one wins)
expect(result.prInfo).toBeDefined();
expect(result.prInfo!.number).toBeGreaterThanOrEqual(1);
expect(result.prInfo!.number).toBeLessThanOrEqual(5);
// Should have all the PR linked log entries
const prLogs = result.log.filter((l) => l.action === "PR linked");
expect(prLogs).toHaveLength(5);
});
});
describe("parseDependenciesFromPrompt", () => {
it("returns single dependency from PROMPT.md", async () => {
const task = await store.createTask({ description: "Task with dep" });
@@ -1022,4 +1368,199 @@ describe("TaskStore", () => {
expect(events).toHaveLength(2);
});
});
// ── Duplicate Task Tests ─────────────────────────────────────────
describe("duplicateTask", () => {
it("duplicates from triage column", async () => {
const task = await store.createTask({ description: "Test task" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.id).not.toBe(task.id);
expect(duplicated.id).toMatch(/^KB-\d+$/);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(task.description);
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from todo column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from in-progress column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
await store.moveTask(task.id, "in-progress");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from in-review column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("duplicates from done column", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
});
it("new task is always in triage regardless of source column", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.column).toBe("triage");
});
it("description includes source reference", async () => {
const task = await store.createTask({ description: "Original description" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.description).toBe(`Original description\n\n(Duplicated from ${task.id})`);
});
it("resets execution state (no steps, no worktree, etc.)", async () => {
const task = await store.createTask({ description: "Test task", column: "todo" });
// Add some execution state
await store.updateTask(task.id, { worktree: "/some/path", status: "executing" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.steps).toEqual([]);
expect(duplicated.currentStep).toBe(0);
expect(duplicated.worktree).toBeUndefined();
expect(duplicated.status).toBeUndefined();
});
it("does NOT copy dependencies", async () => {
const dep = await store.createTask({ description: "Dependency" });
const task = await store.createTask({ description: "Test task", dependencies: [dep.id] });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.dependencies).toEqual([]);
});
it("does NOT copy attachments", async () => {
const task = await store.createTask({ description: "Test task" });
// Add an attachment
await store.addAttachment(task.id, "test.png", Buffer.from("fake"), "image/png");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.attachments).toBeUndefined();
});
it("does NOT copy steering comments", async () => {
const task = await store.createTask({ description: "Test task" });
await store.addSteeringComment(task.id, "Test comment");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.steeringComments).toBeUndefined();
});
it("emits task:created event", async () => {
const task = await store.createTask({ description: "Test task" });
const events: any[] = [];
store.on("task:created", (t) => events.push(t));
const duplicated = await store.duplicateTask(task.id);
expect(events).toHaveLength(1);
expect(events[0].id).toBe(duplicated.id);
});
it("adds log entry for duplicate action", async () => {
const task = await store.createTask({ description: "Test task" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.log).toHaveLength(1);
expect(duplicated.log[0].action).toContain(`Duplicated from ${task.id}`);
});
it("copies source PROMPT.md content", async () => {
const task = await store.createTask({ description: "Test task" });
const sourceDetail = await store.getTask(task.id);
const duplicated = await store.duplicateTask(task.id);
const dupDetail = await store.getTask(duplicated.id);
expect(dupDetail.prompt).toBe(sourceDetail.prompt);
});
it("throws ENOENT when source task does not exist", async () => {
await expect(store.duplicateTask("KB-999")).rejects.toThrow();
});
it("copies title if present", async () => {
const task = await store.createTask({ title: "My Task", description: "Test" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.title).toBe("My Task");
});
it("does NOT copy prInfo", async () => {
const task = await store.createTask({ description: "Test task" });
await store.updatePrInfo(task.id, {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Test PR",
headBranch: "kb/kb-001",
baseBranch: "main",
commentCount: 0,
});
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.prInfo).toBeUndefined();
});
it("does NOT copy paused state", async () => {
const task = await store.createTask({ description: "Test task" });
await store.pauseTask(task.id, true);
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.paused).toBeUndefined();
});
it("does NOT copy blockedBy", async () => {
const blocker = await store.createTask({ description: "Blocker" });
const task = await store.createTask({ description: "Test task" });
await store.updateTask(task.id, { blockedBy: blocker.id });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.blockedBy).toBeUndefined();
});
it("does NOT copy baseBranch", async () => {
const task = await store.createTask({ description: "Test task" });
await store.updateTask(task.id, { baseBranch: "some-branch" });
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.baseBranch).toBeUndefined();
});
});
});

View File

@@ -213,6 +213,51 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return task;
}
/**
* Duplicate an existing task, creating a fresh copy in triage.
* Copies title and description with source reference, but resets all
* execution state. The new task will be re-specified by the AI.
*/
async duplicateTask(id: string): Promise<Task> {
// Read the source task with its prompt
const sourceTask = await this.getTask(id);
// Allocate a new ID
const newId = await this.allocateId();
const now = new Date().toISOString();
// Create new task with copied title/description, but fresh state
const newTask: Task = {
id: newId,
title: sourceTask.title,
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
column: "triage",
dependencies: [], // Fresh task should have no dependencies
steps: [], // Reset execution state
currentStep: 0,
log: [{ timestamp: now, action: `Duplicated from ${id}` }],
columnMovedAt: now,
createdAt: now,
updatedAt: now,
// Explicitly NOT copied: worktree, status, blockedBy, paused, baseBranch,
// attachments, steeringComments, prInfo, agent logs, size, reviewLevel
};
const newDir = this.taskDir(newId);
await mkdir(newDir, { recursive: true });
await this.atomicWriteTaskJson(newDir, newTask);
// Copy source PROMPT.md content (the AI will re-specify it in triage)
const sourcePrompt = sourceTask.prompt;
await writeFile(join(newDir, "PROMPT.md"), sourcePrompt);
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(newId, { ...newTask });
this.emit("task:created", newTask);
return newTask;
}
/**
* Read a task's JSON and prompt content.
*
@@ -295,7 +340,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number },
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null },
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
@@ -337,6 +382,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
if (updates.size !== undefined) task.size = updates.size;
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
if (updates.mergeRetries !== undefined) task.mergeRetries = updates.mergeRetries;
if (updates.modelProvider === null) {
task.modelProvider = undefined;
} else if (updates.modelProvider !== undefined) {
task.modelProvider = updates.modelProvider;
}
if (updates.modelId === null) {
task.modelId = undefined;
} else if (updates.modelId !== undefined) {
task.modelId = updates.modelId;
}
if (updates.validatorModelProvider === null) {
task.validatorModelProvider = undefined;
} else if (updates.validatorModelProvider !== undefined) {
task.validatorModelProvider = updates.validatorModelProvider;
}
if (updates.validatorModelId === null) {
task.validatorModelId = undefined;
} else if (updates.validatorModelId !== undefined) {
task.validatorModelId = updates.validatorModelId;
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
@@ -981,6 +1047,99 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.emit("agent:log", entry);
}
/**
* Add a steering comment to a task.
* Steering comments are user-provided feedback injected into the AI execution context.
*/
async addSteeringComment(
id: string,
text: string,
author: "user" | "agent" = "user",
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Generate unique ID: timestamp + random suffix for collision resistance
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const comment: import("./types.js").SteeringComment = {
id: commentId,
text,
createdAt: new Date().toISOString(),
author,
};
if (!task.steeringComments) {
task.steeringComments = [];
}
task.steeringComments.push(comment);
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: "Steering comment added",
outcome: `by ${author}`,
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Update or clear PR information for a task.
* Updates task.json atomically and emits `task:updated` event.
*
* @param id - The task ID
* @param prInfo - The PR info to set, or null to clear
* @returns The updated task
*/
async updatePrInfo(
id: string,
prInfo: import("./types.js").PrInfo | null,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const prevPrNumber = task.prInfo?.number;
const prevPrStatus = task.prInfo?.status;
if (prInfo) {
task.prInfo = prInfo;
task.log.push({
timestamp: new Date().toISOString(),
action: "PR linked",
outcome: `PR #${prInfo.number}: ${prInfo.url}`,
});
} else {
task.prInfo = undefined;
if (prevPrNumber) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR unlinked",
outcome: `PR #${prevPrNumber} removed`,
});
}
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
// Only emit if PR info actually changed
if (prevPrNumber !== prInfo?.number || prevPrStatus !== prInfo?.status) {
this.emit("task:updated", task);
}
return task;
});
}
/**
* Read all historical agent log entries for a task from its agent log file.
* Returns entries in chronological order (oldest first).

View File

@@ -5,6 +5,20 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
export type Column = (typeof COLUMNS)[number];
export type PrStatus = "open" | "closed" | "merged";
export interface PrInfo {
url: string;
number: number;
status: PrStatus;
title: string;
headBranch: string;
baseBranch: string;
commentCount: number;
lastCommentAt?: string;
lastCheckedAt?: string;
}
export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
export interface TaskStep {
@@ -49,6 +63,13 @@ export interface TaskAttachment {
createdAt: string;
}
export interface SteeringComment {
id: string;
text: string;
createdAt: string;
author: "user" | "agent";
}
export interface Task {
id: string;
title?: string;
@@ -73,9 +94,30 @@ export interface Task {
* dependency's branch instead of HEAD. Cleared after worktree creation. */
baseBranch?: string;
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
log: TaskLogEntry[];
size?: "S" | "M" | "L";
reviewLevel?: number;
/** AI model provider override for the executor agent (e.g., "anthropic").
* Must be set together with `modelId`. When both model fields are undefined,
* the executor uses global settings defaults. */
modelProvider?: string;
/** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5").
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. */
modelId?: string;
/** AI model provider override for the validator/reviewer agent.
* Must be set together with `validatorModelId`. When both validator model fields
* are undefined, the reviewer uses global settings defaults. */
validatorModelProvider?: string;
/** AI model ID override for the validator/reviewer agent.
* Must be set together with `validatorModelProvider`. When both validator model
* fields are undefined, the reviewer uses global settings defaults. */
validatorModelId?: string;
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
mergeRetries?: number;
/** ISO-8601 timestamp of when the task last entered its current column.
* Used to sort cards within a column so that recently-moved cards appear at the top. */
columnMovedAt?: string;
@@ -133,6 +175,9 @@ export interface Settings {
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
* their original IDs. */
taskPrefix?: string;
/** Whether GitHub token is configured for PR operations (read-only, set by server).
* When false, PR creation features are disabled in the UI. */
githubTokenConfigured?: boolean;
/** When true, merge commit messages include the task ID as the conventional
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
* omitted (e.g. `feat: ...`). Default: true. */
@@ -150,6 +195,20 @@ export interface Settings {
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
/** When true, auto-merge will automatically resolve common conflict patterns
* (lock files, generated files, trivial conflicts) without requiring AI
* intervention. When AI resolution fails, the system will retry with escalating
* strategies. Default: true. */
autoResolveConflicts?: boolean;
/** Alias for autoResolveConflicts. When true, enables automatic resolution of
* lock files (ours), generated files (theirs), and trivial whitespace conflicts
* without spawning an AI agent. Default: true. */
smartConflictResolution?: boolean;
/** When enabled, AI-generated task specifications require manual approval
* before the task can move from triage to todo. Tasks with approved specs
* remain in triage with status "awaiting-approval" until a user approves
* or rejects the plan. Default: false. */
requirePlanApproval?: boolean;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -167,6 +226,9 @@ export const DEFAULT_SETTINGS: Settings = {
defaultProvider: undefined,
defaultModelId: undefined,
defaultThinkingLevel: undefined,
autoResolveConflicts: true,
smartConflictResolution: true,
requirePlanApproval: false,
};
export interface BoardConfig {
@@ -181,6 +243,14 @@ export interface MergeResult {
worktreeRemoved: boolean;
branchDeleted: boolean;
error?: string;
/** Strategy that successfully resolved the merge, if any */
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
/** Alias for resolutionStrategy — how conflicts were resolved (for metrics/debugging) */
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
attemptsMade?: 1 | 2 | 3;
/** Number of files auto-resolved (for tracking mixed resolution scenarios) */
autoResolvedCount?: number;
}
export const COLUMN_LABELS: Record<Column, string> = {

View File

@@ -3,5 +3,12 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
coverage: {
enabled: false,
reporter: ["text", "html", "json"],
reportsDirectory: "./coverage",
include: ["src/**/*.ts"],
exclude: ["**/*.test.ts", "**/*.d.ts", "dist/**"],
},
},
});