agileflow 2.95.2 → 2.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -6
  3. package/lib/api-routes.js +605 -0
  4. package/lib/api-server.js +260 -0
  5. package/lib/claude-cli-bridge.js +221 -0
  6. package/lib/dashboard-protocol.js +541 -0
  7. package/lib/dashboard-server.js +1601 -0
  8. package/lib/drivers/claude-driver.ts +310 -0
  9. package/lib/drivers/codex-driver.ts +454 -0
  10. package/lib/drivers/driver-manager.ts +158 -0
  11. package/lib/drivers/gemini-driver.ts +485 -0
  12. package/lib/drivers/index.ts +17 -0
  13. package/lib/flag-detection.js +350 -0
  14. package/lib/git-operations.js +267 -0
  15. package/lib/lock-file.js +144 -0
  16. package/lib/merge-operations.js +959 -0
  17. package/lib/protocol/driver.ts +360 -0
  18. package/lib/protocol/index.ts +12 -0
  19. package/lib/protocol/ir.ts +271 -0
  20. package/lib/session-display.js +330 -0
  21. package/lib/worktree-operations.js +221 -0
  22. package/package.json +2 -2
  23. package/scripts/agileflow-welcome.js +272 -24
  24. package/scripts/api-server-runner.js +177 -0
  25. package/scripts/archive-completed-stories.sh +22 -0
  26. package/scripts/automation-run-due.js +126 -0
  27. package/scripts/backfill-ideation-status.js +124 -0
  28. package/scripts/claude-tmux.sh +62 -1
  29. package/scripts/context-loader.js +292 -0
  30. package/scripts/dashboard-serve.js +323 -0
  31. package/scripts/lib/automation-registry.js +544 -0
  32. package/scripts/lib/automation-runner.js +476 -0
  33. package/scripts/lib/concurrency-limiter.js +513 -0
  34. package/scripts/lib/configure-features.js +46 -0
  35. package/scripts/lib/context-formatter.js +61 -0
  36. package/scripts/lib/damage-control-utils.js +29 -4
  37. package/scripts/lib/hook-metrics.js +324 -0
  38. package/scripts/lib/ideation-index.js +1196 -0
  39. package/scripts/lib/process-cleanup.js +359 -0
  40. package/scripts/lib/quality-gates.js +574 -0
  41. package/scripts/lib/status-task-bridge.js +522 -0
  42. package/scripts/lib/sync-ideation-status.js +292 -0
  43. package/scripts/lib/task-registry-cache.js +490 -0
  44. package/scripts/lib/task-registry.js +1181 -0
  45. package/scripts/migrate-ideation-index.js +515 -0
  46. package/scripts/precompact-context.sh +104 -0
  47. package/scripts/ralph-loop.js +2 -2
  48. package/scripts/session-manager.js +363 -2770
  49. package/scripts/spawn-parallel.js +45 -9
  50. package/src/core/agents/api-validator.md +180 -0
  51. package/src/core/agents/api.md +2 -0
  52. package/src/core/agents/code-reviewer.md +289 -0
  53. package/src/core/agents/configuration/damage-control.md +17 -0
  54. package/src/core/agents/database.md +2 -0
  55. package/src/core/agents/error-analyzer.md +203 -0
  56. package/src/core/agents/logic-analyzer-edge.md +171 -0
  57. package/src/core/agents/logic-analyzer-flow.md +254 -0
  58. package/src/core/agents/logic-analyzer-invariant.md +207 -0
  59. package/src/core/agents/logic-analyzer-race.md +267 -0
  60. package/src/core/agents/logic-analyzer-type.md +218 -0
  61. package/src/core/agents/logic-consensus.md +256 -0
  62. package/src/core/agents/orchestrator.md +89 -1
  63. package/src/core/agents/schema-validator.md +451 -0
  64. package/src/core/agents/team-coordinator.md +328 -0
  65. package/src/core/agents/ui-validator.md +328 -0
  66. package/src/core/agents/ui.md +2 -0
  67. package/src/core/commands/api.md +267 -0
  68. package/src/core/commands/automate.md +415 -0
  69. package/src/core/commands/babysit.md +290 -9
  70. package/src/core/commands/ideate/history.md +403 -0
  71. package/src/core/commands/{ideate.md → ideate/new.md} +244 -34
  72. package/src/core/commands/logic/audit.md +368 -0
  73. package/src/core/commands/roadmap/analyze.md +1 -1
  74. package/src/core/experts/documentation/expertise.yaml +29 -2
  75. package/src/core/templates/CONTEXT.md.example +49 -0
  76. package/src/core/templates/claude-settings.advanced.example.json +4 -0
  77. package/tools/cli/commands/serve.js +456 -0
  78. package/tools/cli/installers/core/installer.js +7 -2
  79. package/tools/cli/installers/ide/claude-code.js +85 -0
  80. package/tools/cli/lib/content-injector.js +27 -1
  81. package/tools/cli/lib/ui.js +26 -57
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.96.0] - 2026-02-05
11
+
12
+ ### Added
13
+ - Multi-agent logic analyzer, ideation enhancements, and serve improvements
14
+
15
+ ## [2.95.3] - 2026-02-01
16
+
17
+ ### Added
18
+ - Fresh restart flag for tmux with conversation auto-resume
19
+
10
20
  ## [2.95.2] - 2026-02-01
11
21
 
12
22
  ### Changed
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  </p>
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/agileflow?color=brightgreen)](https://www.npmjs.com/package/agileflow)
6
- [![Commands](https://img.shields.io/badge/commands-81-blue)](docs/04-architecture/commands.md)
7
- [![Agents/Experts](https://img.shields.io/badge/agents%2Fexperts-34-orange)](docs/04-architecture/subagents.md)
6
+ [![Commands](https://img.shields.io/badge/commands-85-blue)](docs/04-architecture/commands.md)
7
+ [![Agents/Experts](https://img.shields.io/badge/agents%2Fexperts-46-orange)](docs/04-architecture/subagents.md)
8
8
  [![Skills](https://img.shields.io/badge/skills-dynamic-purple)](docs/04-architecture/skills.md)
9
9
 
10
10
  **AI-driven agile development for Claude Code, Cursor, Windsurf, OpenAI Codex CLI, and more.** Combining Scrum, Kanban, ADRs, and docs-as-code principles into one framework-agnostic system.
@@ -65,8 +65,8 @@ AgileFlow combines three proven methodologies:
65
65
 
66
66
  | Component | Count | Description |
67
67
  |-----------|-------|-------------|
68
- | [Commands](docs/04-architecture/commands.md) | 81 | Slash commands for agile workflows |
69
- | [Agents/Experts](docs/04-architecture/subagents.md) | 34 | Specialized agents with self-improving knowledge bases |
68
+ | [Commands](docs/04-architecture/commands.md) | 85 | Slash commands for agile workflows |
69
+ | [Agents/Experts](docs/04-architecture/subagents.md) | 46 | Specialized agents with self-improving knowledge bases |
70
70
  | [Skills](docs/04-architecture/skills.md) | Dynamic | Generated on-demand with `/agileflow:skill:create` |
71
71
 
72
72
  ---
@@ -76,8 +76,8 @@ AgileFlow combines three proven methodologies:
76
76
  Full documentation lives in [`docs/04-architecture/`](docs/04-architecture/):
77
77
 
78
78
  ### Reference
79
- - [Commands](docs/04-architecture/commands.md) - All 81 slash commands
80
- - [Agents/Experts](docs/04-architecture/subagents.md) - 34 specialized agents with self-improving knowledge
79
+ - [Commands](docs/04-architecture/commands.md) - All 85 slash commands
80
+ - [Agents/Experts](docs/04-architecture/subagents.md) - 46 specialized agents with self-improving knowledge
81
81
  - [Skills](docs/04-architecture/skills.md) - Dynamic skill generator with MCP integration
82
82
 
83
83
  ### Architecture
@@ -0,0 +1,605 @@
1
+ /**
2
+ * api-routes.js - Route Handlers for AgileFlow REST API
3
+ *
4
+ * Provides route handlers for exposing AgileFlow state:
5
+ * - Sessions (from .agileflow/sessions/registry.json)
6
+ * - Status (from docs/09-agents/status.json)
7
+ * - Tasks (from .agileflow/state/task-dependencies.json)
8
+ * - Bus messages (from docs/09-agents/bus/log.jsonl)
9
+ * - Metrics (aggregated from all sources)
10
+ *
11
+ * All routes are READ-ONLY. Writes go through CLI commands.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const readline = require('readline');
19
+ const {
20
+ getStatusPath,
21
+ getSessionStatePath,
22
+ getBusLogPath,
23
+ getEpicsDir,
24
+ getStoriesDir,
25
+ getAgentsDir,
26
+ } = require('./paths');
27
+ const { SessionRegistry } = require('./session-registry');
28
+ const { getTaskRegistry } = require('../scripts/lib/task-registry');
29
+
30
+ /**
31
+ * Get API route handlers
32
+ *
33
+ * @param {string} rootDir - Project root directory
34
+ * @param {ApiCache} cache - Cache instance
35
+ * @returns {{ static: Object, dynamic: Object }}
36
+ */
37
+ function getApiRoutes(rootDir, cache) {
38
+ // Initialize session registry
39
+ const sessionRegistry = new SessionRegistry(rootDir);
40
+
41
+ return {
42
+ // Static routes (exact match)
43
+ static: {
44
+ '/api': () => getApiInfo(),
45
+ '/api/health': () => getHealth(rootDir),
46
+ '/api/sessions': () => getSessions(sessionRegistry, cache),
47
+ '/api/status': () => getStatus(rootDir, cache),
48
+ '/api/tasks': queryParams => getTasks(rootDir, queryParams, cache),
49
+ '/api/bus/messages': queryParams => getBusMessages(rootDir, queryParams, cache),
50
+ '/api/metrics': () => getMetrics(rootDir, cache),
51
+ '/api/epics': () => getEpics(rootDir, cache),
52
+ '/api/stories': queryParams => getStories(rootDir, queryParams, cache),
53
+ },
54
+
55
+ // Dynamic routes (with parameters)
56
+ dynamic: {
57
+ '/api/sessions/:id': (queryParams, params) =>
58
+ getSessionById(sessionRegistry, params.id, cache),
59
+ '/api/tasks/:id': (queryParams, params) => getTaskById(rootDir, params.id, cache),
60
+ '/api/epics/:id': (queryParams, params) => getEpicById(rootDir, params.id, cache),
61
+ '/api/stories/:id': (queryParams, params) => getStoryById(rootDir, params.id, cache),
62
+ },
63
+ };
64
+ }
65
+
66
+ // ============================================================================
67
+ // Route Handlers
68
+ // ============================================================================
69
+
70
+ /**
71
+ * GET /api - API information
72
+ */
73
+ function getApiInfo() {
74
+ return {
75
+ name: 'AgileFlow API',
76
+ version: '1.0.0',
77
+ description: 'REST API for AgileFlow state exposure',
78
+ endpoints: {
79
+ '/api': 'API information (this endpoint)',
80
+ '/api/health': 'Health check',
81
+ '/api/sessions': 'List active sessions',
82
+ '/api/sessions/:id': 'Get session by ID',
83
+ '/api/status': 'Get status.json (epics/stories state)',
84
+ '/api/tasks': 'List tasks (filterable)',
85
+ '/api/tasks/:id': 'Get task by ID',
86
+ '/api/bus/messages': 'Get bus messages (paginated)',
87
+ '/api/metrics': 'Aggregated metrics',
88
+ '/api/epics': 'List epics',
89
+ '/api/epics/:id': 'Get epic by ID',
90
+ '/api/stories': 'List stories (filterable)',
91
+ '/api/stories/:id': 'Get story by ID',
92
+ },
93
+ note: 'All endpoints are read-only. Mutations go through CLI commands.',
94
+ };
95
+ }
96
+
97
+ /**
98
+ * GET /api/health - Health check
99
+ */
100
+ function getHealth(rootDir) {
101
+ const statusPath = getStatusPath(rootDir);
102
+ const statusExists = fs.existsSync(statusPath);
103
+
104
+ return {
105
+ status: 'ok',
106
+ timestamp: new Date().toISOString(),
107
+ project: path.basename(rootDir),
108
+ checks: {
109
+ status_file: statusExists ? 'present' : 'missing',
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * GET /api/sessions - List active sessions
116
+ */
117
+ async function getSessions(sessionRegistry, cache) {
118
+ const cacheKey = 'sessions';
119
+ const cached = cache.get(cacheKey);
120
+ if (cached) return cached;
121
+
122
+ const sessions = await sessionRegistry.getAllSessions();
123
+ const counts = await sessionRegistry.countSessions();
124
+
125
+ const result = {
126
+ sessions: Object.entries(sessions).map(([id, session]) => ({
127
+ id,
128
+ ...session,
129
+ })),
130
+ counts,
131
+ timestamp: new Date().toISOString(),
132
+ };
133
+
134
+ cache.set(cacheKey, result);
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * GET /api/sessions/:id - Get session by ID
140
+ */
141
+ async function getSessionById(sessionRegistry, id, cache) {
142
+ const cacheKey = `session-${id}`;
143
+ const cached = cache.get(cacheKey);
144
+ if (cached) return cached;
145
+
146
+ const session = await sessionRegistry.getSession(id);
147
+
148
+ if (!session) {
149
+ return { error: 'Session not found', id };
150
+ }
151
+
152
+ const result = { id, ...session };
153
+ cache.set(cacheKey, result);
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * GET /api/status - Get status.json
159
+ */
160
+ function getStatus(rootDir, cache) {
161
+ const cacheKey = 'status';
162
+ const cached = cache.get(cacheKey);
163
+ if (cached) return cached;
164
+
165
+ const statusPath = getStatusPath(rootDir);
166
+
167
+ if (!fs.existsSync(statusPath)) {
168
+ return { error: 'Status file not found', path: statusPath };
169
+ }
170
+
171
+ try {
172
+ const content = fs.readFileSync(statusPath, 'utf8');
173
+ const status = JSON.parse(content);
174
+
175
+ const result = {
176
+ ...status,
177
+ _meta: {
178
+ path: statusPath,
179
+ loaded_at: new Date().toISOString(),
180
+ },
181
+ };
182
+
183
+ cache.set(cacheKey, result);
184
+ return result;
185
+ } catch (error) {
186
+ return { error: 'Failed to parse status file', message: error.message };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * GET /api/tasks - List tasks
192
+ *
193
+ * Query params:
194
+ * - state: Filter by state (queued, running, completed, failed, blocked)
195
+ * - story_id: Filter by story ID
196
+ * - subagent_type: Filter by agent type
197
+ */
198
+ function getTasks(rootDir, queryParams, cache) {
199
+ const cacheKey = `tasks-${queryParams.toString()}`;
200
+ const cached = cache.get(cacheKey);
201
+ if (cached) return cached;
202
+
203
+ try {
204
+ const registry = getTaskRegistry({ rootDir });
205
+ const filter = {};
206
+
207
+ if (queryParams.get('state')) {
208
+ filter.state = queryParams.get('state');
209
+ }
210
+ if (queryParams.get('story_id')) {
211
+ filter.story_id = queryParams.get('story_id');
212
+ }
213
+ if (queryParams.get('subagent_type')) {
214
+ filter.subagent_type = queryParams.get('subagent_type');
215
+ }
216
+
217
+ const tasks = registry.getAll(filter);
218
+ const stats = registry.getStats();
219
+
220
+ const result = {
221
+ tasks,
222
+ stats,
223
+ timestamp: new Date().toISOString(),
224
+ };
225
+
226
+ cache.set(cacheKey, result);
227
+ return result;
228
+ } catch (error) {
229
+ return { error: 'Failed to load tasks', message: error.message };
230
+ }
231
+ }
232
+
233
+ /**
234
+ * GET /api/tasks/:id - Get task by ID
235
+ */
236
+ function getTaskById(rootDir, id, cache) {
237
+ const cacheKey = `task-${id}`;
238
+ const cached = cache.get(cacheKey);
239
+ if (cached) return cached;
240
+
241
+ try {
242
+ const registry = getTaskRegistry({ rootDir });
243
+ const task = registry.get(id);
244
+
245
+ if (!task) {
246
+ return { error: 'Task not found', id };
247
+ }
248
+
249
+ cache.set(cacheKey, task);
250
+ return task;
251
+ } catch (error) {
252
+ return { error: 'Failed to load task', message: error.message };
253
+ }
254
+ }
255
+
256
+ /**
257
+ * GET /api/bus/messages - Get bus messages
258
+ *
259
+ * Query params:
260
+ * - limit: Max messages to return (default: 100)
261
+ * - offset: Skip first N messages (default: 0)
262
+ * - story_id: Filter by story ID
263
+ * - from: Filter by sender agent
264
+ * - since: Filter by timestamp (ISO string)
265
+ */
266
+ async function getBusMessages(rootDir, queryParams, cache) {
267
+ const limit = parseInt(queryParams.get('limit') || '100', 10);
268
+ const offset = parseInt(queryParams.get('offset') || '0', 10);
269
+ const storyId = queryParams.get('story_id');
270
+ const from = queryParams.get('from');
271
+ const since = queryParams.get('since');
272
+
273
+ const cacheKey = `bus-${limit}-${offset}-${storyId || ''}-${from || ''}-${since || ''}`;
274
+ const cached = cache.get(cacheKey);
275
+ if (cached) return cached;
276
+
277
+ const busLogPath = getBusLogPath(rootDir);
278
+
279
+ if (!fs.existsSync(busLogPath)) {
280
+ return { messages: [], total: 0, timestamp: new Date().toISOString() };
281
+ }
282
+
283
+ try {
284
+ const messages = await readBusLog(busLogPath, {
285
+ limit,
286
+ offset,
287
+ storyId,
288
+ from,
289
+ since,
290
+ });
291
+
292
+ const result = {
293
+ messages,
294
+ count: messages.length,
295
+ limit,
296
+ offset,
297
+ timestamp: new Date().toISOString(),
298
+ };
299
+
300
+ cache.set(cacheKey, result);
301
+ return result;
302
+ } catch (error) {
303
+ return { error: 'Failed to read bus log', message: error.message };
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Read JSONL bus log with filtering
309
+ */
310
+ async function readBusLog(filePath, options = {}) {
311
+ const { limit = 100, offset = 0, storyId, from, since } = options;
312
+
313
+ return new Promise((resolve, reject) => {
314
+ const messages = [];
315
+ let lineCount = 0;
316
+ let skipped = 0;
317
+
318
+ const rl = readline.createInterface({
319
+ input: fs.createReadStream(filePath),
320
+ crlfDelay: Infinity,
321
+ });
322
+
323
+ rl.on('line', line => {
324
+ if (!line.trim()) return;
325
+
326
+ try {
327
+ const msg = JSON.parse(line);
328
+
329
+ // Apply filters
330
+ if (storyId && msg.story !== storyId) return;
331
+ if (from && msg.from !== from) return;
332
+ if (since && new Date(msg.ts) < new Date(since)) return;
333
+
334
+ lineCount++;
335
+
336
+ // Skip offset
337
+ if (skipped < offset) {
338
+ skipped++;
339
+ return;
340
+ }
341
+
342
+ // Collect up to limit
343
+ if (messages.length < limit) {
344
+ messages.push(msg);
345
+ }
346
+ } catch {
347
+ // Skip invalid lines
348
+ }
349
+ });
350
+
351
+ rl.on('close', () => resolve(messages));
352
+ rl.on('error', reject);
353
+ });
354
+ }
355
+
356
+ /**
357
+ * GET /api/metrics - Aggregated metrics
358
+ */
359
+ function getMetrics(rootDir, cache) {
360
+ const cacheKey = 'metrics';
361
+ const cached = cache.get(cacheKey);
362
+ if (cached) return cached;
363
+
364
+ const metrics = {
365
+ timestamp: new Date().toISOString(),
366
+ };
367
+
368
+ // Status metrics
369
+ try {
370
+ const statusPath = getStatusPath(rootDir);
371
+ if (fs.existsSync(statusPath)) {
372
+ const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
373
+ const stories = Object.values(status.stories || {});
374
+
375
+ metrics.stories = {
376
+ total: stories.length,
377
+ by_status: {
378
+ ready: stories.filter(s => s.status === 'ready').length,
379
+ 'in-progress': stories.filter(s => s.status === 'in-progress').length,
380
+ blocked: stories.filter(s => s.status === 'blocked').length,
381
+ 'in-review': stories.filter(s => s.status === 'in-review').length,
382
+ done: stories.filter(s => s.status === 'done').length,
383
+ },
384
+ completion_percent:
385
+ stories.length > 0
386
+ ? Math.round((stories.filter(s => s.status === 'done').length / stories.length) * 100)
387
+ : 0,
388
+ };
389
+ }
390
+ } catch {
391
+ metrics.stories = { error: 'Failed to load status' };
392
+ }
393
+
394
+ // Task metrics
395
+ try {
396
+ const registry = getTaskRegistry({ rootDir });
397
+ metrics.tasks = registry.getStats();
398
+ } catch {
399
+ metrics.tasks = { error: 'Failed to load tasks' };
400
+ }
401
+
402
+ // Epic metrics
403
+ try {
404
+ const epicsDir = getEpicsDir(rootDir);
405
+ if (fs.existsSync(epicsDir)) {
406
+ const epicFiles = fs.readdirSync(epicsDir).filter(f => f.endsWith('.md'));
407
+ metrics.epics = { total: epicFiles.length };
408
+ }
409
+ } catch {
410
+ metrics.epics = { error: 'Failed to count epics' };
411
+ }
412
+
413
+ cache.set(cacheKey, metrics);
414
+ return metrics;
415
+ }
416
+
417
+ /**
418
+ * GET /api/epics - List epics
419
+ */
420
+ function getEpics(rootDir, cache) {
421
+ const cacheKey = 'epics';
422
+ const cached = cache.get(cacheKey);
423
+ if (cached) return cached;
424
+
425
+ const epicsDir = getEpicsDir(rootDir);
426
+
427
+ if (!fs.existsSync(epicsDir)) {
428
+ return { epics: [], timestamp: new Date().toISOString() };
429
+ }
430
+
431
+ try {
432
+ const epicFiles = fs.readdirSync(epicsDir).filter(f => f.endsWith('.md'));
433
+ const epics = epicFiles.map(file => {
434
+ const id = file.replace('.md', '');
435
+ const content = fs.readFileSync(path.join(epicsDir, file), 'utf8');
436
+ const title = extractTitle(content) || id;
437
+ const status = extractStatus(content) || 'active';
438
+
439
+ return { id, title, status, file };
440
+ });
441
+
442
+ const result = {
443
+ epics,
444
+ count: epics.length,
445
+ timestamp: new Date().toISOString(),
446
+ };
447
+
448
+ cache.set(cacheKey, result);
449
+ return result;
450
+ } catch (error) {
451
+ return { error: 'Failed to list epics', message: error.message };
452
+ }
453
+ }
454
+
455
+ /**
456
+ * GET /api/epics/:id - Get epic by ID
457
+ */
458
+ function getEpicById(rootDir, id, cache) {
459
+ const cacheKey = `epic-${id}`;
460
+ const cached = cache.get(cacheKey);
461
+ if (cached) return cached;
462
+
463
+ const epicsDir = getEpicsDir(rootDir);
464
+ const epicPath = path.join(epicsDir, `${id}.md`);
465
+
466
+ if (!fs.existsSync(epicPath)) {
467
+ return { error: 'Epic not found', id };
468
+ }
469
+
470
+ try {
471
+ const content = fs.readFileSync(epicPath, 'utf8');
472
+ const result = {
473
+ id,
474
+ title: extractTitle(content) || id,
475
+ status: extractStatus(content) || 'active',
476
+ content,
477
+ };
478
+
479
+ cache.set(cacheKey, result);
480
+ return result;
481
+ } catch (error) {
482
+ return { error: 'Failed to read epic', message: error.message };
483
+ }
484
+ }
485
+
486
+ /**
487
+ * GET /api/stories - List stories
488
+ *
489
+ * Query params:
490
+ * - status: Filter by status
491
+ * - epic_id: Filter by epic ID
492
+ * - owner: Filter by owner
493
+ */
494
+ function getStories(rootDir, queryParams, cache) {
495
+ const status = queryParams.get('status');
496
+ const epicId = queryParams.get('epic_id');
497
+ const owner = queryParams.get('owner');
498
+
499
+ const cacheKey = `stories-${status || ''}-${epicId || ''}-${owner || ''}`;
500
+ const cached = cache.get(cacheKey);
501
+ if (cached) return cached;
502
+
503
+ try {
504
+ // First try to get from status.json
505
+ const statusPath = getStatusPath(rootDir);
506
+ if (fs.existsSync(statusPath)) {
507
+ const statusData = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
508
+ let stories = Object.entries(statusData.stories || {}).map(([id, story]) => ({
509
+ id,
510
+ ...story,
511
+ }));
512
+
513
+ // Apply filters
514
+ if (status) {
515
+ stories = stories.filter(s => s.status === status);
516
+ }
517
+ if (epicId) {
518
+ stories = stories.filter(s => s.epic_id === epicId);
519
+ }
520
+ if (owner) {
521
+ stories = stories.filter(s => s.owner === owner);
522
+ }
523
+
524
+ const result = {
525
+ stories,
526
+ count: stories.length,
527
+ timestamp: new Date().toISOString(),
528
+ };
529
+
530
+ cache.set(cacheKey, result);
531
+ return result;
532
+ }
533
+
534
+ return { stories: [], count: 0, timestamp: new Date().toISOString() };
535
+ } catch (error) {
536
+ return { error: 'Failed to list stories', message: error.message };
537
+ }
538
+ }
539
+
540
+ /**
541
+ * GET /api/stories/:id - Get story by ID
542
+ */
543
+ function getStoryById(rootDir, id, cache) {
544
+ const cacheKey = `story-${id}`;
545
+ const cached = cache.get(cacheKey);
546
+ if (cached) return cached;
547
+
548
+ try {
549
+ // Get from status.json
550
+ const statusPath = getStatusPath(rootDir);
551
+ if (fs.existsSync(statusPath)) {
552
+ const statusData = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
553
+ const story = statusData.stories?.[id];
554
+
555
+ if (story) {
556
+ const result = { id, ...story };
557
+
558
+ // Try to get story file content
559
+ const storiesDir = getStoriesDir(rootDir);
560
+ const storyPath = path.join(storiesDir, `${id}.md`);
561
+ if (fs.existsSync(storyPath)) {
562
+ result.content = fs.readFileSync(storyPath, 'utf8');
563
+ }
564
+
565
+ cache.set(cacheKey, result);
566
+ return result;
567
+ }
568
+ }
569
+
570
+ return { error: 'Story not found', id };
571
+ } catch (error) {
572
+ return { error: 'Failed to read story', message: error.message };
573
+ }
574
+ }
575
+
576
+ // ============================================================================
577
+ // Utility Functions
578
+ // ============================================================================
579
+
580
+ /**
581
+ * Extract title from markdown content (first H1)
582
+ */
583
+ function extractTitle(content) {
584
+ const match = content.match(/^#\s+(.+)$/m);
585
+ return match ? match[1].trim() : null;
586
+ }
587
+
588
+ /**
589
+ * Extract status from markdown frontmatter or content
590
+ */
591
+ function extractStatus(content) {
592
+ // Try frontmatter
593
+ const fmMatch = content.match(/^---[\s\S]*?status:\s*(\w+)[\s\S]*?---/m);
594
+ if (fmMatch) return fmMatch[1];
595
+
596
+ // Try inline status
597
+ const inlineMatch = content.match(/\*\*Status\*\*:\s*(\w+)/i);
598
+ if (inlineMatch) return inlineMatch[1].toLowerCase();
599
+
600
+ return null;
601
+ }
602
+
603
+ module.exports = {
604
+ getApiRoutes,
605
+ };