From 2ed6e6868677ab7929b4e6c93b5bdbea9983e61b Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Sun, 5 Apr 2026 07:40:47 +0300 Subject: [PATCH] feat(CF-2885): Add timeline MCP tool for unified chronological event view New `timeline(subject, since, until)` tool stitches events across session-mcp sources (sessions, notes, commits, plans, task-commit links) into a single time-ordered stream for LLM consumption. Phase 1 of CF-2885: read-time stitching only, no business-system changes. Per CF-2830 heise article thesis (Event Sourcing + MCP for LLMs). Subject accepts Jira key, session id, or project key. Optional sources filter, since/until window (supports relative -7d shorthand), limit. Returns markdown-formatted timeline grouped by date with per-source icons. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 6 + src/tools/index.ts | 21 +++ src/tools/timeline.ts | 410 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 src/tools/timeline.ts diff --git a/src/index.ts b/src/index.ts index 4bd05a9..adeb6df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import { taskDelegations, taskDelegationQuery } from './tools/delegations.js'; import { projectLock, projectUnlock, projectLockStatus, projectContext } from './tools/locks.js'; import { taskCommitAdd, taskCommitRemove, taskCommitsList, taskLinkCommits, sessionTasks } from './tools/commits.js'; import { changelogAdd, changelogSinceSession, changelogList } from './tools/changelog.js'; +import { timeline } from './tools/timeline.js'; import { componentRegister, componentList, @@ -186,6 +187,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { result = await changelogList(a.days_back, a.limit); break; + // Event Timeline (CF-2885) + case 'timeline': + result = await timeline(a as any); + break; + // Impact Analysis case 'component_register': result = JSON.stringify(await componentRegister(a.id, a.name, a.type, { diff --git a/src/tools/index.ts b/src/tools/index.ts index ac9c7c4..7bdb44b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -144,6 +144,27 @@ export const toolDefinitions = [ }, }, + // Event Timeline (CF-2885) + { + name: 'timeline', + description: 'Unified chronological event timeline for a subject. Stitches sessions, notes, commits, plans, and task-commit links from all sessions touching the subject. Subject can be a Jira issue key (e.g., CF-2872), a session id, or a project key (e.g., CF). Returns events sorted oldest → newest.', + inputSchema: { + type: 'object', + properties: { + subject: { type: 'string', description: 'Jira issue key (CF-123), session id, or project key (CF)' }, + since: { type: 'string', description: 'ISO8601 timestamp or relative "-7d"/"-24h"/"-30m" (default: -7d)' }, + until: { type: 'string', description: 'ISO8601 timestamp (default: now)' }, + sources: { + type: 'array', + items: { type: 'string', enum: ['session', 'note', 'commit', 'plan', 'task_commit'] }, + description: 'Optional filter: which event sources to include (default: all)', + }, + limit: { type: 'number', description: 'Max events to return (default: 100)' }, + }, + required: ['subject'], + }, + }, + // Project Lock Tools { name: 'project_lock', diff --git a/src/tools/timeline.ts b/src/tools/timeline.ts new file mode 100644 index 0000000..d50392e --- /dev/null +++ b/src/tools/timeline.ts @@ -0,0 +1,410 @@ +// CF-2885: Event Timeline — unified chronological view across session-mcp sources +// Stitches sessions, notes, commits, plans, and task-commit links into a single +// time-ordered event stream for LLM consumption. + +import { query } from '../db.js'; + +export type EventSource = 'session' | 'note' | 'commit' | 'plan' | 'task_commit'; + +export interface TimelineEvent { + ts: string; // ISO8601 + source: EventSource; + type: string; // e.g. "session_start", "note:decision", "commit" + subject: string; // Jira key | session id | repo + summary: string; // 1-line human readable + details: Record; + links: { + session?: string; + jira?: string; + commit?: { sha: string; repo: string }; + }; +} + +interface TimelineArgs { + subject: string; // Jira key (CF-123) | session id | project key (CF) + since?: string; // ISO8601 or relative like "-7d" (default: -7d) + until?: string; // ISO8601 (default: now) + sources?: EventSource[]; // optional filter + limit?: number; // default: 100 +} + +// ---------- helpers ---------- + +const JIRA_KEY_RE = /^[A-Z]{2,10}-\d+$/; +const PROJECT_KEY_RE = /^[A-Z]{2,10}$/; + +function resolveSince(since?: string): string { + if (!since) return 'NOW() - INTERVAL \'7 days\''; + // Relative shorthand: -7d, -24h, -30m + const rel = since.match(/^-(\d+)([dhm])$/); + if (rel) { + const n = rel[1]; + const unit = rel[2] === 'd' ? 'days' : rel[2] === 'h' ? 'hours' : 'minutes'; + return `NOW() - INTERVAL '${n} ${unit}'`; + } + return `'${since}'::timestamptz`; +} + +function resolveUntil(until?: string): string { + if (!until) return 'NOW()'; + return `'${until}'::timestamptz`; +} + +function shortSha(sha: string): string { + return sha.substring(0, 7); +} + +function truncate(s: string | null | undefined, n: number): string { + if (!s) return ''; + return s.length > n ? s.substring(0, n - 1) + '…' : s; +} + +// ---------- subject classification ---------- + +type SubjectKind = 'jira' | 'session' | 'project' | 'unknown'; + +function classifySubject(subject: string): SubjectKind { + if (JIRA_KEY_RE.test(subject)) { + // Could be Jira issue or session id (sessions.id often uses jira key format) + // We'll query both — distinguish at query time + return 'jira'; + } + if (PROJECT_KEY_RE.test(subject)) return 'project'; + return 'unknown'; +} + +// ---------- source queries ---------- + +async function fetchSessionEvents( + subject: string, + kind: SubjectKind, + since: string, + until: string +): Promise { + // Match sessions by: id (session id), jira_issue_key (linked ticket), project (broad) + const where = + kind === 'jira' + ? '(s.id = $1 OR s.jira_issue_key = $1)' + : kind === 'project' + ? 's.project = $1' + : '1=0'; + + const rows = await query<{ + id: string; + project: string; + jira_issue_key: string | null; + started_at: string; + ended_at: string | null; + summary: string | null; + status: string | null; + duration_minutes: number | null; + }>( + `SELECT id, project, jira_issue_key, started_at, ended_at, summary, status, duration_minutes + FROM sessions s + WHERE ${where} + AND s.started_at >= ${since} + AND s.started_at <= ${until} + ORDER BY s.started_at DESC + LIMIT 100`, + [subject] + ); + + const events: TimelineEvent[] = []; + for (const r of rows) { + events.push({ + ts: r.started_at, + source: 'session', + type: 'session_start', + subject: r.id, + summary: `Session ${r.id} started${r.jira_issue_key ? ` on ${r.jira_issue_key}` : ''}`, + details: { project: r.project, status: r.status }, + links: { + session: r.id, + jira: r.jira_issue_key || undefined, + }, + }); + if (r.ended_at) { + events.push({ + ts: r.ended_at, + source: 'session', + type: 'session_end', + subject: r.id, + summary: `Session ${r.id} ended (${r.duration_minutes ?? '?'}min)${r.summary ? ': ' + truncate(r.summary, 120) : ''}`, + details: { summary: r.summary, status: r.status }, + links: { session: r.id, jira: r.jira_issue_key || undefined }, + }); + } + } + return events; +} + +async function fetchNoteEvents( + subject: string, + kind: SubjectKind, + since: string, + until: string +): Promise { + // Notes belong to sessions. Find via session linkage. + const sessionFilter = + kind === 'jira' + ? '(s.id = $1 OR s.jira_issue_key = $1)' + : kind === 'project' + ? 's.project = $1' + : '1=0'; + + const rows = await query<{ + id: number; + session_id: string; + note_type: string; + content: string; + created_at: string; + jira_issue_key: string | null; + }>( + `SELECT n.id, n.session_id, n.note_type, n.content, n.created_at, s.jira_issue_key + FROM session_notes n + JOIN sessions s ON s.id = n.session_id + WHERE ${sessionFilter} + AND n.created_at >= ${since} + AND n.created_at <= ${until} + ORDER BY n.created_at DESC + LIMIT 200`, + [subject] + ); + + return rows.map((r) => ({ + ts: r.created_at, + source: 'note' as const, + type: `note:${r.note_type}`, + subject: r.session_id, + summary: `[${r.note_type}] ${truncate(r.content, 140)}`, + details: { full: r.content }, + links: { session: r.session_id, jira: r.jira_issue_key || undefined }, + })); +} + +async function fetchCommitEvents( + subject: string, + kind: SubjectKind, + since: string, + until: string +): Promise { + // session_commits: via session linkage + const sessionFilter = + kind === 'jira' + ? '(s.id = $1 OR s.jira_issue_key = $1)' + : kind === 'project' + ? 's.project = $1' + : '1=0'; + + const rows = await query<{ + commit_sha: string; + repo: string; + commit_message: string | null; + committed_at: string | null; + created_at: string; + session_id: string; + jira_issue_key: string | null; + }>( + `SELECT c.commit_sha, c.repo, c.commit_message, c.committed_at, c.created_at, + c.session_id, s.jira_issue_key + FROM session_commits c + JOIN sessions s ON s.id = c.session_id + WHERE ${sessionFilter} + AND COALESCE(c.committed_at, c.created_at) >= ${since} + AND COALESCE(c.committed_at, c.created_at) <= ${until} + ORDER BY COALESCE(c.committed_at, c.created_at) DESC + LIMIT 200`, + [subject] + ); + + return rows.map((r) => ({ + ts: r.committed_at || r.created_at, + source: 'commit' as const, + type: 'commit', + subject: `${r.repo}@${shortSha(r.commit_sha)}`, + summary: `${shortSha(r.commit_sha)} (${r.repo}) ${truncate(r.commit_message, 100)}`, + details: { full_message: r.commit_message }, + links: { + session: r.session_id, + jira: r.jira_issue_key || undefined, + commit: { sha: r.commit_sha, repo: r.repo }, + }, + })); +} + +async function fetchTaskCommitEvents( + subject: string, + kind: SubjectKind, + since: string, + until: string +): Promise { + // task_commits links Jira issue → commit independently of sessions + if (kind !== 'jira') return []; + + const rows = await query<{ + task_id: string; + commit_sha: string; + repo: string; + source: string | null; + created_at: string; + }>( + `SELECT task_id, commit_sha, repo, source, created_at + FROM task_commits + WHERE task_id = $1 + AND created_at >= ${since} + AND created_at <= ${until} + ORDER BY created_at DESC + LIMIT 200`, + [subject] + ); + + return rows.map((r) => ({ + ts: r.created_at, + source: 'task_commit' as const, + type: 'commit_link', + subject: r.task_id, + summary: `${shortSha(r.commit_sha)} linked to ${r.task_id} (${r.repo}) [${r.source || 'manual'}]`, + details: { source: r.source }, + links: { + jira: r.task_id, + commit: { sha: r.commit_sha, repo: r.repo }, + }, + })); +} + +async function fetchPlanEvents( + subject: string, + kind: SubjectKind, + since: string, + until: string +): Promise { + const sessionFilter = + kind === 'jira' + ? '(s.id = $1 OR s.jira_issue_key = $1)' + : kind === 'project' + ? 's.project = $1' + : '1=0'; + + const rows = await query<{ + id: number; + session_id: string; + plan_file_name: string | null; + status: string | null; + created_at: string; + approved_at: string | null; + completed_at: string | null; + jira_issue_key: string | null; + }>( + `SELECT p.id, p.session_id, p.plan_file_name, p.status, + p.created_at, p.approved_at, p.completed_at, s.jira_issue_key + FROM session_plans p + JOIN sessions s ON s.id = p.session_id + WHERE ${sessionFilter} + AND p.created_at >= ${since} + AND p.created_at <= ${until} + ORDER BY p.created_at DESC + LIMIT 50`, + [subject] + ); + + const events: TimelineEvent[] = []; + for (const r of rows) { + const label = r.plan_file_name || `plan#${r.id}`; + events.push({ + ts: r.created_at, + source: 'plan', + type: 'plan_created', + subject: r.session_id, + summary: `Plan created: ${label}`, + details: { status: r.status }, + links: { session: r.session_id, jira: r.jira_issue_key || undefined }, + }); + if (r.approved_at) { + events.push({ + ts: r.approved_at, + source: 'plan', + type: 'plan_approved', + subject: r.session_id, + summary: `Plan approved: ${label}`, + details: {}, + links: { session: r.session_id, jira: r.jira_issue_key || undefined }, + }); + } + if (r.completed_at) { + events.push({ + ts: r.completed_at, + source: 'plan', + type: 'plan_completed', + subject: r.session_id, + summary: `Plan completed: ${label}`, + details: {}, + links: { session: r.session_id, jira: r.jira_issue_key || undefined }, + }); + } + } + return events; +} + +// ---------- main tool ---------- + +export async function timeline(args: TimelineArgs): Promise { + const subject = args.subject?.trim(); + if (!subject) { + return 'Error: subject is required (Jira key, session id, or project key)'; + } + + const kind = classifySubject(subject); + if (kind === 'unknown') { + return `Error: could not classify subject "${subject}". Expected Jira key (CF-123), session id, or project key (CF).`; + } + + const since = resolveSince(args.since); + const until = resolveUntil(args.until); + const limit = args.limit ?? 100; + const sourceFilter = new Set(args.sources ?? ['session', 'note', 'commit', 'plan', 'task_commit']); + + // Run all queries in parallel + const [sessionEvents, noteEvents, commitEvents, planEvents, taskCommitEvents] = await Promise.all([ + sourceFilter.has('session') ? fetchSessionEvents(subject, kind, since, until) : Promise.resolve([]), + sourceFilter.has('note') ? fetchNoteEvents(subject, kind, since, until) : Promise.resolve([]), + sourceFilter.has('commit') ? fetchCommitEvents(subject, kind, since, until) : Promise.resolve([]), + sourceFilter.has('plan') ? fetchPlanEvents(subject, kind, since, until) : Promise.resolve([]), + sourceFilter.has('task_commit') ? fetchTaskCommitEvents(subject, kind, since, until) : Promise.resolve([]), + ]); + + const all = [...sessionEvents, ...noteEvents, ...commitEvents, ...planEvents, ...taskCommitEvents]; + + // Sort chronologically (oldest → newest by default for narrative reading) + all.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); + + const limited = all.slice(-limit); + + if (limited.length === 0) { + return `📭 No events for ${subject} (${kind}) in window ${args.since || '-7d'} → ${args.until || 'now'}`; + } + + // Format as markdown timeline + let output = `📜 **Timeline: ${subject}** (${kind}, ${limited.length} events)\n`; + output += `Window: ${args.since || '-7d'} → ${args.until || 'now'}\n\n`; + + let lastDate = ''; + for (const e of limited) { + const d = new Date(e.ts); + const dateStr = d.toISOString().substring(0, 10); + const timeStr = d.toISOString().substring(11, 16); + if (dateStr !== lastDate) { + output += `\n**${dateStr}**\n`; + lastDate = dateStr; + } + const icon = { + session: '🗂️', + note: '📝', + commit: '🔨', + plan: '📋', + task_commit: '🔗', + }[e.source]; + output += ` \`${timeStr}\` ${icon} \`${e.type}\` ${e.summary}\n`; + } + + return output; +}