diff --git a/src/cli/commands/memory.ts b/src/cli/commands/memory.ts index eb11589..36d0597 100644 --- a/src/cli/commands/memory.ts +++ b/src/cli/commands/memory.ts @@ -2,7 +2,20 @@ import { input, select } from '@inquirer/prompts'; import { addMemory, searchMemory, listMemory } from '../../core/services/memoryService.js'; import type { Memory } from '../../core/schema.js'; -export async function memoryAdd(cwd: string, options: Partial = {}): Promise { +export interface MemoryAddOptions { + title?: string; + category?: Memory['category']; + content?: string; + /** Raw --task option value: single ID, comma-sep IDs, or already an array. */ + task?: string | string[]; + /** Pre-resolved array of task IDs (takes precedence over task). */ + relatedTasks?: string[]; + tokens?: number; + duration?: number; + by?: string; +} + +export async function memoryAdd(cwd: string, options: MemoryAddOptions = {}): Promise { const title = options.title ?? await input({ message: 'Memory title:' }); const category = options.category ?? await select({ message: 'Category:', @@ -16,7 +29,28 @@ export async function memoryAdd(cwd: string, options: Partial = {}): Pro }); const content = options.content ?? await input({ message: 'Content:' }); - const memory = addMemory(cwd, { title, category, content }); + // Resolve relatedTasks: explicit array wins; otherwise parse --task string. + const relatedTasks: string[] = + options.relatedTasks ?? + (options.task + ? (Array.isArray(options.task) + ? options.task + : options.task + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) + : []); + + const memory = addMemory(cwd, { + title, + category, + content, + relatedTasks, + tokens: options.tokens, + duration: options.duration, + by: options.by, + } as Partial); + console.log(`Memory saved as ${memory.id}.`); } diff --git a/src/cli/commands/task.ts b/src/cli/commands/task.ts index fbebd44..c272e7f 100644 --- a/src/cli/commands/task.ts +++ b/src/cli/commands/task.ts @@ -42,7 +42,15 @@ export function taskClaim(cwd: string, id: string, agentName: string): void { console.log(`${id} claimed by ${agentName}.`); } -export function taskDone(cwd: string, id: string): void { - doneTask(cwd, id); +export function taskDone( + cwd: string, + id: string, + meta?: { tokens?: number; duration?: number; by?: string }, +): void { + doneTask(cwd, id, { + doneBy: meta?.by, + doneTokens: meta?.tokens, + doneDuration: meta?.duration, + }); console.log(`${id} marked as done.`); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 96f14d3..6a9c488 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -108,15 +108,50 @@ export function createProgram(cwd: string): Command { .option('--title ', 'Title') .option('--category <category>', 'Category') .option('--content <content>', 'Content') + .option( + '--task <ids>', + 'Link to task IDs (comma-separated, e.g. TSK-0001 or TSK-0001,TSK-0002)', + ) + .option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)') + .option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)') + .option('--by <agent>', 'Agent that produced this result (optional)') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); + + // Parse CLI string values into the types the service expects. + const relatedTasks: string[] = options.task + ? (Array.isArray(options.task) + ? options.task + : options.task + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean)) + : []; + const tokens = options.tokens != null ? Number(options.tokens) : undefined; + const duration = options.duration != null ? Number(options.duration) : undefined; + if (serverUrl) { await runRemote(serverUrl, async () => { - const memory = await remoteClient.addMemory(serverUrl, options); + const memory = await remoteClient.addMemory(serverUrl, { + title: options.title as string | undefined, + category: options.category as string | undefined, + content: options.content as string | undefined, + relatedTasks, + tokens, + duration, + by: options.by as string | undefined, + } as Partial<import('../core/schema.js').Memory>); console.log(`Memory saved as ${memory.id}.`); }); } else { - await memoryAdd(projectCwd, options); + await memoryAdd(projectCwd, { + title: options.title as string | undefined, + content: options.content as string | undefined, + relatedTasks, + tokens, + duration, + by: options.by as string | undefined, + }); } }); memoryCmd @@ -220,15 +255,23 @@ export function createProgram(cwd: string): Command { taskCmd .command('done <id>') .description('Mark a task as done') - .action(async (id) => { + .option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)') + .option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)') + .option('--by <agent>', 'Agent that completed the task (optional)') + .action(async (id, options) => { + const meta = { + tokens: options.tokens != null ? Number(options.tokens) : undefined, + duration: options.duration != null ? Number(options.duration) : undefined, + by: options.by as string | undefined, + }; const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { - await remoteClient.doneTask(serverUrl, id); + await remoteClient.doneTask(serverUrl, id, meta); console.log(`${id} marked as done.`); }); } else { - taskDone(projectCwd, id); + taskDone(projectCwd, id, meta); } }); program.addCommand(taskCmd); diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index c66c153..47aa0c2 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -1,4 +1,4 @@ -import type { Task, Handoff, Decision, Memory } from '../core/schema.js'; +import type { Task, Handoff, Decision, Memory, ActivityItem } from '../core/schema.js'; import type { IndexEntry } from '../core/index.js'; export class RemoteError extends Error { @@ -58,8 +58,21 @@ export const remoteClient = { return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName }); }, - async doneTask(baseUrl: string, id: string): Promise<Task> { - return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'done' }); + async doneTask( + baseUrl: string, + id: string, + meta?: { tokens?: number; duration?: number; by?: string }, + ): Promise<Task> { + return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { + status: 'done', + doneTokens: meta?.tokens, + doneDuration: meta?.duration, + doneBy: meta?.by, + }); + }, + + async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> { + return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`); }, async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> { diff --git a/src/core/index.ts b/src/core/index.ts index d60669a..2b10c4b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -20,6 +20,10 @@ export interface IndexEntry { toRole?: string; fromAgent?: string; toAgent?: string; + // Handoff → task link + taskId?: string; + // Memory → task links (JSON array string, e.g. '["TSK-0001"]') + relatedTasks?: string; } export class Index { @@ -45,16 +49,18 @@ export class Index { fromRole TEXT, toRole TEXT, fromAgent TEXT, - toAgent TEXT + toAgent TEXT, + taskId TEXT, + relatedTasks TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content); `); - // Migration: add handoff-routing columns to existing DBs that pre-date this schema. + // Migration: add columns to existing DBs that pre-date this schema. const existingCols = new Set( (this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), ); - for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent']) { + for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { if (!existingCols.has(col)) { this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); } @@ -71,17 +77,20 @@ export class Index { toRole: null, fromAgent: null, toAgent: null, + taskId: null, + relatedTasks: null, ...entry, }; const insert = this.db.prepare(` - INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent) - VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent) + INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) + VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) ON CONFLICT(id) DO UPDATE SET type=@type, title=@title, content=@content, filePath=@filePath, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, assignedTo=@assignedTo, tags=@tags, - fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent + fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent, + taskId=@taskId, relatedTasks=@relatedTasks `); insert.run(params); @@ -129,6 +138,26 @@ export class Index { return stmt.all(params) as IndexEntry[]; } + /** Returns all handoff entries whose taskId matches the given task ID. */ + listHandoffsByTask(taskId: string): IndexEntry[] { + const stmt = this.db.prepare( + "SELECT * FROM entities WHERE type = 'handoff' AND taskId = @taskId ORDER BY createdAt ASC", + ); + return stmt.all({ taskId }) as IndexEntry[]; + } + + /** + * Returns all memory entries whose relatedTasks JSON array contains the given + * task ID. Uses a LIKE search on the serialised JSON string — safe because + * task IDs follow the form TSK-NNNN and cannot be substrings of each other. + */ + listMemoryByTask(taskId: string): IndexEntry[] { + const stmt = this.db.prepare( + "SELECT * FROM entities WHERE type = 'memory' AND relatedTasks LIKE @pattern ORDER BY createdAt ASC", + ); + return stmt.all({ pattern: `%${taskId}%` }) as IndexEntry[]; + } + close(): void { this.db.close(); } diff --git a/src/core/schema.ts b/src/core/schema.ts index fdc6262..38a87ee 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -21,6 +21,10 @@ export const TaskSchema = z.object({ tags: z.array(z.string()).default([]), acceptanceCriteria: z.array(z.string()).default([]), sourceHandoff: z.string().optional(), + // Optional completion metadata (set via `task done --tokens --duration --by`) + doneBy: z.string().optional(), + doneTokens: z.number().int().nonnegative().optional(), + doneDuration: z.number().int().nonnegative().optional(), }); export const HandoffSchema = z.object({ @@ -61,8 +65,23 @@ export const MemorySchema = z.object({ relatedDecisions: z.array(z.string()).default([]), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), + // Optional agent-supplied metadata (set via `memory add --tokens --duration --by`) + tokens: z.number().int().nonnegative().optional(), + duration: z.number().int().nonnegative().optional(), + by: z.string().optional(), }); +// Activity timeline item returned by GET /tasks/:id/activity +export const ActivityItemSchema = z.object({ + at: z.string().datetime(), + kind: z.enum(['created', 'handoff', 'result', 'status']), + actor: z.string(), + summary: z.string(), + meta: z.record(z.unknown()).optional(), +}); + +export type ActivityItem = z.infer<typeof ActivityItemSchema>; + export const StatusSchema = z.object({ generatedAt: z.string().datetime(), activeTasks: z.array(z.string()).default([]), diff --git a/src/core/services/activityService.ts b/src/core/services/activityService.ts new file mode 100644 index 0000000..b4f4773 --- /dev/null +++ b/src/core/services/activityService.ts @@ -0,0 +1,137 @@ +import { getTask } from './taskService.js'; +import { Index } from '../index.js'; +import { readEntity } from '../files.js'; +import { MemorySchema } from '../schema.js'; +import type { ActivityItem } from '../schema.js'; + +/** + * Build a time-sorted activity timeline for a single task. + * + * Sources (all assembled without rebuilding existing data): + * - Task file → "created" event + final status/assignee event + * - Handoffs → "handoff" events (filtered from SQLite by taskId) + * - Memory → "result" events (filtered from SQLite by relatedTasks) + * + * Each item: { at, kind, actor, summary, meta? } + * The meta object carries tokens/duration badges when the agent supplied them, + * and routing details (fromRole/toRole) for handoff items. + */ +export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] { + const { task } = getTask(cwd, taskId); + + const items: ActivityItem[] = []; + + // ── 1. Created event ───────────────────────────────────────────────────── + items.push({ + at: task.createdAt, + kind: 'created', + actor: task.role ?? 'user', + summary: 'Task created', + }); + + // ── 2. Handoff events ──────────────────────────────────────────────────── + const index = new Index(cwd); + const handoffEntries = index.listHandoffsByTask(taskId); + index.close(); + + for (const h of handoffEntries) { + const from = h.fromRole ?? '?'; + const to = h.toRole ?? '?'; + const meta: Record<string, unknown> = { + handoffId: h.id, + fromRole: h.fromRole, + toRole: h.toRole, + }; + if (h.fromAgent) meta.fromAgent = h.fromAgent; + if (h.toAgent) meta.toAgent = h.toAgent; + + items.push({ + at: h.createdAt, + kind: 'handoff', + actor: `${from} → ${to}`, + summary: h.title, + meta, + }); + } + + // ── 3. Memory / result events ──────────────────────────────────────────── + const memIndex = new Index(cwd); + const memEntries = memIndex.listMemoryByTask(taskId); + memIndex.close(); + + for (const entry of memEntries) { + // Read the file to get category, tokens, duration, by (not stored in index) + let category: string = 'technical'; + let tokens: number | undefined; + let duration: number | undefined; + let by: string | undefined; + + try { + const { frontmatter } = readEntity(entry.filePath); + const parsed = MemorySchema.safeParse(frontmatter); + if (parsed.success) { + category = parsed.data.category; + tokens = parsed.data.tokens; + duration = parsed.data.duration; + by = parsed.data.by; + } + } catch { + // File unreadable — use index-level data and defaults + } + + const meta: Record<string, unknown> = { memoryId: entry.id, category }; + if (tokens != null) meta.tokens = tokens; + if (duration != null) meta.duration = duration; + if (by != null) meta.by = by; + + items.push({ + at: entry.createdAt, + kind: 'result', + actor: category, + summary: entry.title, + meta, + }); + } + + // ── 4. Current status event (only when task has progressed past "open") ── + // We use updatedAt as the timestamp; this is a best-effort reconstruction + // since we don't yet have a persistent per-transition audit log. + if (task.status !== 'open') { + let summary: string; + switch (task.status) { + case 'in_progress': + summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed'; + break; + case 'review': + summary = 'Submitted for review'; + break; + case 'done': + summary = 'Task completed'; + break; + case 'cancelled': + summary = 'Task cancelled'; + break; + default: + summary = `Status: ${task.status}`; + } + + const meta: Record<string, unknown> = { status: task.status }; + if (task.assignedTo) meta.assignedTo = task.assignedTo; + if (task.doneBy) meta.by = task.doneBy; + if (task.doneTokens != null) meta.tokens = task.doneTokens; + if (task.doneDuration != null) meta.duration = task.doneDuration; + + items.push({ + at: task.updatedAt, + kind: 'status', + actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown', + summary, + meta, + }); + } + + // ── Sort by timestamp, ascending ────────────────────────────────────────── + items.sort((a, b) => a.at.localeCompare(b.at)); + + return items; +} diff --git a/src/core/services/handoffService.ts b/src/core/services/handoffService.ts index 8eb8c86..d4c6de4 100644 --- a/src/core/services/handoffService.ts +++ b/src/core/services/handoffService.ts @@ -38,6 +38,7 @@ export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Hand toRole: handoff.toRole, fromAgent: handoff.fromAgent, toAgent: handoff.toAgent, + taskId: handoff.taskId, }); index.close(); diff --git a/src/core/services/memoryService.ts b/src/core/services/memoryService.ts index b5952b1..7156112 100644 --- a/src/core/services/memoryService.ts +++ b/src/core/services/memoryService.ts @@ -18,6 +18,9 @@ export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory { relatedDecisions: options.relatedDecisions ?? [], createdAt: now, updatedAt: now, + tokens: options.tokens, + duration: options.duration, + by: options.by, }); const filePath = join(getEntityDir(cwd, 'memory'), `${memory.id}.md`); @@ -33,6 +36,7 @@ export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory { createdAt: memory.createdAt, updatedAt: memory.updatedAt, tags: JSON.stringify(memory.tags), + relatedTasks: JSON.stringify(memory.relatedTasks), }); index.close(); diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index 38ad63a..9fde0fd 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -59,8 +59,17 @@ export function claimTask(cwd: string, id: string, agentName: string): Task { return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName }); } -export function doneTask(cwd: string, id: string): Task { - return updateTask(cwd, id, { status: 'done' }); +export function doneTask( + cwd: string, + id: string, + meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number }, +): Task { + return updateTask(cwd, id, { + status: 'done', + doneBy: meta?.doneBy, + doneTokens: meta?.doneTokens, + doneDuration: meta?.doneDuration, + }); } export function reviewTask(cwd: string, id: string): Task { diff --git a/src/server/board.ts b/src/server/board.ts index dc2a9a0..90edd99 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -4,12 +4,10 @@ * Design constraints (MVP — maximum simplicity): * - One static HTML page: inline CSS + JS, no build step, no framework, no npm deps. * - Reads only the existing same-origin endpoints (`/tasks`, `/handoffs`, - * `/decisions`). It never mutates state — purely an observability surface. + * `/decisions`, `/tasks/:id/activity`). It never mutates state. * - Auto-refreshes on a small interval via `setInterval` + `fetch`. - * - * The column skeleton is rendered server-side so the markup (and its - * `data-column` markers) exist even before the client JS runs; JS only fills - * the cards. + * - Each task card is clickable: expanding it fetches and renders the + * per-task activity timeline (created → handoff → result → status). */ export interface BoardColumn { @@ -118,7 +116,10 @@ export function renderBoardHtml(): string { border-left: 3px solid var(--accent); border-radius: 8px; padding: 8px 10px; + cursor: pointer; + user-select: none; } + .card:hover { border-color: rgba(139,148,158,.5); } .column[data-column="open"] .card { border-left-color: var(--open); } .column[data-column="in_progress"] .card { border-left-color: var(--in_progress); } .column[data-column="review"] .card { border-left-color: var(--review); } @@ -136,6 +137,35 @@ export function renderBoardHtml(): string { .badge.agent { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); } .empty { color: var(--muted); font-size: 12px; padding: 4px 2px; } + /* ── Activity timeline ─────────────────────────────────────────────────── */ + .timeline { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border); + } + .card.expanded .timeline { display: block; } + .tl-row { + display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; + padding: 4px 0; border-top: 1px solid rgba(48,54,61,.5); font-size: 12px; + } + .tl-row:first-child { border-top: 0; } + .tl-when { color: var(--muted); font-size: 11px; white-space: nowrap; min-width: 52px; } + .tl-kind { + font-size: 10px; border-radius: 999px; padding: 1px 6px; + border: 1px solid transparent; white-space: nowrap; + } + .tl-kind-created { background: rgba(48,54,61,.6); color: var(--muted); border-color: var(--border); } + .tl-kind-handoff { background: rgba(88,166,255,.12); color: var(--accent); border-color: rgba(88,166,255,.25); } + .tl-kind-result { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); } + .tl-kind-status { background: rgba(210,153,34,.12); color: var(--review); border-color: rgba(210,153,34,.25); } + .tl-actor { color: var(--muted); font-size: 11px; white-space: nowrap; } + .tl-summary { flex: 1; min-width: 0; word-break: break-word; } + .tl-meta { display: flex; gap: 4px; flex-wrap: wrap; } + .badge.tl-tokens { background: rgba(163,113,247,.12); color: #a371f7; border-color: rgba(163,113,247,.25); } + .badge.tl-dur { background: rgba(255,166,87,.12); color: #ffa657; border-color: rgba(255,166,87,.25); } + .tl-loading, .tl-empty { color: var(--muted); font-size: 12px; padding: 2px 0; } + .panels { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 20px; @@ -199,19 +229,73 @@ ${columnSkeleton()} if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; } + function msToHuman(ms) { + if (ms < 1000) return ms + 'ms'; + if (ms < 60000) return Math.round(ms / 1000) + 's'; + if (ms < 3600000) return Math.round(ms / 60000) + 'm'; + return Math.round(ms / 3600000) + 'h'; + } async function getJSON(path) { var res = await fetch(path, { headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(path + ' -> ' + res.status); return res.json(); } + + // ── Activity timeline ────────────────────────────────────────────────── + function renderTimelineItems(items) { + if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>'; + return items.map(function(item) { + var meta = ''; + if (item.meta) { + if (item.meta.tokens != null) { + meta += '<span class="badge tl-tokens">' + esc(item.meta.tokens) + ' tok</span>'; + } + if (item.meta.duration != null) { + meta += '<span class="badge tl-dur">' + esc(msToHuman(item.meta.duration)) + '</span>'; + } + } + return '<div class="tl-row">' + + '<span class="tl-when">' + esc(ago(item.at)) + '</span>' + + '<span class="tl-kind tl-kind-' + esc(item.kind) + '">' + esc(item.kind) + '</span>' + + '<span class="tl-actor">' + esc(item.actor) + '</span>' + + '<span class="tl-summary">' + esc(item.summary) + '</span>' + + (meta ? '<span class="tl-meta">' + meta + '</span>' : '') + + '</div>'; + }).join(''); + } + + function loadTimeline(card, id) { + var tl = card.querySelector('.timeline'); + if (!tl) return; + tl.innerHTML = '<div class="tl-loading">loading…</div>'; + getJSON('/tasks/' + encodeURIComponent(id) + '/activity') + .then(function(items) { tl.innerHTML = renderTimelineItems(items); }) + .catch(function() { tl.innerHTML = '<div class="tl-empty">could not load activity</div>'; }); + } + + // Delegate click handling to the board container so it survives re-renders. + document.getElementById('board').addEventListener('click', function(e) { + var card = e.target.closest('.card[data-id]'); + if (!card) return; + var id = card.dataset.id; + var wasExpanded = card.classList.contains('expanded'); + card.classList.toggle('expanded'); + // Only fetch when opening (not on every toggle so cached content stays). + if (!wasExpanded) loadTimeline(card, id); + }); + + // ── Task card rendering ──────────────────────────────────────────────── function taskCard(t) { var tags = ''; if (t.role) tags += '<span class="badge role">' + esc(t.role) + '</span>'; if (t.assignedTo) tags += '<span class="badge agent">@' + esc(t.assignedTo) + '</span>'; - return '<div class="card">' + - '<div class="id">' + esc(t.id) + '</div>' + - '<div class="title">' + esc(t.title) + '</div>' + - (tags ? '<div class="tags">' + tags + '</div>' : '') + + return '<div class="card" data-id="' + esc(t.id) + '">' + + '<div class="card-head">' + + '<div class="id">' + esc(t.id) + '</div>' + + '<div class="title">' + esc(t.title) + '</div>' + + (tags ? '<div class="tags">' + tags + '</div>' : '') + + '</div>' + + '<div class="timeline"></div>' + '</div>'; } function renderBoard(tasks) { @@ -223,17 +307,29 @@ ${columnSkeleton()} }); COLUMNS.forEach(function (k) { var list = byCol[k]; - var cards = document.querySelector('[data-cards="' + k + '"]'); + var cardsEl = document.querySelector('[data-cards="' + k + '"]'); var count = document.querySelector('[data-count="' + k + '"]'); if (count) count.textContent = String(list.length); - if (!cards) return; - cards.innerHTML = list.length + if (!cardsEl) return; + + // Preserve expanded state: collect which IDs are expanded before re-render. + var expanded = {}; + cardsEl.querySelectorAll('.card.expanded[data-id]').forEach(function(c) { + expanded[c.dataset.id] = true; + }); + + cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">—</div>'; + + // Re-expand any cards that were open, but do NOT re-fetch; keep old content. + Object.keys(expanded).forEach(function(id) { + var card = cardsEl.querySelector('.card[data-id="' + id + '"]'); + if (card) card.classList.add('expanded'); + }); }); } function handoffRoute(h) { - // Build "fromRole[@fromAgent] → toRole[@toAgent]" label var from = esc(h.fromRole || '?'); var to = esc(h.toRole || '?'); if (h.fromAgent) from += '<span class="badge agent">@' + esc(h.fromAgent) + '</span>'; diff --git a/src/server/routes.ts b/src/server/routes.ts index f83c1da..cda6d2a 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyReply } from 'fastify'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask } from '../core/services/taskService.js'; +import { getTaskActivity } from '../core/services/activityService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; @@ -115,6 +116,15 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise } }); + app.get('/tasks/:id/activity', async (request, reply) => { + const { id } = request.params as { id: string }; + try { + return getTaskActivity(cwd, id); + } catch { + return notFound(reply, 'Task'); + } + }); + app.patch('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; const patch = request.body as Partial<Task>; @@ -126,7 +136,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise task = claimTask(cwd, id, patch.assignedTo); break; case 'done': - task = doneTask(cwd, id); + task = doneTask(cwd, id, { + doneBy: patch.doneBy as string | undefined, + doneTokens: patch.doneTokens as number | undefined, + doneDuration: patch.doneDuration as number | undefined, + }); break; case 'review': task = reviewTask(cwd, id); diff --git a/tests/activityService.test.ts b/tests/activityService.test.ts new file mode 100644 index 0000000..7901075 --- /dev/null +++ b/tests/activityService.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js'; +import { createHandoff } from '../src/core/services/handoffService.js'; +import { addMemory } from '../src/core/services/memoryService.js'; +import { getTaskActivity } from '../src/core/services/activityService.js'; + +describe('activityService', () => { + let cwd: string; + + beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-act-')); }); + afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); + + it('returns a "created" event for every task', () => { + const task = createTask(cwd, { title: 'Auth refactor', role: 'implementer' }); + const items = getTaskActivity(cwd, task.id); + expect(items.length).toBeGreaterThanOrEqual(1); + const created = items.find((i) => i.kind === 'created'); + expect(created).toBeDefined(); + expect(created!.at).toBe(task.createdAt); + expect(created!.summary).toBe('Task created'); + }); + + it('does not include a status event for an open task', () => { + const task = createTask(cwd, { title: 'Open task', role: 'architect' }); + const items = getTaskActivity(cwd, task.id); + expect(items.every((i) => i.kind !== 'status')).toBe(true); + }); + + it('includes a status event once a task is claimed', () => { + const task = createTask(cwd, { title: 'Claim me', role: 'implementer' }); + claimTask(cwd, task.id, 'codex'); + const items = getTaskActivity(cwd, task.id); + const status = items.find((i) => i.kind === 'status'); + expect(status).toBeDefined(); + expect(status!.summary).toBe('Claimed by codex'); + expect(status!.meta?.assignedTo).toBe('codex'); + }); + + it('includes a status event with tokens/duration when task is done with metadata', () => { + const task = createTask(cwd, { title: 'Finish me', role: 'implementer' }); + doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 }); + const items = getTaskActivity(cwd, task.id); + const status = items.find((i) => i.kind === 'status'); + expect(status).toBeDefined(); + expect(status!.summary).toBe('Task completed'); + expect(status!.meta?.tokens).toBe(8500); + expect(status!.meta?.duration).toBe(120_000); + expect(status!.meta?.by).toBe('claude'); + }); + + it('includes handoff events linked by taskId', () => { + const task = createTask(cwd, { title: 'Delegated task', role: 'architect' }); + createHandoff(cwd, { + fromRole: 'architect', + toRole: 'implementer', + fromAgent: 'claude', + toAgent: 'codex', + taskId: task.id, + summary: 'Design is ready', + }); + const items = getTaskActivity(cwd, task.id); + const handoff = items.find((i) => i.kind === 'handoff'); + expect(handoff).toBeDefined(); + expect(handoff!.summary).toBe('Design is ready'); + expect(handoff!.actor).toBe('architect → implementer'); + expect(handoff!.meta?.fromAgent).toBe('claude'); + expect(handoff!.meta?.toAgent).toBe('codex'); + }); + + it('does NOT include handoffs for other tasks', () => { + const taskA = createTask(cwd, { title: 'A', role: 'implementer' }); + const taskB = createTask(cwd, { title: 'B', role: 'architect' }); + createHandoff(cwd, { + fromRole: 'architect', + toRole: 'implementer', + taskId: taskB.id, + summary: 'For B only', + }); + const items = getTaskActivity(cwd, taskA.id); + expect(items.every((i) => i.kind !== 'handoff')).toBe(true); + }); + + it('includes memory/result events linked via relatedTasks', () => { + const task = createTask(cwd, { title: 'Build DNS cache', role: 'implementer' }); + addMemory(cwd, { + title: 'DNS TTL observation', + category: 'technical', + content: 'Default TTL is 300s', + relatedTasks: [task.id], + }); + const items = getTaskActivity(cwd, task.id); + const result = items.find((i) => i.kind === 'result'); + expect(result).toBeDefined(); + expect(result!.summary).toBe('DNS TTL observation'); + expect(result!.actor).toBe('technical'); + expect(result!.meta?.category).toBe('technical'); + }); + + it('surfaces tokens and duration from memory in the result meta', () => { + const task = createTask(cwd, { title: 'Perf tuning', role: 'implementer' }); + addMemory(cwd, { + title: 'Profiling results', + category: 'implementation', + content: '50ms p99', + relatedTasks: [task.id], + tokens: 2000, + duration: 45_000, + by: 'kimi', + }); + const items = getTaskActivity(cwd, task.id); + const result = items.find((i) => i.kind === 'result'); + expect(result!.meta?.tokens).toBe(2000); + expect(result!.meta?.duration).toBe(45_000); + expect(result!.meta?.by).toBe('kimi'); + }); + + it('does NOT include memory linked to other tasks', () => { + const taskA = createTask(cwd, { title: 'A', role: 'implementer' }); + const taskB = createTask(cwd, { title: 'B', role: 'implementer' }); + addMemory(cwd, { + title: 'About B', + category: 'lesson', + content: 'lesson', + relatedTasks: [taskB.id], + }); + const items = getTaskActivity(cwd, taskA.id); + expect(items.every((i) => i.kind !== 'result')).toBe(true); + }); + + it('returns items sorted by timestamp ascending', () => { + const task = createTask(cwd, { title: 'Sorted timeline', role: 'implementer' }); + createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: task.id, summary: 'H1' }); + addMemory(cwd, { title: 'M1', category: 'lesson', content: 'x', relatedTasks: [task.id] }); + claimTask(cwd, task.id, 'codex'); + + const items = getTaskActivity(cwd, task.id); + for (let i = 1; i < items.length; i++) { + expect(items[i].at >= items[i - 1].at).toBe(true); + } + }); + + it('throws when the task does not exist', () => { + expect(() => getTaskActivity(cwd, 'TSK-9999')).toThrow(); + }); +}); diff --git a/tests/handoffService.test.ts b/tests/handoffService.test.ts index 4eb1b5a..875c1ac 100644 --- a/tests/handoffService.test.ts +++ b/tests/handoffService.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { createHandoff, listHandoffs, getHandoff } from '../src/core/services/handoffService.js'; +import { Index } from '../src/core/index.js'; describe('handoffService', () => { let cwd: string; @@ -46,4 +47,25 @@ describe('handoffService', () => { expect(items[0].fromAgent).toBe('claude'); expect(items[0].toAgent).toBe('codex'); }); + + it('listHandoffsByTask returns only handoffs linked to that taskId', () => { + createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: 'TSK-0001', summary: 'For 0001' }); + createHandoff(cwd, { fromRole: 'reviewer', toRole: 'implementer', taskId: 'TSK-0002', summary: 'For 0002' }); + createHandoff(cwd, { fromRole: 'tester', toRole: 'implementer', summary: 'No task' }); + + const index = new Index(cwd); + const results = index.listHandoffsByTask('TSK-0001'); + index.close(); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('For 0001'); + }); + + it('listHandoffsByTask returns empty when no handoff links to the task', () => { + createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: 'TSK-0002', summary: 'Other' }); + const index = new Index(cwd); + const results = index.listHandoffsByTask('TSK-0001'); + index.close(); + expect(results).toHaveLength(0); + }); }); diff --git a/tests/memoryService.test.ts b/tests/memoryService.test.ts index 4bc08c7..37f9a83 100644 --- a/tests/memoryService.test.ts +++ b/tests/memoryService.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { addMemory, searchMemory, listMemory } from '../src/core/services/memoryService.js'; +import { Index } from '../src/core/index.js'; describe('memoryService', () => { let cwd: string; @@ -16,4 +17,46 @@ describe('memoryService', () => { expect(searchMemory(cwd, 'TTL')).toHaveLength(1); expect(listMemory(cwd)).toHaveLength(1); }); + + it('stores relatedTasks in the memory object', () => { + const m = addMemory(cwd, { + title: 'TTL lesson', + category: 'lesson', + content: 'Use short TTL', + relatedTasks: ['TSK-0001', 'TSK-0002'], + }); + expect(m.relatedTasks).toEqual(['TSK-0001', 'TSK-0002']); + }); + + it('stores optional tokens, duration, by fields', () => { + const m = addMemory(cwd, { + title: 'Perf stats', + category: 'implementation', + content: 'p99=50ms', + tokens: 1500, + duration: 30_000, + by: 'kimi', + }); + expect(m.tokens).toBe(1500); + expect(m.duration).toBe(30_000); + expect(m.by).toBe('kimi'); + }); + + it('listMemoryByTask returns entries linked to a given task', () => { + addMemory(cwd, { title: 'Related', category: 'technical', content: 'x', relatedTasks: ['TSK-0042'] }); + addMemory(cwd, { title: 'Unrelated', category: 'technical', content: 'y', relatedTasks: ['TSK-0099'] }); + const index = new Index(cwd); + const results = index.listMemoryByTask('TSK-0042'); + index.close(); + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Related'); + }); + + it('listMemoryByTask returns empty when no memory is linked', () => { + addMemory(cwd, { title: 'Other', category: 'technical', content: 'y', relatedTasks: ['TSK-0099'] }); + const index = new Index(cwd); + const results = index.listMemoryByTask('TSK-0001'); + index.close(); + expect(results).toHaveLength(0); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index a567190..92e3dac 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -121,6 +121,64 @@ describe('server routes', () => { expect(html).toContain('setInterval(refresh'); }); + // ── Activity timeline endpoint ───────────────────────────────────────── + it('GET /tasks/:id/activity returns 404 for unknown task', async () => { + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999/activity' }); + expect(res.statusCode).toBe(404); + }); + + it('GET /tasks/:id/activity returns a created event', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Auth', role: 'implementer' } }); + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' }); + expect(res.statusCode).toBe(200); + const items = JSON.parse(res.payload) as Array<{ kind: string; summary: string }>; + expect(items.some((i) => i.kind === 'created')).toBe(true); + }); + + it('GET /tasks/:id/activity includes handoff events linked by taskId', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Auth', role: 'implementer' } }); + await app.inject({ + method: 'POST', + url: '/handoffs', + payload: { fromRole: 'architect', toRole: 'implementer', taskId: 'TSK-0001', summary: 'Design done' }, + }); + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' }); + const items = JSON.parse(res.payload) as Array<{ kind: string; summary: string }>; + const handoff = items.find((i) => i.kind === 'handoff'); + expect(handoff).toBeDefined(); + expect(handoff!.summary).toBe('Design done'); + }); + + it('GET /tasks/:id/activity includes memory result events linked by relatedTasks', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'DNS task', role: 'implementer' } }); + await app.inject({ + method: 'POST', + url: '/memory', + payload: { title: 'TTL insight', category: 'technical', content: 'Use 300s', relatedTasks: ['TSK-0001'] }, + }); + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' }); + const items = JSON.parse(res.payload) as Array<{ kind: string; summary: string }>; + const result = items.find((i) => i.kind === 'result'); + expect(result).toBeDefined(); + expect(result!.summary).toBe('TTL insight'); + }); + + it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } }); + await app.inject({ + method: 'PATCH', + url: '/tasks/TSK-0001', + payload: { status: 'done', doneTokens: 5000, doneDuration: 60000, doneBy: 'claude' }, + }); + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' }); + const items = JSON.parse(res.payload) as Array<{ kind: string; meta?: Record<string, unknown> }>; + const status = items.find((i) => i.kind === 'status'); + expect(status).toBeDefined(); + expect(status!.meta?.tokens).toBe(5000); + expect(status!.meta?.duration).toBe(60000); + expect(status!.meta?.by).toBe('claude'); + }); + it('board HTML contains the who-arrow rendering logic', async () => { const res = await app.inject({ method: 'GET', url: '/board' }); const html = res.payload; @@ -131,6 +189,23 @@ describe('server routes', () => { expect(html).toContain('who-cell'); }); + it('board HTML contains activity timeline expansion logic', async () => { + const res = await app.inject({ method: 'GET', url: '/board' }); + const html = res.payload; + // Cards must be clickable and carry a data-id attribute + expect(html).toContain('data-id'); + // The timeline div must be present inside cards + expect(html).toContain('class="timeline"'); + // The activity fetch must call the /activity endpoint + expect(html).toContain('/activity'); + // The expansion toggle CSS class must exist + expect(html).toContain('expanded'); + // Token and duration badges must be defined + expect(html).toContain('tl-tokens'); + expect(html).toContain('tl-dur'); + expect(html).toContain('msToHuman'); + }); + it('GET /handoffs returns fromRole and toRole fields', async () => { await app.inject({ method: 'POST',