src / tasks / taskStore.ts
src / tasks / taskStore.ts
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
export type TaskItemStatus =
| "pending"
| "in_progress"
| "completed"
| "blocked"
| "canceled";
export type TaskPriority = "low" | "normal" | "high" | "critical";
export type TaskBoardStatus = "active" | "completed" | "archived";
export interface TaskItem {
id: string;
text: string;
status: TaskItemStatus;
priority: TaskPriority;
details?: string;
dependsOn: string[];
evidence: string[];
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export interface TaskCheckpoint {
id: string;
createdAt: string;
summary: string;
decisions: string[];
blockers: string[];
next: string[];
}
export interface TaskBoard {
version: 1;
id: string;
title: string;
objective?: string;
status: TaskBoardStatus;
createdAt: string;
updatedAt: string;
completedAt?: string;
archivedAt?: string;
items: TaskItem[];
notes: string[];
decisions: string[];
checkpoints: TaskCheckpoint[];
}
export interface NewTaskItem {
text: string;
status?: TaskItemStatus;
priority?: TaskPriority;
details?: string;
dependsOn?: string[];
}
const taskBoardLocks = new Map<string, Promise<void>>();
function validateBoardId(id: string): void {
if (!/^todo_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid task board id: ${id}`);
}
}
function validateItemId(id: string): void {
if (!/^item_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid task item id: ${id}`);
}
}
function cleanText(value: string, field: string, limit: number): string {
const text = value.trim();
if (!text) throw new AgenticError("INVALID_INPUT", `${field} may not be empty.`);
if (text.length > limit) {
throw new AgenticError("INVALID_INPUT", `${field} is limited to ${limit} characters.`);
}
return text;
}
function unique(values: string[]): string[] {
return [...new Set(values)];
}
/**
* Resolves one `dependsOn` entry written against a batch of items that is being
* minted right now (`create`, or one `add` call).
*
* At create time no `item_` id exists yet, so a model has no way to express a
* dependency at all unless positions are accepted: a live 27B run wrote
* `depends_on: ["1"]` twice and burned a round on `INVALID_INPUT: Invalid task
* item id: 1` both times. A bare 1-based decimal therefore names the n-th item
* of this same call and resolves to the id minted for it; anything else must be
* a real `item_` id. Forward references are legal — they describe a DAG, not a
* cycle — and `validateTaskGraph` still rejects real cycles once resolved.
*/
function resolveDependency(raw: string, index: number, batchIds: string[]): string {
const value = raw.trim();
if (/^\d+$/.test(value)) {
const position = Number(value);
if (position < 1 || position > batchIds.length) {
throw new AgenticError(
"INVALID_INPUT",
`Task item ${index + 1} depends on position ${position}, but this call has ${
batchIds.length
} item(s); use 1-${batchIds.length} or an item_ id.`,
);
}
if (position === index + 1) {
throw new AgenticError(
"INVALID_INPUT",
`Task item ${position} cannot depend on itself.`,
);
}
return batchIds[position - 1];
}
if (!/^item_[a-z0-9_]+$/i.test(value)) {
throw new AgenticError(
"INVALID_INPUT",
`Invalid task item reference: ${raw}. Use an item_ id or a 1-based position within this call.`,
);
}
return value;
}
/**
* Validates one `dependsOn` entry outside a minting batch (`update`), where a
* position has nothing to index into. Routing these through `validateItemId`
* answered `depends_on: ["1"]` with `Invalid task item id: 1` — the exact
* message positional support was added to kill, which reads as "positions are
* not supported" rather than "not here". Positions genuinely cannot work in
* `update`, so this is the message, not the rule.
*/
function validateDependencyReference(raw: string): void {
const value = raw.trim();
if (/^item_[a-z0-9_]+$/i.test(value)) return;
throw new AgenticError(
"INVALID_INPUT",
`Invalid task item reference: ${raw}. Use an item_ id; a 1-based position is only valid inside the create/add call that mints the items.`,
);
}
function validateTaskGraph(board: TaskBoard): void {
const ids = new Set(board.items.map((item) => item.id));
for (const item of board.items) {
for (const dependency of item.dependsOn) {
validateItemId(dependency);
if (dependency === item.id) {
throw new AgenticError("INVALID_INPUT", `Task item ${item.id} cannot depend on itself.`);
}
if (!ids.has(dependency)) {
throw new AgenticError(
"NOT_FOUND",
`Task item ${item.id} depends on missing item ${dependency}.`,
);
}
}
}
const visiting = new Set<string>();
const visited = new Set<string>();
const byId = new Map(board.items.map((item) => [item.id, item]));
const visit = (id: string): void => {
if (visited.has(id)) return;
if (visiting.has(id)) {
throw new AgenticError("INVALID_INPUT", `Task dependency cycle detected at ${id}.`);
}
visiting.add(id);
for (const dependency of byId.get(id)?.dependsOn ?? []) visit(dependency);
visiting.delete(id);
visited.add(id);
};
for (const item of board.items) visit(item.id);
}
export class TaskBoardStore {
private readonly boardsRelative: string;
public constructor(private readonly storage: InternalStorage) {
this.boardsRelative = storage.relative("tasks");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.boardsRelative);
}
public async create(input: {
title: string;
objective?: string;
items?: NewTaskItem[];
}): Promise<TaskBoard> {
await this.initialize();
const now = new Date().toISOString();
const board: TaskBoard = {
version: 1,
id: createId("todo"),
title: cleanText(input.title, "Task board title", 300),
...(input.objective?.trim()
? { objective: cleanText(input.objective, "Task board objective", 10_000) }
: {}),
status: "active",
createdAt: now,
updatedAt: now,
items: [],
notes: [],
decisions: [],
checkpoints: [],
};
board.items = this.makeItems(input.items ?? []);
validateTaskGraph(board);
await this.storage.ensureDirectory(this.boardRelative(board.id));
await this.save(board);
return board;
}
public async load(id: string): Promise<TaskBoard> {
validateBoardId(id);
try {
const board = await this.storage.readJson<TaskBoard>(this.stateRelative(id));
if (board.version !== 1 || board.id !== id || !Array.isArray(board.items)) {
throw new AgenticError("INTERNAL", `Task board state is invalid: ${id}`);
}
return board;
} catch (error) {
if (error instanceof AgenticError && error.code === "NOT_FOUND") {
throw new AgenticError("NOT_FOUND", `Task board not found: ${id}`);
}
throw error;
}
}
public async save(board: TaskBoard): Promise<void> {
validateBoardId(board.id);
await this.withLock(board.id, async () => {
board.updatedAt = new Date().toISOString();
await this.storage.writeJson(this.stateRelative(board.id), board);
});
}
public async list(limit = 30, includeArchived = false): Promise<TaskBoard[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.boardsRelative, {
withFileTypes: true,
});
const boards: TaskBoard[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("todo_")) continue;
try {
const board = await this.load(entry.name);
if (includeArchived || board.status !== "archived") boards.push(board);
} catch {
// Keep corrupt/incomplete storage inspectable without breaking all boards.
}
}
return boards
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public async addItems(id: string, items: NewTaskItem[]): Promise<TaskBoard> {
if (!Array.isArray(items) || items.length === 0) {
throw new AgenticError("INVALID_INPUT", "At least one task item is required.");
}
if (items.length > 100) {
throw new AgenticError("INVALID_INPUT", "At most 100 task items may be added at once.");
}
return await this.mutate(id, (board) => {
if (board.items.length + items.length > 1000) {
throw new AgenticError("INVALID_INPUT", "A task board may contain at most 1,000 items.");
}
board.items.push(...this.makeItems(items));
validateTaskGraph(board);
board.status = "active";
delete board.completedAt;
});
}
public async updateItem(
id: string,
itemId: string,
patch: {
text?: string;
status?: TaskItemStatus;
priority?: TaskPriority;
details?: string;
dependsOn?: string[];
evidence?: string[];
},
): Promise<TaskBoard> {
validateItemId(itemId);
return await this.mutate(id, (board) => {
const item = board.items.find((candidate) => candidate.id === itemId);
if (!item) throw new AgenticError("NOT_FOUND", `Task item not found: ${itemId}`);
if (patch.text !== undefined) item.text = cleanText(patch.text, "Task item text", 2000);
if (patch.status !== undefined) {
item.status = patch.status;
if (patch.status === "completed") item.completedAt = new Date().toISOString();
else delete item.completedAt;
}
if (patch.priority !== undefined) item.priority = patch.priority;
if (patch.details !== undefined) {
const details = patch.details.trim();
if (details.length > 10_000) {
throw new AgenticError("INVALID_INPUT", "Task item details are limited to 10,000 characters.");
}
if (details) item.details = details;
else delete item.details;
}
if (patch.dependsOn !== undefined) {
const dependencies = unique(patch.dependsOn.map((entry) => entry.trim()));
for (const dependency of dependencies) validateDependencyReference(dependency);
if (dependencies.includes(itemId)) {
throw new AgenticError("INVALID_INPUT", "A task item cannot depend on itself.");
}
item.dependsOn = dependencies;
validateTaskGraph(board);
}
if (patch.evidence !== undefined) {
item.evidence = unique(
patch.evidence.map((entry) => entry.trim()).filter(Boolean),
)
.slice(0, 50)
.map((entry) => entry.slice(0, 2000));
}
item.updatedAt = new Date().toISOString();
this.refreshBoardCompletion(board);
});
}
public async removeItem(id: string, itemId: string): Promise<TaskBoard> {
validateItemId(itemId);
return await this.mutate(id, (board) => {
const before = board.items.length;
board.items = board.items.filter((item) => item.id !== itemId);
if (board.items.length === before) {
throw new AgenticError("NOT_FOUND", `Task item not found: ${itemId}`);
}
for (const item of board.items) {
item.dependsOn = item.dependsOn.filter((dependency) => dependency !== itemId);
}
this.refreshBoardCompletion(board);
});
}
public async addNote(
id: string,
input: { note?: string; decision?: string },
): Promise<TaskBoard> {
if (!input.note?.trim() && !input.decision?.trim()) {
throw new AgenticError("INVALID_INPUT", "Provide a note or decision.");
}
return await this.mutate(id, (board) => {
if (input.note?.trim()) {
board.notes = unique([...board.notes, cleanText(input.note, "Note", 4000)]).slice(-100);
}
if (input.decision?.trim()) {
board.decisions = unique([
...board.decisions,
cleanText(input.decision, "Decision", 4000),
]).slice(-100);
}
});
}
public async checkpoint(
id: string,
input: {
summary: string;
decisions?: string[];
blockers?: string[];
next?: string[];
},
): Promise<TaskBoard> {
return await this.mutate(id, (board) => {
board.checkpoints = [
...board.checkpoints,
{
id: createId("checkpoint"),
createdAt: new Date().toISOString(),
summary: cleanText(input.summary, "Checkpoint summary", 8000),
decisions: (input.decisions ?? []).slice(0, 30).map((item) => item.slice(0, 2000)),
blockers: (input.blockers ?? []).slice(0, 30).map((item) => item.slice(0, 2000)),
next: (input.next ?? []).slice(0, 30).map((item) => item.slice(0, 2000)),
},
].slice(-50);
});
}
public async archive(id: string): Promise<TaskBoard> {
return await this.mutate(id, (board) => {
board.status = "archived";
board.archivedAt = new Date().toISOString();
});
}
public async reopen(id: string): Promise<TaskBoard> {
return await this.mutate(id, (board) => {
board.status = "active";
delete board.archivedAt;
delete board.completedAt;
});
}
public nextActionable(board: TaskBoard): TaskItem[] {
const completed = new Set(
board.items.filter((item) => item.status === "completed").map((item) => item.id),
);
const rank: Record<TaskPriority, number> = {
critical: 4,
high: 3,
normal: 2,
low: 1,
};
return board.items
.filter(
(item) =>
(item.status === "pending" || item.status === "in_progress") &&
item.dependsOn.every((dependency) => completed.has(dependency)),
)
.sort((a, b) => {
if (a.status !== b.status) return a.status === "in_progress" ? -1 : 1;
return rank[b.priority] - rank[a.priority] || a.createdAt.localeCompare(b.createdAt);
});
}
public stateRelativePath(id: string): string {
validateBoardId(id);
return this.stateRelative(id);
}
private makeItems(items: NewTaskItem[]): TaskItem[] {
if (items.length > 200) {
throw new AgenticError("INVALID_INPUT", "A task board may contain at most 200 initial items.");
}
const now = new Date().toISOString();
// Ids are minted before the map so a positional dependency can name any
// item of this batch, including one declared after it.
const batchIds = items.map(() => createId("item"));
return items.map((input, index) => ({
id: batchIds[index],
text: cleanText(input.text, "Task item text", 2000),
status: input.status ?? "pending",
priority: input.priority ?? "normal",
...(input.details?.trim()
? { details: cleanText(input.details, "Task item details", 10_000) }
: {}),
dependsOn: unique(
(input.dependsOn ?? []).map((dependency) =>
resolveDependency(dependency, index, batchIds),
),
),
evidence: [],
createdAt: now,
updatedAt: now,
...(input.status === "completed" ? { completedAt: now } : {}),
}));
}
private async mutate(
id: string,
action: (board: TaskBoard) => void,
): Promise<TaskBoard> {
validateBoardId(id);
return await this.withLock(id, async () => {
const board = await this.load(id);
action(board);
board.updatedAt = new Date().toISOString();
await this.storage.writeJson(this.stateRelative(id), board);
return board;
});
}
private refreshBoardCompletion(board: TaskBoard): void {
const live = board.items.filter((item) => item.status !== "canceled");
if (live.length > 0 && live.every((item) => item.status === "completed")) {
board.status = "completed";
board.completedAt = new Date().toISOString();
} else if (board.status !== "archived") {
board.status = "active";
delete board.completedAt;
}
}
private async withLock<T>(id: string, action: () => Promise<T>): Promise<T> {
validateBoardId(id);
const key = `${this.storage.boundary.realRoot}\0${id}`;
const previous = taskBoardLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
taskBoardLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (taskBoardLocks.get(key) === queued) taskBoardLocks.delete(key);
}
}
private boardRelative(id: string): string {
validateBoardId(id);
return `${this.boardsRelative}/${id}`;
}
private stateRelative(id: string): string {
return `${this.boardRelative(id)}/state.json`;
}
}
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
export type TaskItemStatus =
| "pending"
| "in_progress"
| "completed"
| "blocked"
| "canceled";
export type TaskPriority = "low" | "normal" | "high" | "critical";
export type TaskBoardStatus = "active" | "completed" | "archived";
export interface TaskItem {
id: string;
text: string;
status: TaskItemStatus;
priority: TaskPriority;
details?: string;
dependsOn: string[];
evidence: string[];
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export interface TaskCheckpoint {
id: string;
createdAt: string;
summary: string;
decisions: string[];
blockers: string[];
next: string[];
}
export interface TaskBoard {
version: 1;
id: string;
title: string;
objective?: string;
status: TaskBoardStatus;
createdAt: string;
updatedAt: string;
completedAt?: string;
archivedAt?: string;
items: TaskItem[];
notes: string[];
decisions: string[];
checkpoints: TaskCheckpoint[];
}
export interface NewTaskItem {
text: string;
status?: TaskItemStatus;
priority?: TaskPriority;
details?: string;
dependsOn?: string[];
}
const taskBoardLocks = new Map<string, Promise<void>>();
function validateBoardId(id: string): void {
if (!/^todo_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid task board id: ${id}`);
}
}
function validateItemId(id: string): void {
if (!/^item_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid task item id: ${id}`);
}
}
function cleanText(value: string, field: string, limit: number): string {
const text = value.trim();
if (!text) throw new AgenticError("INVALID_INPUT", `${field} may not be empty.`);
if (text.length > limit) {
throw new AgenticError("INVALID_INPUT", `${field} is limited to ${limit} characters.`);
}
return text;
}
function unique(values: string[]): string[] {
return [...new Set(values)];
}
/**
* Resolves one `dependsOn` entry written against a batch of items that is being
* minted right now (`create`, or one `add` call).
*
* At create time no `item_` id exists yet, so a model has no way to express a
* dependency at all unless positions are accepted: a live 27B run wrote
* `depends_on: ["1"]` twice and burned a round on `INVALID_INPUT: Invalid task
* item id: 1` both times. A bare 1-based decimal therefore names the n-th item
* of this same call and resolves to the id minted for it; anything else must be
* a real `item_` id. Forward references are legal — they describe a DAG, not a
* cycle — and `validateTaskGraph` still rejects real cycles once resolved.
*/
function resolveDependency(raw: string, index: number, batchIds: string[]): string {
const value = raw.trim();
if (/^\d+$/.test(value)) {
const position = Number(value);
if (position < 1 || position > batchIds.length) {
throw new AgenticError(
"INVALID_INPUT",
`Task item ${index + 1} depends on position ${position}, but this call has ${
batchIds.length
} item(s); use 1-${batchIds.length} or an item_ id.`,
);
}
if (position === index + 1) {
throw new AgenticError(
"INVALID_INPUT",
`Task item ${position} cannot depend on itself.`,
);
}
return batchIds[position - 1];
}
if (!/^item_[a-z0-9_]+$/i.test(value)) {
throw new AgenticError(
"INVALID_INPUT",
`Invalid task item reference: ${raw}. Use an item_ id or a 1-based position within this call.`,
);
}
return value;
}
/**
* Validates one `dependsOn` entry outside a minting batch (`update`), where a
* position has nothing to index into. Routing these through `validateItemId`
* answered `depends_on: ["1"]` with `Invalid task item id: 1` — the exact
* message positional support was added to kill, which reads as "positions are
* not supported" rather than "not here". Positions genuinely cannot work in
* `update`, so this is the message, not the rule.
*/
function validateDependencyReference(raw: string): void {
const value = raw.trim();
if (/^item_[a-z0-9_]+$/i.test(value)) return;
throw new AgenticError(
"INVALID_INPUT",
`Invalid task item reference: ${raw}. Use an item_ id; a 1-based position is only valid inside the create/add call that mints the items.`,
);
}
function validateTaskGraph(board: TaskBoard): void {
const ids = new Set(board.items.map((item) => item.id));
for (const item of board.items) {
for (const dependency of item.dependsOn) {
validateItemId(dependency);
if (dependency === item.id) {
throw new AgenticError("INVALID_INPUT", `Task item ${item.id} cannot depend on itself.`);
}
if (!ids.has(dependency)) {
throw new AgenticError(
"NOT_FOUND",
`Task item ${item.id} depends on missing item ${dependency}.`,
);
}
}
}
const visiting = new Set<string>();
const visited = new Set<string>();
const byId = new Map(board.items.map((item) => [item.id, item]));
const visit = (id: string): void => {
if (visited.has(id)) return;
if (visiting.has(id)) {
throw new AgenticError("INVALID_INPUT", `Task dependency cycle detected at ${id}.`);
}
visiting.add(id);
for (const dependency of byId.get(id)?.dependsOn ?? []) visit(dependency);
visiting.delete(id);
visited.add(id);
};
for (const item of board.items) visit(item.id);
}
export class TaskBoardStore {
private readonly boardsRelative: string;
public constructor(private readonly storage: InternalStorage) {
this.boardsRelative = storage.relative("tasks");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.boardsRelative);
}
public async create(input: {
title: string;
objective?: string;
items?: NewTaskItem[];
}): Promise<TaskBoard> {
await this.initialize();
const now = new Date().toISOString();
const board: TaskBoard = {
version: 1,
id: createId("todo"),
title: cleanText(input.title, "Task board title", 300),
...(input.objective?.trim()
? { objective: cleanText(input.objective, "Task board objective", 10_000) }
: {}),
status: "active",
createdAt: now,
updatedAt: now,
items: [],
notes: [],
decisions: [],
checkpoints: [],
};
board.items = this.makeItems(input.items ?? []);
validateTaskGraph(board);
await this.storage.ensureDirectory(this.boardRelative(board.id));
await this.save(board);
return board;
}
public async load(id: string): Promise<TaskBoard> {
validateBoardId(id);
try {
const board = await this.storage.readJson<TaskBoard>(this.stateRelative(id));
if (board.version !== 1 || board.id !== id || !Array.isArray(board.items)) {
throw new AgenticError("INTERNAL", `Task board state is invalid: ${id}`);
}
return board;
} catch (error) {
if (error instanceof AgenticError && error.code === "NOT_FOUND") {
throw new AgenticError("NOT_FOUND", `Task board not found: ${id}`);
}
throw error;
}
}
public async save(board: TaskBoard): Promise<void> {
validateBoardId(board.id);
await this.withLock(board.id, async () => {
board.updatedAt = new Date().toISOString();
await this.storage.writeJson(this.stateRelative(board.id), board);
});
}
public async list(limit = 30, includeArchived = false): Promise<TaskBoard[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.boardsRelative, {
withFileTypes: true,
});
const boards: TaskBoard[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("todo_")) continue;
try {
const board = await this.load(entry.name);
if (includeArchived || board.status !== "archived") boards.push(board);
} catch {
// Keep corrupt/incomplete storage inspectable without breaking all boards.
}
}
return boards
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public async addItems(id: string, items: NewTaskItem[]): Promise<TaskBoard> {
if (!Array.isArray(items) || items.length === 0) {
throw new AgenticError("INVALID_INPUT", "At least one task item is required.");
}
if (items.length > 100) {
throw new AgenticError("INVALID_INPUT", "At most 100 task items may be added at once.");
}
return await this.mutate(id, (board) => {
if (board.items.length + items.length > 1000) {
throw new AgenticError("INVALID_INPUT", "A task board may contain at most 1,000 items.");
}
board.items.push(...this.makeItems(items));
validateTaskGraph(board);
board.status = "active";
delete board.completedAt;
});
}
public async updateItem(
id: string,
itemId: string,
patch: {
text?: string;
status?: TaskItemStatus;
priority?: TaskPriority;
details?: string;
dependsOn?: string[];
evidence?: string[];
},
): Promise<TaskBoard> {
validateItemId(itemId);
return await this.mutate(id, (board) => {
const item = board.items.find((candidate) => candidate.id === itemId);
if (!item) throw new AgenticError("NOT_FOUND", `Task item not found: ${itemId}`);
if (patch.text !== undefined) item.text = cleanText(patch.text, "Task item text", 2000);
if (patch.status !== undefined) {
item.status = patch.status;
if (patch.status === "completed") item.completedAt = new Date().toISOString();
else delete item.completedAt;
}
if (patch.priority !== undefined) item.priority = patch.priority;
if (patch.details !== undefined) {
const details = patch.details.trim();
if (details.length > 10_000) {
throw new AgenticError("INVALID_INPUT", "Task item details are limited to 10,000 characters.");
}
if (details) item.details = details;
else delete item.details;
}
if (patch.dependsOn !== undefined) {
const dependencies = unique(patch.dependsOn.map((entry) => entry.trim()));
for (const dependency of dependencies) validateDependencyReference(dependency);
if (dependencies.includes(itemId)) {
throw new AgenticError("INVALID_INPUT", "A task item cannot depend on itself.");
}
item.dependsOn = dependencies;
validateTaskGraph(board);
}
if (patch.evidence !== undefined) {
item.evidence = unique(
patch.evidence.map((entry) => entry.trim()).filter(Boolean),
)
.slice(0, 50)
.map((entry) => entry.slice(0, 2000));
}
item.updatedAt = new Date().toISOString();
this.refreshBoardCompletion(board);
});
}
public async removeItem(id: string, itemId: string): Promise<TaskBoard> {
validateItemId(itemId);
return await this.mutate(id, (board) => {
const before = board.items.length;
board.items = board.items.filter((item) => item.id !== itemId);
if (board.items.length === before) {
throw new AgenticError("NOT_FOUND", `Task item not found: ${itemId}`);
}
for (const item of board.items) {
item.dependsOn = item.dependsOn.filter((dependency) => dependency !== itemId);
}
this.refreshBoardCompletion(board);
});
}
public async addNote(
id: string,
input: { note?: string; decision?: string },
): Promise<TaskBoard> {
if (!input.note?.trim() && !input.decision?.trim()) {
throw new AgenticError("INVALID_INPUT", "Provide a note or decision.");
}
return await this.mutate(id, (board) => {
if (input.note?.trim()) {
board.notes = unique([...board.notes, cleanText(input.note, "Note", 4000)]).slice(-100);
}
if (input.decision?.trim()) {
board.decisions = unique([
...board.decisions,
cleanText(input.decision, "Decision", 4000),
]).slice(-100);
}
});
}
public async checkpoint(
id: string,
input: {
summary: string;
decisions?: string[];
blockers?: string[];
next?: string[];
},
): Promise<TaskBoard> {
return await this.mutate(id, (board) => {
board.checkpoints = [
...board.checkpoints,
{
id: createId("checkpoint"),
createdAt: new Date().toISOString(),
summary: cleanText(input.summary, "Checkpoint summary", 8000),
decisions: (input.decisions ?? []).slice(0, 30).map((item) => item.slice(0, 2000)),
blockers: (input.blockers ?? []).slice(0, 30).map((item) => item.slice(0, 2000)),
next: (input.next ?? []).slice(0, 30).map((item) => item.slice(0, 2000)),
},
].slice(-50);
});
}
public async archive(id: string): Promise<TaskBoard> {
return await this.mutate(id, (board) => {
board.status = "archived";
board.archivedAt = new Date().toISOString();
});
}
public async reopen(id: string): Promise<TaskBoard> {
return await this.mutate(id, (board) => {
board.status = "active";
delete board.archivedAt;
delete board.completedAt;
});
}
public nextActionable(board: TaskBoard): TaskItem[] {
const completed = new Set(
board.items.filter((item) => item.status === "completed").map((item) => item.id),
);
const rank: Record<TaskPriority, number> = {
critical: 4,
high: 3,
normal: 2,
low: 1,
};
return board.items
.filter(
(item) =>
(item.status === "pending" || item.status === "in_progress") &&
item.dependsOn.every((dependency) => completed.has(dependency)),
)
.sort((a, b) => {
if (a.status !== b.status) return a.status === "in_progress" ? -1 : 1;
return rank[b.priority] - rank[a.priority] || a.createdAt.localeCompare(b.createdAt);
});
}
public stateRelativePath(id: string): string {
validateBoardId(id);
return this.stateRelative(id);
}
private makeItems(items: NewTaskItem[]): TaskItem[] {
if (items.length > 200) {
throw new AgenticError("INVALID_INPUT", "A task board may contain at most 200 initial items.");
}
const now = new Date().toISOString();
// Ids are minted before the map so a positional dependency can name any
// item of this batch, including one declared after it.
const batchIds = items.map(() => createId("item"));
return items.map((input, index) => ({
id: batchIds[index],
text: cleanText(input.text, "Task item text", 2000),
status: input.status ?? "pending",
priority: input.priority ?? "normal",
...(input.details?.trim()
? { details: cleanText(input.details, "Task item details", 10_000) }
: {}),
dependsOn: unique(
(input.dependsOn ?? []).map((dependency) =>
resolveDependency(dependency, index, batchIds),
),
),
evidence: [],
createdAt: now,
updatedAt: now,
...(input.status === "completed" ? { completedAt: now } : {}),
}));
}
private async mutate(
id: string,
action: (board: TaskBoard) => void,
): Promise<TaskBoard> {
validateBoardId(id);
return await this.withLock(id, async () => {
const board = await this.load(id);
action(board);
board.updatedAt = new Date().toISOString();
await this.storage.writeJson(this.stateRelative(id), board);
return board;
});
}
private refreshBoardCompletion(board: TaskBoard): void {
const live = board.items.filter((item) => item.status !== "canceled");
if (live.length > 0 && live.every((item) => item.status === "completed")) {
board.status = "completed";
board.completedAt = new Date().toISOString();
} else if (board.status !== "archived") {
board.status = "active";
delete board.completedAt;
}
}
private async withLock<T>(id: string, action: () => Promise<T>): Promise<T> {
validateBoardId(id);
const key = `${this.storage.boundary.realRoot}\0${id}`;
const previous = taskBoardLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
taskBoardLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (taskBoardLocks.get(key) === queued) taskBoardLocks.delete(key);
}
}
private boardRelative(id: string): string {
validateBoardId(id);
return `${this.boardsRelative}/${id}`;
}
private stateRelative(id: string): string {
return `${this.boardRelative(id)}/state.json`;
}
}