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:
@@ -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, {
|
||||
|
||||
@@ -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
410
src/tools/timeline.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user