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.
- package/CHANGELOG.md +10 -0
- package/README.md +6 -6
- package/lib/api-routes.js +605 -0
- package/lib/api-server.js +260 -0
- package/lib/claude-cli-bridge.js +221 -0
- package/lib/dashboard-protocol.js +541 -0
- package/lib/dashboard-server.js +1601 -0
- package/lib/drivers/claude-driver.ts +310 -0
- package/lib/drivers/codex-driver.ts +454 -0
- package/lib/drivers/driver-manager.ts +158 -0
- package/lib/drivers/gemini-driver.ts +485 -0
- package/lib/drivers/index.ts +17 -0
- package/lib/flag-detection.js +350 -0
- package/lib/git-operations.js +267 -0
- package/lib/lock-file.js +144 -0
- package/lib/merge-operations.js +959 -0
- package/lib/protocol/driver.ts +360 -0
- package/lib/protocol/index.ts +12 -0
- package/lib/protocol/ir.ts +271 -0
- package/lib/session-display.js +330 -0
- package/lib/worktree-operations.js +221 -0
- package/package.json +2 -2
- package/scripts/agileflow-welcome.js +272 -24
- package/scripts/api-server-runner.js +177 -0
- package/scripts/archive-completed-stories.sh +22 -0
- package/scripts/automation-run-due.js +126 -0
- package/scripts/backfill-ideation-status.js +124 -0
- package/scripts/claude-tmux.sh +62 -1
- package/scripts/context-loader.js +292 -0
- package/scripts/dashboard-serve.js +323 -0
- package/scripts/lib/automation-registry.js +544 -0
- package/scripts/lib/automation-runner.js +476 -0
- package/scripts/lib/concurrency-limiter.js +513 -0
- package/scripts/lib/configure-features.js +46 -0
- package/scripts/lib/context-formatter.js +61 -0
- package/scripts/lib/damage-control-utils.js +29 -4
- package/scripts/lib/hook-metrics.js +324 -0
- package/scripts/lib/ideation-index.js +1196 -0
- package/scripts/lib/process-cleanup.js +359 -0
- package/scripts/lib/quality-gates.js +574 -0
- package/scripts/lib/status-task-bridge.js +522 -0
- package/scripts/lib/sync-ideation-status.js +292 -0
- package/scripts/lib/task-registry-cache.js +490 -0
- package/scripts/lib/task-registry.js +1181 -0
- package/scripts/migrate-ideation-index.js +515 -0
- package/scripts/precompact-context.sh +104 -0
- package/scripts/ralph-loop.js +2 -2
- package/scripts/session-manager.js +363 -2770
- package/scripts/spawn-parallel.js +45 -9
- package/src/core/agents/api-validator.md +180 -0
- package/src/core/agents/api.md +2 -0
- package/src/core/agents/code-reviewer.md +289 -0
- package/src/core/agents/configuration/damage-control.md +17 -0
- package/src/core/agents/database.md +2 -0
- package/src/core/agents/error-analyzer.md +203 -0
- package/src/core/agents/logic-analyzer-edge.md +171 -0
- package/src/core/agents/logic-analyzer-flow.md +254 -0
- package/src/core/agents/logic-analyzer-invariant.md +207 -0
- package/src/core/agents/logic-analyzer-race.md +267 -0
- package/src/core/agents/logic-analyzer-type.md +218 -0
- package/src/core/agents/logic-consensus.md +256 -0
- package/src/core/agents/orchestrator.md +89 -1
- package/src/core/agents/schema-validator.md +451 -0
- package/src/core/agents/team-coordinator.md +328 -0
- package/src/core/agents/ui-validator.md +328 -0
- package/src/core/agents/ui.md +2 -0
- package/src/core/commands/api.md +267 -0
- package/src/core/commands/automate.md +415 -0
- package/src/core/commands/babysit.md +290 -9
- package/src/core/commands/ideate/history.md +403 -0
- package/src/core/commands/{ideate.md → ideate/new.md} +244 -34
- package/src/core/commands/logic/audit.md +368 -0
- package/src/core/commands/roadmap/analyze.md +1 -1
- package/src/core/experts/documentation/expertise.yaml +29 -2
- package/src/core/templates/CONTEXT.md.example +49 -0
- package/src/core/templates/claude-settings.advanced.example.json +4 -0
- package/tools/cli/commands/serve.js +456 -0
- package/tools/cli/installers/core/installer.js +7 -2
- package/tools/cli/installers/ide/claude-code.js +85 -0
- package/tools/cli/lib/content-injector.js +27 -1
- 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
|
[](https://www.npmjs.com/package/agileflow)
|
|
6
|
-
[](docs/04-architecture/commands.md)
|
|
7
|
+
[](docs/04-architecture/subagents.md)
|
|
8
8
|
[](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) |
|
|
69
|
-
| [Agents/Experts](docs/04-architecture/subagents.md) |
|
|
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
|
|
80
|
-
- [Agents/Experts](docs/04-architecture/subagents.md) -
|
|
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
|
+
};
|