agentlytics 0.2.11 → 0.2.12

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/cache.js CHANGED
@@ -7,7 +7,7 @@ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricin
7
7
 
8
8
  const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
9
9
  const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
10
- const SCHEMA_VERSION = 6; // bump this when schema changes to auto-revalidate
10
+ const SCHEMA_VERSION = 7; // bump this when schema changes to auto-revalidate
11
11
 
12
12
  /**
13
13
  * Normalize a folder path for consistent storage/lookup.
@@ -154,6 +154,38 @@ function initDb() {
154
154
  CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id);
155
155
  CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name);
156
156
  CREATE INDEX IF NOT EXISTS idx_tool_calls_chat ON tool_calls(chat_id);
157
+
158
+ CREATE TABLE IF NOT EXISTS gsd_projects (
159
+ folder TEXT PRIMARY KEY,
160
+ name TEXT,
161
+ description TEXT,
162
+ milestone TEXT,
163
+ total_phases INTEGER DEFAULT 0,
164
+ completed_phases INTEGER DEFAULT 0,
165
+ active_phase TEXT,
166
+ todos INTEGER DEFAULT 0,
167
+ backlog INTEGER DEFAULT 0,
168
+ notes INTEGER DEFAULT 0,
169
+ last_modified INTEGER,
170
+ scanned_at INTEGER
171
+ );
172
+
173
+ CREATE TABLE IF NOT EXISTS gsd_phases (
174
+ id TEXT PRIMARY KEY,
175
+ folder TEXT NOT NULL,
176
+ phase_number INTEGER,
177
+ phase_name TEXT,
178
+ status TEXT,
179
+ total_tasks INTEGER DEFAULT 0,
180
+ completed_tasks INTEGER DEFAULT 0,
181
+ has_plan INTEGER DEFAULT 0,
182
+ has_research INTEGER DEFAULT 0,
183
+ has_verification INTEGER DEFAULT 0,
184
+ last_modified INTEGER,
185
+ FOREIGN KEY (folder) REFERENCES gsd_projects(folder)
186
+ );
187
+
188
+ CREATE INDEX IF NOT EXISTS idx_gsd_phases_folder ON gsd_phases(folder);
157
189
  `);
158
190
 
159
191
  // Store schema version so future runs can detect mismatches
@@ -358,6 +390,9 @@ function scanAll(onProgress, opts = {}) {
358
390
  db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('last_scan', Date.now().toString());
359
391
  db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('total_chats', total.toString());
360
392
 
393
+ // GSD scan
394
+ cacheGSDProjects();
395
+
361
396
  return { total, analyzed, skipped };
362
397
  }
363
398
 
@@ -643,7 +678,7 @@ function getCachedChat(id) {
643
678
  createdAt: chat.created_at,
644
679
  lastUpdatedAt: chat.last_updated_at,
645
680
  encrypted: !!chat.encrypted,
646
- messages: messages.map(m => ({ role: m.role, content: m.content, model: m.model })),
681
+ messages: messages.map(m => ({ role: m.role, content: m.content, model: m.model, inputTokens: m.input_tokens || 0, outputTokens: m.output_tokens || 0 })),
647
682
  stats: parsedStats,
648
683
  toolCallDetails,
649
684
  };
@@ -839,6 +874,9 @@ async function scanAllAsync(onProgress, opts = {}) {
839
874
  db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('last_scan', Date.now().toString());
840
875
  db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('total_chats', total.toString());
841
876
 
877
+ // GSD scan
878
+ cacheGSDProjects();
879
+
842
880
  return { total, analyzed, skipped };
843
881
  }
844
882
 
@@ -1300,6 +1338,156 @@ function getCostAnalytics(opts = {}) {
1300
1338
 
1301
1339
  function getDb() { return db; }
1302
1340
 
1341
+ // ============================================================
1342
+ // GSD cache functions
1343
+ // ============================================================
1344
+
1345
+ const gsd = require('./editors/gsd');
1346
+
1347
+ function cacheGSDProjects() {
1348
+ // Get all unique known folders from chats table
1349
+ const rows = db.prepare('SELECT DISTINCT folder FROM chats WHERE folder IS NOT NULL').all();
1350
+ const knownFolders = rows.map(r => r.folder);
1351
+
1352
+ const projects = gsd.getGSDProjects(knownFolders);
1353
+
1354
+ const insProject = db.prepare(`
1355
+ INSERT OR REPLACE INTO gsd_projects
1356
+ (folder, name, description, milestone, total_phases, completed_phases, active_phase, todos, backlog, notes, last_modified, scanned_at)
1357
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1358
+ `);
1359
+ const delPhases = db.prepare('DELETE FROM gsd_phases WHERE folder = ?');
1360
+ const insPhase = db.prepare(`
1361
+ INSERT OR REPLACE INTO gsd_phases
1362
+ (id, folder, phase_number, phase_name, status, total_tasks, completed_tasks, has_plan, has_research, has_verification, last_modified)
1363
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1364
+ `);
1365
+
1366
+ const tx = db.transaction(() => {
1367
+ for (const p of projects) {
1368
+ insProject.run(
1369
+ p.folder, p.name, p.description, p.milestone,
1370
+ p.totalPhases, p.completedPhases, p.activePhase,
1371
+ p.todos, p.backlog, p.notes,
1372
+ p.lastModified, Date.now()
1373
+ );
1374
+ delPhases.run(p.folder);
1375
+ const phases = gsd.getGSDPhases(p.folder);
1376
+ for (const ph of phases) {
1377
+ const id = `${p.folder}::${ph.phaseDir}`;
1378
+ insPhase.run(
1379
+ id, p.folder, ph.number, ph.name, ph.status,
1380
+ ph.tasks.total, ph.tasks.completed,
1381
+ ph.hasPlan ? 1 : 0,
1382
+ ph.hasResearch ? 1 : 0,
1383
+ ph.hasVerification ? 1 : 0,
1384
+ ph.lastModified
1385
+ );
1386
+ }
1387
+ }
1388
+ });
1389
+ tx();
1390
+ }
1391
+
1392
+ function getCachedGSDProjects() {
1393
+ const projects = db.prepare('SELECT * FROM gsd_projects ORDER BY last_modified DESC').all();
1394
+ for (const p of projects) {
1395
+ try {
1396
+ const phases = getGSDPhaseTokens(p.folder);
1397
+ p.total_cost = phases.reduce((s, r) => s + (r.cost || 0), 0);
1398
+ } catch {
1399
+ p.total_cost = 0;
1400
+ }
1401
+ }
1402
+ return projects;
1403
+ }
1404
+
1405
+ function getCachedGSDPhases(folder) {
1406
+ return db.prepare('SELECT * FROM gsd_phases WHERE folder = ? ORDER BY phase_number ASC').all(folder);
1407
+ }
1408
+
1409
+ function getGSDPhaseTokens(folder) {
1410
+ const phases = db.prepare(
1411
+ 'SELECT id, phase_number, phase_name, status, last_modified FROM gsd_phases WHERE folder = ? ORDER BY phase_number ASC'
1412
+ ).all(folder);
1413
+
1414
+ if (phases.length === 0) return [];
1415
+
1416
+ // Sort by last_modified to build sequential non-overlapping time windows.
1417
+ // Phases with no last_modified are placed at the end.
1418
+ const byTime = [...phases]
1419
+ .filter(p => p.last_modified)
1420
+ .sort((a, b) => a.last_modified - b.last_modified);
1421
+
1422
+ const windowMap = new Map();
1423
+ for (let i = 0; i < byTime.length; i++) {
1424
+ const start = i === 0 ? 0 : byTime[i - 1].last_modified;
1425
+ const end = i === byTime.length - 1 ? Date.now() : byTime[i].last_modified;
1426
+ windowMap.set(byTime[i].id, { start, end });
1427
+ }
1428
+
1429
+ const stmt = db.prepare(`
1430
+ SELECT cs.total_input_tokens, cs.total_output_tokens,
1431
+ cs.total_cache_read, cs.total_cache_write, cs.models
1432
+ FROM chats c JOIN chat_stats cs ON cs.chat_id = c.id
1433
+ WHERE c.folder = ? AND COALESCE(c.last_updated_at, c.created_at) BETWEEN ? AND ?
1434
+ `);
1435
+
1436
+ return phases.map(ph => {
1437
+ const win = windowMap.get(ph.id);
1438
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
1439
+ let sessionCount = 0;
1440
+ const modelFreq = {};
1441
+
1442
+ if (win) {
1443
+ const rows = stmt.all(folder, win.start, win.end);
1444
+ for (const row of rows) {
1445
+ totalInput += row.total_input_tokens || 0;
1446
+ totalOutput += row.total_output_tokens || 0;
1447
+ totalCacheRead += row.total_cache_read || 0;
1448
+ totalCacheWrite += row.total_cache_write || 0;
1449
+ sessionCount++;
1450
+ try {
1451
+ const models = JSON.parse(row.models || '[]');
1452
+ for (const m of models) {
1453
+ const key = typeof m === 'string' ? m : (m && m.model);
1454
+ if (key) modelFreq[key] = (modelFreq[key] || 0) + 1;
1455
+ }
1456
+ } catch { /* skip */ }
1457
+ }
1458
+ }
1459
+
1460
+ const dominantModel = Object.entries(modelFreq).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
1461
+ const cost = dominantModel
1462
+ ? (calculateCost(dominantModel, totalInput, totalOutput, totalCacheRead, totalCacheWrite) || 0)
1463
+ : 0;
1464
+ const totalTokens = totalInput + totalOutput;
1465
+
1466
+ return {
1467
+ id: ph.id,
1468
+ phase_number: ph.phase_number,
1469
+ phase_name: ph.phase_name,
1470
+ status: ph.status,
1471
+ total_tokens: totalTokens,
1472
+ cost,
1473
+ session_count: sessionCount,
1474
+ };
1475
+ });
1476
+ }
1477
+
1478
+ function getCachedGSDOverview() {
1479
+ const projects = getCachedGSDProjects();
1480
+ const totalProjects = projects.length;
1481
+ const totalPhases = projects.reduce((s, p) => s + p.total_phases, 0);
1482
+ const completedPhases = projects.reduce((s, p) => s + p.completed_phases, 0);
1483
+ const activePhases = projects
1484
+ .filter(p => p.active_phase)
1485
+ .map(p => ({ folder: p.folder, name: p.name, activePhase: p.active_phase }));
1486
+ const executingPhases = db.prepare("SELECT COUNT(*) as c FROM gsd_phases WHERE status = 'executing'").get().c;
1487
+ const plannedPhases = db.prepare("SELECT COUNT(*) as c FROM gsd_phases WHERE status = 'planned'").get().c;
1488
+ return { totalProjects, totalPhases, completedPhases, activePhases, executingPhases, plannedPhases };
1489
+ }
1490
+
1303
1491
  module.exports = {
1304
1492
  initDb,
1305
1493
  scanAll,
@@ -1317,4 +1505,9 @@ module.exports = {
1317
1505
  getCostBreakdown,
1318
1506
  getCostAnalytics,
1319
1507
  getDb,
1508
+ cacheGSDProjects,
1509
+ getCachedGSDProjects,
1510
+ getCachedGSDPhases,
1511
+ getCachedGSDOverview,
1512
+ getGSDPhaseTokens,
1320
1513
  };
package/editors/gsd.js ADDED
@@ -0,0 +1,366 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ const name = 'gsd';
5
+ const labels = { gsd: 'GSD Workflow' };
6
+
7
+ // ============================================================
8
+ // Helpers
9
+ // ============================================================
10
+
11
+ function readFileSafe(filePath) {
12
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
13
+ }
14
+
15
+ function statSafe(filePath) {
16
+ try { return fs.statSync(filePath); } catch { return null; }
17
+ }
18
+
19
+ function countFiles(dir) {
20
+ try { return fs.readdirSync(dir).filter(f => !f.startsWith('.')).length; } catch { return 0; }
21
+ }
22
+
23
+ /**
24
+ * Parse YAML frontmatter from STATE.md.
25
+ * Returns { status, milestone, stoppedAt, progress } or null.
26
+ */
27
+ function parseStateMd(content) {
28
+ if (!content) return null;
29
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
30
+ if (!m) return null;
31
+ const yaml = m[1];
32
+ function get(key) {
33
+ const r = yaml.match(new RegExp(`^${key}:\\s*(.+)`, 'm'));
34
+ return r ? r[1].trim().replace(/^["']|["']$/g, '') : null;
35
+ }
36
+ function getInt(key) { const v = get(key); return v ? parseInt(v) : null; }
37
+ const progressBlock = yaml.match(/^progress:\s*\n((?:[ \t]+.+\n?)*)/m);
38
+ let progress = null;
39
+ if (progressBlock) {
40
+ const pb = progressBlock[1];
41
+ function pgGet(key) {
42
+ const r = pb.match(new RegExp(`${key}:\\s*(\\d+)`));
43
+ return r ? parseInt(r[1]) : null;
44
+ }
45
+ progress = {
46
+ total_phases: pgGet('total_phases'),
47
+ completed_phases: pgGet('completed_phases'),
48
+ total_plans: pgGet('total_plans'),
49
+ completed_plans: pgGet('completed_plans'),
50
+ };
51
+ }
52
+ return {
53
+ status: get('status'),
54
+ milestone: get('milestone'),
55
+ stoppedAt: get('stopped_at'),
56
+ lastUpdated: get('last_updated'),
57
+ progress,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Parse ROADMAP.md phase checkboxes into a map of phase_number → completed.
63
+ * Phase lines look like: - [x] **Phase 1: Name** or - [ ] Phase 2: Name
64
+ */
65
+ function parseRoadmapPhaseStatus(content) {
66
+ if (!content) return new Map();
67
+ const statusMap = new Map(); // phase_number (int) → 'completed' | 'planned'
68
+ for (const line of content.split('\n')) {
69
+ const cbMatch = line.match(/^[-*]\s+\[([x ])\]\s+(.+)/i);
70
+ if (!cbMatch) continue;
71
+ const text = cbMatch[2];
72
+ if (/\d+-\d+-PLAN\.md/i.test(text) || /PLAN\.md\s*[—–-]/i.test(text)) continue;
73
+ // Extract phase number from patterns: "Phase 1:", "Phase 01:", "**Phase 2:**"
74
+ const numMatch = text.match(/phase\s+(\d+)/i);
75
+ if (!numMatch) continue;
76
+ const num = parseInt(numMatch[1]);
77
+ if (!isNaN(num)) {
78
+ statusMap.set(num, cbMatch[1].toLowerCase() === 'x' ? 'completed' : 'planned');
79
+ }
80
+ }
81
+ return statusMap;
82
+ }
83
+
84
+ /**
85
+ * Parse PROJECT.md — first # heading = project name, rest = description.
86
+ */
87
+ function parseProjectMd(content) {
88
+ if (!content) return { name: null, description: null };
89
+ const lines = content.split('\n');
90
+ let projectName = null;
91
+ const descLines = [];
92
+ for (const line of lines) {
93
+ const h1 = line.match(/^#\s+(.+)/);
94
+ if (h1 && !projectName) {
95
+ projectName = h1[1].trim();
96
+ continue;
97
+ }
98
+ if (projectName && line.trim()) descLines.push(line.trim());
99
+ }
100
+ return {
101
+ name: projectName,
102
+ description: descLines.slice(0, 3).join(' ').substring(0, 300) || null,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Parse ROADMAP.md for phase completion.
108
+ * Supports: - [ ] **Phase N: Name** ... and - [x] **Phase N: Name** ...
109
+ * Also supports emoji: ✅ completed, 🚧 in-progress, □ planned
110
+ */
111
+ function parseRoadmapMd(content) {
112
+ if (!content) return [];
113
+ const phases = [];
114
+ for (const line of content.split('\n')) {
115
+ // Checkbox style: - [ ] or - [x]
116
+ // Only count lines that look like phase entries, NOT plan file listings (e.g. "01-01-PLAN.md")
117
+ const cbMatch = line.match(/^[-*]\s+\[([x ])\]\s+(.+)/i);
118
+ if (cbMatch) {
119
+ const text = cbMatch[2];
120
+ // Skip plan file entries like "01-01-PLAN.md — description"
121
+ if (/\d+-\d+-PLAN\.md/i.test(text) || /PLAN\.md\s*[—–-]/i.test(text)) continue;
122
+ phases.push({ text: text.replace(/\*\*/g, '').trim(), completed: cbMatch[1].toLowerCase() === 'x' });
123
+ continue;
124
+ }
125
+ // Emoji style: ✅ completed, 🚧 in-progress (milestone-level)
126
+ const emojiMatch = line.match(/^[-*]\s+(✅|🚧|⬜|□)\s+(.+)/);
127
+ if (emojiMatch) {
128
+ phases.push({ text: emojiMatch[2].replace(/\*\*/g, '').trim(), completed: emojiMatch[1] === '✅' });
129
+ }
130
+ }
131
+ return phases;
132
+ }
133
+
134
+ /**
135
+ * Detect the currently active milestone name from ROADMAP.md.
136
+ * Looks for 🚧 milestone entries or the first uncompleted milestone block.
137
+ */
138
+ function detectActiveMilestone(content) {
139
+ if (!content) return null;
140
+ for (const line of content.split('\n')) {
141
+ const m = line.match(/🚧\s+\*?\*?([^*\n-]+)/);
142
+ if (m) return m[1].trim().split(' - ')[0].trim();
143
+ // Also handle "in progress" text
144
+ if (/in.progress/i.test(line)) {
145
+ const nm = line.match(/\*?\*?([vV][\d.]+[^*]*)\*?\*?/);
146
+ if (nm) return nm[1].trim();
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Extract phase number prefix from a phase directory name.
154
+ * e.g. "01-auth-ve-giris" → "01"
155
+ * e.g. "999.1-backlog-item" → "999.1"
156
+ */
157
+ function extractPhasePrefix(dirName) {
158
+ const m = dirName.match(/^(\d+(?:\.\d+)?)-/);
159
+ return m ? m[1] : null;
160
+ }
161
+
162
+ /**
163
+ * Parse checkbox tasks from a PLAN.md file.
164
+ */
165
+ function parseCheckboxes(content) {
166
+ if (!content) return { total: 0, completed: 0, tasks: [] };
167
+ const tasks = [];
168
+ for (const line of content.split('\n')) {
169
+ const m = line.match(/^[-*]\s+\[([x ])\]\s+(.+)/i);
170
+ if (!m) continue;
171
+ tasks.push({ name: m[2].trim(), completed: m[1].toLowerCase() === 'x' });
172
+ }
173
+ return { total: tasks.length, completed: tasks.filter(t => t.completed).length, tasks };
174
+ }
175
+
176
+ // ============================================================
177
+ // Public API
178
+ // ============================================================
179
+
180
+ /**
181
+ * Scan known project folders for GSD .planning/ directories.
182
+ * Returns project-level summary for each GSD project found.
183
+ */
184
+ function getGSDProjects(knownFolders) {
185
+ const results = [];
186
+
187
+ for (const folder of knownFolders) {
188
+ if (!folder) continue;
189
+ const planningDir = path.join(folder, '.planning');
190
+ if (!fs.existsSync(planningDir)) continue;
191
+
192
+ // Must have at least PROJECT.md or ROADMAP.md to be a GSD project
193
+ const hasProject = fs.existsSync(path.join(planningDir, 'PROJECT.md'));
194
+ const hasRoadmap = fs.existsSync(path.join(planningDir, 'ROADMAP.md'));
195
+ if (!hasProject && !hasRoadmap) continue;
196
+
197
+ const projectContent = readFileSafe(path.join(planningDir, 'PROJECT.md'));
198
+ const roadmapContent = readFileSafe(path.join(planningDir, 'ROADMAP.md'));
199
+ const stateContent = readFileSafe(path.join(planningDir, 'STATE.md'));
200
+ const stateData = parseStateMd(stateContent);
201
+
202
+ const { name: projectName, description } = parseProjectMd(projectContent);
203
+
204
+ // Use filesystem as source of truth for phase counts
205
+ // Filter out malformed directory names (e.g. dirs with JSON content in name)
206
+ const phases = getGSDPhases(folder, roadmapContent);
207
+ const validPhases = phases.filter(ph => ph.number !== null);
208
+
209
+ const totalPhases = validPhases.length;
210
+ const completedPhases = validPhases.filter(p => p.status === 'completed').length;
211
+ const firstIncomplete = validPhases.find(p => p.status !== 'completed');
212
+ const activePhase = stateData?.stoppedAt || (firstIncomplete ? firstIncomplete.name : null);
213
+
214
+ // Prefer STATE.md milestone, fallback to ROADMAP detection
215
+ const activeMilestone = stateData?.milestone || detectActiveMilestone(roadmapContent);
216
+
217
+ // Count todos/seeds/quick (common GSD directories)
218
+ const todos = countFiles(path.join(planningDir, 'todos'))
219
+ + countFiles(path.join(planningDir, 'seeds'));
220
+ const notes = countFiles(path.join(planningDir, 'quick'));
221
+ const backlog = countFiles(path.join(planningDir, 'backlog'));
222
+
223
+ const planStat = statSafe(planningDir);
224
+ const lastModified = planStat ? Math.round(planStat.mtimeMs) : null;
225
+
226
+ results.push({
227
+ folder,
228
+ name: projectName || path.basename(folder),
229
+ description,
230
+ milestone: activeMilestone,
231
+ totalPhases,
232
+ completedPhases,
233
+ activePhase,
234
+ todos,
235
+ backlog,
236
+ notes,
237
+ lastModified,
238
+ });
239
+ }
240
+
241
+ return results;
242
+ }
243
+
244
+ /**
245
+ * Return phase details for a single GSD project.
246
+ * Phases live in .planning/phases/<phaseDir>/
247
+ * roadmapContent is optionally passed to cross-reference checkbox status.
248
+ */
249
+ function getGSDPhases(folder, roadmapContent) {
250
+ if (roadmapContent === undefined) {
251
+ roadmapContent = readFileSafe(path.join(folder, '.planning', 'ROADMAP.md'));
252
+ }
253
+ const roadmapStatus = parseRoadmapPhaseStatus(roadmapContent);
254
+ const phasesDir = path.join(folder, '.planning', 'phases');
255
+ let phaseDirs;
256
+ try {
257
+ phaseDirs = fs.readdirSync(phasesDir)
258
+ .filter(f => {
259
+ try { return fs.statSync(path.join(phasesDir, f)).isDirectory(); } catch { return false; }
260
+ })
261
+ .sort((a, b) => {
262
+ // Sort by numeric prefix (supports decimals like 999.1)
263
+ const na = parseFloat(extractPhasePrefix(a) || '9999');
264
+ const nb = parseFloat(extractPhasePrefix(b) || '9999');
265
+ return na - nb;
266
+ });
267
+ } catch { return []; }
268
+
269
+ const phases = [];
270
+ for (const phaseDir of phaseDirs) {
271
+ const phaseFullDir = path.join(phasesDir, phaseDir);
272
+ const prefix = extractPhasePrefix(phaseDir);
273
+
274
+ // Phase name: everything after the leading number prefix
275
+ const nameRaw = phaseDir.replace(/^\d+(?:\.\d+)?-/, '').replace(/-/g, ' ');
276
+ const phaseName = nameRaw.length > 0 ? nameRaw : phaseDir;
277
+
278
+ // Phase number (numeric value for display)
279
+ const phaseNumber = prefix ? parseFloat(prefix) : null;
280
+
281
+ // Detect artifact files using the phase prefix pattern
282
+ let hasPlan = false, hasResearch = false, hasVerification = false;
283
+ let planCount = 0, summaryCount = 0;
284
+ let latestMtime = 0;
285
+
286
+ try {
287
+ const files = fs.readdirSync(phaseFullDir);
288
+ for (const f of files) {
289
+ const fPath = path.join(phaseFullDir, f);
290
+ const st = statSafe(fPath);
291
+ if (st && st.mtimeMs > latestMtime) latestMtime = st.mtimeMs;
292
+
293
+ // PLAN files: {prefix}-{n}-PLAN.md
294
+ if (f.match(/PLAN\.md$/i)) { hasPlan = true; planCount++; }
295
+ // SUMMARY files: indicates a plan was executed
296
+ if (f.match(/SUMMARY\.md$/i)) summaryCount++;
297
+ // VERIFICATION
298
+ if (f.match(/VERIFICATION\.md$/i)) hasVerification = true;
299
+ // RESEARCH
300
+ if (f.match(/RESEARCH\.md$/i)) hasResearch = true;
301
+ }
302
+ } catch { /* skip */ }
303
+
304
+ // Determine status: file-based first, then cross-reference ROADMAP.md checkboxes
305
+ let status = 'planned';
306
+ if (hasVerification) {
307
+ status = 'completed';
308
+ } else if (roadmapStatus.get(phaseNumber) === 'completed') {
309
+ // ROADMAP.md marks this phase [x] even without VERIFICATION.md
310
+ status = 'completed';
311
+ } else if (summaryCount > 0) {
312
+ status = 'executing';
313
+ } else if (hasPlan) {
314
+ status = 'planned';
315
+ }
316
+
317
+ phases.push({
318
+ phaseDir,
319
+ number: phaseNumber,
320
+ name: phaseName,
321
+ status,
322
+ tasks: { total: planCount, completed: summaryCount },
323
+ hasVerification,
324
+ hasPlan,
325
+ hasResearch,
326
+ lastModified: latestMtime || null,
327
+ });
328
+ }
329
+
330
+ return phases;
331
+ }
332
+
333
+ /**
334
+ * Return combined PLAN.md content for a phase.
335
+ * Aggregates all *-PLAN.md files in order.
336
+ */
337
+ function getGSDPlanDetail(folder, phaseDir) {
338
+ const phaseFullDir = path.join(folder, '.planning', 'phases', phaseDir);
339
+ if (!fs.existsSync(phaseFullDir)) return null;
340
+
341
+ let files;
342
+ try { files = fs.readdirSync(phaseFullDir).filter(f => f.match(/PLAN\.md$/i)).sort(); }
343
+ catch { return null; }
344
+ if (files.length === 0) return null;
345
+
346
+ const sections = [];
347
+ const allTasks = [];
348
+
349
+ for (const f of files) {
350
+ const content = readFileSafe(path.join(phaseFullDir, f));
351
+ if (!content) continue;
352
+ sections.push(`## ${f}\n\n${content}`);
353
+ const { tasks } = parseCheckboxes(content);
354
+ allTasks.push(...tasks);
355
+ }
356
+
357
+ return { content: sections.join('\n\n---\n\n'), tasks: allTasks };
358
+ }
359
+
360
+ module.exports = {
361
+ name,
362
+ labels,
363
+ getGSDProjects,
364
+ getGSDPhases,
365
+ getGSDPlanDetail,
366
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {