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