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 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 category = options.category ?? await select({
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 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}.`);
}

View File

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

View File

@ -108,15 +108,50 @@ export function createProgram(cwd: string): Command {
.option('--title <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);

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';
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> {

View File

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

View File

@ -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([]),

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,
fromAgent: handoff.fromAgent,
toAgent: handoff.toAgent,
taskId: handoff.taskId,
});
index.close();

View File

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

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 });
}
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 {

View File

@ -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) + '&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) {
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">' +
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>';

View File

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

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 { 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);
});
});

View File

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

View File

@ -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',