feat(activity): per-task timeline endpoint, board expand, --task/--tokens/--duration/--by flags

GET /tasks/:id/activity returns a time-sorted ActivityItem[] assembled from existing data (task created, handoffs by taskId, memory by relatedTasks, current status). memory add + task done gain optional tokens/duration/by metadata surfaced in the timeline. Board cards expand inline to show the timeline. Index gains taskId + relatedTasks columns with migrations. 74 -> 97 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-26 14:49:47 +02:00
parent 4e5e5ae6bb
commit 9c749beacb
16 changed files with 729 additions and 34 deletions

View File

@ -2,7 +2,20 @@ import { input, select } from '@inquirer/prompts';
import { addMemory, searchMemory, listMemory } from '../../core/services/memoryService.js'; import { addMemory, searchMemory, listMemory } from '../../core/services/memoryService.js';
import type { Memory } from '../../core/schema.js'; import type { Memory } from '../../core/schema.js';
export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Promise<void> { 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<void> {
const title = options.title ?? await input({ message: 'Memory title:' }); const title = options.title ?? await input({ message: 'Memory title:' });
const category = options.category ?? await select({ const category = options.category ?? await select({
message: 'Category:', message: 'Category:',
@ -16,7 +29,28 @@ export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Pro
}); });
const content = options.content ?? await input({ message: 'Content:' }); 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<Memory>);
console.log(`Memory saved as ${memory.id}.`); console.log(`Memory saved as ${memory.id}.`);
} }

View File

@ -42,7 +42,15 @@ export function taskClaim(cwd: string, id: string, agentName: string): void {
console.log(`${id} claimed by ${agentName}.`); console.log(`${id} claimed by ${agentName}.`);
} }
export function taskDone(cwd: string, id: string): void { export function taskDone(
doneTask(cwd, id); 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.`); console.log(`${id} marked as done.`);
} }

View File

@ -108,15 +108,50 @@ export function createProgram(cwd: string): Command {
.option('--title <title>', 'Title') .option('--title <title>', 'Title')
.option('--category <category>', 'Category') .option('--category <category>', 'Category')
.option('--content <content>', 'Content') .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) => { .action(async (options) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd); 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) { if (serverUrl) {
await runRemote(serverUrl, async () => { 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}.`); console.log(`Memory saved as ${memory.id}.`);
}); });
} else { } 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 memoryCmd
@ -220,15 +255,23 @@ export function createProgram(cwd: string): Command {
taskCmd taskCmd
.command('done <id>') .command('done <id>')
.description('Mark a task as done') .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); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) { if (serverUrl) {
await runRemote(serverUrl, async () => { await runRemote(serverUrl, async () => {
await remoteClient.doneTask(serverUrl, id); await remoteClient.doneTask(serverUrl, id, meta);
console.log(`${id} marked as done.`); console.log(`${id} marked as done.`);
}); });
} else { } else {
taskDone(projectCwd, id); taskDone(projectCwd, id, meta);
} }
}); });
program.addCommand(taskCmd); program.addCommand(taskCmd);

View File

@ -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'; import type { IndexEntry } from '../core/index.js';
export class RemoteError extends Error { export class RemoteError extends Error {
@ -58,8 +58,21 @@ export const remoteClient = {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName }); return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
}, },
async doneTask(baseUrl: string, id: string): Promise<Task> { async doneTask(
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'done' }); 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> { async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {

View File

@ -20,6 +20,10 @@ export interface IndexEntry {
toRole?: string; toRole?: string;
fromAgent?: string; fromAgent?: string;
toAgent?: string; toAgent?: string;
// Handoff → task link
taskId?: string;
// Memory → task links (JSON array string, e.g. '["TSK-0001"]')
relatedTasks?: string;
} }
export class Index { export class Index {
@ -45,16 +49,18 @@ export class Index {
fromRole TEXT, fromRole TEXT,
toRole TEXT, toRole TEXT,
fromAgent TEXT, fromAgent TEXT,
toAgent TEXT toAgent TEXT,
taskId TEXT,
relatedTasks TEXT
); );
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content); 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( const existingCols = new Set(
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), (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)) { if (!existingCols.has(col)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
} }
@ -71,17 +77,20 @@ export class Index {
toRole: null, toRole: null,
fromAgent: null, fromAgent: null,
toAgent: null, toAgent: null,
taskId: null,
relatedTasks: null,
...entry, ...entry,
}; };
const insert = this.db.prepare(` const insert = this.db.prepare(`
INSERT INTO entities (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) VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath, type=@type, title=@title, content=@content, filePath=@filePath,
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
assignedTo=@assignedTo, tags=@tags, 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); insert.run(params);
@ -129,6 +138,26 @@ export class Index {
return stmt.all(params) as IndexEntry[]; 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 { close(): void {
this.db.close(); this.db.close();
} }

View File

@ -21,6 +21,10 @@ export const TaskSchema = z.object({
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
acceptanceCriteria: z.array(z.string()).default([]), acceptanceCriteria: z.array(z.string()).default([]),
sourceHandoff: z.string().optional(), 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({ export const HandoffSchema = z.object({
@ -61,8 +65,23 @@ export const MemorySchema = z.object({
relatedDecisions: z.array(z.string()).default([]), relatedDecisions: z.array(z.string()).default([]),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: 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({ export const StatusSchema = z.object({
generatedAt: z.string().datetime(), generatedAt: z.string().datetime(),
activeTasks: z.array(z.string()).default([]), activeTasks: z.array(z.string()).default([]),

View File

@ -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;
}

View File

@ -38,6 +38,7 @@ export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Hand
toRole: handoff.toRole, toRole: handoff.toRole,
fromAgent: handoff.fromAgent, fromAgent: handoff.fromAgent,
toAgent: handoff.toAgent, toAgent: handoff.toAgent,
taskId: handoff.taskId,
}); });
index.close(); index.close();

View File

@ -18,6 +18,9 @@ export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory {
relatedDecisions: options.relatedDecisions ?? [], relatedDecisions: options.relatedDecisions ?? [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
tokens: options.tokens,
duration: options.duration,
by: options.by,
}); });
const filePath = join(getEntityDir(cwd, 'memory'), `${memory.id}.md`); 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, createdAt: memory.createdAt,
updatedAt: memory.updatedAt, updatedAt: memory.updatedAt,
tags: JSON.stringify(memory.tags), tags: JSON.stringify(memory.tags),
relatedTasks: JSON.stringify(memory.relatedTasks),
}); });
index.close(); index.close();

View File

@ -59,8 +59,17 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName }); return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
} }
export function doneTask(cwd: string, id: string): Task { export function doneTask(
return updateTask(cwd, id, { status: 'done' }); 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 { export function reviewTask(cwd: string, id: string): Task {

View File

@ -4,12 +4,10 @@
* Design constraints (MVP maximum simplicity): * Design constraints (MVP maximum simplicity):
* - One static HTML page: inline CSS + JS, no build step, no framework, no npm deps. * - One static HTML page: inline CSS + JS, no build step, no framework, no npm deps.
* - Reads only the existing same-origin endpoints (`/tasks`, `/handoffs`, * - 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`. * - Auto-refreshes on a small interval via `setInterval` + `fetch`.
* * - Each task card is clickable: expanding it fetches and renders the
* The column skeleton is rendered server-side so the markup (and its * per-task activity timeline (created handoff result status).
* `data-column` markers) exist even before the client JS runs; JS only fills
* the cards.
*/ */
export interface BoardColumn { export interface BoardColumn {
@ -118,7 +116,10 @@ export function renderBoardHtml(): string {
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; 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="open"] .card { border-left-color: var(--open); }
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); } .column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
.column[data-column="review"] .card { border-left-color: var(--review); } .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); } .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; } .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 { .panels {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
margin-top: 20px; margin-top: 20px;
@ -199,19 +229,73 @@ ${columnSkeleton()}
if (s < 86400) return Math.floor(s / 3600) + 'h ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd 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) { async function getJSON(path) {
var res = await fetch(path, { headers: { accept: 'application/json' } }); var res = await fetch(path, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(path + ' -> ' + res.status); if (!res.ok) throw new Error(path + ' -> ' + res.status);
return res.json(); 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) + '&thinsp;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) { function taskCard(t) {
var tags = ''; var tags = '';
if (t.role) tags += '<span class="badge role">' + esc(t.role) + '</span>'; if (t.role) tags += '<span class="badge role">' + esc(t.role) + '</span>';
if (t.assignedTo) tags += '<span class="badge agent">@' + esc(t.assignedTo) + '</span>'; if (t.assignedTo) tags += '<span class="badge agent">@' + esc(t.assignedTo) + '</span>';
return '<div class="card">' + return '<div class="card" data-id="' + esc(t.id) + '">' +
'<div class="id">' + esc(t.id) + '</div>' + '<div class="card-head">' +
'<div class="title">' + esc(t.title) + '</div>' + '<div class="id">' + esc(t.id) + '</div>' +
(tags ? '<div class="tags">' + tags + '</div>' : '') + '<div class="title">' + esc(t.title) + '</div>' +
(tags ? '<div class="tags">' + tags + '</div>' : '') +
'</div>' +
'<div class="timeline"></div>' +
'</div>'; '</div>';
} }
function renderBoard(tasks) { function renderBoard(tasks) {
@ -223,17 +307,29 @@ ${columnSkeleton()}
}); });
COLUMNS.forEach(function (k) { COLUMNS.forEach(function (k) {
var list = byCol[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 + '"]'); var count = document.querySelector('[data-count="' + k + '"]');
if (count) count.textContent = String(list.length); if (count) count.textContent = String(list.length);
if (!cards) return; if (!cardsEl) return;
cards.innerHTML = list.length
// 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('') ? list.map(taskCard).join('')
: '<div class="empty">—</div>'; : '<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) { function handoffRoute(h) {
// Build "fromRole[@fromAgent] → toRole[@toAgent]" label
var from = esc(h.fromRole || '?'); var from = esc(h.fromRole || '?');
var to = esc(h.toRole || '?'); var to = esc(h.toRole || '?');
if (h.fromAgent) from += '<span class="badge agent">@' + esc(h.fromAgent) + '</span>'; if (h.fromAgent) from += '<span class="badge agent">@' + esc(h.fromAgent) + '</span>';

View File

@ -1,5 +1,6 @@
import { FastifyInstance, FastifyReply } from 'fastify'; import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask } from '../core/services/taskService.js'; 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 { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.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) => { app.patch('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const patch = request.body as Partial<Task>; 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); task = claimTask(cwd, id, patch.assignedTo);
break; break;
case 'done': 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; break;
case 'review': case 'review':
task = reviewTask(cwd, id); task = reviewTask(cwd, id);

View File

@ -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();
});
});

View File

@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { createHandoff, listHandoffs, getHandoff } from '../src/core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../src/core/services/handoffService.js';
import { Index } from '../src/core/index.js';
describe('handoffService', () => { describe('handoffService', () => {
let cwd: string; let cwd: string;
@ -46,4 +47,25 @@ describe('handoffService', () => {
expect(items[0].fromAgent).toBe('claude'); expect(items[0].fromAgent).toBe('claude');
expect(items[0].toAgent).toBe('codex'); 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);
});
}); });

View File

@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { addMemory, searchMemory, listMemory } from '../src/core/services/memoryService.js'; import { addMemory, searchMemory, listMemory } from '../src/core/services/memoryService.js';
import { Index } from '../src/core/index.js';
describe('memoryService', () => { describe('memoryService', () => {
let cwd: string; let cwd: string;
@ -16,4 +17,46 @@ describe('memoryService', () => {
expect(searchMemory(cwd, 'TTL')).toHaveLength(1); expect(searchMemory(cwd, 'TTL')).toHaveLength(1);
expect(listMemory(cwd)).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);
});
}); });

View File

@ -121,6 +121,64 @@ describe('server routes', () => {
expect(html).toContain('setInterval(refresh'); 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 () => { it('board HTML contains the who-arrow rendering logic', async () => {
const res = await app.inject({ method: 'GET', url: '/board' }); const res = await app.inject({ method: 'GET', url: '/board' });
const html = res.payload; const html = res.payload;
@ -131,6 +189,23 @@ describe('server routes', () => {
expect(html).toContain('who-cell'); 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 () => { it('GET /handoffs returns fromRole and toRole fields', async () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',