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) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-04-05 07:40:47 +03:00
parent 0fad29801e
commit 2ed6e68686
3 changed files with 437 additions and 0 deletions

View File

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

View File

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

410
src/tools/timeline.ts Normal file
View File

@@ -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<string, unknown>;
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<TimelineEvent[]> {
// 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<TimelineEvent[]> {
// 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<TimelineEvent[]> {
// 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<TimelineEvent[]> {
// 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<TimelineEvent[]> {
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<string> {
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<EventSource>(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;
}