feat(KB-156): implement archive cleanup with restoration support

- Add ArchiveEntry type and archive.jsonl storage format for compact task archives
- Implement archiveTask() with optional cleanup and archiveTaskAndCleanup() convenience method
- Add restoreFromArchive() to reconstruct tasks from archive entries when directory is missing
- Implement cleanupArchivedTasks() for bulk cleanup of all archived task directories
- Add comprehensive tests for archive, cleanup, and restore operations (385+ lines)
- Add readArchiveLog(), findInArchive(), and unarchiveTask() API methods
- Document archive storage pattern and restoration behavior in AGENTS.md
- Include changeset for patch release noting archive cleanup feature
This commit is contained in:
gsxdsm
2026-03-30 11:01:48 -07:00
parent e02356b836
commit 904a95f7c0
6 changed files with 768 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export {

View File

@@ -2284,4 +2284,389 @@ describe("TaskStore", () => {
expect(detail.prompt).toMatch(/^# KB-001: Build the authentication system/);
});
});
// ── Archive Cleanup Tests ────────────────────────────────────────
describe("cleanupArchivedTasks", () => {
it("writes compact entry to archive.jsonl without agent log", async () => {
// Create and archive a task
const task = await store.createTask({ description: "Test cleanup", title: "Cleanup 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);
// Add an agent log entry (should not be in archive)
await store.appendAgentLog(task.id, "Test agent log", "text");
// Cleanup archived tasks
const cleaned = await store.cleanupArchivedTasks();
expect(cleaned).toContain(task.id);
// Read archive.jsonl
const archivePath = join(rootDir, ".kb", "archive.jsonl");
const content = await readFile(archivePath, "utf-8");
const entry = JSON.parse(content.trim()) as import("./types.js").ArchivedTaskEntry;
expect(entry.id).toBe(task.id);
expect(entry.title).toBe("Cleanup Task");
expect(entry.description).toBe("Test cleanup");
expect(entry.column).toBe("archived");
// Agent log should NOT be in the archive entry
expect(entry).not.toHaveProperty("agentLog");
});
it("removes task directory after archiving", async () => {
const task = await store.createTask({ description: "Test dir removal" });
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 dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(true);
await store.cleanupArchivedTasks();
expect(existsSync(dir)).toBe(false);
});
it("skips already-cleaned-up tasks (idempotent)", async () => {
const task = await store.createTask({ description: "Test idempotent" });
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);
// First cleanup
const cleaned1 = await store.cleanupArchivedTasks();
expect(cleaned1).toContain(task.id);
// Second cleanup should skip
const cleaned2 = await store.cleanupArchivedTasks();
expect(cleaned2).not.toContain(task.id);
expect(cleaned2).toHaveLength(0);
});
it("preserves task metadata in archive entry", async () => {
const task = await store.createTask({
description: "Test metadata",
title: "Metadata 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");
// Add some metadata via updateTask
await store.updateTask(task.id, {
reviewLevel: 2,
size: "M",
});
// Add an attachment (metadata only, no content)
await store.addAttachment(task.id, "test.txt", Buffer.from("test"), "text/plain");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const archivePath = join(rootDir, ".kb", "archive.jsonl");
const content = await readFile(archivePath, "utf-8");
const entry = JSON.parse(content.trim()) as import("./types.js").ArchivedTaskEntry;
expect(entry.id).toBe(task.id);
expect(entry.title).toBe("Metadata Task");
expect(entry.size).toBe("M");
expect(entry.reviewLevel).toBe(2);
expect(entry.attachments).toHaveLength(1);
expect(entry.attachments![0].originalName).toBe("test.txt");
});
});
describe("readArchiveLog", () => {
it("returns empty array when archive.jsonl does not exist", async () => {
const entries = await store.readArchiveLog();
expect(entries).toEqual([]);
});
it("returns parsed entries from archive.jsonl", async () => {
const task = await store.createTask({ description: "Test read" });
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.cleanupArchivedTasks();
const entries = await store.readArchiveLog();
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe(task.id);
expect(entries[0].description).toBe("Test read");
});
it("handles multiple entries in archive.jsonl", async () => {
// Archive and cleanup task 1
const task1 = await store.createTask({ description: "Task 1" });
await store.moveTask(task1.id, "todo");
await store.moveTask(task1.id, "in-progress");
await store.moveTask(task1.id, "in-review");
await store.moveTask(task1.id, "done");
await store.archiveTask(task1.id);
await store.cleanupArchivedTasks();
// Archive and cleanup task 2
const task2 = await store.createTask({ description: "Task 2" });
await store.moveTask(task2.id, "todo");
await store.moveTask(task2.id, "in-progress");
await store.moveTask(task2.id, "in-review");
await store.moveTask(task2.id, "done");
await store.archiveTask(task2.id);
await store.cleanupArchivedTasks();
const entries = await store.readArchiveLog();
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.id).sort()).toEqual([task1.id, task2.id].sort());
});
});
describe("findInArchive", () => {
it("returns undefined when task not in archive", async () => {
const entry = await store.findInArchive("KB-999");
expect(entry).toBeUndefined();
});
it("returns archive entry for specific task", async () => {
const task = await store.createTask({ description: "Test find" });
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.cleanupArchivedTasks();
const entry = await store.findInArchive(task.id);
expect(entry).toBeDefined();
expect(entry!.id).toBe(task.id);
expect(entry!.description).toBe("Test find");
});
});
describe("unarchiveTask with restore", () => {
it("restores missing task from archive.jsonl", async () => {
const task = await store.createTask({ description: "Test restore" });
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.cleanupArchivedTasks();
const dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
// Unarchive should restore from archive
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
expect(unarchived.description).toBe("Test restore");
// Directory should be recreated
expect(existsSync(dir)).toBe(true);
});
it("works normally when task directory exists", async () => {
const task = await store.createTask({ description: "Test normal" });
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);
// Note: NOT calling cleanupArchivedTasks, so directory exists
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
});
it("restored task has correct column (done) and preserved metadata", async () => {
const task = await store.createTask({
description: "Test metadata preserve",
title: "Preserved 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");
// Set metadata via updateTask
await store.updateTask(task.id, { size: "L", reviewLevel: 2 });
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
expect(unarchived.title).toBe("Preserved Task");
expect(unarchived.size).toBe("L");
expect(unarchived.reviewLevel).toBe(2);
expect(unarchived.description).toBe("Test metadata preserve");
});
it("throws error when task directory missing and not in archive", async () => {
// Create a fake archived task by manually moving column
const task = await store.createTask({ description: "Not in archive" });
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);
// Delete directory without archiving
const dir = join(rootDir, ".kb", "tasks", task.id);
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
await expect(store.unarchiveTask(task.id)).rejects.toThrow("not found in archive");
});
it("adds log entry for restore action", async () => {
const task = await store.createTask({ description: "Test restore log" });
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.cleanupArchivedTasks();
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.log.some((l) => l.action === "Task restored from archive")).toBe(true);
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
});
it("recreates PROMPT.md after restore", async () => {
const task = await store.createTask({ description: "Test prompt restore" });
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.cleanupArchivedTasks();
await store.unarchiveTask(task.id);
// Verify PROMPT.md was recreated
const detail = await store.getTask(task.id);
expect(detail.prompt).toContain(task.id);
expect(detail.prompt).toContain("Test prompt restore");
});
it("recreates attachments directory (empty) after restore", async () => {
const task = await store.createTask({ description: "Test attach restore" });
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");
// Add an attachment
await store.addAttachment(task.id, "test.txt", Buffer.from("test"), "text/plain");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
await store.unarchiveTask(task.id);
// Directory should exist with empty attachments folder
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "attachments"))).toBe(true);
});
});
describe("archiveTask with cleanup", () => {
it("archiveTask(true) archives and cleans up immediately", async () => {
const task = await store.createTask({ description: "Immediate cleanup" });
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, true);
expect(archived.column).toBe("archived");
// Directory should be gone immediately
const dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
// Should be in archive.jsonl
const entry = await store.findInArchive(task.id);
expect(entry).toBeDefined();
expect(entry!.description).toBe("Immediate cleanup");
});
it("archiveTaskAndCleanup is convenience method", async () => {
const task = await store.createTask({ description: "Convenience method" });
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.archiveTaskAndCleanup(task.id);
expect(archived.column).toBe("archived");
const dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
});
it("archiveTask(false) preserves directory (backward compatibility)", async () => {
const task = await store.createTask({ description: "No cleanup" });
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, false);
expect(archived.column).toBe("archived");
// Directory should still exist
const dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(true);
});
it("default cleanup parameter is false", async () => {
const task = await store.createTask({ description: "Default cleanup" });
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); // No cleanup param
expect(archived.column).toBe("archived");
// Directory should still exist (default is false)
const dir = join(rootDir, ".kb", "tasks", task.id);
expect(existsSync(dir)).toBe(true);
});
});
describe("archive log persistence", () => {
it("archive log survives TaskStore reinitialization", async () => {
const task = await store.createTask({ description: "Survival test" });
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.cleanupArchivedTasks();
// Create new store instance
const newStore = new TaskStore(rootDir);
await newStore.init();
const entries = await newStore.readArchiveLog();
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe(task.id);
expect(entries[0].description).toBe("Survival test");
});
});
});

View File

@@ -20,6 +20,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private kbDir: string;
private tasksDir: string;
private configPath: string;
private archiveLogPath: string;
/** File-system watcher instance */
private watcher: FSWatcher | null = null;
@@ -42,6 +43,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.kbDir = join(rootDir, ".kb");
this.tasksDir = join(this.kbDir, "tasks");
this.configPath = join(this.kbDir, "config.json");
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
}
async init(): Promise<void> {
@@ -867,8 +869,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/**
* Archive a done task (move from done → archived).
* Logs the action and emits `task:moved` event.
* @param cleanup - If true, immediately cleans up the task directory after archiving
* by writing a compact entry to archive.jsonl and removing files.
* Default: false for backward compatibility.
*/
async archiveTask(id: string): Promise<Task> {
async archiveTask(id: string, cleanup: boolean = false): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -892,23 +897,93 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
action: "Task archived",
});
await this.atomicWriteTaskJson(dir, task);
// If cleanup requested, write archive entry BEFORE removing directory
if (cleanup) {
const entry: import("./types.js").ArchivedTaskEntry = {
id: task.id,
title: task.title,
description: task.description,
column: "archived",
dependencies: task.dependencies,
steps: task.steps,
currentStep: task.currentStep,
size: task.size,
reviewLevel: task.reviewLevel,
prInfo: task.prInfo,
issueInfo: task.issueInfo,
attachments: task.attachments,
log: task.log,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
columnMovedAt: task.columnMovedAt,
archivedAt: task.columnMovedAt,
modelProvider: task.modelProvider,
modelId: task.modelId,
validatorModelProvider: task.validatorModelProvider,
validatorModelId: task.validatorModelId,
breakIntoSubtasks: task.breakIntoSubtasks,
paused: task.paused,
baseBranch: task.baseBranch,
mergeRetries: task.mergeRetries,
error: task.error,
};
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task });
// Write to archive.jsonl atomically (append only)
await appendFile(this.archiveLogPath, JSON.stringify(entry) + "\n");
// Remove task directory recursively
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
// Remove from cache if watcher is active
if (this.watcher) {
this.taskCache.delete(id);
}
} else {
// Normal archive - just write task.json
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;
});
}
/**
* Archive a task and immediately clean up its directory.
* Convenience method equivalent to `archiveTask(id, true)`.
*/
async archiveTaskAndCleanup(id: string): Promise<Task> {
return this.archiveTask(id, true);
}
/**
* Unarchive an archived task (move from archived → done).
* If the task directory was cleaned up, restores from archive.jsonl first.
* Logs the action and emits `task:moved` event.
*/
async unarchiveTask(id: string): Promise<Task> {
const dir = this.taskDir(id);
// Check if directory exists BEFORE acquiring lock
if (!existsSync(dir)) {
// Task was cleaned up - restore from archive
const entry = await this.findInArchive(id);
if (!entry) {
throw new Error(
`Cannot unarchive ${id}: task directory missing and not found in archive`,
);
}
// Restore the task directory first
await this.restoreFromArchive(entry);
}
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
// Re-read task.json (either existing or freshly restored)
const task = await this.readTaskJson(dir);
// Initialize log array if missing (for legacy tasks)
@@ -1447,6 +1522,204 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return entries;
}
// ── Archive Cleanup Methods ─────────────────────────────────────────
/**
* Read and parse the archive log file (archive.jsonl).
* Returns empty array if archive file doesn't exist.
*/
async readArchiveLog(): Promise<import("./types.js").ArchivedTaskEntry[]> {
if (!existsSync(this.archiveLogPath)) {
return [];
}
const content = await readFile(this.archiveLogPath, "utf-8");
const entries: import("./types.js").ArchivedTaskEntry[] = [];
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line) as import("./types.js").ArchivedTaskEntry);
} catch {
// Skip malformed lines
}
}
return entries;
}
/**
* Find a specific task in the archive log by ID.
* Returns undefined if not found or archive doesn't exist.
*/
async findInArchive(id: string): Promise<import("./types.js").ArchivedTaskEntry | undefined> {
const entries = await this.readArchiveLog();
return entries.find((e) => e.id === id);
}
/**
* Cleanup archived tasks by condensing them into compact archive entries.
* For each archived task with an existing directory:
* - Creates a compact archive entry (metadata only, no agent logs)
* - Appends entry to archive.jsonl atomically
* - Removes the entire task directory
* Skips tasks already cleaned up (directory already gone).
*/
async cleanupArchivedTasks(): Promise<string[]> {
const archivedTasks = await this.listTasks().then((tasks) =>
tasks.filter((t) => t.column === "archived"),
);
const cleanedUpIds: string[] = [];
for (const task of archivedTasks) {
const dir = this.taskDir(task.id);
// Skip if directory already cleaned up
if (!existsSync(dir)) {
continue;
}
// Create compact archive entry (exclude agent logs)
const entry: import("./types.js").ArchivedTaskEntry = {
id: task.id,
title: task.title,
description: task.description,
column: "archived",
dependencies: task.dependencies,
steps: task.steps,
currentStep: task.currentStep,
size: task.size,
reviewLevel: task.reviewLevel,
prInfo: task.prInfo,
issueInfo: task.issueInfo,
attachments: task.attachments,
log: task.log,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
columnMovedAt: task.columnMovedAt,
archivedAt: new Date().toISOString(),
modelProvider: task.modelProvider,
modelId: task.modelId,
validatorModelProvider: task.validatorModelProvider,
validatorModelId: task.validatorModelId,
breakIntoSubtasks: task.breakIntoSubtasks,
paused: task.paused,
baseBranch: task.baseBranch,
mergeRetries: task.mergeRetries,
error: task.error,
};
// Atomic append to archive.jsonl
await appendFile(this.archiveLogPath, JSON.stringify(entry) + "\n");
// Remove task directory recursively
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
// Remove from cache if watcher is active
if (this.watcher) {
this.taskCache.delete(task.id);
}
cleanedUpIds.push(task.id);
}
return cleanedUpIds;
}
/**
* Restore a task from an archive entry.
* Recreates task directory with task.json and PROMPT.md.
* Clears transient execution state (worktree, status, blockedBy, etc.).
* Does NOT recreate agent.log (intentionally lost during archive).
*/
private async restoreFromArchive(entry: import("./types.js").ArchivedTaskEntry): Promise<Task> {
const dir = this.taskDir(entry.id);
// Create task directory
await mkdir(dir, { recursive: true });
// Build restored task (clear transient fields)
const restoredTask: Task = {
id: entry.id,
title: entry.title,
description: entry.description,
column: "archived", // Will be changed to "done" by unarchiveTask
dependencies: entry.dependencies,
steps: entry.steps,
currentStep: entry.currentStep,
size: entry.size,
reviewLevel: entry.reviewLevel,
prInfo: entry.prInfo,
issueInfo: entry.issueInfo,
attachments: entry.attachments,
log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }],
createdAt: entry.createdAt,
updatedAt: new Date().toISOString(),
columnMovedAt: entry.columnMovedAt,
modelProvider: entry.modelProvider,
modelId: entry.modelId,
validatorModelProvider: entry.validatorModelProvider,
validatorModelId: entry.validatorModelId,
breakIntoSubtasks: entry.breakIntoSubtasks,
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, error, steeringComments
};
// Write task.json
await this.atomicWriteTaskJson(dir, restoredTask);
// Generate PROMPT.md with preserved steps
const prompt = this.generatePromptFromArchiveEntry(entry);
await writeFile(join(dir, "PROMPT.md"), prompt);
// Create empty attachments directory if attachments existed
if (entry.attachments && entry.attachments.length > 0) {
await mkdir(join(dir, "attachments"), { recursive: true });
}
return restoredTask;
}
/**
* Generate a PROMPT.md from an archive entry, preserving the original step structure.
*/
private generatePromptFromArchiveEntry(entry: import("./types.js").ArchivedTaskEntry): string {
const deps =
entry.dependencies.length > 0
? entry.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
: "- **None**";
const heading = entry.title ? `${entry.id}: ${entry.title}` : entry.id;
// Build steps section from preserved steps
let stepsSection = "## Steps\n\n";
if (entry.steps && entry.steps.length > 0) {
for (let i = 0; i < entry.steps.length; i++) {
const step = entry.steps[i];
const status = step.status === "done" ? "[x]" : "[ ]";
stepsSection += `### Step ${i}: ${step.name}\n\n- ${status} ${step.name}\n\n`;
}
} else {
stepsSection += "### Step 0: Preflight\n\n- [ ] Review and verify\n\n";
}
return `# ${heading}
**Created:** ${entry.createdAt.split("T")[0]}
${entry.size ? `**Size:** ${entry.size}` : "**Size:** M"}
## Mission
${entry.description}
## Dependencies
${deps}
${stepsSection}`;
}
getRootDir(): string {
return this.rootDir;
}

View File

@@ -366,6 +366,42 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
// ── Planning Mode Types ────────────────────────────────────────────────────
/** Entry in the archive log (archive.jsonl) representing a compact,
* restorable snapshot of an archived task without agent log content.
*/
export interface ArchivedTaskEntry {
id: string;
title?: string;
description: string;
column: "archived"; // Always archived when in the log
dependencies: string[];
steps: TaskStep[];
currentStep: number;
size?: "S" | "M" | "L";
reviewLevel?: number;
prInfo?: PrInfo;
issueInfo?: IssueInfo;
/** Attachment metadata (filenames, mime types, etc.) without file content */
attachments?: TaskAttachment[];
log: TaskLogEntry[];
createdAt: string;
updatedAt: string;
columnMovedAt?: string;
/** Timestamp when the task was archived to the log */
archivedAt: string;
/** Optional: model override fields for executor and validator */
modelProvider?: string;
modelId?: string;
validatorModelProvider?: string;
validatorModelId?: string;
/** Optional: other metadata to preserve */
breakIntoSubtasks?: boolean;
paused?: boolean;
baseBranch?: string;
mergeRetries?: number;
error?: string;
}
/** Type of planning question presented to the user */
export type PlanningQuestionType = "text" | "single_select" | "multi_select" | "confirm";