From ea2d4ddd2cdf8de33a2018cc2c68b71d1966ed9a Mon Sep 17 00:00:00 2001 From: Teg Ryan Date: Mon, 23 Mar 2026 13:26:00 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Control=20Center=20v2=20=E2=80=94?= =?UTF-8?q?=20Jira,=20Gmail,=20Calendar,=20and=20developer=20workflow=20fe?= =?UTF-8?q?atures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork of t3code extended with a complete developer workstation platform: **Contracts & Model (Wave 1-2):** - Extended OrchestrationProject with 14 Jira metadata fields - 3 new commands (jira-metadata.update, note.update, touch) + events - Decider, projector, and schemas handle new event types - 4 new SQLite migrations (projects metadata, prompt history, Jira cache, specs) - Web types and store hydration updated **External Services (Wave 3):** - Jira service: REST API with ~/.netrc auth, ADF text extraction, ticket CRUD - Gmail service: gogcli CLI wrapper with email categorization (ACTION/REVIEW/FYI/NOISE) - Calendar service: gcalcli CLI wrapper with TSV agenda parsing - Full WS API wiring for all services (contracts → server → web) **UI Features (Wave 4):** - Sidebar: Jira ticket badges, status indicators, priority markers - Sidebar: Per-project notes (persisted via events) and scratchpad (localStorage) - Sidebar: Prompt history with click-to-resend - Command Tray: Customizable bottom bar with /update, /triage, /commit presets - Copy Terminal Output: Hover overlay button on terminal panes - PR Creation Modal: 3-step flow (review → create → Jira comment) - Spec/Planning Editor: Per-project textarea with debounced auto-save **Orchestrated Workflows (Wave 5):** - Context seeding: Auto-generates CLAUDE.md from Jira ticket data - /update skill: Aggregates Jira, Gmail, Calendar for daily status reports - /triage skill: Scans all channels, produces prioritized action list 36 files (18 modified, 18 new), 846 lines added. All 521 tests pass. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/calendar/Errors.ts | 5 + .../src/calendar/Layers/CalendarService.ts | 58 +++ .../src/calendar/Services/CalendarService.ts | 12 + apps/server/src/gmail/Errors.ts | 5 + apps/server/src/gmail/Layers/GmailService.ts | 92 +++++ .../server/src/gmail/Services/GmailService.ts | 13 + apps/server/src/jira/Errors.ts | 11 + apps/server/src/jira/Layers/JiraService.ts | 196 +++++++++ apps/server/src/jira/Services/JiraService.ts | 31 ++ apps/server/src/jira/contextSeeding.ts | 107 +++++ .../Layers/OrchestrationEngine.ts | 3 + apps/server/src/orchestration/Schemas.ts | 6 + apps/server/src/orchestration/decider.ts | 77 ++++ apps/server/src/orchestration/projector.ts | 60 +++ apps/server/src/persistence/Migrations.ts | 8 + .../016_ProjectionProjectsJiraMetadata.ts | 26 ++ .../Migrations/017_PromptHistory.ts | 20 + .../persistence/Migrations/018_JiraCache.ts | 43 ++ .../src/persistence/Migrations/019_Specs.ts | 21 + apps/server/src/serverLayers.ts | 6 + apps/server/src/skills/triageSkill.ts | 242 +++++++++++ apps/server/src/skills/updateSkill.ts | 121 ++++++ apps/server/src/wsServer.ts | 188 ++++++++- apps/web/src/commandTrayStore.ts | 56 +++ apps/web/src/components/CommandTray.tsx | 185 +++++++++ apps/web/src/components/PRCreationModal.tsx | 378 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 162 +++++++- apps/web/src/components/SpecEditor.tsx | 69 ++++ .../src/components/ThreadTerminalDrawer.tsx | 49 ++- apps/web/src/lib/jiraReactQuery.ts | 29 ++ apps/web/src/routes/_chat.tsx | 25 +- apps/web/src/scratchpadStore.ts | 58 +++ apps/web/src/store.ts | 14 + apps/web/src/types.ts | 15 + apps/web/src/wsNativeApi.ts | 24 ++ packages/contracts/src/calendar.ts | 30 ++ packages/contracts/src/gmail.ts | 42 ++ packages/contracts/src/index.ts | 4 + packages/contracts/src/ipc.ts | 39 ++ packages/contracts/src/jira.ts | 61 +++ packages/contracts/src/orchestration.ts | 96 +++++ packages/contracts/src/spec.ts | 27 ++ packages/contracts/src/ws.ts | 80 ++++ 43 files changed, 2783 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/calendar/Errors.ts create mode 100644 apps/server/src/calendar/Layers/CalendarService.ts create mode 100644 apps/server/src/calendar/Services/CalendarService.ts create mode 100644 apps/server/src/gmail/Errors.ts create mode 100644 apps/server/src/gmail/Layers/GmailService.ts create mode 100644 apps/server/src/gmail/Services/GmailService.ts create mode 100644 apps/server/src/jira/Errors.ts create mode 100644 apps/server/src/jira/Layers/JiraService.ts create mode 100644 apps/server/src/jira/Services/JiraService.ts create mode 100644 apps/server/src/jira/contextSeeding.ts create mode 100644 apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts create mode 100644 apps/server/src/persistence/Migrations/017_PromptHistory.ts create mode 100644 apps/server/src/persistence/Migrations/018_JiraCache.ts create mode 100644 apps/server/src/persistence/Migrations/019_Specs.ts create mode 100644 apps/server/src/skills/triageSkill.ts create mode 100644 apps/server/src/skills/updateSkill.ts create mode 100644 apps/web/src/commandTrayStore.ts create mode 100644 apps/web/src/components/CommandTray.tsx create mode 100644 apps/web/src/components/PRCreationModal.tsx create mode 100644 apps/web/src/components/SpecEditor.tsx create mode 100644 apps/web/src/lib/jiraReactQuery.ts create mode 100644 apps/web/src/scratchpadStore.ts create mode 100644 packages/contracts/src/calendar.ts create mode 100644 packages/contracts/src/gmail.ts create mode 100644 packages/contracts/src/jira.ts create mode 100644 packages/contracts/src/spec.ts diff --git a/apps/server/src/calendar/Errors.ts b/apps/server/src/calendar/Errors.ts new file mode 100644 index 0000000000..dec2ece028 --- /dev/null +++ b/apps/server/src/calendar/Errors.ts @@ -0,0 +1,5 @@ +import { Data } from "effect"; + +export class CalendarError extends Data.TaggedError("CalendarError")<{ + readonly message: string; +}> {} diff --git a/apps/server/src/calendar/Layers/CalendarService.ts b/apps/server/src/calendar/Layers/CalendarService.ts new file mode 100644 index 0000000000..5d7e0ba1de --- /dev/null +++ b/apps/server/src/calendar/Layers/CalendarService.ts @@ -0,0 +1,58 @@ +import { Effect, Layer } from "effect"; +import { execSync } from "node:child_process"; +import { CalendarService } from "../Services/CalendarService.ts"; +import { CalendarError } from "../Errors.ts"; +import type { CalendarEvent } from "@t3tools/contracts"; + +function runGcalcli(args: string): Effect.Effect { + return Effect.try({ + try: () => execSync(`gcalcli ${args}`, { encoding: "utf-8", timeout: 30000 }), + catch: (e) => new CalendarError({ message: `gcalcli failed: ${e}` }), + }); +} + +function parseTsvAgenda(tsv: string): CalendarEvent[] { + const lines = tsv.trim().split("\n").filter(Boolean); + const events: CalendarEvent[] = []; + + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length < 4) continue; + + const [startDate, startTime, endDate, endTime, ...titleParts] = parts; + const title = titleParts.join("\t").trim(); + if (!title) continue; + + const isAllDay = !startTime || startTime.trim() === ""; + const start = isAllDay ? (startDate ?? "").trim() : `${(startDate ?? "").trim()}T${(startTime ?? "").trim()}`; + const end = isAllDay ? (endDate ?? "").trim() : `${(endDate ?? "").trim()}T${(endTime ?? "").trim()}`; + + events.push({ + title: title as CalendarEvent["title"], + start, + end, + isAllDay, + } as CalendarEvent); + } + + return events; +} + +export const CalendarServiceLive = Layer.succeed( + CalendarService, + CalendarService.of({ + agenda: ({ date }) => + Effect.gen(function* () { + const dateArg = date ?? "today"; + const output = yield* runGcalcli(`agenda ${dateArg} --tsv`); + return parseTsvAgenda(output); + }), + + meetingPrep: ({ title, start }) => + Effect.gen(function* () { + // Generate meeting prep notes based on title and time + const notes = `## Meeting Prep: ${title}\n**Time:** ${start}\n\n### Talking Points\n- \n\n### Questions\n- \n\n### Action Items\n- `; + return { notes }; + }), + }), +); diff --git a/apps/server/src/calendar/Services/CalendarService.ts b/apps/server/src/calendar/Services/CalendarService.ts new file mode 100644 index 0000000000..b2ea0b26ca --- /dev/null +++ b/apps/server/src/calendar/Services/CalendarService.ts @@ -0,0 +1,12 @@ +import { ServiceMap, Effect } from "effect"; +import type { CalendarEvent } from "@t3tools/contracts"; +import type { CalendarError } from "../Errors.ts"; + +export interface CalendarServiceShape { + readonly agenda: (input: { date?: string }) => Effect.Effect, CalendarError>; + readonly meetingPrep: (input: { title: string; start: string }) => Effect.Effect<{ notes: string }, CalendarError>; +} + +export class CalendarService extends ServiceMap.Service()( + "t3/calendar/Services/CalendarService", +) {} diff --git a/apps/server/src/gmail/Errors.ts b/apps/server/src/gmail/Errors.ts new file mode 100644 index 0000000000..a33d683f5b --- /dev/null +++ b/apps/server/src/gmail/Errors.ts @@ -0,0 +1,5 @@ +import { Data } from "effect"; + +export class GmailError extends Data.TaggedError("GmailError")<{ + readonly message: string; +}> {} diff --git a/apps/server/src/gmail/Layers/GmailService.ts b/apps/server/src/gmail/Layers/GmailService.ts new file mode 100644 index 0000000000..d5af2cb477 --- /dev/null +++ b/apps/server/src/gmail/Layers/GmailService.ts @@ -0,0 +1,92 @@ +import { Effect, Layer } from "effect"; +import { execSync } from "node:child_process"; +import { GmailService } from "../Services/GmailService.ts"; +import { GmailError } from "../Errors.ts"; +import type { GmailMessage } from "@t3tools/contracts"; + +const ACCOUNT = "tryan@mediafly.com"; + +function runGogcli(args: string): Effect.Effect { + return Effect.try({ + try: () => execSync(`gogcli ${args}`, { encoding: "utf-8", timeout: 30000 }), + catch: (e) => new GmailError({ message: `gogcli failed: ${e}` }), + }); +} + +function categorizeEmail(from: string, subject: string): "ACTION" | "REVIEW" | "FYI" | "NOISE" { + const lowerSubject = subject.toLowerCase(); + const lowerFrom = from.toLowerCase(); + + // Noise patterns + if (lowerFrom.includes("noreply") || lowerFrom.includes("no-reply")) return "NOISE"; + if (lowerSubject.includes("[resolved]")) return "NOISE"; + if (lowerFrom.includes("grafana") && lowerSubject.includes("[ok]")) return "NOISE"; + if (lowerFrom.includes("jira@mediafly") || lowerFrom.includes("atlassian")) return "NOISE"; + if (lowerFrom.includes("salesforce")) return "NOISE"; + + // Review patterns + if (lowerSubject.includes("review requested") || lowerSubject.includes("pull request")) return "REVIEW"; + if (lowerFrom.includes("github.com")) return "REVIEW"; + + // Action patterns + if (lowerSubject.includes("action required") || lowerSubject.includes("urgent")) return "ACTION"; + + return "FYI"; +} + +function parseGogcliOutput(output: string): GmailMessage[] { + // gogcli json output returns an array of message objects + try { + const messages = JSON.parse(output); + if (!Array.isArray(messages)) return []; + return messages.map((m: any) => ({ + id: m.id ?? m.messageId ?? "", + threadId: m.threadId ?? "", + from: m.from ?? "", + subject: m.subject ?? "", + snippet: m.snippet ?? "", + date: m.date ?? m.internalDate ?? "", + isUnread: m.labelIds?.includes("UNREAD") ?? true, + category: categorizeEmail(m.from ?? "", m.subject ?? ""), + })); + } catch { + // Fallback: parse plain text output + return output.split("\n").filter(Boolean).map((line, i) => ({ + id: `msg-${i}`, + threadId: `thread-${i}`, + from: "", + subject: line.trim(), + snippet: "", + date: new Date().toISOString(), + isUnread: true, + })); + } +} + +export const GmailServiceLive = Layer.succeed( + GmailService, + GmailService.of({ + search: ({ query, maxResults }) => + Effect.gen(function* () { + const max = maxResults ?? 50; + const output = yield* runGogcli( + `gmail search "${query}" --account ${ACCOUNT} --json --max ${max}`, + ); + return parseGogcliOutput(output); + }), + + markRead: ({ threadId }) => + runGogcli( + `gmail thread modify ${threadId} --remove UNREAD --account ${ACCOUNT} --force`, + ).pipe(Effect.asVoid), + + createDraft: ({ to, subject, body, replyToMessageId }) => + Effect.gen(function* () { + const replyArg = replyToMessageId ? ` --reply-to-message-id "${replyToMessageId}"` : ""; + yield* runGogcli( + `gmail drafts create --account ${ACCOUNT} --to "${to}" --subject "${subject}" --body "${body}"${replyArg}`, + ); + return { id: crypto.randomUUID() }; + }), + }), +); diff --git a/apps/server/src/gmail/Services/GmailService.ts b/apps/server/src/gmail/Services/GmailService.ts new file mode 100644 index 0000000000..7cdf8fba9f --- /dev/null +++ b/apps/server/src/gmail/Services/GmailService.ts @@ -0,0 +1,13 @@ +import { ServiceMap, Effect } from "effect"; +import type { GmailMessage } from "@t3tools/contracts"; +import type { GmailError } from "../Errors.ts"; + +export interface GmailServiceShape { + readonly search: (input: { query: string; maxResults?: number }) => Effect.Effect, GmailError>; + readonly markRead: (input: { threadId: string }) => Effect.Effect; + readonly createDraft: (input: { to: string; subject: string; body: string; replyToMessageId?: string }) => Effect.Effect<{ id: string }, GmailError>; +} + +export class GmailService extends ServiceMap.Service()( + "t3/gmail/Services/GmailService", +) {} diff --git a/apps/server/src/jira/Errors.ts b/apps/server/src/jira/Errors.ts new file mode 100644 index 0000000000..14c37fca5d --- /dev/null +++ b/apps/server/src/jira/Errors.ts @@ -0,0 +1,11 @@ +import { Data } from "effect"; + +export class JiraApiError extends Data.TaggedError("JiraApiError")<{ + readonly message: string; + readonly statusCode?: number; + readonly ticketKey?: string; +}> {} + +export class JiraConfigError extends Data.TaggedError("JiraConfigError")<{ + readonly message: string; +}> {} diff --git a/apps/server/src/jira/Layers/JiraService.ts b/apps/server/src/jira/Layers/JiraService.ts new file mode 100644 index 0000000000..70d6ae5552 --- /dev/null +++ b/apps/server/src/jira/Layers/JiraService.ts @@ -0,0 +1,196 @@ +import { Effect, Layer } from "effect"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { JiraService } from "../Services/JiraService.ts"; +import { JiraApiError, JiraConfigError } from "../Errors.ts"; +import type { JiraTicket } from "@t3tools/contracts"; + +const JIRA_BASE_URL = process.env.JIRA_BASE_URL ?? "https://mediafly.atlassian.net"; + +function readNetrcCredentials(host: string): { login: string; password: string } | null { + try { + const netrcPath = path.join(os.homedir(), ".netrc"); + const content = fs.readFileSync(netrcPath, "utf-8"); + const machineRegex = new RegExp( + `machine\\s+${host.replace(/\./g, "\\.")}\\s+login\\s+(\\S+)\\s+password\\s+(\\S+)`, + ); + const match = content.match(machineRegex); + if (match) return { login: match[1]!, password: match[2]! }; + return null; + } catch { + return null; + } +} + +function getAuthHeader(): Effect.Effect { + return Effect.sync(() => { + const url = new URL(JIRA_BASE_URL); + const creds = readNetrcCredentials(url.hostname); + if (!creds) { + return Effect.fail( + new JiraConfigError({ message: `No credentials found in ~/.netrc for ${url.hostname}` }), + ); + } + return Effect.succeed( + `Basic ${Buffer.from(`${creds.login}:${creds.password}`).toString("base64")}`, + ); + }).pipe(Effect.flatten); +} + +function mapIssueToTicket(issue: any): JiraTicket { + const fields = issue.fields ?? {}; + return { + key: issue.key, + summary: fields.summary ?? "Untitled", + status: fields.status?.name ?? "Unknown", + priority: fields.priority?.name ?? "Medium", + issueType: fields.issuetype?.name ?? "Task", + assignee: fields.assignee?.displayName ?? null, + reporter: fields.reporter?.displayName ?? null, + description: extractTextFromAdf(fields.description), + components: (fields.components ?? []).map((c: any) => c.name), + labels: fields.labels ?? [], + parentKey: fields.parent?.key ?? null, + url: `${JIRA_BASE_URL}/browse/${issue.key}`, + created: fields.created ?? new Date().toISOString(), + updated: fields.updated ?? new Date().toISOString(), + } as JiraTicket; +} + +function extractTextFromAdf(adf: any): string | null { + if (!adf || typeof adf !== "object") return null; + if (adf.type === "text") return adf.text ?? ""; + if (Array.isArray(adf.content)) { + return adf.content.map(extractTextFromAdf).filter(Boolean).join(""); + } + return null; +} + +export const JiraServiceLive = Layer.effect( + JiraService, + Effect.gen(function* () { + return JiraService.of({ + listTickets: ({ assignee, status, maxResults }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const jql = [ + assignee ? `assignee = "${assignee}"` : "assignee = currentUser()", + "resolution = Unresolved", + status ? `status = "${status}"` : undefined, + ] + .filter(Boolean) + .join(" AND "); + const params = new URLSearchParams({ + jql, + maxResults: String(maxResults ?? 50), + }); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/search/jql?${params}`, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}` }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + }); + } + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ issues: any[] }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}` }), + }); + return (data.issues ?? []).map(mapIssueToTicket); + }), + + getTicket: ({ ticketKey }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}`, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}`, ticketKey }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + ticketKey, + }); + } + const issue = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}`, ticketKey }), + }); + return mapIssueToTicket(issue); + }), + + searchTickets: ({ jql, maxResults }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const params = new URLSearchParams({ + jql, + maxResults: String(maxResults ?? 50), + }); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/search/jql?${params}`, { + headers: { Authorization: auth, Accept: "application/json" }, + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}` }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + }); + } + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ issues: any[] }>, + catch: (e) => new JiraApiError({ message: `JSON parse failed: ${e}` }), + }); + return (data.issues ?? []).map(mapIssueToTicket); + }), + + postComment: ({ ticketKey, body }) => + Effect.gen(function* () { + const auth = yield* getAuthHeader(); + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}/comment`, { + method: "POST", + headers: { + Authorization: auth, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + body: { + type: "doc", + version: 1, + content: [ + { type: "paragraph", content: [{ type: "text", text: body }] }, + ], + }, + }), + }), + catch: (e) => new JiraApiError({ message: `Fetch failed: ${e}`, ticketKey }), + }); + if (!response.ok) { + return yield* new JiraApiError({ + message: `Jira API error: ${response.status}`, + statusCode: response.status, + ticketKey, + }); + } + }), + + refreshCache: () => Effect.succeed({ count: 0 }), + }); + }), +); diff --git a/apps/server/src/jira/Services/JiraService.ts b/apps/server/src/jira/Services/JiraService.ts new file mode 100644 index 0000000000..7c6d574c29 --- /dev/null +++ b/apps/server/src/jira/Services/JiraService.ts @@ -0,0 +1,31 @@ +import { ServiceMap, Effect } from "effect"; +import type { JiraTicket } from "@t3tools/contracts"; +import type { JiraApiError, JiraConfigError } from "../Errors.ts"; + +export interface JiraServiceShape { + readonly listTickets: (input: { + readonly assignee?: string; + readonly status?: string; + readonly maxResults?: number; + }) => Effect.Effect, JiraApiError | JiraConfigError>; + + readonly getTicket: (input: { + readonly ticketKey: string; + }) => Effect.Effect; + + readonly searchTickets: (input: { + readonly jql: string; + readonly maxResults?: number; + }) => Effect.Effect, JiraApiError | JiraConfigError>; + + readonly postComment: (input: { + readonly ticketKey: string; + readonly body: string; + }) => Effect.Effect; + + readonly refreshCache: () => Effect.Effect<{ count: number }, JiraApiError | JiraConfigError>; +} + +export class JiraService extends ServiceMap.Service()( + "t3/jira/Services/JiraService", +) {} diff --git a/apps/server/src/jira/contextSeeding.ts b/apps/server/src/jira/contextSeeding.ts new file mode 100644 index 0000000000..813eba4dd4 --- /dev/null +++ b/apps/server/src/jira/contextSeeding.ts @@ -0,0 +1,107 @@ +import { Data, Effect } from "effect"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { JiraService } from "./Services/JiraService.ts"; + +export class ContextSeedingError extends Data.TaggedError("ContextSeedingError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * Generate a CLAUDE.md context file for a project workspace from Jira ticket data. + * This seeds the AI agent with relevant project context. + */ +export const generateContextFile = ({ + workspaceRoot, + ticketKey, + specContent, +}: { + workspaceRoot: string; + ticketKey: string; + specContent?: string; +}) => + Effect.gen(function* () { + const jiraService = yield* JiraService; + + // Fetch ticket data + const ticket = yield* jiraService.getTicket({ ticketKey }).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + + const sections: string[] = []; + + // Header + sections.push(`# Project Context — ${ticketKey}`); + sections.push(""); + + if (ticket) { + // Ticket info + sections.push(`## Jira Ticket: ${ticket.key}`); + sections.push(""); + sections.push(`- **Summary:** ${ticket.summary}`); + sections.push(`- **Status:** ${ticket.status}`); + sections.push(`- **Priority:** ${ticket.priority}`); + sections.push(`- **Type:** ${ticket.issueType}`); + if (ticket.assignee) sections.push(`- **Assignee:** ${ticket.assignee}`); + if (ticket.reporter) sections.push(`- **Reporter:** ${ticket.reporter}`); + if (ticket.components.length > 0) { + sections.push(`- **Components:** ${ticket.components.join(", ")}`); + } + if (ticket.labels.length > 0) { + sections.push(`- **Labels:** ${ticket.labels.join(", ")}`); + } + if (ticket.parentKey) { + sections.push(`- **Parent:** ${ticket.parentKey}`); + } + sections.push(`- **URL:** ${ticket.url}`); + sections.push(""); + + // Description + if (ticket.description) { + sections.push("## Description"); + sections.push(""); + sections.push(ticket.description); + sections.push(""); + } + } + + // Spec/planning notes + if (specContent && specContent.trim().length > 0) { + sections.push("## Planning Notes"); + sections.push(""); + sections.push(specContent); + sections.push(""); + } + + // Instructions + sections.push("## Instructions"); + sections.push(""); + sections.push("- This file was auto-generated from Jira ticket data and planning notes."); + sections.push("- Focus on implementing the requirements described above."); + sections.push("- Reference the Jira ticket for additional context and acceptance criteria."); + sections.push("- Update the ticket status when work is complete."); + sections.push(""); + + const content = sections.join("\n"); + + // Write to workspace + yield* Effect.try({ + try: () => { + const claudeMdPath = path.join(workspaceRoot, "CLAUDE.md"); + // Don't overwrite if it already exists and has custom content + if (fs.existsSync(claudeMdPath)) { + const existing = fs.readFileSync(claudeMdPath, "utf-8"); + if (!existing.includes("# Project Context —")) { + // File exists with custom content, append instead + fs.appendFileSync(claudeMdPath, "\n\n" + content); + return; + } + } + fs.writeFileSync(claudeMdPath, content, "utf-8"); + }, + catch: (cause) => new ContextSeedingError({ message: `Failed to write CLAUDE.md`, cause }), + }); + + return { path: path.join(workspaceRoot, "CLAUDE.md"), ticketKey }; + }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 69b28b9d3c..f05387898c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -37,6 +37,9 @@ function commandToAggregateRef(command: OrchestrationCommand): { case "project.create": case "project.meta.update": case "project.delete": + case "project.jira-metadata.update": + case "project.note.update": + case "project.touch": return { aggregateKind: "project", aggregateId: command.projectId, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index c96385cad1..a41fb2025c 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -2,6 +2,9 @@ import { ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, + ProjectJiraMetadataUpdatedPayload as ContractsProjectJiraMetadataUpdatedPayloadSchema, + ProjectNoteUpdatedPayload as ContractsProjectNoteUpdatedPayloadSchema, + ProjectTouchedPayload as ContractsProjectTouchedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, @@ -24,6 +27,9 @@ import { export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; +export const ProjectJiraMetadataUpdatedPayload = ContractsProjectJiraMetadataUpdatedPayloadSchema; +export const ProjectNoteUpdatedPayload = ContractsProjectNoteUpdatedPayloadSchema; +export const ProjectTouchedPayload = ContractsProjectTouchedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c51759..461e47ded4 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -111,6 +111,83 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "project.jira-metadata.update": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.jira-metadata-updated", + payload: { + projectId: command.projectId, + ...(command.ticketKey !== undefined ? { ticketKey: command.ticketKey } : {}), + ...(command.jiraStatus !== undefined ? { jiraStatus: command.jiraStatus } : {}), + ...(command.priority !== undefined ? { priority: command.priority } : {}), + ...(command.jiraUrl !== undefined ? { jiraUrl: command.jiraUrl } : {}), + ...(command.components !== undefined ? { components: command.components } : {}), + ...(command.labels !== undefined ? { labels: command.labels } : {}), + ...(command.assignee !== undefined ? { assignee: command.assignee } : {}), + ...(command.reporter !== undefined ? { reporter: command.reporter } : {}), + ...(command.description !== undefined ? { description: command.description } : {}), + ...(command.parentKey !== undefined ? { parentKey: command.parentKey } : {}), + ...(command.suggestedRepo !== undefined ? { suggestedRepo: command.suggestedRepo } : {}), + updatedAt: occurredAt, + }, + }; + } + + case "project.note.update": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.note-updated", + payload: { + projectId: command.projectId, + note: command.note, + updatedAt: occurredAt, + }, + }; + } + + case "project.touch": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt: command.lastAccessedAt, + commandId: command.commandId, + }), + type: "project.touched", + payload: { + projectId: command.projectId, + lastAccessedAt: command.lastAccessedAt, + }, + }; + } + case "project.delete": { yield* requireProject({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a677..40114ff861 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -12,7 +12,10 @@ import { MessageSentPayloadSchema, ProjectCreatedPayload, ProjectDeletedPayload, + ProjectJiraMetadataUpdatedPayload, ProjectMetaUpdatedPayload, + ProjectNoteUpdatedPayload, + ProjectTouchedPayload, ThreadActivityAppendedPayload, ThreadCreatedPayload, ThreadDeletedPayload, @@ -238,6 +241,63 @@ export function projectEvent( })), ); + case "project.jira-metadata-updated": + return decodeForEvent(ProjectJiraMetadataUpdatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + ...(payload.ticketKey !== undefined ? { ticketKey: payload.ticketKey } : {}), + ...(payload.jiraStatus !== undefined ? { jiraStatus: payload.jiraStatus } : {}), + ...(payload.priority !== undefined ? { priority: payload.priority } : {}), + ...(payload.jiraUrl !== undefined ? { jiraUrl: payload.jiraUrl } : {}), + ...(payload.components !== undefined ? { components: payload.components } : {}), + ...(payload.labels !== undefined ? { labels: payload.labels } : {}), + ...(payload.assignee !== undefined ? { assignee: payload.assignee } : {}), + ...(payload.reporter !== undefined ? { reporter: payload.reporter } : {}), + ...(payload.description !== undefined ? { description: payload.description } : {}), + ...(payload.parentKey !== undefined ? { parentKey: payload.parentKey } : {}), + ...(payload.suggestedRepo !== undefined ? { suggestedRepo: payload.suggestedRepo } : {}), + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + + case "project.note-updated": + return decodeForEvent(ProjectNoteUpdatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + note: payload.note, + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + + case "project.touched": + return decodeForEvent(ProjectTouchedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + lastAccessedAt: payload.lastAccessedAt, + } + : project, + ), + })), + ); + case "thread.created": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ea1821014a..8b8d5d8265 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -27,6 +27,10 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; +import Migration0016 from "./Migrations/016_ProjectionProjectsJiraMetadata.ts"; +import Migration0017 from "./Migrations/017_PromptHistory.ts"; +import Migration0018 from "./Migrations/018_JiraCache.ts"; +import Migration0019 from "./Migrations/019_Specs.ts"; import { Effect } from "effect"; /** @@ -55,6 +59,10 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, + "16_ProjectionProjectsJiraMetadata": Migration0016, + "17_PromptHistory": Migration0017, + "18_JiraCache": Migration0018, + "19_Specs": Migration0019, }); /** diff --git a/apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts b/apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts new file mode 100644 index 0000000000..48e89b9dc9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionProjectsJiraMetadata.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Jira metadata fields + yield* sql`ALTER TABLE projection_projects ADD COLUMN ticket_key TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN jira_status TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN priority TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN jira_url TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN components_json TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN labels_json TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN assignee TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN reporter TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN description TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN parent_key TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN suggested_repo TEXT`; + + // User note + yield* sql`ALTER TABLE projection_projects ADD COLUMN note TEXT`; + + // Access tracking + yield* sql`ALTER TABLE projection_projects ADD COLUMN last_accessed_at TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN archived_at TEXT`; +}); diff --git a/apps/server/src/persistence/Migrations/017_PromptHistory.ts b/apps/server/src/persistence/Migrations/017_PromptHistory.ts new file mode 100644 index 0000000000..40ec31966c --- /dev/null +++ b/apps/server/src/persistence/Migrations/017_PromptHistory.ts @@ -0,0 +1,20 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS prompt_history ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + prompt TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_prompt_history_project_created + ON prompt_history(project_id, created_at DESC) + `; +}); diff --git a/apps/server/src/persistence/Migrations/018_JiraCache.ts b/apps/server/src/persistence/Migrations/018_JiraCache.ts new file mode 100644 index 0000000000..61ebfbc21a --- /dev/null +++ b/apps/server/src/persistence/Migrations/018_JiraCache.ts @@ -0,0 +1,43 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS jira_tickets ( + key TEXT PRIMARY KEY, + summary TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + issue_type TEXT NOT NULL, + assignee TEXT, + reporter TEXT, + description TEXT, + components_json TEXT NOT NULL DEFAULT '[]', + labels_json TEXT NOT NULL DEFAULT '[]', + parent_key TEXT, + url TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + cached_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS component_repo_map ( + component TEXT PRIMARY KEY, + repo TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_jira_tickets_status + ON jira_tickets(status) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_jira_tickets_assignee + ON jira_tickets(assignee) + `; +}); diff --git a/apps/server/src/persistence/Migrations/019_Specs.ts b/apps/server/src/persistence/Migrations/019_Specs.ts new file mode 100644 index 0000000000..d4ce90156a --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_Specs.ts @@ -0,0 +1,21 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS specs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_specs_project_id + ON specs(project_id) + `; +}); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 7250f8566c..1882927df7 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -35,6 +35,9 @@ import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { JiraServiceLive } from "./jira/Layers/JiraService"; +import { GmailServiceLive } from "./gmail/Layers/GmailService"; +import { CalendarServiceLive } from "./calendar/Layers/CalendarService"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, @@ -128,5 +131,8 @@ export function makeServerRuntimeServicesLayer() { gitManagerLayer, terminalLayer, KeybindingsLive, + JiraServiceLive, + GmailServiceLive, + CalendarServiceLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/skills/triageSkill.ts b/apps/server/src/skills/triageSkill.ts new file mode 100644 index 0000000000..2543264b93 --- /dev/null +++ b/apps/server/src/skills/triageSkill.ts @@ -0,0 +1,242 @@ +import { Effect } from "effect"; +import { JiraService } from "../jira/Services/JiraService.ts"; +import { GmailService } from "../gmail/Services/GmailService.ts"; +import { CalendarService } from "../calendar/Services/CalendarService.ts"; + +export interface TriageItem { + priority: "urgent" | "respond" | "prepare" | "progress" | "housekeeping"; + source: "jira" | "email" | "calendar" | "github"; + summary: string; + action?: string; + link?: string; +} + +export interface TriageReport { + timestamp: string; + items: TriageItem[]; + stats: { + unreadEmails: number; + actionEmails: number; + noiseEmails: number; + jiraTickets: number; + jiraChanged: number; + meetingsToday: number; + meetingsTomorrow: number; + }; + markdown: string; +} + +/** + * Scan all communication channels and generate a prioritized action list. + */ +export const generateTriageReport = Effect.gen(function* () { + const jiraService = yield* JiraService; + const gmailService = yield* GmailService; + const calendarService = yield* CalendarService; + + const now = new Date(); + const timestamp = now.toISOString(); + const items: TriageItem[] = []; + + // Fetch all data in parallel + const [tickets, unreadEmails, todayMeetings, tomorrowMeetings] = yield* Effect.all( + [ + jiraService.listTickets({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + gmailService.search({ query: "is:unread", maxResults: 50 }).pipe(Effect.catch(() => Effect.succeed([] as const))), + calendarService.agenda({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + calendarService.agenda({ date: "tomorrow" }).pipe(Effect.catch(() => Effect.succeed([] as const))), + ] as const, + { concurrency: 4 }, + ); + + // --- Email Triage --- + let actionEmails = 0; + let noiseEmails = 0; + + for (const email of unreadEmails) { + const category = email.category ?? "FYI"; + + if (category === "NOISE") { + noiseEmails++; + continue; + } + + if (category === "ACTION") { + actionEmails++; + items.push({ + priority: "respond", + source: "email", + summary: `[EMAIL] From: ${email.from} — ${email.subject}`, + action: "Reply needed", + }); + } else if (category === "REVIEW") { + items.push({ + priority: "respond", + source: "email", + summary: `[REVIEW] ${email.subject}`, + action: "Review requested", + }); + } + } + + // --- Meeting Prep --- + const realMeetings = todayMeetings.filter((m) => !m.isAllDay); + const upcomingMeetings = realMeetings.filter((m) => { + try { + return new Date(m.start) > now; + } catch { + return false; + } + }); + + for (const meeting of upcomingMeetings.slice(0, 3)) { + const startTime = new Date(meeting.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + items.push({ + priority: "prepare", + source: "calendar", + summary: `[${startTime}] ${meeting.title}`, + action: "Prepare talking points", + }); + } + + // --- Jira Activity --- + const inProgress = tickets.filter((t) => t.status === "In Progress"); + const staleTickets = tickets.filter((t) => { + try { + const updated = new Date(t.updated); + const daysSinceUpdate = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24); + return daysSinceUpdate > 14; + } catch { + return false; + } + }); + const agingReview = tickets.filter((t) => { + try { + if (t.status !== "In Review") return false; + const updated = new Date(t.updated); + const daysSinceUpdate = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24); + return daysSinceUpdate > 5; + } catch { + return false; + } + }); + + for (const ticket of inProgress.slice(0, 5)) { + items.push({ + priority: "progress", + source: "jira", + summary: `${ticket.key}: ${ticket.summary}`, + action: `Status: ${ticket.status} | Priority: ${ticket.priority}`, + link: ticket.url, + }); + } + + for (const ticket of agingReview) { + items.push({ + priority: "urgent", + source: "jira", + summary: `${ticket.key} has been In Review for too long`, + action: "Follow up on review", + link: ticket.url, + }); + } + + for (const ticket of staleTickets.slice(0, 3)) { + items.push({ + priority: "housekeeping", + source: "jira", + summary: `${ticket.key}: No update in 14+ days — close or update?`, + link: ticket.url, + }); + } + + // Housekeeping: noise emails + if (noiseEmails > 0) { + items.push({ + priority: "housekeeping", + source: "email", + summary: `Archive ${noiseEmails} noise email(s)`, + action: "Auto-mark as read", + }); + } + + // Sort by priority + const priorityOrder = { urgent: 0, respond: 1, prepare: 2, progress: 3, housekeeping: 4 }; + items.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + + // Generate markdown + const sections: string[] = [ + `# Triage Report — ${now.toISOString().split("T")[0]} ${now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`, + "", + ]; + + const urgent = items.filter((i) => i.priority === "urgent"); + const respond = items.filter((i) => i.priority === "respond"); + const prepare = items.filter((i) => i.priority === "prepare"); + const progress = items.filter((i) => i.priority === "progress"); + const housekeeping = items.filter((i) => i.priority === "housekeeping"); + + if (urgent.length > 0) { + sections.push("## Do Right Now"); + for (const item of urgent) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (respond.length > 0) { + sections.push("## Respond To"); + for (const item of respond) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (prepare.length > 0) { + sections.push("## Meeting Prep"); + for (const item of prepare) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (progress.length > 0) { + sections.push("## Keep Working On"); + for (const item of progress) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + if (housekeeping.length > 0) { + sections.push("## Housekeeping"); + for (const item of housekeeping) { + sections.push(`- ${item.summary}${item.action ? ` — ${item.action}` : ""}`); + } + sections.push(""); + } + + sections.push("## Inbox Summary"); + sections.push(`- ${unreadEmails.length} unread emails (${actionEmails} action, ${noiseEmails} noise)`); + sections.push(`- ${tickets.length} Jira tickets`); + sections.push(`- ${realMeetings.length} meetings today, ${tomorrowMeetings.length} tomorrow`); + + const markdown = sections.join("\n"); + + const report: TriageReport = { + timestamp, + items, + stats: { + unreadEmails: unreadEmails.length, + actionEmails, + noiseEmails, + jiraTickets: tickets.length, + jiraChanged: 0, + meetingsToday: realMeetings.length, + meetingsTomorrow: tomorrowMeetings.length, + }, + markdown, + }; + + return report; +}); diff --git a/apps/server/src/skills/updateSkill.ts b/apps/server/src/skills/updateSkill.ts new file mode 100644 index 0000000000..4d1d11a75d --- /dev/null +++ b/apps/server/src/skills/updateSkill.ts @@ -0,0 +1,121 @@ +import { Effect } from "effect"; +import { JiraService } from "../jira/Services/JiraService.ts"; +import { GmailService } from "../gmail/Services/GmailService.ts"; +import { CalendarService } from "../calendar/Services/CalendarService.ts"; + +export interface UpdateReport { + date: string; + yesterday: string[]; + today: string[]; + blockers: string[]; + stats: { + jiraTickets: number; + inProgress: number; + waiting: number; + unreadEmails: number; + meetingsToday: number; + }; + markdown: string; +} + +/** + * Generate a daily status update by aggregating data from all services. + */ +export const generateDailyUpdate = Effect.gen(function* () { + const jiraService = yield* JiraService; + const gmailService = yield* GmailService; + const calendarService = yield* CalendarService; + + const today = new Date().toISOString().split("T")[0]!; + + // Fetch data from all services in parallel + const [tickets, emails, meetings] = yield* Effect.all( + [ + jiraService.listTickets({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + gmailService.search({ query: "is:unread newer_than:1d" }).pipe(Effect.catch(() => Effect.succeed([] as const))), + calendarService.agenda({}).pipe(Effect.catch(() => Effect.succeed([] as const))), + ] as const, + { concurrency: 3 }, + ); + + // Categorize tickets + const inProgress = tickets.filter((t) => t.status === "In Progress"); + const waiting = tickets.filter( + (t) => t.status === "Waiting" || t.status === "In Review" || t.status === "Blocked", + ); + const done = tickets.filter((t) => t.status === "Done" || t.status === "Closed"); + + // Build yesterday section (from recently updated tickets) + const yesterday: string[] = []; + for (const ticket of inProgress.slice(0, 3)) { + yesterday.push(`Worked on ${ticket.key}: ${ticket.summary}`); + } + if (done.length > 0) { + for (const ticket of done.slice(0, 2)) { + yesterday.push(`Completed ${ticket.key}: ${ticket.summary}`); + } + } + if (yesterday.length === 0) { + yesterday.push("Continued work on active tickets"); + } + + // Build today section + const todayItems: string[] = []; + for (const ticket of inProgress.slice(0, 3)) { + todayItems.push(`Continue ${ticket.key}: ${ticket.summary} (${ticket.priority})`); + } + if (meetings.length > 0) { + const realMeetings = meetings.filter((m) => !m.isAllDay); + if (realMeetings.length > 0) { + todayItems.push(`${realMeetings.length} meeting(s) scheduled`); + } + } + if (todayItems.length === 0) { + todayItems.push("Review and prioritize backlog"); + } + + // Build blockers section + const blockers: string[] = []; + for (const ticket of waiting) { + blockers.push(`${ticket.key}: ${ticket.summary} — ${ticket.status}`); + } + if (blockers.length === 0) { + blockers.push("None"); + } + + // Generate markdown + const markdown = [ + `# Daily Status Update — ${today}`, + "", + "## Yesterday", + ...yesterday.map((item) => `- ${item}`), + "", + "## Today", + ...todayItems.map((item) => `- ${item}`), + "", + "## Blockers", + ...blockers.map((item) => `- ${item}`), + "", + "---", + `📋 ${tickets.length} Jira tickets (${inProgress.length} in progress, ${waiting.length} waiting)`, + `📧 ${emails.length} unread emails`, + `📅 ${meetings.length} events today`, + ].join("\n"); + + const report: UpdateReport = { + date: today, + yesterday, + today: todayItems, + blockers, + stats: { + jiraTickets: tickets.length, + inProgress: inProgress.length, + waiting: waiting.length, + unreadEmails: emails.length, + meetingsToday: meetings.length, + }, + markdown, + }; + + return report; +}); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..f72cb73c84 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -45,6 +45,7 @@ import { } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; @@ -74,6 +75,9 @@ import { } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { JiraService } from "./jira/Services/JiraService.ts"; +import { CalendarService } from "./calendar/Services/CalendarService.ts"; +import { GmailService } from "./gmail/Services/GmailService.ts"; import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; @@ -89,7 +93,7 @@ export interface ServerShape { readonly start: Effect.Effect< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | SqlClient.SqlClient >; /** @@ -217,7 +221,10 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | Open - | AnalyticsService; + | AnalyticsService + | JiraService + | CalendarService + | GmailService; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -234,7 +241,7 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | SqlClient.SqlClient > { const serverConfig = yield* ServerConfig; const { @@ -257,6 +264,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => @@ -603,6 +611,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; const { openInEditor } = yield* Open; + const jiraService = yield* JiraService; + const calendarService = yield* CalendarService; + const gmailService = yield* GmailService; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -883,6 +894,177 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.specGet: { + const body = stripRequestTag(request.body); + const rows = yield* sql` + SELECT id, project_id, content, created_at, updated_at + FROM specs + WHERE project_id = ${body.projectId} + LIMIT 1 + `; + if (rows.length === 0) return null; + const r = rows[0] as any; + return { id: r.id, projectId: r.project_id, content: r.content, createdAt: r.created_at, updatedAt: r.updated_at }; + } + + case WS_METHODS.specUpdate: { + const body = stripRequestTag(request.body); + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + yield* sql` + INSERT INTO specs (id, project_id, content, created_at, updated_at) + VALUES (${id}, ${body.projectId}, ${body.content}, ${now}, ${now}) + ON CONFLICT(project_id) DO UPDATE SET + content = ${body.content}, + updated_at = ${now} + `; + return { id }; + } + + case WS_METHODS.promptHistoryList: { + const { projectId, limit } = request.body; + const maxLimit = limit ?? 50; + const rows = yield* sql` + SELECT id, project_id, prompt, created_at + FROM prompt_history + WHERE project_id = ${projectId} + ORDER BY created_at DESC + LIMIT ${maxLimit} + `; + return rows.map((r: any) => ({ + id: r.id, + projectId: r.project_id, + prompt: r.prompt, + createdAt: r.created_at, + })); + } + + case WS_METHODS.promptHistoryAdd: { + const { projectId, prompt } = request.body; + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + yield* sql` + INSERT INTO prompt_history (id, project_id, prompt, created_at) + VALUES (${id}, ${projectId}, ${prompt}, ${createdAt}) + `; + return { id }; + } + + case WS_METHODS.gmailSearch: { + const body = stripRequestTag(request.body); + return yield* gmailService.search({ + query: body.query, + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.gmailMarkRead: { + const body = stripRequestTag(request.body); + yield* gmailService.markRead({ threadId: body.threadId }); + return {}; + } + + case WS_METHODS.gmailCreateDraft: { + const body = stripRequestTag(request.body); + return yield* gmailService.createDraft({ + to: body.to, + subject: body.subject, + body: body.body, + ...(body.replyToMessageId !== undefined ? { replyToMessageId: body.replyToMessageId } : {}), + }); + } + + case WS_METHODS.calendarAgenda: { + const body = stripRequestTag(request.body); + return yield* calendarService.agenda({ + ...(body.date !== undefined ? { date: body.date } : {}), + }); + } + + case WS_METHODS.calendarMeetingPrep: { + const body = stripRequestTag(request.body); + return yield* calendarService.meetingPrep({ + title: body.title, + start: body.start, + }); + } + + case WS_METHODS.jiraList: { + const body = stripRequestTag(request.body); + return yield* jiraService.listTickets({ + ...(body.assignee !== undefined ? { assignee: body.assignee } : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.jiraGet: { + const body = stripRequestTag(request.body); + return yield* jiraService.getTicket({ ticketKey: body.ticketKey }); + } + + case WS_METHODS.jiraSearch: { + const body = stripRequestTag(request.body); + return yield* jiraService.searchTickets({ + jql: body.jql, + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.jiraRefresh: { + return yield* jiraService.refreshCache(); + } + + case WS_METHODS.jiraPostComment: { + const body = stripRequestTag(request.body); + yield* jiraService.postComment({ + ticketKey: body.ticketKey, + body: body.body, + }); + return {}; + } + + // Gmail methods + case WS_METHODS.gmailSearch: { + const body = stripRequestTag(request.body); + return yield* gmailService.search({ + query: body.query, + ...(body.maxResults !== undefined ? { maxResults: body.maxResults } : {}), + }); + } + + case WS_METHODS.gmailMarkRead: { + const body = stripRequestTag(request.body); + yield* gmailService.markRead({ threadId: body.threadId }); + return {}; + } + + case WS_METHODS.gmailCreateDraft: { + const body = stripRequestTag(request.body); + return yield* gmailService.createDraft({ + to: body.to, + subject: body.subject, + body: body.body, + ...(body.replyToMessageId !== undefined ? { replyToMessageId: body.replyToMessageId } : {}), + }); + } + + // Calendar methods + case WS_METHODS.calendarAgenda: { + const body = stripRequestTag(request.body); + return yield* calendarService.agenda({ + ...(body.date !== undefined ? { date: body.date } : {}), + }); + } + + case WS_METHODS.calendarMeetingPrep: { + const body = stripRequestTag(request.body); + return yield* calendarService.meetingPrep({ + title: body.title, + start: body.start, + }); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/src/commandTrayStore.ts b/apps/web/src/commandTrayStore.ts new file mode 100644 index 0000000000..aa7da6a24a --- /dev/null +++ b/apps/web/src/commandTrayStore.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export interface CommandTrayButton { + id: string; + label: string; + command: string; + icon?: string; +} + +interface CommandTrayState { + buttons: CommandTrayButton[]; + setButtons: (buttons: CommandTrayButton[]) => void; + addButton: (button: CommandTrayButton) => void; + removeButton: (id: string) => void; + updateButton: (id: string, updates: Partial) => void; + resetToDefaults: () => void; +} + +const DEFAULT_BUTTONS: CommandTrayButton[] = [ + { id: "update", label: "/update", command: "/update\n" }, + { id: "triage", label: "/triage", command: "/triage\n" }, + { id: "commit", label: "/commit", command: "/commit\n" }, + { id: "shell", label: "Shell", command: "" }, + { id: "claude", label: "Claude", command: "" }, +]; + +const COMMAND_TRAY_STORAGE_KEY = "t3code:command-tray:v1"; + +export const useCommandTrayStore = create()( + persist( + (set) => ({ + buttons: DEFAULT_BUTTONS, + setButtons: (buttons) => set({ buttons }), + addButton: (button) => + set((state) => ({ buttons: [...state.buttons, button] })), + removeButton: (id) => + set((state) => ({ + buttons: state.buttons.filter((btn) => btn.id !== id), + })), + updateButton: (id, updates) => + set((state) => ({ + buttons: state.buttons.map((btn) => + btn.id === id ? { ...btn, ...updates } : btn, + ), + })), + resetToDefaults: () => set({ buttons: DEFAULT_BUTTONS }), + }), + { + name: COMMAND_TRAY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ buttons: state.buttons }), + }, + ), +); diff --git a/apps/web/src/components/CommandTray.tsx b/apps/web/src/components/CommandTray.tsx new file mode 100644 index 0000000000..5a0b4aee56 --- /dev/null +++ b/apps/web/src/components/CommandTray.tsx @@ -0,0 +1,185 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { Pencil, Plus, Trash2, X } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { + type CommandTrayButton, + useCommandTrayStore, +} from "~/commandTrayStore"; +import { readNativeApi } from "~/nativeApi"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, + DialogClose, +} from "~/components/ui/dialog"; + +interface CommandTrayProps { + threadId: ThreadId | null; + terminalId: string; +} + +function CommandTray({ threadId, terminalId }: CommandTrayProps) { + const buttons = useCommandTrayStore((state) => state.buttons); + const [editOpen, setEditOpen] = useState(false); + + const sendCommand = useCallback( + (command: string) => { + if (!command || !threadId) return; + const api = readNativeApi(); + if (!api) return; + void api.terminal.write({ threadId, terminalId, data: command }); + }, + [threadId, terminalId], + ); + + return ( +
+ {buttons.map((btn) => ( + + ))} + + + setEditOpen(false)} /> + +
+ ); +} + +function EditCommandTrayDialog({ onClose }: { onClose: () => void }) { + const buttons = useCommandTrayStore((state) => state.buttons); + const addButton = useCommandTrayStore((state) => state.addButton); + const removeButton = useCommandTrayStore((state) => state.removeButton); + const updateButton = useCommandTrayStore((state) => state.updateButton); + const resetToDefaults = useCommandTrayStore((state) => state.resetToDefaults); + + const [newLabel, setNewLabel] = useState(""); + const [newCommand, setNewCommand] = useState(""); + + const handleAdd = () => { + const label = newLabel.trim(); + const command = newCommand.trim(); + if (!label) return; + addButton({ + id: `custom-${Date.now()}`, + label, + command: command ? `${command}\n` : "", + }); + setNewLabel(""); + setNewCommand(""); + }; + + return ( + + + Edit Command Tray + + Customize the command buttons shown at the bottom of the chat. + + + +
+ {buttons.map((btn) => ( +
+ ) => + updateButton(btn.id, { + label: (e.target as HTMLInputElement).value, + }) + } + placeholder="Label" + size="sm" + className="flex-1" + /> + ) => + updateButton(btn.id, { + command: (e.target as HTMLInputElement).value + ? `${(e.target as HTMLInputElement).value}\n` + : "", + }) + } + placeholder="Command" + size="sm" + className="flex-1" + /> + +
+ ))} +
+ ) => + setNewLabel((e.target as HTMLInputElement).value) + } + placeholder="New label" + size="sm" + className="flex-1" + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") handleAdd(); + }} + /> + ) => + setNewCommand((e.target as HTMLInputElement).value) + } + placeholder="New command" + size="sm" + className="flex-1" + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") handleAdd(); + }} + /> + +
+
+
+ + + Done} /> + +
+ ); +} + +export default CommandTray; diff --git a/apps/web/src/components/PRCreationModal.tsx b/apps/web/src/components/PRCreationModal.tsx new file mode 100644 index 0000000000..bd7d686cf7 --- /dev/null +++ b/apps/web/src/components/PRCreationModal.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogTitle, +} from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { ensureNativeApi } from "~/nativeApi"; +import type { GitStatusResult, ProjectId, ThreadId } from "@t3tools/contracts"; + +interface PRCreationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: ProjectId; + threadId: ThreadId; + ticketKey?: string; + workspaceRoot: string; +} + +type Step = "review" | "create" | "jira"; + +export function PRCreationModal({ + open, + onOpenChange, + projectId, + threadId, + ticketKey, + workspaceRoot, +}: PRCreationModalProps) { + const [step, setStep] = useState("review"); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [baseBranch, setBaseBranch] = useState("main"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [gitStatus, setGitStatus] = useState(null); + const [jiraComment, setJiraComment] = useState(""); + const [jiraPosted, setJiraPosted] = useState(false); + + // Reset state when modal opens + useEffect(() => { + if (open) { + setStep("review"); + setTitle(ticketKey ? `${ticketKey}: ` : ""); + setBody(""); + setBaseBranch("main"); + setLoading(false); + setError(null); + setGitStatus(null); + setJiraComment(""); + setJiraPosted(false); + } + }, [open, ticketKey]); + + // Fetch git status when review step is active + useEffect(() => { + if (!open || step !== "review") return; + let cancelled = false; + + async function fetchStatus() { + try { + setLoading(true); + setError(null); + const api = ensureNativeApi(); + const status = await api.git.status({ cwd: workspaceRoot }); + if (!cancelled) { + setGitStatus(status); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch git status"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void fetchStatus(); + return () => { + cancelled = true; + }; + }, [open, step, workspaceRoot]); + + const handleCreatePR = useCallback(async () => { + if (!title.trim()) { + setError("PR title is required"); + return; + } + + setLoading(true); + setError(null); + + try { + const api = ensureNativeApi(); + // Escape double quotes in title and body for the shell command + const escapedTitle = title.replace(/"/g, '\\"'); + const escapedBody = body.replace(/"/g, '\\"'); + const command = `gh pr create --title "${escapedTitle}" --body "${escapedBody}" --base ${baseBranch}\n`; + + await api.terminal.write({ + threadId, + data: command, + }); + + // Pre-fill Jira comment if ticket key is available + if (ticketKey) { + setJiraComment( + `Pull request created for branch ${gitStatus?.branch ?? "unknown"}:\n\nTitle: ${title}\nBase: ${baseBranch}`, + ); + setStep("jira"); + } else { + onOpenChange(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create pull request"); + } finally { + setLoading(false); + } + }, [title, body, baseBranch, threadId, ticketKey, gitStatus?.branch, onOpenChange]); + + const handlePostJiraComment = useCallback(async () => { + if (!ticketKey || !jiraComment.trim()) return; + + setLoading(true); + setError(null); + + try { + const api = ensureNativeApi(); + await api.jira.postComment({ + ticketKey, + body: jiraComment, + }); + setJiraPosted(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to post Jira comment"); + } finally { + setLoading(false); + } + }, [ticketKey, jiraComment]); + + return ( + + + + + {step === "review" && "Review Changes"} + {step === "create" && "Create Pull Request"} + {step === "jira" && "Post to Jira"} + + + {step === "review" && "Review your current branch and changes before creating a PR."} + {step === "create" && "Fill in the pull request details."} + {step === "jira" && `Post a comment to ${ticketKey} about this PR.`} + + + + + {/* Step 1: Review */} + {step === "review" && ( +
+ {loading && !gitStatus && ( +

Loading git status...

+ )} + + {error &&

{error}

} + + {gitStatus && ( + <> +
+ + Branch + + {gitStatus.branch ?? "detached HEAD"} +
+ +
+ + Upstream + + + {gitStatus.hasUpstream + ? `${gitStatus.aheadCount} ahead, ${gitStatus.behindCount} behind` + : "No upstream branch"} + +
+ + {gitStatus.pr && ( +
+ + Existing PR + + + #{gitStatus.pr.number} {gitStatus.pr.title} ({gitStatus.pr.state}) + +
+ )} + +
+ + Diff Summary + + + {gitStatus.workingTree.files.length} file + {gitStatus.workingTree.files.length !== 1 ? "s" : ""} changed + {gitStatus.workingTree.insertions > 0 && + `, +${gitStatus.workingTree.insertions}`} + {gitStatus.workingTree.deletions > 0 && + `, -${gitStatus.workingTree.deletions}`} + +
+ + {gitStatus.workingTree.files.length > 0 && ( +
+ + Changed Files + +
    + {gitStatus.workingTree.files.map((file) => ( +
  • + {file.path} + + {file.insertions > 0 && ( + +{file.insertions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} + +
  • + ))} +
+
+ )} + + )} +
+ )} + + {/* Step 2: Create PR */} + {step === "create" && ( +
+ {error &&

{error}

} + +
+ + setTitle((e.target as HTMLInputElement).value)} + placeholder="PR title" + /> +
+ +
+ +