From bd3999416b20e3aea8c6740ca5774c97f31cf0be Mon Sep 17 00:00:00 2001
From: webdevcody
Date: Fri, 16 Jan 2026 18:37:11 -0500
Subject: [PATCH] feat: implement notifications and event history features
- Added Notification Service to manage project-level notifications, including creation, listing, marking as read, and dismissing notifications.
- Introduced Event History Service to store and manage historical events, allowing for listing, retrieval, deletion, and replaying of events.
- Integrated notifications into the server and UI, providing real-time updates for feature statuses and operations.
- Enhanced sidebar and project switcher components to display unread notifications count.
- Created dedicated views for managing notifications and event history, improving user experience and accessibility.
These changes enhance the application's ability to inform users about important events and statuses, improving overall usability and responsiveness.
---
apps/server/src/index.ts | 19 +-
.../app-spec/parse-and-create-features.ts | 10 +
.../server/src/routes/event-history/common.ts | 19 +
apps/server/src/routes/event-history/index.ts | 68 ++++
.../src/routes/event-history/routes/clear.ts | 33 ++
.../src/routes/event-history/routes/delete.ts | 43 +++
.../src/routes/event-history/routes/get.ts | 46 +++
.../src/routes/event-history/routes/list.ts | 53 +++
.../src/routes/event-history/routes/replay.ts | 234 ++++++++++++
apps/server/src/routes/features/index.ts | 10 +-
.../src/routes/features/routes/create.ts | 13 +-
.../server/src/routes/notifications/common.ts | 21 ++
apps/server/src/routes/notifications/index.ts | 62 ++++
.../routes/notifications/routes/dismiss.ts | 53 +++
.../src/routes/notifications/routes/list.ts | 39 ++
.../routes/notifications/routes/mark-read.ts | 50 +++
.../notifications/routes/unread-count.ts | 39 ++
apps/server/src/services/auto-mode-service.ts | 24 ++
.../src/services/event-history-service.ts | 338 +++++++++++++++++
.../server/src/services/event-hook-service.ts | 71 +++-
.../src/services/notification-service.ts | 280 ++++++++++++++
.../components/notification-bell.tsx | 207 +++++++++++
.../project-switcher/project-switcher.tsx | 6 +
apps/ui/src/components/layout/sidebar.tsx | 5 +
.../layout/sidebar/hooks/use-navigation.ts | 20 +
.../components/views/notifications-view.tsx | 272 ++++++++++++++
.../event-hooks/event-history-view.tsx | 341 ++++++++++++++++++
.../event-hooks/event-hook-dialog.tsx | 1 +
.../event-hooks/event-hooks-section.tsx | 124 ++++---
apps/ui/src/hooks/use-notification-events.ts | 78 ++++
apps/ui/src/lib/electron.ts | 84 +++++
apps/ui/src/lib/http-api-client.ts | 43 ++-
apps/ui/src/routes/notifications.tsx | 6 +
apps/ui/src/store/app-store.ts | 2 +
apps/ui/src/store/notifications-store.ts | 129 +++++++
libs/platform/src/index.ts | 6 +
libs/platform/src/paths.ts | 63 ++++
libs/types/src/event-history.ts | 123 +++++++
libs/types/src/event.ts | 4 +-
libs/types/src/index.ts | 15 +
libs/types/src/notification.ts | 58 +++
libs/types/src/settings.ts | 6 +
42 files changed, 3056 insertions(+), 62 deletions(-)
create mode 100644 apps/server/src/routes/event-history/common.ts
create mode 100644 apps/server/src/routes/event-history/index.ts
create mode 100644 apps/server/src/routes/event-history/routes/clear.ts
create mode 100644 apps/server/src/routes/event-history/routes/delete.ts
create mode 100644 apps/server/src/routes/event-history/routes/get.ts
create mode 100644 apps/server/src/routes/event-history/routes/list.ts
create mode 100644 apps/server/src/routes/event-history/routes/replay.ts
create mode 100644 apps/server/src/routes/notifications/common.ts
create mode 100644 apps/server/src/routes/notifications/index.ts
create mode 100644 apps/server/src/routes/notifications/routes/dismiss.ts
create mode 100644 apps/server/src/routes/notifications/routes/list.ts
create mode 100644 apps/server/src/routes/notifications/routes/mark-read.ts
create mode 100644 apps/server/src/routes/notifications/routes/unread-count.ts
create mode 100644 apps/server/src/services/event-history-service.ts
create mode 100644 apps/server/src/services/notification-service.ts
create mode 100644 apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx
create mode 100644 apps/ui/src/components/views/notifications-view.tsx
create mode 100644 apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx
create mode 100644 apps/ui/src/hooks/use-notification-events.ts
create mode 100644 apps/ui/src/routes/notifications.tsx
create mode 100644 apps/ui/src/store/notifications-store.ts
create mode 100644 libs/types/src/event-history.ts
create mode 100644 libs/types/src/notification.ts
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 3a59d4d3..e6f9d0d2 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -79,6 +79,10 @@ import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js';
import { eventHookService } from './services/event-hook-service.js';
+import { createNotificationsRoutes } from './routes/notifications/index.js';
+import { getNotificationService } from './services/notification-service.js';
+import { createEventHistoryRoutes } from './routes/event-history/index.js';
+import { getEventHistoryService } from './services/event-history-service.js';
// Load environment variables
dotenv.config();
@@ -208,8 +212,15 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
-// Initialize Event Hook Service for custom event triggers
-eventHookService.initialize(events, settingsService);
+// Initialize Notification Service with event emitter for real-time updates
+const notificationService = getNotificationService();
+notificationService.setEventEmitter(events);
+
+// Initialize Event History Service
+const eventHistoryService = getEventHistoryService();
+
+// Initialize Event Hook Service for custom event triggers (with history storage)
+eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services
(async () => {
@@ -264,7 +275,7 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
-app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService));
+app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
@@ -285,6 +296,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
+app.use('/api/notifications', createNotificationsRoutes(notificationService));
+app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
// Create HTTP server
const server = createServer(app);
diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts
index 78137a73..080486fb 100644
--- a/apps/server/src/routes/app-spec/parse-and-create-features.ts
+++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts
@@ -8,6 +8,7 @@ import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
+import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecRegeneration');
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath,
});
+
+ // Create notification for spec generation completion
+ const notificationService = getNotificationService();
+ await notificationService.createNotification({
+ type: 'spec_regeneration_complete',
+ title: 'Spec Generation Complete',
+ message: `Created ${createdFeatures.length} features from the project specification.`,
+ projectPath: projectPath,
+ });
} catch (error) {
logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error);
diff --git a/apps/server/src/routes/event-history/common.ts b/apps/server/src/routes/event-history/common.ts
new file mode 100644
index 00000000..bd0ad3fe
--- /dev/null
+++ b/apps/server/src/routes/event-history/common.ts
@@ -0,0 +1,19 @@
+/**
+ * Common utilities for event history routes
+ */
+
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
+
+/** Logger instance for event history operations */
+export const logger = createLogger('EventHistory');
+
+/**
+ * Extract user-friendly error message from error objects
+ */
+export { getErrorMessageShared as getErrorMessage };
+
+/**
+ * Log error with automatic logger binding
+ */
+export const logError = createLogError(logger);
diff --git a/apps/server/src/routes/event-history/index.ts b/apps/server/src/routes/event-history/index.ts
new file mode 100644
index 00000000..93297ddd
--- /dev/null
+++ b/apps/server/src/routes/event-history/index.ts
@@ -0,0 +1,68 @@
+/**
+ * Event History routes - HTTP API for event history management
+ *
+ * Provides endpoints for:
+ * - Listing events with filtering
+ * - Getting individual event details
+ * - Deleting events
+ * - Clearing all events
+ * - Replaying events to test hooks
+ *
+ * Mounted at /api/event-history in the main server.
+ */
+
+import { Router } from 'express';
+import type { EventHistoryService } from '../../services/event-history-service.js';
+import type { SettingsService } from '../../services/settings-service.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createListHandler } from './routes/list.js';
+import { createGetHandler } from './routes/get.js';
+import { createDeleteHandler } from './routes/delete.js';
+import { createClearHandler } from './routes/clear.js';
+import { createReplayHandler } from './routes/replay.js';
+
+/**
+ * Create event history router with all endpoints
+ *
+ * Endpoints:
+ * - POST /list - List events with optional filtering
+ * - POST /get - Get a single event by ID
+ * - POST /delete - Delete an event by ID
+ * - POST /clear - Clear all events for a project
+ * - POST /replay - Replay an event to trigger hooks
+ *
+ * @param eventHistoryService - Instance of EventHistoryService
+ * @param settingsService - Instance of SettingsService (for replay)
+ * @returns Express Router configured with all event history endpoints
+ */
+export function createEventHistoryRoutes(
+ eventHistoryService: EventHistoryService,
+ settingsService: SettingsService
+): Router {
+ const router = Router();
+
+ // List events with filtering
+ router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
+
+ // Get single event
+ router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
+
+ // Delete event
+ router.post(
+ '/delete',
+ validatePathParams('projectPath'),
+ createDeleteHandler(eventHistoryService)
+ );
+
+ // Clear all events
+ router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
+
+ // Replay event
+ router.post(
+ '/replay',
+ validatePathParams('projectPath'),
+ createReplayHandler(eventHistoryService, settingsService)
+ );
+
+ return router;
+}
diff --git a/apps/server/src/routes/event-history/routes/clear.ts b/apps/server/src/routes/event-history/routes/clear.ts
new file mode 100644
index 00000000..c6e6bb58
--- /dev/null
+++ b/apps/server/src/routes/event-history/routes/clear.ts
@@ -0,0 +1,33 @@
+/**
+ * POST /api/event-history/clear - Clear all events for a project
+ *
+ * Request body: { projectPath: string }
+ * Response: { success: true, cleared: number }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createClearHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body as { projectPath: string };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const cleared = await eventHistoryService.clearEvents(projectPath);
+
+ res.json({
+ success: true,
+ cleared,
+ });
+ } catch (error) {
+ logError(error, 'Clear events failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/event-history/routes/delete.ts b/apps/server/src/routes/event-history/routes/delete.ts
new file mode 100644
index 00000000..ea3f6b16
--- /dev/null
+++ b/apps/server/src/routes/event-history/routes/delete.ts
@@ -0,0 +1,43 @@
+/**
+ * POST /api/event-history/delete - Delete an event by ID
+ *
+ * Request body: { projectPath: string, eventId: string }
+ * Response: { success: true } or { success: false, error: string }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createDeleteHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, eventId } = req.body as {
+ projectPath: string;
+ eventId: string;
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!eventId || typeof eventId !== 'string') {
+ res.status(400).json({ success: false, error: 'eventId is required' });
+ return;
+ }
+
+ const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
+
+ if (!deleted) {
+ res.status(404).json({ success: false, error: 'Event not found' });
+ return;
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Delete event failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/event-history/routes/get.ts b/apps/server/src/routes/event-history/routes/get.ts
new file mode 100644
index 00000000..f892fd41
--- /dev/null
+++ b/apps/server/src/routes/event-history/routes/get.ts
@@ -0,0 +1,46 @@
+/**
+ * POST /api/event-history/get - Get a single event by ID
+ *
+ * Request body: { projectPath: string, eventId: string }
+ * Response: { success: true, event: StoredEvent } or { success: false, error: string }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createGetHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, eventId } = req.body as {
+ projectPath: string;
+ eventId: string;
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!eventId || typeof eventId !== 'string') {
+ res.status(400).json({ success: false, error: 'eventId is required' });
+ return;
+ }
+
+ const event = await eventHistoryService.getEvent(projectPath, eventId);
+
+ if (!event) {
+ res.status(404).json({ success: false, error: 'Event not found' });
+ return;
+ }
+
+ res.json({
+ success: true,
+ event,
+ });
+ } catch (error) {
+ logError(error, 'Get event failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/event-history/routes/list.ts b/apps/server/src/routes/event-history/routes/list.ts
new file mode 100644
index 00000000..551594f2
--- /dev/null
+++ b/apps/server/src/routes/event-history/routes/list.ts
@@ -0,0 +1,53 @@
+/**
+ * POST /api/event-history/list - List events for a project
+ *
+ * Request body: {
+ * projectPath: string,
+ * filter?: {
+ * trigger?: EventHookTrigger,
+ * featureId?: string,
+ * since?: string,
+ * until?: string,
+ * limit?: number,
+ * offset?: number
+ * }
+ * }
+ * Response: { success: true, events: StoredEventSummary[], total: number }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import type { EventHistoryFilter } from '@automaker/types';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createListHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, filter } = req.body as {
+ projectPath: string;
+ filter?: EventHistoryFilter;
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const events = await eventHistoryService.getEvents(projectPath, filter);
+ const total = await eventHistoryService.getEventCount(projectPath, {
+ ...filter,
+ limit: undefined,
+ offset: undefined,
+ });
+
+ res.json({
+ success: true,
+ events,
+ total,
+ });
+ } catch (error) {
+ logError(error, 'List events failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/event-history/routes/replay.ts b/apps/server/src/routes/event-history/routes/replay.ts
new file mode 100644
index 00000000..c6f27a40
--- /dev/null
+++ b/apps/server/src/routes/event-history/routes/replay.ts
@@ -0,0 +1,234 @@
+/**
+ * POST /api/event-history/replay - Replay an event to trigger hooks
+ *
+ * Request body: {
+ * projectPath: string,
+ * eventId: string,
+ * hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
+ * }
+ * Response: { success: true, result: EventReplayResult }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { getErrorMessage, logError, logger } from '../common.js';
+
+const execAsync = promisify(exec);
+
+/** Default timeout for shell commands (30 seconds) */
+const DEFAULT_SHELL_TIMEOUT = 30000;
+
+/** Default timeout for HTTP requests (10 seconds) */
+const DEFAULT_HTTP_TIMEOUT = 10000;
+
+interface HookContext {
+ featureId?: string;
+ featureName?: string;
+ projectPath?: string;
+ projectName?: string;
+ error?: string;
+ errorType?: string;
+ timestamp: string;
+ eventType: string;
+}
+
+/**
+ * Substitute {{variable}} placeholders in a string
+ */
+function substituteVariables(template: string, context: HookContext): string {
+ return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
+ const value = context[variable as keyof HookContext];
+ if (value === undefined || value === null) {
+ return '';
+ }
+ return String(value);
+ });
+}
+
+/**
+ * Execute a single hook and return the result
+ */
+async function executeHook(hook: EventHook, context: HookContext): Promise {
+ const hookName = hook.name || hook.id;
+ const startTime = Date.now();
+
+ try {
+ if (hook.action.type === 'shell') {
+ const command = substituteVariables(hook.action.command, context);
+ const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
+
+ logger.info(`Replaying shell hook "${hookName}": ${command}`);
+
+ await execAsync(command, {
+ timeout,
+ maxBuffer: 1024 * 1024,
+ });
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: true,
+ durationMs: Date.now() - startTime,
+ };
+ } else if (hook.action.type === 'http') {
+ const url = substituteVariables(hook.action.url, context);
+ const method = hook.action.method || 'POST';
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+ if (hook.action.headers) {
+ for (const [key, value] of Object.entries(hook.action.headers)) {
+ headers[key] = substituteVariables(value, context);
+ }
+ }
+
+ let body: string | undefined;
+ if (hook.action.body) {
+ body = substituteVariables(hook.action.body, context);
+ } else if (method !== 'GET') {
+ body = JSON.stringify({
+ eventType: context.eventType,
+ timestamp: context.timestamp,
+ featureId: context.featureId,
+ projectPath: context.projectPath,
+ projectName: context.projectName,
+ error: context.error,
+ });
+ }
+
+ logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
+
+ const response = await fetch(url, {
+ method,
+ headers,
+ body: method !== 'GET' ? body : undefined,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: false,
+ error: `HTTP ${response.status}: ${response.statusText}`,
+ durationMs: Date.now() - startTime,
+ };
+ }
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: true,
+ durationMs: Date.now() - startTime,
+ };
+ }
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: false,
+ error: 'Unknown hook action type',
+ durationMs: Date.now() - startTime,
+ };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error
+ ? error.name === 'AbortError'
+ ? 'Request timed out'
+ : error.message
+ : String(error);
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: false,
+ error: errorMessage,
+ durationMs: Date.now() - startTime,
+ };
+ }
+}
+
+export function createReplayHandler(
+ eventHistoryService: EventHistoryService,
+ settingsService: SettingsService
+) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, eventId, hookIds } = req.body as {
+ projectPath: string;
+ eventId: string;
+ hookIds?: string[];
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!eventId || typeof eventId !== 'string') {
+ res.status(400).json({ success: false, error: 'eventId is required' });
+ return;
+ }
+
+ // Get the event
+ const event = await eventHistoryService.getEvent(projectPath, eventId);
+ if (!event) {
+ res.status(404).json({ success: false, error: 'Event not found' });
+ return;
+ }
+
+ // Get hooks from settings
+ const settings = await settingsService.getGlobalSettings();
+ let hooks = settings.eventHooks || [];
+
+ // Filter to matching trigger and enabled hooks
+ hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
+
+ // If specific hook IDs requested, filter to those
+ if (hookIds && hookIds.length > 0) {
+ hooks = hooks.filter((h) => hookIds.includes(h.id));
+ }
+
+ // Build context for variable substitution
+ const context: HookContext = {
+ featureId: event.featureId,
+ featureName: event.featureName,
+ projectPath: event.projectPath,
+ projectName: event.projectName,
+ error: event.error,
+ errorType: event.errorType,
+ timestamp: event.timestamp,
+ eventType: event.trigger,
+ };
+
+ // Execute all hooks in parallel
+ const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
+
+ const result: EventReplayResult = {
+ eventId,
+ hooksTriggered: hooks.length,
+ hookResults,
+ };
+
+ logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
+
+ res.json({
+ success: true,
+ result,
+ });
+ } catch (error) {
+ logError(error, 'Replay event failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts
index dd58e4aa..439ab6a9 100644
--- a/apps/server/src/routes/features/index.ts
+++ b/apps/server/src/routes/features/index.ts
@@ -5,6 +5,7 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
+import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
@@ -18,13 +19,18 @@ import { createGenerateTitleHandler } from './routes/generate-title.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
- settingsService?: SettingsService
+ settingsService?: SettingsService,
+ events?: EventEmitter
): Router {
const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
- router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
+ router.post(
+ '/create',
+ validatePathParams('projectPath'),
+ createCreateHandler(featureLoader, events)
+ );
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post(
'/bulk-update',
diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts
index 5f04ecdb..e7a11f83 100644
--- a/apps/server/src/routes/features/routes/create.ts
+++ b/apps/server/src/routes/features/routes/create.ts
@@ -4,10 +4,11 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
+import type { EventEmitter } from '../../../lib/events.js';
import type { Feature } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
-export function createCreateHandler(featureLoader: FeatureLoader) {
+export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise => {
try {
const { projectPath, feature } = req.body as {
@@ -24,6 +25,16 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
}
const created = await featureLoader.create(projectPath, feature);
+
+ // Emit feature_created event for hooks
+ if (events) {
+ events.emit('feature:created', {
+ featureId: created.id,
+ featureName: created.name,
+ projectPath,
+ });
+ }
+
res.json({ success: true, feature: created });
} catch (error) {
logError(error, 'Create feature failed');
diff --git a/apps/server/src/routes/notifications/common.ts b/apps/server/src/routes/notifications/common.ts
new file mode 100644
index 00000000..707e3a0d
--- /dev/null
+++ b/apps/server/src/routes/notifications/common.ts
@@ -0,0 +1,21 @@
+/**
+ * Common utilities for notification routes
+ *
+ * Provides logger and error handling utilities shared across all notification endpoints.
+ */
+
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
+
+/** Logger instance for notification-related operations */
+export const logger = createLogger('Notifications');
+
+/**
+ * Extract user-friendly error message from error objects
+ */
+export { getErrorMessageShared as getErrorMessage };
+
+/**
+ * Log error with automatic logger binding
+ */
+export const logError = createLogError(logger);
diff --git a/apps/server/src/routes/notifications/index.ts b/apps/server/src/routes/notifications/index.ts
new file mode 100644
index 00000000..2def111a
--- /dev/null
+++ b/apps/server/src/routes/notifications/index.ts
@@ -0,0 +1,62 @@
+/**
+ * Notifications routes - HTTP API for project-level notifications
+ *
+ * Provides endpoints for:
+ * - Listing notifications
+ * - Getting unread count
+ * - Marking notifications as read
+ * - Dismissing notifications
+ *
+ * All endpoints use handler factories that receive the NotificationService instance.
+ * Mounted at /api/notifications in the main server.
+ */
+
+import { Router } from 'express';
+import type { NotificationService } from '../../services/notification-service.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createListHandler } from './routes/list.js';
+import { createUnreadCountHandler } from './routes/unread-count.js';
+import { createMarkReadHandler } from './routes/mark-read.js';
+import { createDismissHandler } from './routes/dismiss.js';
+
+/**
+ * Create notifications router with all endpoints
+ *
+ * Endpoints:
+ * - POST /list - List all notifications for a project
+ * - POST /unread-count - Get unread notification count
+ * - POST /mark-read - Mark notification(s) as read
+ * - POST /dismiss - Dismiss notification(s)
+ *
+ * @param notificationService - Instance of NotificationService
+ * @returns Express Router configured with all notification endpoints
+ */
+export function createNotificationsRoutes(notificationService: NotificationService): Router {
+ const router = Router();
+
+ // List notifications
+ router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
+
+ // Get unread count
+ router.post(
+ '/unread-count',
+ validatePathParams('projectPath'),
+ createUnreadCountHandler(notificationService)
+ );
+
+ // Mark as read (single or all)
+ router.post(
+ '/mark-read',
+ validatePathParams('projectPath'),
+ createMarkReadHandler(notificationService)
+ );
+
+ // Dismiss (single or all)
+ router.post(
+ '/dismiss',
+ validatePathParams('projectPath'),
+ createDismissHandler(notificationService)
+ );
+
+ return router;
+}
diff --git a/apps/server/src/routes/notifications/routes/dismiss.ts b/apps/server/src/routes/notifications/routes/dismiss.ts
new file mode 100644
index 00000000..c609f170
--- /dev/null
+++ b/apps/server/src/routes/notifications/routes/dismiss.ts
@@ -0,0 +1,53 @@
+/**
+ * POST /api/notifications/dismiss - Dismiss notification(s)
+ *
+ * Request body: { projectPath: string, notificationId?: string }
+ * - If notificationId provided: dismisses that notification
+ * - If notificationId not provided: dismisses all notifications
+ *
+ * Response: { success: true, dismissed: boolean | count: number }
+ */
+
+import type { Request, Response } from 'express';
+import type { NotificationService } from '../../../services/notification-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+/**
+ * Create handler for POST /api/notifications/dismiss
+ *
+ * @param notificationService - Instance of NotificationService
+ * @returns Express request handler
+ */
+export function createDismissHandler(notificationService: NotificationService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, notificationId } = req.body;
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ // If notificationId provided, dismiss single notification
+ if (notificationId) {
+ const dismissed = await notificationService.dismissNotification(
+ projectPath,
+ notificationId
+ );
+ if (!dismissed) {
+ res.status(404).json({ success: false, error: 'Notification not found' });
+ return;
+ }
+ res.json({ success: true, dismissed: true });
+ return;
+ }
+
+ // Otherwise dismiss all
+ const count = await notificationService.dismissAll(projectPath);
+ res.json({ success: true, count });
+ } catch (error) {
+ logError(error, 'Dismiss failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/notifications/routes/list.ts b/apps/server/src/routes/notifications/routes/list.ts
new file mode 100644
index 00000000..46197fe9
--- /dev/null
+++ b/apps/server/src/routes/notifications/routes/list.ts
@@ -0,0 +1,39 @@
+/**
+ * POST /api/notifications/list - List all notifications for a project
+ *
+ * Request body: { projectPath: string }
+ * Response: { success: true, notifications: Notification[] }
+ */
+
+import type { Request, Response } from 'express';
+import type { NotificationService } from '../../../services/notification-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+/**
+ * Create handler for POST /api/notifications/list
+ *
+ * @param notificationService - Instance of NotificationService
+ * @returns Express request handler
+ */
+export function createListHandler(notificationService: NotificationService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body;
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const notifications = await notificationService.getNotifications(projectPath);
+
+ res.json({
+ success: true,
+ notifications,
+ });
+ } catch (error) {
+ logError(error, 'List notifications failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/notifications/routes/mark-read.ts b/apps/server/src/routes/notifications/routes/mark-read.ts
new file mode 100644
index 00000000..4c9bfeb5
--- /dev/null
+++ b/apps/server/src/routes/notifications/routes/mark-read.ts
@@ -0,0 +1,50 @@
+/**
+ * POST /api/notifications/mark-read - Mark notification(s) as read
+ *
+ * Request body: { projectPath: string, notificationId?: string }
+ * - If notificationId provided: marks that notification as read
+ * - If notificationId not provided: marks all notifications as read
+ *
+ * Response: { success: true, count?: number, notification?: Notification }
+ */
+
+import type { Request, Response } from 'express';
+import type { NotificationService } from '../../../services/notification-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+/**
+ * Create handler for POST /api/notifications/mark-read
+ *
+ * @param notificationService - Instance of NotificationService
+ * @returns Express request handler
+ */
+export function createMarkReadHandler(notificationService: NotificationService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, notificationId } = req.body;
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ // If notificationId provided, mark single notification
+ if (notificationId) {
+ const notification = await notificationService.markAsRead(projectPath, notificationId);
+ if (!notification) {
+ res.status(404).json({ success: false, error: 'Notification not found' });
+ return;
+ }
+ res.json({ success: true, notification });
+ return;
+ }
+
+ // Otherwise mark all as read
+ const count = await notificationService.markAllAsRead(projectPath);
+ res.json({ success: true, count });
+ } catch (error) {
+ logError(error, 'Mark read failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/notifications/routes/unread-count.ts b/apps/server/src/routes/notifications/routes/unread-count.ts
new file mode 100644
index 00000000..98d8e198
--- /dev/null
+++ b/apps/server/src/routes/notifications/routes/unread-count.ts
@@ -0,0 +1,39 @@
+/**
+ * POST /api/notifications/unread-count - Get unread notification count
+ *
+ * Request body: { projectPath: string }
+ * Response: { success: true, count: number }
+ */
+
+import type { Request, Response } from 'express';
+import type { NotificationService } from '../../../services/notification-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+/**
+ * Create handler for POST /api/notifications/unread-count
+ *
+ * @param notificationService - Instance of NotificationService
+ * @returns Express request handler
+ */
+export function createUnreadCountHandler(notificationService: NotificationService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body;
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const count = await notificationService.getUnreadCount(projectPath);
+
+ res.json({
+ success: true,
+ count,
+ });
+ } catch (error) {
+ logError(error, 'Get unread count failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts
index 05722181..d97e3402 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -60,6 +60,7 @@ import {
getMCPServersFromSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
+import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec);
@@ -386,6 +387,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message,
errorType: errorInfo.type,
+ projectPath,
});
});
}
@@ -1547,6 +1549,7 @@ Address the follow-up instructions above. Review the previous work and make the
message: allPassed
? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
+ projectPath,
});
return allPassed;
@@ -1620,6 +1623,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
+ projectPath,
});
return hash.trim();
@@ -2101,6 +2105,26 @@ Format your response as a structured markdown document.`;
feature.justFinishedAt = undefined;
}
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
+
+ // Create notifications for important status changes
+ const notificationService = getNotificationService();
+ if (status === 'waiting_approval') {
+ await notificationService.createNotification({
+ type: 'feature_waiting_approval',
+ title: 'Feature Ready for Review',
+ message: `"${feature.name || featureId}" is ready for your review and approval.`,
+ featureId,
+ projectPath,
+ });
+ } else if (status === 'verified') {
+ await notificationService.createNotification({
+ type: 'feature_verified',
+ title: 'Feature Verified',
+ message: `"${feature.name || featureId}" has been verified and is complete.`,
+ featureId,
+ projectPath,
+ });
+ }
} catch {
// Feature file may not exist
}
diff --git a/apps/server/src/services/event-history-service.ts b/apps/server/src/services/event-history-service.ts
new file mode 100644
index 00000000..b983af09
--- /dev/null
+++ b/apps/server/src/services/event-history-service.ts
@@ -0,0 +1,338 @@
+/**
+ * Event History Service - Stores and retrieves event records for debugging and replay
+ *
+ * Provides persistent storage for events in {projectPath}/.automaker/events/
+ * Each event is stored as a separate JSON file with an index for quick listing.
+ *
+ * Features:
+ * - Store events when they occur
+ * - List and filter historical events
+ * - Replay events to test hook configurations
+ * - Delete old events to manage disk space
+ */
+
+import { createLogger } from '@automaker/utils';
+import * as secureFs from '../lib/secure-fs.js';
+import {
+ getEventHistoryDir,
+ getEventHistoryIndexPath,
+ getEventPath,
+ ensureEventHistoryDir,
+} from '@automaker/platform';
+import type {
+ StoredEvent,
+ StoredEventIndex,
+ StoredEventSummary,
+ EventHistoryFilter,
+ EventHookTrigger,
+} from '@automaker/types';
+import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
+import { randomUUID } from 'crypto';
+
+const logger = createLogger('EventHistoryService');
+
+/** Maximum events to keep in the index (oldest are pruned) */
+const MAX_EVENTS_IN_INDEX = 1000;
+
+/**
+ * Atomic file write - write to temp file then rename
+ */
+async function atomicWriteJson(filePath: string, data: unknown): Promise {
+ const tempPath = `${filePath}.tmp.${Date.now()}`;
+ const content = JSON.stringify(data, null, 2);
+
+ try {
+ await secureFs.writeFile(tempPath, content, 'utf-8');
+ await secureFs.rename(tempPath, filePath);
+ } catch (error) {
+ try {
+ await secureFs.unlink(tempPath);
+ } catch {
+ // Ignore cleanup errors
+ }
+ throw error;
+ }
+}
+
+/**
+ * Safely read JSON file with fallback to default
+ */
+async function readJsonFile(filePath: string, defaultValue: T): Promise {
+ try {
+ const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
+ return JSON.parse(content) as T;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return defaultValue;
+ }
+ logger.error(`Error reading ${filePath}:`, error);
+ return defaultValue;
+ }
+}
+
+/**
+ * Input for storing a new event
+ */
+export interface StoreEventInput {
+ trigger: EventHookTrigger;
+ projectPath: string;
+ featureId?: string;
+ featureName?: string;
+ error?: string;
+ errorType?: string;
+ passes?: boolean;
+ metadata?: Record;
+}
+
+/**
+ * EventHistoryService - Manages persistent storage of events
+ */
+export class EventHistoryService {
+ /**
+ * Store a new event to history
+ *
+ * @param input - Event data to store
+ * @returns Promise resolving to the stored event
+ */
+ async storeEvent(input: StoreEventInput): Promise {
+ const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
+ input;
+
+ // Ensure events directory exists
+ await ensureEventHistoryDir(projectPath);
+
+ const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
+ const timestamp = new Date().toISOString();
+ const projectName = this.extractProjectName(projectPath);
+
+ const event: StoredEvent = {
+ id: eventId,
+ trigger,
+ timestamp,
+ projectPath,
+ projectName,
+ featureId,
+ featureName,
+ error,
+ errorType,
+ passes,
+ metadata,
+ };
+
+ // Write the full event to its own file
+ const eventPath = getEventPath(projectPath, eventId);
+ await atomicWriteJson(eventPath, event);
+
+ // Update the index
+ await this.addToIndex(projectPath, event);
+
+ logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
+
+ return event;
+ }
+
+ /**
+ * Get all events for a project with optional filtering
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param filter - Optional filter criteria
+ * @returns Promise resolving to array of event summaries
+ */
+ async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise {
+ const indexPath = getEventHistoryIndexPath(projectPath);
+ const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
+
+ let events = [...index.events];
+
+ // Apply filters
+ if (filter) {
+ if (filter.trigger) {
+ events = events.filter((e) => e.trigger === filter.trigger);
+ }
+ if (filter.featureId) {
+ events = events.filter((e) => e.featureId === filter.featureId);
+ }
+ if (filter.since) {
+ const sinceDate = new Date(filter.since).getTime();
+ events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
+ }
+ if (filter.until) {
+ const untilDate = new Date(filter.until).getTime();
+ events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
+ }
+ }
+
+ // Sort by timestamp (newest first)
+ events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
+
+ // Apply pagination
+ if (filter?.offset) {
+ events = events.slice(filter.offset);
+ }
+ if (filter?.limit) {
+ events = events.slice(0, filter.limit);
+ }
+
+ return events;
+ }
+
+ /**
+ * Get a single event by ID
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param eventId - Event identifier
+ * @returns Promise resolving to the full event or null if not found
+ */
+ async getEvent(projectPath: string, eventId: string): Promise {
+ const eventPath = getEventPath(projectPath, eventId);
+ try {
+ const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
+ return JSON.parse(content) as StoredEvent;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return null;
+ }
+ logger.error(`Error reading event ${eventId}:`, error);
+ return null;
+ }
+ }
+
+ /**
+ * Delete an event by ID
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param eventId - Event identifier
+ * @returns Promise resolving to true if deleted
+ */
+ async deleteEvent(projectPath: string, eventId: string): Promise {
+ // Remove from index
+ const indexPath = getEventHistoryIndexPath(projectPath);
+ const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
+
+ const initialLength = index.events.length;
+ index.events = index.events.filter((e) => e.id !== eventId);
+
+ if (index.events.length === initialLength) {
+ return false; // Event not found in index
+ }
+
+ await atomicWriteJson(indexPath, index);
+
+ // Delete the event file
+ const eventPath = getEventPath(projectPath, eventId);
+ try {
+ await secureFs.unlink(eventPath);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ logger.error(`Error deleting event file ${eventId}:`, error);
+ }
+ }
+
+ logger.info(`Deleted event ${eventId}`);
+ return true;
+ }
+
+ /**
+ * Clear all events for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Promise resolving to number of events cleared
+ */
+ async clearEvents(projectPath: string): Promise {
+ const indexPath = getEventHistoryIndexPath(projectPath);
+ const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
+
+ const count = index.events.length;
+
+ // Delete all event files
+ for (const event of index.events) {
+ const eventPath = getEventPath(projectPath, event.id);
+ try {
+ await secureFs.unlink(eventPath);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ logger.error(`Error deleting event file ${event.id}:`, error);
+ }
+ }
+ }
+
+ // Reset the index
+ await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
+
+ logger.info(`Cleared ${count} events for project`);
+ return count;
+ }
+
+ /**
+ * Get event count for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param filter - Optional filter criteria
+ * @returns Promise resolving to event count
+ */
+ async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise {
+ const events = await this.getEvents(projectPath, {
+ ...filter,
+ limit: undefined,
+ offset: undefined,
+ });
+ return events.length;
+ }
+
+ /**
+ * Add an event to the index (internal)
+ */
+ private async addToIndex(projectPath: string, event: StoredEvent): Promise {
+ const indexPath = getEventHistoryIndexPath(projectPath);
+ const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
+
+ const summary: StoredEventSummary = {
+ id: event.id,
+ trigger: event.trigger,
+ timestamp: event.timestamp,
+ featureName: event.featureName,
+ featureId: event.featureId,
+ };
+
+ // Add to beginning (newest first)
+ index.events.unshift(summary);
+
+ // Prune old events if over limit
+ if (index.events.length > MAX_EVENTS_IN_INDEX) {
+ const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
+ // Delete the pruned event files
+ for (const oldEvent of removed) {
+ const eventPath = getEventPath(projectPath, oldEvent.id);
+ try {
+ await secureFs.unlink(eventPath);
+ } catch {
+ // Ignore deletion errors for pruned events
+ }
+ }
+ logger.info(`Pruned ${removed.length} old events from history`);
+ }
+
+ await atomicWriteJson(indexPath, index);
+ }
+
+ /**
+ * Extract project name from path
+ */
+ private extractProjectName(projectPath: string): string {
+ const parts = projectPath.split(/[/\\]/);
+ return parts[parts.length - 1] || projectPath;
+ }
+}
+
+// Singleton instance
+let eventHistoryServiceInstance: EventHistoryService | null = null;
+
+/**
+ * Get the singleton event history service instance
+ */
+export function getEventHistoryService(): EventHistoryService {
+ if (!eventHistoryServiceInstance) {
+ eventHistoryServiceInstance = new EventHistoryService();
+ }
+ return eventHistoryServiceInstance;
+}
diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts
index d6d2d0b8..08da71dd 100644
--- a/apps/server/src/services/event-hook-service.ts
+++ b/apps/server/src/services/event-hook-service.ts
@@ -5,7 +5,10 @@
* - Shell commands: Executed with configurable timeout
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
*
+ * Also stores events to history for debugging and replay.
+ *
* Supported events:
+ * - feature_created: A new feature was created
* - feature_success: Feature completed successfully
* - feature_error: Feature failed with an error
* - auto_mode_complete: Auto mode finished all features (idle state)
@@ -17,6 +20,7 @@ import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
+import type { EventHistoryService } from './event-history-service.js';
import type {
EventHook,
EventHookTrigger,
@@ -60,27 +64,45 @@ interface AutoModeEventPayload {
projectPath?: string;
}
+/**
+ * Feature created event payload structure
+ */
+interface FeatureCreatedPayload {
+ featureId: string;
+ featureName?: string;
+ projectPath: string;
+}
+
/**
* Event Hook Service
*
* Manages execution of user-configured event hooks in response to system events.
+ * Also stores events to history for debugging and replay.
*/
export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
+ private eventHistoryService: EventHistoryService | null = null;
private unsubscribe: (() => void) | null = null;
/**
- * Initialize the service with event emitter and settings service
+ * Initialize the service with event emitter, settings service, and event history service
*/
- initialize(emitter: EventEmitter, settingsService: SettingsService): void {
+ initialize(
+ emitter: EventEmitter,
+ settingsService: SettingsService,
+ eventHistoryService?: EventHistoryService
+ ): void {
this.emitter = emitter;
this.settingsService = settingsService;
+ this.eventHistoryService = eventHistoryService || null;
- // Subscribe to auto-mode events
+ // Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEvent(payload as AutoModeEventPayload);
+ } else if (type === 'feature:created') {
+ this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
}
});
@@ -97,6 +119,7 @@ export class EventHookService {
}
this.emitter = null;
this.settingsService = null;
+ this.eventHistoryService = null;
}
/**
@@ -137,17 +160,51 @@ export class EventHookService {
eventType: trigger,
};
- // Execute matching hooks
- await this.executeHooksForTrigger(trigger, context);
+ // Execute matching hooks (pass passes for feature completion events)
+ await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
}
/**
- * Execute all enabled hooks matching the given trigger
+ * Handle feature:created events and trigger matching hooks
+ */
+ private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise {
+ const context: HookContext = {
+ featureId: payload.featureId,
+ featureName: payload.featureName,
+ projectPath: payload.projectPath,
+ projectName: this.extractProjectName(payload.projectPath),
+ timestamp: new Date().toISOString(),
+ eventType: 'feature_created',
+ };
+
+ await this.executeHooksForTrigger('feature_created', context);
+ }
+
+ /**
+ * Execute all enabled hooks matching the given trigger and store event to history
*/
private async executeHooksForTrigger(
trigger: EventHookTrigger,
- context: HookContext
+ context: HookContext,
+ additionalData?: { passes?: boolean }
): Promise {
+ // Store event to history (even if no hooks match)
+ if (this.eventHistoryService && context.projectPath) {
+ try {
+ await this.eventHistoryService.storeEvent({
+ trigger,
+ projectPath: context.projectPath,
+ featureId: context.featureId,
+ featureName: context.featureName,
+ error: context.error,
+ errorType: context.errorType,
+ passes: additionalData?.passes,
+ });
+ } catch (error) {
+ logger.error('Failed to store event to history:', error);
+ }
+ }
+
if (!this.settingsService) {
logger.warn('Settings service not available');
return;
diff --git a/apps/server/src/services/notification-service.ts b/apps/server/src/services/notification-service.ts
new file mode 100644
index 00000000..21685308
--- /dev/null
+++ b/apps/server/src/services/notification-service.ts
@@ -0,0 +1,280 @@
+/**
+ * Notification Service - Handles reading/writing notifications to JSON files
+ *
+ * Provides persistent storage for project-level notifications in
+ * {projectPath}/.automaker/notifications.json
+ *
+ * Notifications alert users when:
+ * - Features reach specific statuses (waiting_approval, verified)
+ * - Long-running operations complete (spec generation)
+ */
+
+import { createLogger } from '@automaker/utils';
+import * as secureFs from '../lib/secure-fs.js';
+import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
+import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
+import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
+import type { EventEmitter } from '../lib/events.js';
+import { randomUUID } from 'crypto';
+
+const logger = createLogger('NotificationService');
+
+/**
+ * Atomic file write - write to temp file then rename
+ */
+async function atomicWriteJson(filePath: string, data: unknown): Promise {
+ const tempPath = `${filePath}.tmp.${Date.now()}`;
+ const content = JSON.stringify(data, null, 2);
+
+ try {
+ await secureFs.writeFile(tempPath, content, 'utf-8');
+ await secureFs.rename(tempPath, filePath);
+ } catch (error) {
+ // Clean up temp file if it exists
+ try {
+ await secureFs.unlink(tempPath);
+ } catch {
+ // Ignore cleanup errors
+ }
+ throw error;
+ }
+}
+
+/**
+ * Safely read JSON file with fallback to default
+ */
+async function readJsonFile(filePath: string, defaultValue: T): Promise {
+ try {
+ const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
+ return JSON.parse(content) as T;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return defaultValue;
+ }
+ logger.error(`Error reading ${filePath}:`, error);
+ return defaultValue;
+ }
+}
+
+/**
+ * Input for creating a new notification
+ */
+export interface CreateNotificationInput {
+ type: NotificationType;
+ title: string;
+ message: string;
+ featureId?: string;
+ projectPath: string;
+}
+
+/**
+ * NotificationService - Manages persistent storage of notifications
+ *
+ * Handles reading and writing notifications to JSON files with atomic operations
+ * for reliability. Each project has its own notifications.json file.
+ */
+export class NotificationService {
+ private events: EventEmitter | null = null;
+
+ /**
+ * Set the event emitter for broadcasting notification events
+ */
+ setEventEmitter(events: EventEmitter): void {
+ this.events = events;
+ }
+
+ /**
+ * Get all notifications for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Promise resolving to array of notifications
+ */
+ async getNotifications(projectPath: string): Promise {
+ const notificationsPath = getNotificationsPath(projectPath);
+ const file = await readJsonFile(
+ notificationsPath,
+ DEFAULT_NOTIFICATIONS_FILE
+ );
+ // Filter out dismissed notifications and sort by date (newest first)
+ return file.notifications
+ .filter((n) => !n.dismissed)
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ }
+
+ /**
+ * Get unread notification count for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Promise resolving to unread count
+ */
+ async getUnreadCount(projectPath: string): Promise {
+ const notifications = await this.getNotifications(projectPath);
+ return notifications.filter((n) => !n.read).length;
+ }
+
+ /**
+ * Create a new notification
+ *
+ * @param input - Notification creation input
+ * @returns Promise resolving to the created notification
+ */
+ async createNotification(input: CreateNotificationInput): Promise {
+ const { projectPath, type, title, message, featureId } = input;
+
+ // Ensure automaker directory exists
+ await ensureAutomakerDir(projectPath);
+
+ const notificationsPath = getNotificationsPath(projectPath);
+ const file = await readJsonFile(
+ notificationsPath,
+ DEFAULT_NOTIFICATIONS_FILE
+ );
+
+ const notification: Notification = {
+ id: randomUUID(),
+ type,
+ title,
+ message,
+ createdAt: new Date().toISOString(),
+ read: false,
+ dismissed: false,
+ featureId,
+ projectPath,
+ };
+
+ file.notifications.push(notification);
+ await atomicWriteJson(notificationsPath, file);
+
+ logger.info(`Created notification: ${title} for project ${projectPath}`);
+
+ // Emit event for real-time updates
+ if (this.events) {
+ this.events.emit('notification:created', notification);
+ }
+
+ return notification;
+ }
+
+ /**
+ * Mark a notification as read
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param notificationId - ID of the notification to mark as read
+ * @returns Promise resolving to the updated notification or null if not found
+ */
+ async markAsRead(projectPath: string, notificationId: string): Promise {
+ const notificationsPath = getNotificationsPath(projectPath);
+ const file = await readJsonFile(
+ notificationsPath,
+ DEFAULT_NOTIFICATIONS_FILE
+ );
+
+ const notification = file.notifications.find((n) => n.id === notificationId);
+ if (!notification) {
+ return null;
+ }
+
+ notification.read = true;
+ await atomicWriteJson(notificationsPath, file);
+
+ logger.info(`Marked notification ${notificationId} as read`);
+ return notification;
+ }
+
+ /**
+ * Mark all notifications as read for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Promise resolving to number of notifications marked as read
+ */
+ async markAllAsRead(projectPath: string): Promise {
+ const notificationsPath = getNotificationsPath(projectPath);
+ const file = await readJsonFile(
+ notificationsPath,
+ DEFAULT_NOTIFICATIONS_FILE
+ );
+
+ let count = 0;
+ for (const notification of file.notifications) {
+ if (!notification.read && !notification.dismissed) {
+ notification.read = true;
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ await atomicWriteJson(notificationsPath, file);
+ logger.info(`Marked ${count} notifications as read`);
+ }
+
+ return count;
+ }
+
+ /**
+ * Dismiss a notification
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param notificationId - ID of the notification to dismiss
+ * @returns Promise resolving to true if notification was dismissed
+ */
+ async dismissNotification(projectPath: string, notificationId: string): Promise {
+ const notificationsPath = getNotificationsPath(projectPath);
+ const file = await readJsonFile(
+ notificationsPath,
+ DEFAULT_NOTIFICATIONS_FILE
+ );
+
+ const notification = file.notifications.find((n) => n.id === notificationId);
+ if (!notification) {
+ return false;
+ }
+
+ notification.dismissed = true;
+ await atomicWriteJson(notificationsPath, file);
+
+ logger.info(`Dismissed notification ${notificationId}`);
+ return true;
+ }
+
+ /**
+ * Dismiss all notifications for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Promise resolving to number of notifications dismissed
+ */
+ async dismissAll(projectPath: string): Promise {
+ const notificationsPath = getNotificationsPath(projectPath);
+ const file = await readJsonFile(
+ notificationsPath,
+ DEFAULT_NOTIFICATIONS_FILE
+ );
+
+ let count = 0;
+ for (const notification of file.notifications) {
+ if (!notification.dismissed) {
+ notification.dismissed = true;
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ await atomicWriteJson(notificationsPath, file);
+ logger.info(`Dismissed ${count} notifications`);
+ }
+
+ return count;
+ }
+}
+
+// Singleton instance
+let notificationServiceInstance: NotificationService | null = null;
+
+/**
+ * Get the singleton notification service instance
+ */
+export function getNotificationService(): NotificationService {
+ if (!notificationServiceInstance) {
+ notificationServiceInstance = new NotificationService();
+ }
+ return notificationServiceInstance;
+}
diff --git a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx
new file mode 100644
index 00000000..adcd7b64
--- /dev/null
+++ b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx
@@ -0,0 +1,207 @@
+/**
+ * Notification Bell - Bell icon with unread count and popover
+ */
+
+import { useCallback } from 'react';
+import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
+import { useNavigate } from '@tanstack/react-router';
+import { useNotificationsStore } from '@/store/notifications-store';
+import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { Button } from '@/components/ui/button';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import type { Notification } from '@automaker/types';
+import { cn } from '@/lib/utils';
+
+/**
+ * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
+ */
+function formatRelativeTime(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) return 'just now';
+ if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
+ if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
+ if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
+ return date.toLocaleDateString();
+}
+
+interface NotificationBellProps {
+ projectPath: string | null;
+}
+
+export function NotificationBell({ projectPath }: NotificationBellProps) {
+ const navigate = useNavigate();
+ const {
+ notifications,
+ unreadCount,
+ isPopoverOpen,
+ setPopoverOpen,
+ markAsRead,
+ dismissNotification,
+ } = useNotificationsStore();
+
+ // Load notifications and subscribe to events
+ useLoadNotifications(projectPath);
+ useNotificationEvents(projectPath);
+
+ const handleMarkAsRead = useCallback(
+ async (notificationId: string) => {
+ if (!projectPath) return;
+
+ // Optimistic update
+ markAsRead(notificationId);
+
+ // Sync with server
+ const api = getHttpApiClient();
+ await api.notifications.markAsRead(projectPath, notificationId);
+ },
+ [projectPath, markAsRead]
+ );
+
+ const handleDismiss = useCallback(
+ async (notificationId: string) => {
+ if (!projectPath) return;
+
+ // Optimistic update
+ dismissNotification(notificationId);
+
+ // Sync with server
+ const api = getHttpApiClient();
+ await api.notifications.dismiss(projectPath, notificationId);
+ },
+ [projectPath, dismissNotification]
+ );
+
+ const handleNotificationClick = useCallback(
+ (notification: Notification) => {
+ // Mark as read
+ handleMarkAsRead(notification.id);
+ setPopoverOpen(false);
+
+ // Navigate to the relevant view based on notification type
+ if (notification.featureId) {
+ navigate({ to: '/board' });
+ }
+ },
+ [handleMarkAsRead, setPopoverOpen, navigate]
+ );
+
+ const handleViewAll = useCallback(() => {
+ setPopoverOpen(false);
+ navigate({ to: '/notifications' });
+ }, [setPopoverOpen, navigate]);
+
+ const getNotificationIcon = (type: string) => {
+ switch (type) {
+ case 'feature_waiting_approval':
+ return ;
+ case 'feature_verified':
+ return ;
+ case 'spec_regeneration_complete':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ // Show recent 3 notifications in popover
+ const recentNotifications = notifications.slice(0, 3);
+
+ if (!projectPath) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
Notifications
+ {unreadCount > 0 && (
+ {unreadCount} unread
+ )}
+
+
+ {recentNotifications.length === 0 ? (
+
+ ) : (
+
+ {recentNotifications.map((notification) => (
+
handleNotificationClick(notification)}
+ >
+
{getNotificationIcon(notification.type)}
+
+
+
{notification.title}
+ {!notification.read && (
+
+ )}
+
+
+ {notification.message}
+
+
+ {formatRelativeTime(new Date(notification.createdAt))}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {notifications.length > 0 && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx
index 0713df72..426777b5 100644
--- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx
+++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx
@@ -7,6 +7,7 @@ import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog';
+import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
@@ -327,6 +328,11 @@ export function ProjectSwitcher() {
v{appVersion} {versionSuffix}
+
+ {/* Notification Bell */}
+
+
+
diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx
index 5bdf8a92..6cdb32cd 100644
--- a/apps/ui/src/components/layout/sidebar.tsx
+++ b/apps/ui/src/components/layout/sidebar.tsx
@@ -5,6 +5,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
+import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
@@ -62,6 +63,9 @@ export function Sidebar() {
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
+ // Get unread notifications count
+ const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
+
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -238,6 +242,7 @@ export function Sidebar() {
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
+ unreadNotificationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec,
});
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
index 110fa26c..ff8b7b0b 100644
--- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
@@ -11,6 +11,7 @@ import {
Lightbulb,
Brain,
Network,
+ Bell,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -35,6 +36,7 @@ interface UseNavigationProps {
ideation: string;
githubIssues: string;
githubPrs: string;
+ notifications: string;
};
hideSpecEditor: boolean;
hideContext: boolean;
@@ -49,6 +51,8 @@ interface UseNavigationProps {
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
+ /** Count of unread notifications to show on Notifications nav item */
+ unreadNotificationsCount?: number;
/** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean;
}
@@ -67,6 +71,7 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
+ unreadNotificationsCount,
isSpecGenerating,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
@@ -199,6 +204,20 @@ export function useNavigation({
});
}
+ // Add Other section with notifications
+ sections.push({
+ label: 'Other',
+ items: [
+ {
+ id: 'notifications',
+ label: 'Notifications',
+ icon: Bell,
+ shortcut: shortcuts.notifications,
+ count: unreadNotificationsCount,
+ },
+ ],
+ });
+
return sections;
}, [
shortcuts,
@@ -207,6 +226,7 @@ export function useNavigation({
hideTerminal,
hasGitHubRemote,
unviewedValidationsCount,
+ unreadNotificationsCount,
isSpecGenerating,
]);
diff --git a/apps/ui/src/components/views/notifications-view.tsx b/apps/ui/src/components/views/notifications-view.tsx
new file mode 100644
index 00000000..aaffb011
--- /dev/null
+++ b/apps/ui/src/components/views/notifications-view.tsx
@@ -0,0 +1,272 @@
+/**
+ * Notifications View - Full page view for all notifications
+ */
+
+import { useEffect, useCallback } from 'react';
+import { useAppStore } from '@/store/app-store';
+import { useNotificationsStore } from '@/store/notifications-store';
+import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
+import { useNavigate } from '@tanstack/react-router';
+import type { Notification } from '@automaker/types';
+
+/**
+ * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
+ */
+function formatRelativeTime(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) return 'just now';
+ if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
+ if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
+ if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
+ return date.toLocaleDateString();
+}
+
+export function NotificationsView() {
+ const { currentProject } = useAppStore();
+ const projectPath = currentProject?.path ?? null;
+ const navigate = useNavigate();
+
+ const {
+ notifications,
+ unreadCount,
+ isLoading,
+ error,
+ setNotifications,
+ setUnreadCount,
+ markAsRead,
+ dismissNotification,
+ markAllAsRead,
+ dismissAll,
+ } = useNotificationsStore();
+
+ // Load notifications when project changes
+ useLoadNotifications(projectPath);
+
+ // Subscribe to real-time notification events
+ useNotificationEvents(projectPath);
+
+ const handleMarkAsRead = useCallback(
+ async (notificationId: string) => {
+ if (!projectPath) return;
+
+ // Optimistic update
+ markAsRead(notificationId);
+
+ // Sync with server
+ const api = getHttpApiClient();
+ await api.notifications.markAsRead(projectPath, notificationId);
+ },
+ [projectPath, markAsRead]
+ );
+
+ const handleDismiss = useCallback(
+ async (notificationId: string) => {
+ if (!projectPath) return;
+
+ // Optimistic update
+ dismissNotification(notificationId);
+
+ // Sync with server
+ const api = getHttpApiClient();
+ await api.notifications.dismiss(projectPath, notificationId);
+ },
+ [projectPath, dismissNotification]
+ );
+
+ const handleMarkAllAsRead = useCallback(async () => {
+ if (!projectPath) return;
+
+ // Optimistic update
+ markAllAsRead();
+
+ // Sync with server
+ const api = getHttpApiClient();
+ await api.notifications.markAsRead(projectPath);
+ }, [projectPath, markAllAsRead]);
+
+ const handleDismissAll = useCallback(async () => {
+ if (!projectPath) return;
+
+ // Optimistic update
+ dismissAll();
+
+ // Sync with server
+ const api = getHttpApiClient();
+ await api.notifications.dismiss(projectPath);
+ }, [projectPath, dismissAll]);
+
+ const handleNotificationClick = useCallback(
+ (notification: Notification) => {
+ // Mark as read
+ handleMarkAsRead(notification.id);
+
+ // Navigate to the relevant view based on notification type
+ if (notification.featureId) {
+ // Navigate to board view - feature will be selected
+ navigate({ to: '/board' });
+ }
+ },
+ [handleMarkAsRead, navigate]
+ );
+
+ const getNotificationIcon = (type: string) => {
+ switch (type) {
+ case 'feature_waiting_approval':
+ return ;
+ case 'feature_verified':
+ return ;
+ case 'spec_regeneration_complete':
+ return ;
+ case 'agent_complete':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ if (!projectPath) {
+ return (
+
+
+
Select a project to view notifications
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
Loading notifications...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
Notifications
+
+ {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
+
+
+ {notifications.length > 0 && (
+
+
+
+
+ )}
+
+
+ {notifications.length === 0 ? (
+
+
+
+ No notifications
+
+ Notifications will appear here when features are ready for review or operations
+ complete.
+
+
+
+ ) : (
+
+ {notifications.map((notification) => (
+
handleNotificationClick(notification)}
+ >
+
+ {getNotificationIcon(notification.type)}
+
+
+ {notification.title}
+ {!notification.read && (
+
+ )}
+
+
{notification.message}
+
+ {formatRelativeTime(new Date(notification.createdAt))}
+
+
+
+ {!notification.read && (
+
+ )}
+
+ {notification.featureId && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx
new file mode 100644
index 00000000..780f5f98
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx
@@ -0,0 +1,341 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import {
+ History,
+ RefreshCw,
+ Trash2,
+ Play,
+ ChevronDown,
+ ChevronRight,
+ CheckCircle,
+ XCircle,
+ Clock,
+ AlertCircle,
+} from 'lucide-react';
+import { useAppStore } from '@/store/app-store';
+import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types';
+import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { ConfirmDialog } from '@/components/ui/confirm-dialog';
+
+export function EventHistoryView() {
+ const currentProject = useAppStore((state) => state.currentProject);
+ const projectPath = currentProject?.path;
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [expandedEvent, setExpandedEvent] = useState(null);
+ const [expandedEventData, setExpandedEventData] = useState(null);
+ const [replayingEvent, setReplayingEvent] = useState(null);
+ const [clearDialogOpen, setClearDialogOpen] = useState(false);
+
+ const loadEvents = useCallback(async () => {
+ if (!projectPath) return;
+
+ setLoading(true);
+ try {
+ const api = getHttpApiClient();
+ const result = await api.eventHistory.list(projectPath, { limit: 100 });
+ if (result.success && result.events) {
+ setEvents(result.events);
+ }
+ } catch (error) {
+ console.error('Failed to load events:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [projectPath]);
+
+ useEffect(() => {
+ loadEvents();
+ }, [loadEvents]);
+
+ const handleExpand = async (eventId: string) => {
+ if (expandedEvent === eventId) {
+ setExpandedEvent(null);
+ setExpandedEventData(null);
+ return;
+ }
+
+ if (!projectPath) return;
+
+ setExpandedEvent(eventId);
+ try {
+ const api = getHttpApiClient();
+ const result = await api.eventHistory.get(projectPath, eventId);
+ if (result.success && result.event) {
+ setExpandedEventData(result.event);
+ }
+ } catch (error) {
+ console.error('Failed to load event details:', error);
+ }
+ };
+
+ const handleReplay = async (eventId: string) => {
+ if (!projectPath) return;
+
+ setReplayingEvent(eventId);
+ try {
+ const api = getHttpApiClient();
+ const result = await api.eventHistory.replay(projectPath, eventId);
+ if (result.success && result.result) {
+ const { hooksTriggered, hookResults } = result.result;
+ const successCount = hookResults.filter((r) => r.success).length;
+ const failCount = hookResults.filter((r) => !r.success).length;
+
+ if (hooksTriggered === 0) {
+ alert('No matching hooks found for this event trigger.');
+ } else if (failCount === 0) {
+ alert(`Successfully ran ${successCount} hook(s).`);
+ } else {
+ alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to replay event:', error);
+ alert('Failed to replay event. Check console for details.');
+ } finally {
+ setReplayingEvent(null);
+ }
+ };
+
+ const handleDelete = async (eventId: string) => {
+ if (!projectPath) return;
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.eventHistory.delete(projectPath, eventId);
+ if (result.success) {
+ setEvents((prev) => prev.filter((e) => e.id !== eventId));
+ if (expandedEvent === eventId) {
+ setExpandedEvent(null);
+ setExpandedEventData(null);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to delete event:', error);
+ }
+ };
+
+ const handleClearAll = async () => {
+ if (!projectPath) return;
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.eventHistory.clear(projectPath);
+ if (result.success) {
+ setEvents([]);
+ setExpandedEvent(null);
+ setExpandedEventData(null);
+ }
+ } catch (error) {
+ console.error('Failed to clear events:', error);
+ }
+ setClearDialogOpen(false);
+ };
+
+ const getTriggerIcon = (trigger: EventHookTrigger) => {
+ switch (trigger) {
+ case 'feature_created':
+ return ;
+ case 'feature_success':
+ return ;
+ case 'feature_error':
+ return ;
+ case 'auto_mode_complete':
+ return ;
+ case 'auto_mode_error':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const formatTimestamp = (timestamp: string) => {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'Just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+ };
+
+ if (!projectPath) {
+ return (
+
+
+
Select a project to view event history
+
+ );
+ }
+
+ return (
+
+ {/* Header with actions */}
+
+
+ {events.length} event{events.length !== 1 ? 's' : ''} recorded
+
+
+
+ {events.length > 0 && (
+
+ )}
+
+
+
+ {/* Events list */}
+ {events.length === 0 ? (
+
+
+
No events recorded yet
+
+ Events will appear here when features are created or completed
+
+
+ ) : (
+
+ {events.map((event) => (
+
+ {/* Event header */}
+
handleExpand(event.id)}
+ >
+
+
+ {getTriggerIcon(event.trigger)}
+
+
+
+ {EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
+
+ {event.featureName && (
+
{event.featureName}
+ )}
+
+
+
+ {formatTimestamp(event.timestamp)}
+
+
+ {/* Actions */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {/* Expanded details */}
+ {expandedEvent === event.id && expandedEventData && (
+
+
+
+
+
Event ID:
+
{expandedEventData.id}
+
+
+
Timestamp:
+
{new Date(expandedEventData.timestamp).toLocaleString()}
+
+ {expandedEventData.featureId && (
+
+
Feature ID:
+
+ {expandedEventData.featureId}
+
+
+ )}
+ {expandedEventData.passes !== undefined && (
+
+
Passed:
+
{expandedEventData.passes ? 'Yes' : 'No'}
+
+ )}
+
+ {expandedEventData.error && (
+
+
Error:
+
+ {expandedEventData.error}
+
+
+ )}
+
+
Project:
+
+ {expandedEventData.projectPath}
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Clear confirmation dialog */}
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx
index 68233b5a..857efb33 100644
--- a/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx
+++ b/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx
@@ -39,6 +39,7 @@ interface EventHookDialogProps {
type ActionType = 'shell' | 'http';
const TRIGGER_OPTIONS: EventHookTrigger[] = [
+ 'feature_created',
'feature_success',
'feature_error',
'auto_mode_complete',
diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx
index dce34433..519ca370 100644
--- a/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx
+++ b/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx
@@ -1,17 +1,20 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
-import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react';
+import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
+import { EventHistoryView } from './event-history-view';
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState(null);
+ const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
const handleAddHook = () => {
setEditingHook(null);
@@ -78,58 +81,85 @@ export function EventHooksSection() {
-
+ {activeTab === 'hooks' && (
+
+ )}
- {/* Content */}
-
- {eventHooks.length === 0 ? (
-
-
-
No event hooks configured
-
- Add hooks to run commands or send webhooks when features complete
-
-
- ) : (
-
- {/* Group by trigger type */}
- {Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
-
-
- {EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
-
-
- {hooks.map((hook) => (
- handleEditHook(hook)}
- onDelete={() => handleDeleteHook(hook.id)}
- onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
- />
- ))}
-
+ {/* Tabs */}
+
setActiveTab(v as 'hooks' | 'history')}>
+
+
+
+
+ Hooks
+
+
+
+ History
+
+
+
+
+ {/* Hooks Tab */}
+
+
+ {eventHooks.length === 0 ? (
+
+
+
No event hooks configured
+
+ Add hooks to run commands or send webhooks when features complete
+
- ))}
+ ) : (
+
+ {/* Group by trigger type */}
+ {Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
+
+
+ {EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
+
+
+ {hooks.map((hook) => (
+ handleEditHook(hook)}
+ onDelete={() => handleDeleteHook(hook.id)}
+ onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
+ />
+ ))}
+
+
+ ))}
+
+ )}
- )}
-
- {/* Variable reference */}
-
-
-
Available variables:
-
- {'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
- {'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
-
-
-
+ {/* Variable reference */}
+
+
+
Available variables:
+
+ {'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
+ {'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
+
+
+
+
+
+ {/* History Tab */}
+
+
+
+
+
+
{/* Dialog */}
s.addNotification);
+
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const api = getHttpApiClient();
+
+ const unsubscribe = api.notifications.onNotificationCreated((notification: Notification) => {
+ // Only handle notifications for the current project
+ if (!pathsEqual(notification.projectPath, projectPath)) return;
+
+ addNotification(notification);
+ });
+
+ return unsubscribe;
+ }, [projectPath, addNotification]);
+}
+
+/**
+ * Hook to load notifications for a project.
+ * Should be called when switching projects or on initial load.
+ */
+export function useLoadNotifications(projectPath: string | null) {
+ const setNotifications = useNotificationsStore((s) => s.setNotifications);
+ const setUnreadCount = useNotificationsStore((s) => s.setUnreadCount);
+ const setLoading = useNotificationsStore((s) => s.setLoading);
+ const setError = useNotificationsStore((s) => s.setError);
+ const reset = useNotificationsStore((s) => s.reset);
+
+ useEffect(() => {
+ if (!projectPath) {
+ reset();
+ return;
+ }
+
+ const loadNotifications = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const api = getHttpApiClient();
+ const [listResult, countResult] = await Promise.all([
+ api.notifications.list(projectPath),
+ api.notifications.getUnreadCount(projectPath),
+ ]);
+
+ if (listResult.success && listResult.notifications) {
+ setNotifications(listResult.notifications);
+ }
+
+ if (countResult.success && countResult.count !== undefined) {
+ setUnreadCount(countResult.count);
+ }
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Failed to load notifications');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadNotifications();
+ }, [projectPath, setNotifications, setUnreadCount, setLoading, setError, reset]);
+}
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts
index 773fdd82..fd9f8588 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -550,6 +550,88 @@ export interface SaveImageResult {
error?: string;
}
+// Notifications API interface
+import type {
+ Notification,
+ StoredEvent,
+ StoredEventSummary,
+ EventHistoryFilter,
+ EventReplayResult,
+} from '@automaker/types';
+
+export interface NotificationsAPI {
+ list: (projectPath: string) => Promise<{
+ success: boolean;
+ notifications?: Notification[];
+ error?: string;
+ }>;
+ getUnreadCount: (projectPath: string) => Promise<{
+ success: boolean;
+ count?: number;
+ error?: string;
+ }>;
+ markAsRead: (
+ projectPath: string,
+ notificationId?: string
+ ) => Promise<{
+ success: boolean;
+ notification?: Notification;
+ count?: number;
+ error?: string;
+ }>;
+ dismiss: (
+ projectPath: string,
+ notificationId?: string
+ ) => Promise<{
+ success: boolean;
+ dismissed?: boolean;
+ count?: number;
+ error?: string;
+ }>;
+}
+
+// Event History API interface
+export interface EventHistoryAPI {
+ list: (
+ projectPath: string,
+ filter?: EventHistoryFilter
+ ) => Promise<{
+ success: boolean;
+ events?: StoredEventSummary[];
+ total?: number;
+ error?: string;
+ }>;
+ get: (
+ projectPath: string,
+ eventId: string
+ ) => Promise<{
+ success: boolean;
+ event?: StoredEvent;
+ error?: string;
+ }>;
+ delete: (
+ projectPath: string,
+ eventId: string
+ ) => Promise<{
+ success: boolean;
+ error?: string;
+ }>;
+ clear: (projectPath: string) => Promise<{
+ success: boolean;
+ cleared?: number;
+ error?: string;
+ }>;
+ replay: (
+ projectPath: string,
+ eventId: string,
+ hookIds?: string[]
+ ) => Promise<{
+ success: boolean;
+ result?: EventReplayResult;
+ error?: string;
+ }>;
+}
+
export interface ElectronAPI {
ping: () => Promise;
getApiKey?: () => Promise;
@@ -760,6 +842,8 @@ export interface ElectronAPI {
}>;
};
ideation?: IdeationAPI;
+ notifications?: NotificationsAPI;
+ eventHistory?: EventHistoryAPI;
codex?: {
getUsage: () => Promise;
getModels: (refresh?: boolean) => Promise<{
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts
index 90781b59..f8a12c14 100644
--- a/apps/ui/src/lib/http-api-client.ts
+++ b/apps/ui/src/lib/http-api-client.ts
@@ -32,7 +32,10 @@ import type {
CreateIdeaInput,
UpdateIdeaInput,
ConvertToFeatureOptions,
+ NotificationsAPI,
+ EventHistoryAPI,
} from './electron';
+import type { EventHistoryFilter } from '@automaker/types';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
@@ -514,7 +517,8 @@ type EventType =
| 'worktree:init-completed'
| 'dev-server:started'
| 'dev-server:output'
- | 'dev-server:stopped';
+ | 'dev-server:stopped'
+ | 'notification:created';
/**
* Dev server log event payloads for WebSocket streaming
@@ -2440,6 +2444,43 @@ export class HttpApiClient implements ElectronAPI {
},
};
+ // Notifications API - project-level notifications
+ notifications: NotificationsAPI & {
+ onNotificationCreated: (callback: (notification: any) => void) => () => void;
+ } = {
+ list: (projectPath: string) => this.post('/api/notifications/list', { projectPath }),
+
+ getUnreadCount: (projectPath: string) =>
+ this.post('/api/notifications/unread-count', { projectPath }),
+
+ markAsRead: (projectPath: string, notificationId?: string) =>
+ this.post('/api/notifications/mark-read', { projectPath, notificationId }),
+
+ dismiss: (projectPath: string, notificationId?: string) =>
+ this.post('/api/notifications/dismiss', { projectPath, notificationId }),
+
+ onNotificationCreated: (callback: (notification: any) => void): (() => void) => {
+ return this.subscribeToEvent('notification:created', callback as EventCallback);
+ },
+ };
+
+ // Event History API - stored events for debugging and replay
+ eventHistory: EventHistoryAPI = {
+ list: (projectPath: string, filter?: EventHistoryFilter) =>
+ this.post('/api/event-history/list', { projectPath, filter }),
+
+ get: (projectPath: string, eventId: string) =>
+ this.post('/api/event-history/get', { projectPath, eventId }),
+
+ delete: (projectPath: string, eventId: string) =>
+ this.post('/api/event-history/delete', { projectPath, eventId }),
+
+ clear: (projectPath: string) => this.post('/api/event-history/clear', { projectPath }),
+
+ replay: (projectPath: string, eventId: string, hookIds?: string[]) =>
+ this.post('/api/event-history/replay', { projectPath, eventId, hookIds }),
+ };
+
// MCP API - Test MCP server connections and list tools
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
// drive-by command execution attacks. Servers must be saved first.
diff --git a/apps/ui/src/routes/notifications.tsx b/apps/ui/src/routes/notifications.tsx
new file mode 100644
index 00000000..6500b8fe
--- /dev/null
+++ b/apps/ui/src/routes/notifications.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { NotificationsView } from '@/components/views/notifications-view';
+
+export const Route = createFileRoute('/notifications')({
+ component: NotificationsView,
+});
diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts
index 23fa5371..8fcbd203 100644
--- a/apps/ui/src/store/app-store.ts
+++ b/apps/ui/src/store/app-store.ts
@@ -233,6 +233,7 @@ export interface KeyboardShortcuts {
settings: string;
terminal: string;
ideation: string;
+ notifications: string;
githubIssues: string;
githubPrs: string;
@@ -268,6 +269,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
settings: 'S',
terminal: 'T',
ideation: 'I',
+ notifications: 'X',
githubIssues: 'G',
githubPrs: 'R',
diff --git a/apps/ui/src/store/notifications-store.ts b/apps/ui/src/store/notifications-store.ts
new file mode 100644
index 00000000..278f645d
--- /dev/null
+++ b/apps/ui/src/store/notifications-store.ts
@@ -0,0 +1,129 @@
+/**
+ * Notifications Store - State management for project-level notifications
+ */
+
+import { create } from 'zustand';
+import type { Notification } from '@automaker/types';
+
+// ============================================================================
+// State Interface
+// ============================================================================
+
+interface NotificationsState {
+ // Notifications for the current project
+ notifications: Notification[];
+ unreadCount: number;
+ isLoading: boolean;
+ error: string | null;
+
+ // Popover state
+ isPopoverOpen: boolean;
+}
+
+// ============================================================================
+// Actions Interface
+// ============================================================================
+
+interface NotificationsActions {
+ // Data management
+ setNotifications: (notifications: Notification[]) => void;
+ setUnreadCount: (count: number) => void;
+ addNotification: (notification: Notification) => void;
+ markAsRead: (notificationId: string) => void;
+ markAllAsRead: () => void;
+ dismissNotification: (notificationId: string) => void;
+ dismissAll: () => void;
+
+ // Loading state
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+
+ // Popover state
+ setPopoverOpen: (open: boolean) => void;
+
+ // Reset
+ reset: () => void;
+}
+
+// ============================================================================
+// Initial State
+// ============================================================================
+
+const initialState: NotificationsState = {
+ notifications: [],
+ unreadCount: 0,
+ isLoading: false,
+ error: null,
+ isPopoverOpen: false,
+};
+
+// ============================================================================
+// Store
+// ============================================================================
+
+export const useNotificationsStore = create(
+ (set, get) => ({
+ ...initialState,
+
+ // Data management
+ setNotifications: (notifications) =>
+ set({
+ notifications,
+ unreadCount: notifications.filter((n) => !n.read).length,
+ }),
+
+ setUnreadCount: (count) => set({ unreadCount: count }),
+
+ addNotification: (notification) =>
+ set((state) => ({
+ notifications: [notification, ...state.notifications],
+ unreadCount: notification.read ? state.unreadCount : state.unreadCount + 1,
+ })),
+
+ markAsRead: (notificationId) =>
+ set((state) => {
+ const notification = state.notifications.find((n) => n.id === notificationId);
+ if (!notification || notification.read) return state;
+
+ return {
+ notifications: state.notifications.map((n) =>
+ n.id === notificationId ? { ...n, read: true } : n
+ ),
+ unreadCount: Math.max(0, state.unreadCount - 1),
+ };
+ }),
+
+ markAllAsRead: () =>
+ set((state) => ({
+ notifications: state.notifications.map((n) => ({ ...n, read: true })),
+ unreadCount: 0,
+ })),
+
+ dismissNotification: (notificationId) =>
+ set((state) => {
+ const notification = state.notifications.find((n) => n.id === notificationId);
+ if (!notification) return state;
+
+ return {
+ notifications: state.notifications.filter((n) => n.id !== notificationId),
+ unreadCount: notification.read ? state.unreadCount : Math.max(0, state.unreadCount - 1),
+ };
+ }),
+
+ dismissAll: () =>
+ set({
+ notifications: [],
+ unreadCount: 0,
+ }),
+
+ // Loading state
+ setLoading: (loading) => set({ isLoading: loading }),
+ setError: (error) => set({ error }),
+
+ // Popover state
+ setPopoverOpen: (open) => set({ isPopoverOpen: open }),
+
+ // Reset
+ reset: () => set(initialState),
+ })
+);
diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts
index cd37da49..d51845f9 100644
--- a/libs/platform/src/index.ts
+++ b/libs/platform/src/index.ts
@@ -19,6 +19,12 @@ export {
getAppSpecPath,
getBranchTrackingPath,
getExecutionStatePath,
+ getNotificationsPath,
+ // Event history paths
+ getEventHistoryDir,
+ getEventHistoryIndexPath,
+ getEventPath,
+ ensureEventHistoryDir,
ensureAutomakerDir,
getGlobalSettingsPath,
getCredentialsPath,
diff --git a/libs/platform/src/paths.ts b/libs/platform/src/paths.ts
index 5a56a2a2..130f54e0 100644
--- a/libs/platform/src/paths.ts
+++ b/libs/platform/src/paths.ts
@@ -161,6 +161,18 @@ export function getAppSpecPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), 'app_spec.txt');
}
+/**
+ * Get the notifications file path for a project
+ *
+ * Stores project-level notifications for feature status changes and operation completions.
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Absolute path to {projectPath}/.automaker/notifications.json
+ */
+export function getNotificationsPath(projectPath: string): string {
+ return path.join(getAutomakerDir(projectPath), 'notifications.json');
+}
+
/**
* Get the branch tracking file path for a project
*
@@ -335,6 +347,57 @@ export async function ensureIdeationDir(projectPath: string): Promise {
return ideationDir;
}
+// ============================================================================
+// Event History Paths
+// ============================================================================
+
+/**
+ * Get the event history directory for a project
+ *
+ * Contains stored event records for debugging and replay.
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Absolute path to {projectPath}/.automaker/events
+ */
+export function getEventHistoryDir(projectPath: string): string {
+ return path.join(getAutomakerDir(projectPath), 'events');
+}
+
+/**
+ * Get the event history index file path
+ *
+ * Stores an index of all events for quick listing without scanning directory.
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Absolute path to {projectPath}/.automaker/events/index.json
+ */
+export function getEventHistoryIndexPath(projectPath: string): string {
+ return path.join(getEventHistoryDir(projectPath), 'index.json');
+}
+
+/**
+ * Get the file path for a specific event
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param eventId - Event identifier
+ * @returns Absolute path to {projectPath}/.automaker/events/{eventId}.json
+ */
+export function getEventPath(projectPath: string, eventId: string): string {
+ return path.join(getEventHistoryDir(projectPath), `${eventId}.json`);
+}
+
+/**
+ * Create the event history directory for a project if it doesn't exist
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Promise resolving to the created events directory path
+ */
+export async function ensureEventHistoryDir(projectPath: string): Promise {
+ const eventsDir = getEventHistoryDir(projectPath);
+ await secureFs.mkdir(eventsDir, { recursive: true });
+ return eventsDir;
+}
+
// ============================================================================
// Global Settings Paths (stored in DATA_DIR from app.getPath('userData'))
// ============================================================================
diff --git a/libs/types/src/event-history.ts b/libs/types/src/event-history.ts
new file mode 100644
index 00000000..09ff92aa
--- /dev/null
+++ b/libs/types/src/event-history.ts
@@ -0,0 +1,123 @@
+/**
+ * Event History Types - Stored event records for debugging and replay
+ *
+ * Events are stored on disk to allow users to:
+ * - View historical events for debugging
+ * - Replay events with custom hooks
+ * - Test hook configurations against past events
+ */
+
+import type { EventHookTrigger } from './settings.js';
+
+/**
+ * StoredEvent - A single event record stored on disk
+ *
+ * Contains all information needed to replay the event or inspect what happened.
+ */
+export interface StoredEvent {
+ /** Unique identifier for this event record */
+ id: string;
+ /** The hook trigger type this event maps to */
+ trigger: EventHookTrigger;
+ /** ISO timestamp when the event occurred */
+ timestamp: string;
+ /** ID of the feature involved (if applicable) */
+ featureId?: string;
+ /** Name of the feature involved (if applicable) */
+ featureName?: string;
+ /** Path to the project where the event occurred */
+ projectPath: string;
+ /** Name of the project (extracted from path) */
+ projectName: string;
+ /** Error message if this was an error event */
+ error?: string;
+ /** Error classification if applicable */
+ errorType?: string;
+ /** Whether the feature passed (for completion events) */
+ passes?: boolean;
+ /** Additional context/metadata for the event */
+ metadata?: Record;
+}
+
+/**
+ * StoredEventIndex - Quick lookup index for event history
+ *
+ * Stored separately for fast listing without loading full event data.
+ */
+export interface StoredEventIndex {
+ /** Version for future migrations */
+ version: number;
+ /** Array of event summaries for quick listing */
+ events: StoredEventSummary[];
+}
+
+/**
+ * StoredEventSummary - Minimal event info for listing
+ */
+export interface StoredEventSummary {
+ /** Event ID */
+ id: string;
+ /** Trigger type */
+ trigger: EventHookTrigger;
+ /** When it occurred */
+ timestamp: string;
+ /** Feature name for display (if applicable) */
+ featureName?: string;
+ /** Feature ID (if applicable) */
+ featureId?: string;
+}
+
+/**
+ * EventHistoryFilter - Options for filtering event history
+ */
+export interface EventHistoryFilter {
+ /** Filter by trigger type */
+ trigger?: EventHookTrigger;
+ /** Filter by feature ID */
+ featureId?: string;
+ /** Filter events after this timestamp */
+ since?: string;
+ /** Filter events before this timestamp */
+ until?: string;
+ /** Maximum number of events to return */
+ limit?: number;
+ /** Number of events to skip (for pagination) */
+ offset?: number;
+}
+
+/**
+ * EventReplayResult - Result of replaying an event
+ */
+export interface EventReplayResult {
+ /** Event that was replayed */
+ eventId: string;
+ /** Number of hooks that were triggered */
+ hooksTriggered: number;
+ /** Results from each hook execution */
+ hookResults: EventReplayHookResult[];
+}
+
+/**
+ * EventReplayHookResult - Result of a single hook execution during replay
+ */
+export interface EventReplayHookResult {
+ /** Hook ID */
+ hookId: string;
+ /** Hook name (if set) */
+ hookName?: string;
+ /** Whether the hook executed successfully */
+ success: boolean;
+ /** Error message if failed */
+ error?: string;
+ /** Execution time in milliseconds */
+ durationMs: number;
+}
+
+/** Current version of the event history index schema */
+export const EVENT_HISTORY_VERSION = 1;
+
+/** Default empty event history index */
+export const DEFAULT_EVENT_HISTORY_INDEX: StoredEventIndex = {
+ version: EVENT_HISTORY_VERSION,
+ events: [],
+};
diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts
index 6e723d86..c274ffb5 100644
--- a/libs/types/src/event.ts
+++ b/libs/types/src/event.ts
@@ -10,6 +10,7 @@ export type EventType =
| 'auto-mode:idle'
| 'auto-mode:error'
| 'backlog-plan:event'
+ | 'feature:created'
| 'feature:started'
| 'feature:completed'
| 'feature:stopped'
@@ -45,6 +46,7 @@ export type EventType =
| 'worktree:init-completed'
| 'dev-server:started'
| 'dev-server:output'
- | 'dev-server:stopped';
+ | 'dev-server:stopped'
+ | 'notification:created';
export type EventCallback = (type: EventType, payload: unknown) => void;
diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts
index ba09a3b2..7f06a33b 100644
--- a/libs/types/src/index.ts
+++ b/libs/types/src/index.ts
@@ -270,3 +270,18 @@ export type {
IdeationStreamEvent,
IdeationAnalysisEvent,
} from './ideation.js';
+
+// Notification types
+export type { NotificationType, Notification, NotificationsFile } from './notification.js';
+export { NOTIFICATIONS_VERSION, DEFAULT_NOTIFICATIONS_FILE } from './notification.js';
+
+// Event history types
+export type {
+ StoredEvent,
+ StoredEventIndex,
+ StoredEventSummary,
+ EventHistoryFilter,
+ EventReplayResult,
+ EventReplayHookResult,
+} from './event-history.js';
+export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js';
diff --git a/libs/types/src/notification.ts b/libs/types/src/notification.ts
new file mode 100644
index 00000000..e134e0ea
--- /dev/null
+++ b/libs/types/src/notification.ts
@@ -0,0 +1,58 @@
+/**
+ * Notification Types - Types for project-level notification system
+ *
+ * Notifications alert users when features reach specific statuses
+ * or when long-running operations complete.
+ */
+
+/**
+ * NotificationType - Types of notifications that can be created
+ */
+export type NotificationType =
+ | 'feature_waiting_approval'
+ | 'feature_verified'
+ | 'spec_regeneration_complete'
+ | 'agent_complete';
+
+/**
+ * Notification - A single notification entry
+ */
+export interface Notification {
+ /** Unique identifier for the notification */
+ id: string;
+ /** Type of notification */
+ type: NotificationType;
+ /** Short title for display */
+ title: string;
+ /** Longer descriptive message */
+ message: string;
+ /** ISO timestamp when notification was created */
+ createdAt: string;
+ /** Whether the notification has been read */
+ read: boolean;
+ /** Whether the notification has been dismissed */
+ dismissed: boolean;
+ /** Associated feature ID if applicable */
+ featureId?: string;
+ /** Project path this notification belongs to */
+ projectPath: string;
+}
+
+/**
+ * NotificationsFile - Structure of the notifications.json file
+ */
+export interface NotificationsFile {
+ /** Version for future migrations */
+ version: number;
+ /** List of notifications */
+ notifications: Notification[];
+}
+
+/** Current version of the notifications file schema */
+export const NOTIFICATIONS_VERSION = 1;
+
+/** Default notifications file structure */
+export const DEFAULT_NOTIFICATIONS_FILE: NotificationsFile = {
+ version: NOTIFICATIONS_VERSION,
+ notifications: [],
+};
diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts
index 6e807f66..ee8a77a3 100644
--- a/libs/types/src/settings.ts
+++ b/libs/types/src/settings.ts
@@ -108,12 +108,14 @@ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
/**
* EventHookTrigger - Event types that can trigger custom hooks
*
+ * - feature_created: A new feature was created
* - feature_success: Feature completed successfully
* - feature_error: Feature failed with an error
* - auto_mode_complete: Auto mode finished processing all features
* - auto_mode_error: Auto mode encountered a critical error and paused
*/
export type EventHookTrigger =
+ | 'feature_created'
| 'feature_success'
| 'feature_error'
| 'auto_mode_complete'
@@ -186,6 +188,7 @@ export interface EventHook {
/** Human-readable labels for event hook triggers */
export const EVENT_HOOK_TRIGGER_LABELS: Record = {
+ feature_created: 'Feature created',
feature_success: 'Feature completed successfully',
feature_error: 'Feature failed with error',
auto_mode_complete: 'Auto mode completed all features',
@@ -298,6 +301,8 @@ export interface KeyboardShortcuts {
settings: string;
/** Open terminal */
terminal: string;
+ /** Open notifications */
+ notifications: string;
/** Toggle sidebar visibility */
toggleSidebar: string;
/** Add new feature */
@@ -800,6 +805,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
context: 'C',
settings: 'S',
terminal: 'T',
+ notifications: 'X',
toggleSidebar: '`',
addFeature: 'N',
addContextFile: 'N',