diff --git a/src/cli/commands/task.ts b/src/cli/commands/task.ts index a2463cb..188e1b5 100644 --- a/src/cli/commands/task.ts +++ b/src/cli/commands/task.ts @@ -1,5 +1,6 @@ import { input, select } from '@inquirer/prompts'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js'; +import { appendTaskLog } from '../../core/services/taskLogService.js'; import type { Task } from '../../core/schema.js'; export async function taskCreate(cwd: string, options: Partial = {}): Promise { @@ -69,3 +70,12 @@ export function taskAssign(cwd: string, id: string, agentName: string): void { assignTask(cwd, id, agentName); console.log(`AgentHub: Task assigned ${id} → ${agentName}`); } + +export function taskLog( + cwd: string, + id: string, + entry: { text: string; agent?: string; level?: string }, +): void { + const rec = appendTaskLog(cwd, id, entry); + console.log(`AgentHub: logged ${id} ${rec.text}`); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 5358e2b..404db34 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { init } from './commands/init.js'; import { status } from './commands/status.js'; import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js'; -import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; +import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { decisionCreate, decisionList } from './commands/decision.js'; import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js'; @@ -365,6 +365,23 @@ export function createProgram(cwd: string): Command { taskReopen(projectCwd, id); } }); + taskCmd + .command('log ') + .description('Append a progress line to a task\'s live console (streams to open task-detail pages)') + .requiredOption('--text ', 'Progress line') + .option('--agent ', 'Reporting agent') + .option('--level ', 'Log level (info | status | warn | error)') + .action(async (id, options: { text: string; agent?: string; level?: string }) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (serverUrl) { + await runRemote(serverUrl, async () => { + await remoteClient.appendTaskLog(serverUrl, id, { text: options.text, agent: options.agent, level: options.level }); + console.log(`AgentHub: logged ${id} ${options.text}`); + }); + } else { + taskLog(projectCwd, id, { text: options.text, agent: options.agent, level: options.level }); + } + }); program.addCommand(taskCmd); const handoffCmd = new Command('handoff').description('Manage handoffs'); diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index 0ed1c5a..b5e2895 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -1,6 +1,7 @@ import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js'; import type { IndexEntry } from '../core/index.js'; import type { InboxMessage } from '../core/services/messageService.js'; +import type { TaskLogEntry } from '../core/services/taskLogService.js'; export class RemoteError extends Error { constructor(public status: number, message: string) { @@ -92,6 +93,14 @@ export const remoteClient = { return request(baseUrl, 'GET', `/tasks/${id}/activity`); }, + async appendTaskLog( + baseUrl: string, + id: string, + entry: { text: string; agent?: string; level?: string }, + ): Promise { + return request(baseUrl, 'POST', `/tasks/${id}/log`, entry); + }, + async createHandoff(baseUrl: string, options: Partial): Promise { return request(baseUrl, 'POST', '/handoffs', options); }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 09e7100..5785bb7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ import { doneTask, } from '../core/services/taskService.js'; import { createHandoff, getHandoff } from '../core/services/handoffService.js'; +import { appendTaskLog } from '../core/services/taskLogService.js'; import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js'; import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { createDecision } from '../core/services/decisionService.js'; @@ -213,6 +214,7 @@ export async function startMcpServer(cwd: string): Promise { 'Never end your turn without relaunching agenthub_work.' : 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' + 'run it in the background so the next task or message reaches you in realtime. ' + + 'Report meaningful progress with agenthub_task_log while you work. ' + 'Never end your turn without relaunching agenthub_work.'; const immediate = await finder(); if (immediate) return asText({ ...immediate, loop: LOOP }); @@ -262,6 +264,11 @@ export async function startMcpServer(cwd: string): Promise { { id: z.string() }, async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id))); + server.tool('agenthub_task_log', + 'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").', + { id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() }, + async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level }))); + server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.', { title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() }, async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o))); diff --git a/src/server/routes.ts b/src/server/routes.ts index ca945de..7bbc16c 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -210,6 +210,19 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise // Token & cost rollup per agent (real recorded + time-estimated, clearly flagged). app.get('/budget', async () => computeBudget(cwd)); + // Auto-log a task status transition to its live console. Best-effort: the + // .log write + task-log SSE fan-out must never fail the underlying mutation. + // (No double-emission: publishLog rides the separate 'log' channel, and + // fsWatch only watches .md files, so the .log append is not re-emitted.) + const logTaskStatus = (id: string, text: string, agent?: string) => { + try { + const entry = appendTaskLog(cwd, id, { text, agent, level: 'status' }); + eventBus.publishLog({ taskId: id, ...entry }); + } catch { + /* logging is best-effort */ + } + }; + // ─── Tasks ─────────────────────────────────────────────────────────────── app.get('/tasks', async (request) => { const { status, role } = request.query as { status?: string; role?: string }; @@ -302,6 +315,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise // change). Fires task/updated so a waiting `agenthub work` auto-claims it. if (patch.assignedTo !== undefined && patch.status === undefined) { const assigned = assignTask(cwd, id, patch.assignedTo); + logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo); emitChange( { type: 'task', @@ -348,6 +362,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); } + // Uniformly log the transition for every status-changing caller (claim / + // review / done / cancel / reopen), so the live console tracks progress. + const logText = + task.status === 'in_progress' ? `Claimed by ${task.claimedBy ?? task.assignedTo ?? 'agent'}` + : task.status === 'review' ? `Submitted for review${task.reviewer ? ` → ${task.reviewer}` : ''}` + : task.status === 'done' ? `Approved — done${task.doneBy ? ` by ${task.doneBy}` : ''}` + : task.status === 'cancelled' ? 'Cancelled' + : task.status === 'open' ? 'Reopened' + : `Status → ${task.status}`; + logTaskStatus(id, logText, task.claimedBy ?? task.assignedTo ?? task.reviewer ?? task.doneBy); + emitChange( { type: 'task', diff --git a/src/server/taskDetail.ts b/src/server/taskDetail.ts index 8f0c335..7e2ff2a 100644 --- a/src/server/taskDetail.ts +++ b/src/server/taskDetail.ts @@ -3,6 +3,7 @@ import { getTask } from '../core/services/taskService.js'; import { getTaskActivity } from '../core/services/activityService.js'; import { getDecision } from '../core/services/decisionService.js'; import { getHandoff, listHandoffs } from '../core/services/handoffService.js'; +import { readTaskLog, type TaskLogEntry } from '../core/services/taskLogService.js'; import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; import type { ActivityItem, Decision, Handoff } from '../core/schema.js'; @@ -127,6 +128,13 @@ export function renderTaskDetailHtml(cwd: string, id: string): string { .join('') : '
No linked decisions for this task.
'; + const logEntries: TaskLogEntry[] = readTaskLog(cwd, id); + const logLine = (e: TaskLogEntry) => + `
${escapeHtml(ago(e.ts))}${e.agent ? `${escapeHtml(e.agent)}` : ''}${escapeHtml(e.text)}
`; + const consoleRows = logEntries.length + ? logEntries.map(logLine).join('') + : '
No console output yet.
'; + const activityRows = activity.length ? activity .map( @@ -178,6 +186,14 @@ export function renderTaskDetailHtml(cwd: string, id: string): string { .kind { color:var(--accent);font:11px/1.4 var(--font-mono); } .summary { min-width:0;overflow-wrap:anywhere; } .empty { color:var(--muted); } + .console { max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px;font:12px/1.5 var(--font-mono); } + .log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; } + .log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; } + .log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; } + .log-text { color:var(--text);min-width:0; } + .log-line[data-level="status"] .log-text { color:var(--status-review); } + .log-line[data-level="warn"] .log-text { color:var(--status-review); } + .log-line[data-level="error"] .log-text { color:#F85149; } @media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} } @@ -190,6 +206,10 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
${taskStats}
${body.trim() ? `
${escapeHtml(body.trim())}
` : ''} +
+

Live Console

+
${consoleRows}
+

Handoffs

${handoffRows} @@ -205,6 +225,26 @@ export function renderTaskDetailHtml(cwd: string, id: string): string { ${taskModalHtml()} ${appHeaderJs()} + `; } diff --git a/tests/taskLog-live.test.ts b/tests/taskLog-live.test.ts new file mode 100644 index 0000000..34cd641 --- /dev/null +++ b/tests/taskLog-live.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { buildApp } from '../src/server/index.js'; +import { init } from '../src/cli/commands/init.js'; +import { eventBus } from '../src/server/events.js'; +import { readTaskLog } from '../src/core/services/taskLogService.js'; +import { remoteClient } from '../src/cli/remoteClient.js'; + +describe('task live console (TSK-0074)', () => { + let cwd: string; + let app: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-')); + init(cwd, { projectName: 'tasklog-test', yes: true }); + app = buildApp(cwd); + }); + + afterEach(() => { + eventBus.removeAllListeners('log'); + eventBus.removeAllListeners('change'); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); + + const logs: Array<{ taskId?: string; text?: string; level?: string }> = []; + const changes: unknown[] = []; + const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p); + const onChange = (e: unknown) => changes.push(e); + eventBus.on('log', onLog); + eventBus.on('change', onChange); + + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); + + eventBus.off('log', onLog); + eventBus.off('change', onChange); + + // Exactly one task-log SSE event, carrying the task id + a "Claimed by" line. + expect(logs).toHaveLength(1); + expect(logs[0].taskId).toBe('TSK-0001'); + expect(logs[0].text).toContain('Claimed by codex'); + expect(logs[0].level).toBe('status'); + // No double-emission on the change channel (one emitChange per PATCH). + expect(changes).toHaveLength(1); + + // Persisted to the task's .log file. + const persisted = readTaskLog(cwd, 'TSK-0001'); + expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true); + }); + + it('logs every status transition (review, done)', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } }); + + const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text); + expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true); + expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true); + expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true); + }); + + it('task detail HTML renders the Live Console with historic lines', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } }); + + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } }); + expect(res.statusCode).toBe(200); + expect(res.payload).toContain('Live Console'); + expect(res.payload).toContain('wrote failing test'); + // Live tail wires to the named task-log SSE event, scoped to this task id. + expect(res.payload).toContain("addEventListener('task-log'"); + expect(res.payload).toContain('"TSK-0001"'); + }); + + it('POST /tasks/:id/log returns the stored entry', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } }); + expect(res.statusCode).toBe(200); + const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string }; + expect(entry.text).toBe('green: 12 tests'); + expect(entry.level).toBe('status'); + expect(entry.ts).toBeTruthy(); + }); + + it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => { + vi.restoreAllMocks(); + const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' }; + globalThis.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(stored)), + } as Response); + + const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' }); + expect(res.text).toBe('progress'); + const call = vi.mocked(fetch).mock.calls[0]; + expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log'); + expect((call[1] as RequestInit).method).toBe('POST'); + }); +});