Implement @mosaic/brain — typed structured data service with MCP + REST API, JSON file backend, and schema validation via Zod. Collections: tasks, projects, events, agents, tickets, appreciations, missions, mission_tasks. MCP tools: brain_tasks, brain_projects, brain_events, brain_agents, brain_tickets, brain_today, brain_stale, brain_stats, brain_search, brain_audit, brain_missions, brain_mission, brain_mission_tasks, plus mutation tools for all collections. REST API mirrors MCP 1:1 at /v1/*. Bearer token auth with timing-safe comparison. Fastify server with per-request MCP instances (stateless HTTP transport). JSON file storage with proper-lockfile for concurrent access. Also adds Brain* types to @mosaic/types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
537 lines
21 KiB
TypeScript
537 lines
21 KiB
TypeScript
import type {
|
|
BrainTask, BrainProject, BrainEvent, BrainAgent,
|
|
BrainTicket, BrainAppreciation, BrainMission, BrainMissionTask,
|
|
BrainTaskFilters, BrainProjectFilters, BrainEventFilters,
|
|
BrainMissionFilters, BrainMissionTaskFilters,
|
|
BrainMissionSummary, BrainTodaySummary, BrainStats,
|
|
BrainStaleReport, BrainAuditResult, BrainSearchResult,
|
|
TaskPriority,
|
|
} from '@mosaic/types';
|
|
import { JsonStore } from './json-store.js';
|
|
|
|
function today(): string {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
interface TasksFile { version: string; tasks: BrainTask[] }
|
|
interface ProjectsFile { version: string; projects: BrainProject[] }
|
|
interface EventsFile { version: string; events: BrainEvent[] }
|
|
interface AgentsFile { version: string; agents: BrainAgent[] }
|
|
interface TicketsFile { version: string; tickets: BrainTicket[] }
|
|
interface AppreciationsFile { version: string; appreciations: BrainAppreciation[] }
|
|
interface MissionsFile { version: string; missions: BrainMission[] }
|
|
interface MissionTasksFile { version: string; mission_tasks: BrainMissionTask[] }
|
|
|
|
const DEFAULT_TASKS: TasksFile = { version: '2.0', tasks: [] };
|
|
const DEFAULT_PROJECTS: ProjectsFile = { version: '2.0', projects: [] };
|
|
const DEFAULT_EVENTS: EventsFile = { version: '2.0', events: [] };
|
|
const DEFAULT_AGENTS: AgentsFile = { version: '2.0', agents: [] };
|
|
const DEFAULT_TICKETS: TicketsFile = { version: '2.0', tickets: [] };
|
|
const DEFAULT_APPRECIATIONS: AppreciationsFile = { version: '2.0', appreciations: [] };
|
|
const DEFAULT_MISSIONS: MissionsFile = { version: '2.0', missions: [] };
|
|
const DEFAULT_MISSION_TASKS: MissionTasksFile = { version: '2.0', mission_tasks: [] };
|
|
|
|
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
|
|
function matchesFilter<T>(item: T, filters: object): boolean {
|
|
for (const [key, value] of Object.entries(filters as Record<string, unknown>)) {
|
|
if (value === undefined || value === null) continue;
|
|
if (key === 'limit') continue;
|
|
if (key === 'due_before') {
|
|
if (!(item as Record<string, unknown>)['due'] || (item as Record<string, unknown>)['due']! > value) return false;
|
|
continue;
|
|
}
|
|
if (key === 'due_after') {
|
|
if (!(item as Record<string, unknown>)['due'] || (item as Record<string, unknown>)['due']! < value) return false;
|
|
continue;
|
|
}
|
|
if (key === 'date_from') {
|
|
if ((item as Record<string, unknown>)['date']! < value) return false;
|
|
continue;
|
|
}
|
|
if (key === 'date_to') {
|
|
if ((item as Record<string, unknown>)['date']! > value) return false;
|
|
continue;
|
|
}
|
|
if (key === 'priority_min') {
|
|
if (((item as Record<string, unknown>)['priority'] as number) < (value as number)) return false;
|
|
continue;
|
|
}
|
|
if (key === 'priority_max') {
|
|
if (((item as Record<string, unknown>)['priority'] as number) > (value as number)) return false;
|
|
continue;
|
|
}
|
|
if ((item as Record<string, unknown>)[key] !== value) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function applyLimit<T>(items: T[], limit?: number): T[] {
|
|
return limit != null && limit > 0 ? items.slice(0, limit) : items;
|
|
}
|
|
|
|
export class Collections {
|
|
constructor(private readonly store: JsonStore) {}
|
|
|
|
// === Tasks ===
|
|
|
|
async getTasks(filters: BrainTaskFilters = {}): Promise<BrainTask[]> {
|
|
const file = await this.store.read('tasks', DEFAULT_TASKS);
|
|
const filtered = file.tasks.filter(t => matchesFilter(t, filters));
|
|
filtered.sort((a, b) =>
|
|
(PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9)
|
|
|| (a.due ?? '9999').localeCompare(b.due ?? '9999'),
|
|
);
|
|
return applyLimit(filtered, filters.limit);
|
|
}
|
|
|
|
async getTask(id: string): Promise<BrainTask | null> {
|
|
const file = await this.store.read('tasks', DEFAULT_TASKS);
|
|
return file.tasks.find(t => t.id === id) ?? null;
|
|
}
|
|
|
|
async addTask(task: BrainTask): Promise<BrainTask> {
|
|
const result = await this.store.modify('tasks', DEFAULT_TASKS, (file) => {
|
|
if (file.tasks.some(t => t.id === task.id)) {
|
|
throw new Error(`Task '${task.id}' already exists`);
|
|
}
|
|
return { ...file, tasks: [...file.tasks, task] };
|
|
});
|
|
return result.tasks.find(t => t.id === task.id)!;
|
|
}
|
|
|
|
async updateTask(id: string, updates: Partial<BrainTask>): Promise<BrainTask> {
|
|
let updated: BrainTask | undefined;
|
|
await this.store.modify('tasks', DEFAULT_TASKS, (file) => {
|
|
const idx = file.tasks.findIndex(t => t.id === id);
|
|
if (idx === -1) throw new Error(`Task '${id}' not found`);
|
|
const tasks = [...file.tasks];
|
|
updated = { ...tasks[idx]!, ...updates, id, updated: today() } as BrainTask;
|
|
tasks[idx] = updated;
|
|
return { ...file, tasks };
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
// === Projects ===
|
|
|
|
async getProjects(filters: BrainProjectFilters = {}): Promise<BrainProject[]> {
|
|
const file = await this.store.read('projects', DEFAULT_PROJECTS);
|
|
const filtered = file.projects.filter(p => matchesFilter(p, filters));
|
|
filtered.sort((a, b) => a.priority - b.priority);
|
|
return applyLimit(filtered, filters.limit);
|
|
}
|
|
|
|
async getProject(id: string): Promise<BrainProject | null> {
|
|
const file = await this.store.read('projects', DEFAULT_PROJECTS);
|
|
return file.projects.find(p => p.id === id) ?? null;
|
|
}
|
|
|
|
async addProject(project: BrainProject): Promise<BrainProject> {
|
|
const result = await this.store.modify('projects', DEFAULT_PROJECTS, (file) => {
|
|
if (file.projects.some(p => p.id === project.id)) {
|
|
throw new Error(`Project '${project.id}' already exists`);
|
|
}
|
|
return { ...file, projects: [...file.projects, project] };
|
|
});
|
|
return result.projects.find(p => p.id === project.id)!;
|
|
}
|
|
|
|
async updateProject(id: string, updates: Partial<BrainProject>): Promise<BrainProject> {
|
|
let updated: BrainProject | undefined;
|
|
await this.store.modify('projects', DEFAULT_PROJECTS, (file) => {
|
|
const idx = file.projects.findIndex(p => p.id === id);
|
|
if (idx === -1) throw new Error(`Project '${id}' not found`);
|
|
const projects = [...file.projects];
|
|
updated = { ...projects[idx]!, ...updates, id, updated: today() } as BrainProject;
|
|
projects[idx] = updated;
|
|
return { ...file, projects };
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
// === Events ===
|
|
|
|
async getEvents(filters: BrainEventFilters = {}): Promise<BrainEvent[]> {
|
|
const file = await this.store.read('events', DEFAULT_EVENTS);
|
|
const filtered = file.events.filter(e => matchesFilter(e, filters));
|
|
filtered.sort((a, b) => a.date.localeCompare(b.date) || (a.time ?? '').localeCompare(b.time ?? ''));
|
|
return applyLimit(filtered, filters.limit);
|
|
}
|
|
|
|
async getEvent(id: string): Promise<BrainEvent | null> {
|
|
const file = await this.store.read('events', DEFAULT_EVENTS);
|
|
return file.events.find(e => e.id === id) ?? null;
|
|
}
|
|
|
|
async addEvent(event: BrainEvent): Promise<BrainEvent> {
|
|
const result = await this.store.modify('events', DEFAULT_EVENTS, (file) => {
|
|
if (file.events.some(e => e.id === event.id)) {
|
|
throw new Error(`Event '${event.id}' already exists`);
|
|
}
|
|
return { ...file, events: [...file.events, event] };
|
|
});
|
|
return result.events.find(e => e.id === event.id)!;
|
|
}
|
|
|
|
async updateEvent(id: string, updates: Partial<BrainEvent>): Promise<BrainEvent> {
|
|
let updated: BrainEvent | undefined;
|
|
await this.store.modify('events', DEFAULT_EVENTS, (file) => {
|
|
const idx = file.events.findIndex(e => e.id === id);
|
|
if (idx === -1) throw new Error(`Event '${id}' not found`);
|
|
const events = [...file.events];
|
|
updated = { ...events[idx]!, ...updates, id } as BrainEvent;
|
|
events[idx] = updated;
|
|
return { ...file, events };
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
// === Agents ===
|
|
|
|
async getAgents(filters: { status?: string; project?: string } = {}): Promise<BrainAgent[]> {
|
|
const file = await this.store.read('agents', DEFAULT_AGENTS);
|
|
return file.agents.filter(a => matchesFilter(a, filters));
|
|
}
|
|
|
|
async updateAgent(id: string, updates: Partial<BrainAgent>): Promise<BrainAgent> {
|
|
let updated: BrainAgent | undefined;
|
|
await this.store.modify('agents', DEFAULT_AGENTS, (file) => {
|
|
const idx = file.agents.findIndex(a => a.id === id);
|
|
if (idx === -1) {
|
|
// Auto-create agent entries
|
|
updated = { id, project: '', status: 'active', updated: new Date().toISOString(), ...updates } as BrainAgent;
|
|
return { ...file, agents: [...file.agents, updated] };
|
|
}
|
|
const agents = [...file.agents];
|
|
updated = { ...agents[idx]!, ...updates, id, updated: new Date().toISOString() } as BrainAgent;
|
|
agents[idx] = updated;
|
|
return { ...file, agents };
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
// === Tickets ===
|
|
|
|
async getTickets(filters: { status?: number; priority?: number; limit?: number } = {}): Promise<BrainTicket[]> {
|
|
const file = await this.store.read('tickets', DEFAULT_TICKETS);
|
|
let filtered = file.tickets;
|
|
if (filters.status !== undefined) filtered = filtered.filter(t => t.status === filters.status);
|
|
if (filters.priority !== undefined) filtered = filtered.filter(t => t.priority === filters.priority);
|
|
return applyLimit(filtered, filters.limit);
|
|
}
|
|
|
|
// === Appreciations ===
|
|
|
|
async getAppreciations(): Promise<BrainAppreciation[]> {
|
|
const file = await this.store.read('appreciations', DEFAULT_APPRECIATIONS);
|
|
return file.appreciations;
|
|
}
|
|
|
|
async addAppreciation(appreciation: BrainAppreciation): Promise<BrainAppreciation> {
|
|
await this.store.modify('appreciations', DEFAULT_APPRECIATIONS, (file) => ({
|
|
...file,
|
|
appreciations: [...file.appreciations, appreciation],
|
|
}));
|
|
return appreciation;
|
|
}
|
|
|
|
// === Missions ===
|
|
|
|
async getMissions(filters: BrainMissionFilters = {}): Promise<BrainMission[]> {
|
|
const file = await this.store.read('missions', DEFAULT_MISSIONS);
|
|
const filtered = file.missions.filter(m => matchesFilter(m, filters));
|
|
return applyLimit(filtered, filters.limit);
|
|
}
|
|
|
|
async getMission(id: string): Promise<BrainMission | null> {
|
|
const file = await this.store.read('missions', DEFAULT_MISSIONS);
|
|
return file.missions.find(m => m.id === id) ?? null;
|
|
}
|
|
|
|
async addMission(mission: BrainMission): Promise<BrainMission> {
|
|
const result = await this.store.modify('missions', DEFAULT_MISSIONS, (file) => {
|
|
if (file.missions.some(m => m.id === mission.id)) {
|
|
throw new Error(`Mission '${mission.id}' already exists`);
|
|
}
|
|
return { ...file, missions: [...file.missions, mission] };
|
|
});
|
|
return result.missions.find(m => m.id === mission.id)!;
|
|
}
|
|
|
|
async updateMission(id: string, updates: Partial<BrainMission>): Promise<BrainMission> {
|
|
let updated: BrainMission | undefined;
|
|
await this.store.modify('missions', DEFAULT_MISSIONS, (file) => {
|
|
const idx = file.missions.findIndex(m => m.id === id);
|
|
if (idx === -1) throw new Error(`Mission '${id}' not found`);
|
|
const missions = [...file.missions];
|
|
updated = { ...missions[idx]!, ...updates, id, updated: today() } as BrainMission;
|
|
missions[idx] = updated;
|
|
return { ...file, missions };
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
async getMissionSummary(id: string): Promise<BrainMissionSummary | null> {
|
|
const mission = await this.getMission(id);
|
|
if (!mission) return null;
|
|
|
|
const tasks = await this.getMissionTasks({ mission_id: id });
|
|
const completed = tasks.filter(t => t.status === 'done');
|
|
const completedIds = new Set(completed.map(t => t.id));
|
|
|
|
const nextAvailable = tasks.filter(t =>
|
|
t.status !== 'done' && t.status !== 'cancelled' && t.status !== 'blocked'
|
|
&& t.dependencies.every(dep => completedIds.has(dep))
|
|
&& !t.assigned_to,
|
|
);
|
|
|
|
const blockedTasks = tasks.filter(t =>
|
|
t.status === 'blocked'
|
|
|| (t.status !== 'done' && t.status !== 'cancelled'
|
|
&& !t.dependencies.every(dep => completedIds.has(dep))),
|
|
);
|
|
|
|
return {
|
|
...mission,
|
|
task_count: tasks.length,
|
|
completed_count: completed.length,
|
|
progress: tasks.length > 0 ? Math.round((completed.length / tasks.length) * 100) : 0,
|
|
next_available: nextAvailable,
|
|
blocked_tasks: blockedTasks,
|
|
};
|
|
}
|
|
|
|
// === Mission Tasks ===
|
|
|
|
async getMissionTasks(filters: BrainMissionTaskFilters): Promise<BrainMissionTask[]> {
|
|
const file = await this.store.read('mission_tasks', DEFAULT_MISSION_TASKS);
|
|
const filtered = file.mission_tasks.filter(t => matchesFilter(t, filters));
|
|
filtered.sort((a, b) => a.order - b.order);
|
|
return filtered;
|
|
}
|
|
|
|
async addMissionTask(task: BrainMissionTask): Promise<BrainMissionTask> {
|
|
const result = await this.store.modify('mission_tasks', DEFAULT_MISSION_TASKS, (file) => {
|
|
if (file.mission_tasks.some(t => t.id === task.id && t.mission_id === task.mission_id)) {
|
|
throw new Error(`Mission task '${task.id}' already exists in mission '${task.mission_id}'`);
|
|
}
|
|
return { ...file, mission_tasks: [...file.mission_tasks, task] };
|
|
});
|
|
return result.mission_tasks.find(t => t.id === task.id && t.mission_id === task.mission_id)!;
|
|
}
|
|
|
|
async updateMissionTask(missionId: string, taskId: string, updates: Partial<BrainMissionTask>): Promise<BrainMissionTask> {
|
|
let updated: BrainMissionTask | undefined;
|
|
await this.store.modify('mission_tasks', DEFAULT_MISSION_TASKS, (file) => {
|
|
const idx = file.mission_tasks.findIndex(t => t.id === taskId && t.mission_id === missionId);
|
|
if (idx === -1) throw new Error(`Mission task '${taskId}' not found in mission '${missionId}'`);
|
|
const tasks = [...file.mission_tasks];
|
|
updated = { ...tasks[idx]!, ...updates, id: taskId, mission_id: missionId, updated: today() } as BrainMissionTask;
|
|
if (updates.status === 'done' && !updated.completed_at) {
|
|
updated = { ...updated, completed_at: new Date().toISOString() };
|
|
}
|
|
tasks[idx] = updated;
|
|
return { ...file, mission_tasks: tasks };
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
// === Computed: Today ===
|
|
|
|
async getToday(date?: string): Promise<BrainTodaySummary> {
|
|
const d = date ?? today();
|
|
const weekFromNow = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
|
const staleCutoff = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
|
|
|
|
const [tasks, projects, events, agents, missions] = await Promise.all([
|
|
this.getTasks(),
|
|
this.getProjects(),
|
|
this.getEvents(),
|
|
this.getAgents(),
|
|
this.getMissions({ status: 'active' }),
|
|
]);
|
|
|
|
const activeTasks = tasks.filter(t => t.status !== 'done' && t.status !== 'cancelled');
|
|
const eventsToday = events.filter(e => e.date === d);
|
|
const eventsUpcoming = events.filter(e => e.date > d && e.date <= weekFromNow);
|
|
const tasksNearTerm = activeTasks.filter(t => t.due && t.due >= d && t.due <= weekFromNow);
|
|
const tasksBlocked = activeTasks.filter(t => t.status === 'blocked');
|
|
const tasksStale = activeTasks.filter(t => t.status === 'in-progress' && t.updated < staleCutoff);
|
|
const tasksAlmostDone = activeTasks.filter(t => t.progress != null && t.progress >= 80);
|
|
|
|
const missionSummaries: BrainMissionSummary[] = [];
|
|
for (const m of missions) {
|
|
const summary = await this.getMissionSummary(m.id);
|
|
if (summary) missionSummaries.push(summary);
|
|
}
|
|
|
|
const tasksByStatus: Record<string, number> = {};
|
|
const tasksByDomain: Record<string, number> = {};
|
|
for (const t of tasks) {
|
|
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
|
|
tasksByDomain[t.domain] = (tasksByDomain[t.domain] ?? 0) + 1;
|
|
}
|
|
const projectsByStatus: Record<string, number> = {};
|
|
for (const p of projects) {
|
|
projectsByStatus[p.status] = (projectsByStatus[p.status] ?? 0) + 1;
|
|
}
|
|
|
|
return {
|
|
date: d,
|
|
events_today: eventsToday,
|
|
events_upcoming: eventsUpcoming,
|
|
tasks_near_term: tasksNearTerm,
|
|
tasks_blocked: tasksBlocked,
|
|
tasks_stale: tasksStale,
|
|
tasks_almost_done: tasksAlmostDone,
|
|
active_missions: missionSummaries,
|
|
stats: {
|
|
tasks: tasks.length,
|
|
projects: projects.length,
|
|
events: events.length,
|
|
agents: agents.length,
|
|
tickets: (await this.getTickets()).length,
|
|
missions: (await this.getMissions()).length,
|
|
tasks_by_status: tasksByStatus,
|
|
tasks_by_domain: tasksByDomain,
|
|
projects_by_status: projectsByStatus,
|
|
},
|
|
};
|
|
}
|
|
|
|
// === Computed: Stale ===
|
|
|
|
async getStale(days: number = 7): Promise<BrainStaleReport> {
|
|
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
|
const tasks = await this.getTasks();
|
|
const projects = await this.getProjects();
|
|
|
|
return {
|
|
days,
|
|
tasks: tasks.filter(t => t.status === 'in-progress' && t.updated < cutoff),
|
|
projects: projects.filter(p => p.status === 'active' && p.updated < cutoff),
|
|
};
|
|
}
|
|
|
|
// === Computed: Stats ===
|
|
|
|
async getStats(): Promise<BrainStats> {
|
|
const [tasks, projects, events, agents, tickets, missions] = await Promise.all([
|
|
this.getTasks(),
|
|
this.getProjects(),
|
|
this.getEvents(),
|
|
this.getAgents(),
|
|
this.getTickets(),
|
|
this.getMissions(),
|
|
]);
|
|
|
|
const tasksByStatus: Record<string, number> = {};
|
|
const tasksByDomain: Record<string, number> = {};
|
|
for (const t of tasks) {
|
|
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
|
|
tasksByDomain[t.domain] = (tasksByDomain[t.domain] ?? 0) + 1;
|
|
}
|
|
const projectsByStatus: Record<string, number> = {};
|
|
for (const p of projects) {
|
|
projectsByStatus[p.status] = (projectsByStatus[p.status] ?? 0) + 1;
|
|
}
|
|
|
|
return {
|
|
tasks: tasks.length,
|
|
projects: projects.length,
|
|
events: events.length,
|
|
agents: agents.length,
|
|
tickets: tickets.length,
|
|
missions: missions.length,
|
|
tasks_by_status: tasksByStatus,
|
|
tasks_by_domain: tasksByDomain,
|
|
projects_by_status: projectsByStatus,
|
|
};
|
|
}
|
|
|
|
// === Computed: Search ===
|
|
|
|
async search(query: string, collection?: string): Promise<BrainSearchResult[]> {
|
|
const q = query.toLowerCase();
|
|
const results: BrainSearchResult[] = [];
|
|
|
|
const searchIn = (items: Array<{ id: string; title: string; notes?: string | null }>, collectionName: string) => {
|
|
for (const item of items) {
|
|
const titleMatch = item.title.toLowerCase().includes(q);
|
|
const notesMatch = item.notes?.toLowerCase().includes(q);
|
|
if (titleMatch || notesMatch) {
|
|
results.push({
|
|
collection: collectionName,
|
|
id: item.id,
|
|
title: item.title,
|
|
match_context: titleMatch ? item.title : (item.notes?.substring(0, 200) ?? ''),
|
|
score: titleMatch ? 1.0 : 0.5,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
if (!collection || collection === 'tasks') searchIn(await this.getTasks(), 'tasks');
|
|
if (!collection || collection === 'projects') {
|
|
const projects = await this.getProjects();
|
|
for (const p of projects) {
|
|
const titleMatch = p.name.toLowerCase().includes(q);
|
|
const notesMatch = p.notes?.toLowerCase().includes(q);
|
|
if (titleMatch || notesMatch) {
|
|
results.push({
|
|
collection: 'projects',
|
|
id: p.id,
|
|
title: p.name,
|
|
match_context: titleMatch ? p.name : (p.notes?.substring(0, 200) ?? ''),
|
|
score: titleMatch ? 1.0 : 0.5,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (!collection || collection === 'events') searchIn(await this.getEvents(), 'events');
|
|
if (!collection || collection === 'missions') searchIn(await this.getMissions(), 'missions');
|
|
|
|
results.sort((a, b) => b.score - a.score);
|
|
return results;
|
|
}
|
|
|
|
// === Computed: Audit ===
|
|
|
|
async audit(): Promise<BrainAuditResult> {
|
|
const tasks = await this.getTasks();
|
|
const projects = await this.getProjects();
|
|
const taskIds = new Set(tasks.map(t => t.id));
|
|
const projectIds = new Set(projects.map(p => p.id));
|
|
const allIds = new Set([...taskIds, ...projectIds]);
|
|
|
|
const orphanRefs: string[] = [];
|
|
const brokenDependencies: string[] = [];
|
|
const missingRequiredFields: string[] = [];
|
|
const duplicateIds: string[] = [];
|
|
|
|
// Check for duplicate task IDs
|
|
const seenTaskIds = new Set<string>();
|
|
for (const t of tasks) {
|
|
if (seenTaskIds.has(t.id)) duplicateIds.push(`task:${t.id}`);
|
|
seenTaskIds.add(t.id);
|
|
}
|
|
|
|
// Check task references
|
|
for (const t of tasks) {
|
|
if (t.project && !projectIds.has(t.project)) {
|
|
orphanRefs.push(`task:${t.id} -> project:${t.project}`);
|
|
}
|
|
for (const dep of t.blocked_by ?? []) {
|
|
if (!allIds.has(dep)) brokenDependencies.push(`task:${t.id} blocked_by:${dep}`);
|
|
}
|
|
for (const dep of t.blocks ?? []) {
|
|
if (!allIds.has(dep)) brokenDependencies.push(`task:${t.id} blocks:${dep}`);
|
|
}
|
|
if (!t.title) missingRequiredFields.push(`task:${t.id} missing title`);
|
|
}
|
|
|
|
return { orphan_refs: orphanRefs, broken_dependencies: brokenDependencies, missing_required_fields: missingRequiredFields, duplicate_ids: duplicateIds };
|
|
}
|
|
}
|