Files
fusion/packages/core/src/store.ts

1962 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
"task:updated": [task: Task];
"task:deleted": [task: Task];
"task:merged": [result: MergeResult];
"settings:updated": [data: { settings: Settings; previous: Settings }];
"agent:log": [entry: AgentLogEntry];
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private kbDir: string;
private tasksDir: string;
private configPath: string;
private archiveLogPath: string;
private activityLogPath: string;
/** File-system watcher instance */
private watcher: FSWatcher | null = null;
/** In-memory cache of tasks for diffing watcher events */
private taskCache: Map<string, Task> = new Map();
/** Paths recently written by in-process mutations (suppresses duplicate events) */
private recentlyWritten: Set<string> = new Set();
/** Pending debounce timers keyed by task ID */
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
/** Debounce interval in ms */
private debounceMs = 150;
/** Per-task promise chain for serializing writes */
private taskLocks: Map<string, Promise<void>> = new Map();
/** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve();
constructor(private rootDir: string) {
super();
this.setMaxListeners(100);
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");
this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
}
async init(): Promise<void> {
await mkdir(this.tasksDir, { recursive: true });
if (!existsSync(this.configPath)) {
await this.writeConfig({ nextId: 1 });
}
}
/**
* Serialize all mutations to config.json by chaining promises.
* Concurrent callers will queue behind each other, preventing
* lost-update races on the nextId counter.
*/
private withConfigLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
const prev = this.configLock;
this.configLock = next;
return prev.then(async () => {
try {
return await fn();
} finally {
resolve!();
}
});
}
/**
* Serialize all mutations to a given task's task.json by chaining promises
* per task ID. Concurrent callers for the same ID will queue behind each other.
*/
private withTaskLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = this.taskLocks.get(id) ?? Promise.resolve();
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
this.taskLocks.set(id, next);
return prev.then(async () => {
try {
return await fn();
} finally {
if (this.taskLocks.get(id) === next) {
this.taskLocks.delete(id);
}
resolve!();
}
});
}
/**
* Read and parse a task.json file. Throws immediately on invalid JSON —
* atomic writes (write-to-temp-then-rename) prevent partial-write
* corruption, so a `SyntaxError` indicates a real bug rather than a race.
*/
private async readTaskJson(dir: string): Promise<Task> {
const filePath = join(dir, "task.json");
const raw = await readFile(filePath, "utf-8");
try {
const task = JSON.parse(raw) as Task;
// Normalize legacy task.json files so newer code can safely mutate them.
if (!Array.isArray(task.log)) task.log = [];
if (!Array.isArray(task.dependencies)) task.dependencies = [];
if (!Array.isArray(task.steps)) task.steps = [];
return task;
} catch (err) {
throw new Error(
`Failed to parse task.json at ${filePath}: ${(err as Error).message}`,
);
}
}
/**
* Atomically write a task.json file by writing to a temp file first,
* then renaming it into place. The rename is atomic on POSIX filesystems,
* preventing partial writes from corrupting the file on crash/kill.
*/
private async atomicWriteTaskJson(dir: string, task: Task): Promise<void> {
const taskJsonPath = join(dir, "task.json");
const tmpPath = join(dir, "task.json.tmp");
this.suppressWatcher(taskJsonPath);
await writeFile(tmpPath, JSON.stringify(task, null, 2));
await rename(tmpPath, taskJsonPath);
}
async getSettings(): Promise<Settings> {
const config = await this.readConfig();
return { ...DEFAULT_SETTINGS, ...config.settings };
}
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
return this.withConfigLock(async () => {
const config = await this.readConfig();
const previous = { ...DEFAULT_SETTINGS, ...config.settings };
const updated = { ...previous, ...patch };
config.settings = updated;
await this.writeConfig(config);
this.emit("settings:updated", { settings: updated, previous });
return updated;
});
}
private async readConfig(): Promise<BoardConfig> {
const data = await readFile(this.configPath, "utf-8");
return JSON.parse(data);
}
/**
* Atomically write config.json by writing to a temp file first, then
* renaming into place. The rename is atomic on POSIX filesystems,
* preventing partial writes from corrupting the file.
*/
private async atomicWriteConfig(config: BoardConfig): Promise<void> {
const tmpPath = this.configPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(config, null, 2));
await rename(tmpPath, this.configPath);
}
private async writeConfig(config: BoardConfig): Promise<void> {
await this.atomicWriteConfig(config);
}
private async allocateId(): Promise<string> {
return this.withConfigLock(async () => {
const config = await this.readConfig();
const prefix = config.settings?.taskPrefix || "KB";
const id = `${prefix}-${String(config.nextId).padStart(3, "0")}`;
config.nextId++;
await this.writeConfig(config);
return id;
});
}
private taskDir(id: string): string {
return join(this.tasksDir, id);
}
async createTask(input: TaskCreateInput): Promise<Task> {
if (!input.description?.trim()) {
throw new Error("Description is required and cannot be empty");
}
const id = await this.allocateId();
// Generate title from description if not provided
const title = input.title?.trim() || generateTitleFromDescription(input.description);
const now = new Date().toISOString();
const task: Task = {
id,
title: title || undefined,
description: input.description,
column: input.column || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
modelProvider: input.modelProvider,
modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider,
validatorModelId: input.validatorModelId,
steps: [],
currentStep: 0,
log: [{ timestamp: now, action: "Task created" }],
columnMovedAt: now,
createdAt: now,
updatedAt: now,
};
const dir = this.taskDir(id);
await mkdir(dir, { recursive: true });
await this.atomicWriteTaskJson(dir, task);
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task });
const heading = task.title ? `${id}: ${task.title}` : id;
const prompt = task.column === "triage"
? `# ${heading}\n\n${task.description}\n`
: this.generateSpecifiedPrompt(task);
await writeFile(join(dir, "PROMPT.md"), prompt);
this.emit("task:created", task);
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;
}
/**
* Create a refinement task from a completed or in-review task.
* The new task is created in triage with a dependency on the original task.
* Validates the original is in 'done' or 'in-review' column.
*/
async refineTask(id: string, feedback: string): Promise<Task> {
// Read the source task with its prompt
const sourceTask = await this.getTask(id);
// Validate task is in done or in-review column
if (sourceTask.column !== "done" && sourceTask.column !== "in-review") {
throw new Error(
`Cannot refine ${id}: task is in '${sourceTask.column}', must be in 'done' or 'in-review'`,
);
}
// Validate feedback is not empty
if (!feedback?.trim()) {
throw new Error("Feedback is required and cannot be empty");
}
// Allocate a new ID
const newId = await this.allocateId();
const now = new Date().toISOString();
// Create new refinement task
const newTask: Task = {
id: newId,
title: `Refinement: ${sourceTask.title || sourceTask.id}`,
description: `${feedback.trim()}\n\nRefines: ${id}`,
column: "triage",
dependencies: [id], // Refinement depends on the original being complete
steps: [], // Reset execution state
currentStep: 0,
log: [{ timestamp: now, action: `Created as refinement of ${id}` }],
columnMovedAt: now,
createdAt: now,
updatedAt: now,
// Copy attachments from original for context (defensive copy)
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
};
const newDir = this.taskDir(newId);
await mkdir(newDir, { recursive: true });
await this.atomicWriteTaskJson(newDir, newTask);
// Create a PROMPT.md for the refinement
const heading = newTask.title;
const prompt = `# ${heading}\n\n${newTask.description}\n`;
await writeFile(join(newDir, "PROMPT.md"), prompt);
// Copy attachments from source if any
if (sourceTask.attachments && sourceTask.attachments.length > 0) {
const sourceAttachDir = join(this.taskDir(id), "attachments");
const targetAttachDir = join(newDir, "attachments");
await mkdir(targetAttachDir, { recursive: true });
for (const attachment of sourceTask.attachments) {
const sourcePath = join(sourceAttachDir, attachment.filename);
const targetPath = join(targetAttachDir, attachment.filename);
if (existsSync(sourcePath)) {
const content = await readFile(sourcePath);
await writeFile(targetPath, content);
}
}
}
// 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.
*
* Retries once after a short delay on non-ENOENT errors to handle
* transient read failures caused by concurrent `writeFile` calls
* (e.g. partial JSON from a non-atomic write during executor updates).
*/
async getTask(id: string): Promise<TaskDetail> {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
let prompt = "";
const promptPath = join(dir, "PROMPT.md");
if (existsSync(promptPath)) {
prompt = await readFile(promptPath, "utf-8");
}
return { ...task, prompt };
}
async listTasks(options?: { limit?: number; offset?: number }): Promise<Task[]> {
if (!existsSync(this.tasksDir)) return [];
const entries = await readdir(this.tasksDir, { withFileTypes: true });
const tasks: Task[] = [];
for (const entry of entries) {
if (entry.isDirectory() && /^[A-Z]+-\d+$/.test(entry.name)) {
try {
tasks.push(await this.readTaskJson(join(this.tasksDir, entry.name)));
} catch {
// skip invalid task dirs
}
}
}
const sorted = tasks.sort((a, b) => {
const cmp = a.createdAt.localeCompare(b.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return aNum - bNum;
});
const offset = Math.max(0, options?.offset ?? 0);
const limit = options?.limit;
if (limit === undefined) {
return sorted.slice(offset);
}
return sorted.slice(offset, offset + Math.max(0, limit));
}
async moveTask(id: string, toColumn: Column): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const validTargets = VALID_TRANSITIONS[task.column];
if (!validTargets.includes(toColumn)) {
throw new Error(
`Invalid transition: '${task.column}' → '${toColumn}'. ` +
`Valid targets: ${validTargets.join(", ") || "none"}`,
);
}
const fromColumn = task.column;
task.column = toColumn;
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
// Clear transient fields when moving to done (matches moveToDone behavior)
if (toColumn === "done") {
task.status = undefined;
task.error = undefined;
task.worktree = undefined;
task.blockedBy = undefined;
}
// Clear transient fields when moving from in-progress to reset columns (todo/triage)
// This ensures failed tasks don't show failed status after being moved for retry
if (fromColumn === "in-progress" && (toColumn === "todo" || toColumn === "triage")) {
task.status = undefined;
task.error = undefined;
task.worktree = undefined;
task.blockedBy = undefined;
}
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: fromColumn, to: toColumn });
return task;
});
}
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; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null },
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
if (updates.title !== undefined) task.title = updates.title;
if (updates.description !== undefined) task.description = updates.description;
if (updates.worktree !== undefined) task.worktree = updates.worktree;
// Detect new dependencies being added to a todo task → auto-move to triage
let movedToTriage = false;
if (updates.dependencies !== undefined) {
const oldDeps = new Set(task.dependencies);
const hasNewDeps = updates.dependencies.some((d) => !oldDeps.has(d));
task.dependencies = updates.dependencies;
if (hasNewDeps && task.column === "todo") {
const fromColumn = task.column;
task.column = "triage";
task.status = undefined;
task.columnMovedAt = new Date().toISOString();
task.log.push({
timestamp: new Date().toISOString(),
action: "Moved to triage for re-specification — new dependency added",
});
movedToTriage = true;
}
}
if (updates.status === null) {
task.status = undefined;
} else if (updates.status !== undefined) {
task.status = updates.status;
}
if (updates.blockedBy === null) {
task.blockedBy = undefined;
} else if (updates.blockedBy !== undefined) {
task.blockedBy = updates.blockedBy;
}
if (updates.paused !== undefined) task.paused = updates.paused || undefined;
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;
}
if (updates.error === null) {
task.error = undefined;
} else if (updates.error !== undefined) {
task.error = updates.error;
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(id, { ...task });
if (updates.prompt !== undefined) {
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
}
if (movedToTriage) {
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column });
}
this.emit("task:updated", task);
return task;
});
}
/**
* Pause or unpause a task. Paused tasks are excluded from all automated
* agent and scheduler interaction. Logs the action and emits `task:updated`.
*/
async pauseTask(id: string, paused: boolean): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
task.paused = paused || undefined;
// When pausing an in-progress task, set status so the UI can show the state.
// When unpausing, clear the "paused" status.
if (task.column === "in-progress") {
task.status = paused ? "paused" : undefined;
}
const now = new Date().toISOString();
task.updatedAt = now;
task.log.push({
timestamp: now,
action: paused ? "Task paused" : "Task unpaused",
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Update a step's status. Automatically advances currentStep.
*/
async updateStep(
id: string,
stepIndex: number,
status: import("./types.js").StepStatus,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Auto-initialize steps from PROMPT.md if empty
if (task.steps.length === 0) {
task.steps = await this.parseStepsFromPrompt(id);
}
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
if (stepIndex < 0 || stepIndex >= task.steps.length) {
throw new Error(
`Step ${stepIndex} out of range (task has ${task.steps.length} steps)`,
);
}
task.steps[stepIndex].status = status;
task.updatedAt = new Date().toISOString();
// Advance currentStep to first non-done step
if (status === "done") {
while (
task.currentStep < task.steps.length &&
task.steps[task.currentStep].status === "done"
) {
task.currentStep++;
}
} else if (status === "in-progress") {
task.currentStep = stepIndex;
}
// Log it
task.log.push({
timestamp: task.updatedAt,
action: `Step ${stepIndex} (${task.steps[stepIndex].name}) → ${status}`,
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Add a log entry to a task.
*/
async logEntry(id: string, action: string, outcome?: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
task.log.push({
timestamp: new Date().toISOString(),
action,
outcome,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Sync steps from PROMPT.md into task.json (called when steps are empty).
*/
async parseStepsFromPrompt(id: string): Promise<import("./types.js").TaskStep[]> {
const dir = this.taskDir(id);
const promptPath = join(dir, "PROMPT.md");
if (!existsSync(promptPath)) return [];
const content = await readFile(promptPath, "utf-8");
const steps: import("./types.js").TaskStep[] = [];
const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
let match;
while ((match = stepRegex.exec(content)) !== null) {
steps.push({ name: match[1].trim(), status: "pending" });
}
return steps;
}
/**
* Parse the `## Dependencies` section from a task's PROMPT.md and extract
* task IDs from lines matching `- **Task:** {ID}` (where ID is `[A-Z]+-\d+`).
*
* Returns an empty array if the section says `- **None**`, has no task
* references, or if the section/file doesn't exist.
*
* @param id - The task ID whose PROMPT.md to parse
* @returns Array of dependency task IDs (e.g. `["KB-001", "KB-002"]`)
*/
async parseDependenciesFromPrompt(id: string): Promise<string[]> {
const dir = this.taskDir(id);
const promptPath = join(dir, "PROMPT.md");
if (!existsSync(promptPath)) return [];
const content = await readFile(promptPath, "utf-8");
// Find the ## Dependencies section.
// We locate the heading then slice to the next heading (or end of file)
// to avoid multiline `$` anchor issues with lazy quantifiers.
const headingMatch = content.match(/^##\s+Dependencies\s*$/m);
if (!headingMatch) return [];
const startIdx = headingMatch.index! + headingMatch[0].length;
const rest = content.slice(startIdx);
const nextHeading = rest.search(/\n##?\s/);
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
const ids: string[] = [];
const taskIdRegex = /^-\s+\*\*Task:\*\*\s+([A-Z]+-\d+)/gm;
let match;
while ((match = taskIdRegex.exec(section)) !== null) {
ids.push(match[1]);
}
return ids;
}
/**
* Parse the `## File Scope` section from a task's PROMPT.md and extract
* backtick-quoted file paths. Glob patterns ending in `/*` are stored
* as directory prefixes for overlap comparison.
*/
async parseFileScopeFromPrompt(id: string): Promise<string[]> {
const dir = this.taskDir(id);
const promptPath = join(dir, "PROMPT.md");
if (!existsSync(promptPath)) return [];
const content = await readFile(promptPath, "utf-8");
// Find the ## File Scope section.
// We locate the heading then slice to the next heading (or end of file)
// to avoid multiline `$` anchor issues with lazy quantifiers.
const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m);
if (!headingMatch) return [];
const startIdx = headingMatch.index! + headingMatch[0].length;
const rest = content.slice(startIdx);
const nextHeading = rest.search(/\n##?\s/);
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
const paths: string[] = [];
const backtickRegex = /`([^`]+)`/g;
let match;
while ((match = backtickRegex.exec(section)) !== null) {
paths.push(match[1]);
}
return paths;
}
async deleteTask(id: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const taskJsonPath = join(dir, "task.json");
this.suppressWatcher(taskJsonPath);
// Remove from cache if watcher is active
if (this.watcher) this.taskCache.delete(id);
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true });
this.emit("task:deleted", task);
return task;
});
}
/**
* Merge an in-review task's branch into the current branch,
* clean up the worktree, and move the task to done.
*/
async mergeTask(id: string): Promise<MergeResult> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
if (task.column !== "in-review") {
throw new Error(
`Cannot merge ${id}: task is in '${task.column}', must be in 'in-review'`,
);
}
const branch = `kb/${id.toLowerCase()}`;
const worktreePath = task.worktree;
const result: MergeResult = {
task,
branch,
merged: false,
worktreeRemoved: false,
branchDeleted: false,
};
// 1. Check the branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
} catch {
// No branch — might have been manually merged. Just move to done.
result.error = `Branch '${branch}' not found — moving to done without merge`;
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
return result;
}
// 2. Merge the branch
try {
execSync(`git merge --squash "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git commit --no-edit -m "feat(${id}): merge ${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.merged = true;
} catch (err: any) {
// Squash conflict — reset and report
try {
execSync("git reset --merge", { cwd: this.rootDir, stdio: "pipe" });
} catch {
// already clean
}
throw new Error(
`Merge conflict merging '${branch}'. Resolve manually:\n` +
` cd ${this.rootDir}\n` +
` git merge --squash ${branch}\n` +
` # resolve conflicts, then: kb task move ${id} done`,
);
}
// 3. Remove worktree
if (worktreePath && existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch {
// Non-fatal — worktree may already be gone
}
}
// 4. Delete the branch
try {
execSync(`git branch -d "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.branchDeleted = true;
} catch {
// Branch might not be fully merged in some edge cases; try force
try {
execSync(`git branch -D "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.branchDeleted = true;
} catch {
// Non-fatal
}
}
// 5. Move task to done
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
return result;
});
}
/**
* Archive all tasks currently in the "done" column.
* Returns an array of archived tasks.
*/
async archiveAllDone(): Promise<Task[]> {
const tasks = await this.listTasks();
const doneTasks = tasks.filter((t) => t.column === "done");
if (doneTasks.length === 0) {
return [];
}
// Archive all done tasks concurrently
const archivedTasks = await Promise.all(
doneTasks.map((task) => this.archiveTask(task.id))
);
return archivedTasks;
}
/**
* 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, cleanup: boolean = false): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
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",
});
// 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,
};
// 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 () => {
// Re-read task.json (either existing or freshly restored)
const task = await this.readTaskJson(dir);
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
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;
task.status = undefined;
task.blockedBy = undefined;
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
await this.atomicWriteTaskJson(dir, task);
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(task.id, { ...task });
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
}
// ── File-system watcher ───────────────────────────────────────────
/**
* Start watching the tasks directory for external changes.
* Populates the in-memory cache and begins emitting events for
* any task.json mutations made outside this process.
*/
async watch(): Promise<void> {
if (this.watcher) return; // already watching
// Populate cache with current state
const tasks = await this.listTasks();
this.taskCache.clear();
for (const task of tasks) {
this.taskCache.set(task.id, { ...task });
}
try {
this.watcher = watch(this.tasksDir, { recursive: true }, (_event, filename) => {
if (typeof filename !== "string") return;
this.handleFsChange(filename);
});
// Ignore watcher errors (e.g. dir deleted) just stop watching
this.watcher.on("error", () => {
this.stopWatching();
});
} catch {
// fs.watch may throw on some platforms; silently degrade
}
}
/**
* Stop the file-system watcher and clean up.
*/
stopWatching(): void {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
this.taskCache.clear();
this.recentlyWritten.clear();
}
/**
* Mark a file path as recently written by an in-process mutation
* so the watcher will skip it.
*/
private suppressWatcher(filePath: string): void {
this.recentlyWritten.add(filePath);
setTimeout(() => {
this.recentlyWritten.delete(filePath);
}, this.debounceMs + 100);
}
/**
* Handle a raw fs.watch callback. `filename` is relative to tasksDir.
*/
private handleFsChange(filename: string): void {
// We only care about task.json files
const parts = filename.split(sep);
// Normalize for platforms that may use forward slashes
const normalizedParts = parts.length === 1 ? filename.split("/") : parts;
if (normalizedParts.length < 2) return;
const taskId = normalizedParts[0];
const file = normalizedParts[normalizedParts.length - 1];
if (file !== "task.json") return;
if (!/^[A-Z]+-\d+$/.test(taskId)) return;
const fullPath = join(this.tasksDir, taskId, "task.json");
// Check suppression
if (this.recentlyWritten.has(fullPath)) return;
// Debounce per task ID
const existing = this.debounceTimers.get(taskId);
if (existing) clearTimeout(existing);
this.debounceTimers.set(
taskId,
setTimeout(() => {
this.debounceTimers.delete(taskId);
this.processTaskChange(taskId, fullPath).catch(() => {
// Ignore errors (file may have been deleted mid-read)
});
}, this.debounceMs),
);
}
/**
* Read a task.json from disk and diff against the cache to emit the right event.
*/
private async processTaskChange(taskId: string, filePath: string): Promise<void> {
const cached = this.taskCache.get(taskId);
if (!existsSync(filePath)) {
// Task was deleted
if (cached) {
this.taskCache.delete(taskId);
this.emit("task:deleted", cached);
}
return;
}
let task: Task;
try {
const taskDir = join(this.tasksDir, taskId);
task = await this.readTaskJson(taskDir);
} catch {
return; // File not readable or invalid JSON
}
if (!cached) {
// New task
this.taskCache.set(taskId, { ...task });
this.emit("task:created", task);
return;
}
// Check for column change → task:moved
if (cached.column !== task.column) {
const from = cached.column;
this.taskCache.set(taskId, { ...task });
this.emit("task:moved", { task, from, to: task.column });
return;
}
// Check for any other field change → task:updated
if (JSON.stringify(cached) !== JSON.stringify(task)) {
this.taskCache.set(taskId, { ...task });
this.emit("task:updated", task);
}
}
private static ALLOWED_MIME_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"application/json",
"text/yaml",
"text/x-toml",
"text/csv",
"application/xml",
]);
private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB
async addAttachment(
id: string,
filename: string,
content: Buffer,
mimeType: string,
): Promise<TaskAttachment> {
if (!TaskStore.ALLOWED_MIME_TYPES.has(mimeType)) {
throw new Error(
`Invalid mime type '${mimeType}'. Allowed: ${[...TaskStore.ALLOWED_MIME_TYPES].join(", ")}`,
);
}
if (content.length > TaskStore.MAX_ATTACHMENT_SIZE) {
throw new Error(
`File too large (${content.length} bytes). Maximum: ${TaskStore.MAX_ATTACHMENT_SIZE} bytes (5MB)`,
);
}
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const attachDir = join(dir, "attachments");
await mkdir(attachDir, { recursive: true });
// Sanitize filename: keep alphanumeric, dots, hyphens, underscores
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storedName = `${Date.now()}-${sanitized}`;
await writeFile(join(attachDir, storedName), content);
const attachment: TaskAttachment = {
filename: storedName,
originalName: filename,
mimeType,
size: content.length,
createdAt: new Date().toISOString(),
};
const task = await this.readTaskJson(dir);
if (!task.attachments) task.attachments = [];
task.attachments.push(attachment);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return attachment;
});
}
async getAttachment(
id: string,
filename: string,
): Promise<{ path: string; mimeType: string }> {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const attachment = task.attachments?.find((a) => a.filename === filename);
if (!attachment) {
const err: NodeJS.ErrnoException = new Error(
`Attachment '${filename}' not found on task ${id}`,
);
err.code = "ENOENT";
throw err;
}
return {
path: join(dir, "attachments", filename),
mimeType: attachment.mimeType,
};
}
async deleteAttachment(id: string, filename: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const idx = task.attachments?.findIndex((a) => a.filename === filename) ?? -1;
if (idx === -1) {
const err: NodeJS.ErrnoException = new Error(
`Attachment '${filename}' not found on task ${id}`,
);
err.code = "ENOENT";
throw err;
}
// Remove file from disk
const filePath = join(dir, "attachments", filename);
try {
await unlink(filePath);
} catch {
// File may already be gone
}
task.attachments!.splice(idx, 1);
if (task.attachments!.length === 0) {
task.attachments = undefined;
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Append an agent log entry to the task's agent log file (JSONL format).
* Each entry is a single JSON line appended to `.kb/tasks/{ID}/agent.log`.
* Also emits an `agent:log` event for live streaming.
*
* @param taskId - The task ID (e.g. "KB-001")
* @param text - The text content (delta for "text"/"thinking", tool name for "tool"/"tool_result"/"tool_error")
* @param type - The entry type discriminator
* @param detail - Optional human-readable summary (tool args, result summary, or error message)
* @param agent - Optional agent role that produced this entry
*/
async appendAgentLog(
taskId: string,
text: string,
type: AgentLogEntry["type"],
detail?: string,
agent?: AgentLogEntry["agent"],
): Promise<void> {
const entry: AgentLogEntry = {
timestamp: new Date().toISOString(),
taskId,
text,
type,
...(detail !== undefined && { detail }),
...(agent !== undefined && { agent }),
};
const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log");
await appendFile(logPath, JSON.stringify(entry) + "\n");
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);
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];
}
// 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 previous = task.prInfo;
const badgeChanged =
previous?.url !== prInfo?.url ||
previous?.number !== prInfo?.number ||
previous?.status !== prInfo?.status ||
previous?.title !== prInfo?.title ||
previous?.headBranch !== prInfo?.headBranch ||
previous?.baseBranch !== prInfo?.baseBranch ||
previous?.commentCount !== prInfo?.commentCount ||
previous?.lastCommentAt !== prInfo?.lastCommentAt;
const linkChanged = previous?.number !== prInfo?.number || previous?.url !== prInfo?.url;
if (prInfo) {
task.prInfo = prInfo;
if (!previous || linkChanged) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR linked",
outcome: `PR #${prInfo.number}: ${prInfo.url}`,
});
} else if (badgeChanged) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR updated",
outcome: `PR #${prInfo.number} badge metadata refreshed`,
});
}
} else {
task.prInfo = undefined;
if (previous?.number) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR unlinked",
outcome: `PR #${previous.number} removed`,
});
}
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
if (badgeChanged) {
this.emit("task:updated", task);
}
return task;
});
}
/**
* Update or clear Issue information for a task.
* Updates task.json atomically and emits `task:updated` event.
*
* @param id - The task ID
* @param issueInfo - The Issue info to set, or null to clear
* @returns The updated task
*/
async updateIssueInfo(
id: string,
issueInfo: import("./types.js").IssueInfo | null,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const previous = task.issueInfo;
const badgeChanged =
previous?.url !== issueInfo?.url ||
previous?.number !== issueInfo?.number ||
previous?.state !== issueInfo?.state ||
previous?.title !== issueInfo?.title ||
previous?.stateReason !== issueInfo?.stateReason;
const linkChanged = previous?.number !== issueInfo?.number || previous?.url !== issueInfo?.url;
if (issueInfo) {
task.issueInfo = issueInfo;
if (!previous || linkChanged) {
task.log.push({
timestamp: new Date().toISOString(),
action: "Issue linked",
outcome: `Issue #${issueInfo.number}: ${issueInfo.url}`,
});
} else if (badgeChanged) {
task.log.push({
timestamp: new Date().toISOString(),
action: "Issue updated",
outcome: `Issue #${issueInfo.number} badge metadata refreshed`,
});
}
} else {
task.issueInfo = undefined;
if (previous?.number) {
task.log.push({
timestamp: new Date().toISOString(),
action: "Issue unlinked",
outcome: `Issue #${previous.number} removed`,
});
}
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
if (badgeChanged) {
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).
*
* @param taskId - The task ID (e.g. "KB-001")
* @returns Array of agent log entries, empty if no log file exists
*/
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log");
if (!existsSync(logPath)) return [];
const content = await readFile(logPath, "utf-8");
const entries: AgentLogEntry[] = [];
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line) as AgentLogEntry);
} catch {
// skip malformed lines
}
}
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;
}
private generateSpecifiedPrompt(task: Task): string {
const deps =
task.dependencies.length > 0
? task.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
: "- **None**";
// Get current settings to check for ntfy configuration
const settings = this.getSettingsSync();
const notificationsSection =
settings.ntfyEnabled && settings.ntfyTopic
? `\n## Notifications\n\nntfy topic: \`${settings.ntfyTopic}\`\n`
: "";
const heading = task.title ? `${task.id}: ${task.title}` : task.id;
return `# ${heading}
**Created:** ${task.createdAt.split("T")[0]}
**Size:** M
## Mission
${task.description}
## Dependencies
${deps}
## Steps
### Step 1: Implementation
- [ ] Implement the required changes
- [ ] Verify changes work correctly
### Step 2: Testing & Verification
- [ ] All tests pass
- [ ] No regressions introduced
### Step 3: Documentation & Delivery
- [ ] Update relevant documentation
- [ ] .DONE created
## Acceptance Criteria
- [ ] All steps complete
- [ ] All tests passing
${notificationsSection}`;
}
/**
* Synchronous version of getSettings for internal use.
* Returns cached settings or default settings if not loaded.
*/
private getSettingsSync(): Settings {
// Since we can't easily make generateSpecifiedPrompt async,
// we read settings synchronously from the file.
// The settings file is read during init and on each update,
// so this should be reasonably up-to-date for prompt generation.
try {
const config = JSON.parse(readFileSync(this.configPath, "utf-8"));
return { ...DEFAULT_SETTINGS, ...config.settings };
} catch {
return DEFAULT_SETTINGS;
}
}
// ── Activity Log Methods ─────────────────────────────────────────
/**
* Record an activity log entry to the global activity log.
* Appends to .kb/activity-log.jsonl (JSON Lines format).
* Auto-generates ID and timestamp.
*/
async recordActivity(entry: Omit<ActivityLogEntry, "id" | "timestamp">): Promise<ActivityLogEntry> {
const fullEntry: ActivityLogEntry = {
...entry,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: new Date().toISOString(),
};
try {
await appendFile(this.activityLogPath, JSON.stringify(fullEntry) + "\n");
} catch (err) {
// Best-effort: log errors but don't break operations
console.error("Failed to record activity:", err);
}
return fullEntry;
}
/**
* Get activity log entries from the JSONL file.
* Returns entries sorted newest first.
* Supports filtering by limit, since timestamp, and event type.
*/
async getActivityLog(options?: { limit?: number; since?: string; type?: ActivityEventType }): Promise<ActivityLogEntry[]> {
if (!existsSync(this.activityLogPath)) {
return [];
}
const content = await readFile(this.activityLogPath, "utf-8");
const entries: ActivityLogEntry[] = [];
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line) as ActivityLogEntry);
} catch {
// Skip malformed lines
}
}
// Filter by since timestamp if provided
let filtered = entries;
if (options?.since) {
filtered = filtered.filter((e) => e.timestamp > options.since!);
}
// Filter by type if provided
if (options?.type) {
filtered = filtered.filter((e) => e.type === options.type);
}
// Sort newest first (by timestamp descending)
filtered.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply limit if provided
if (options?.limit && options.limit > 0) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
/**
* Clear all activity log entries by truncating the log file.
* Use with caution - this permanently deletes activity history.
*/
async clearActivityLog(): Promise<void> {
try {
if (existsSync(this.activityLogPath)) {
await writeFile(this.activityLogPath, "", "utf-8");
}
} catch (err) {
console.error("Failed to clear activity log:", err);
throw err;
}
}
}
/**
* Generate a concise title from a description by extracting the first N words.
* Uses first 8-10 words, capped at ~50 characters.
* Handles edge cases: very short descriptions, descriptions with only special chars.
*
* @param description - The task description
* @returns A generated title string, or empty string if no valid words found
*/
function generateTitleFromDescription(description: string): string {
if (!description?.trim()) {
return "";
}
// Normalize whitespace and remove extra newlines
const normalized = description.trim().replace(/\s+/g, " ");
// Extract words (sequences of alphanumeric chars, preserving internal hyphens/apostrophes)
const words = normalized.match(/[a-zA-Z0-9]+(?:['\-_][a-zA-Z0-9]+)*/g) || [];
if (words.length === 0) {
// If no alphanumeric words found, fallback to first 50 chars of normalized text
const fallback = normalized.slice(0, 50).trim();
return fallback || "";
}
// Build title from first 8-10 words (max ~50 chars)
const maxWords = Math.min(10, words.length);
const minWords = Math.min(8, words.length);
let title = "";
let wordCount = 0;
for (let i = 0; i < words.length; i++) {
const word = words[i];
// Check if adding this word would exceed ~50 chars
const candidate = wordCount === 0 ? word : `${title} ${word}`;
if (candidate.length > 50 && wordCount >= minWords) {
break;
}
title = candidate;
wordCount++;
// Stop at max words
if (wordCount >= maxWords) {
break;
}
}
return title || "";
}