agentlytics 0.2.10 → 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 +195 -2
- package/editors/gsd.js +366 -0
- package/index.js +23 -9
- package/package.json +1 -1
- package/server.js +102 -0
- package/ui/src/App.jsx +4 -1
- package/ui/src/components/ChatSidebar.jsx +31 -2
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/lib/api.js +43 -0
- package/ui/src/pages/GSD.jsx +726 -0
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 =
|
|
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/index.js
CHANGED
|
@@ -201,16 +201,30 @@ try {
|
|
|
201
201
|
require('better-sqlite3');
|
|
202
202
|
} catch (e) {
|
|
203
203
|
if (e.message && e.message.includes('Could not locate the bindings file')) {
|
|
204
|
-
console.log(chalk.cyan(' ⟳
|
|
204
|
+
console.log(chalk.cyan(' ⟳ Native SQLite module not found, downloading prebuilt binary...'));
|
|
205
|
+
const bsqlDir = path.dirname(require.resolve('better-sqlite3/package.json'));
|
|
206
|
+
let rebuilt = false;
|
|
207
|
+
// 1) Use prebuild-install (dep of better-sqlite3) to download a prebuilt binary
|
|
205
208
|
try {
|
|
206
|
-
const
|
|
207
|
-
execSync(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
console.
|
|
213
|
-
|
|
209
|
+
const prebuildBin = require.resolve('prebuild-install/bin.js');
|
|
210
|
+
execSync(`node "${prebuildBin}" -r napi`, { cwd: bsqlDir, stdio: 'pipe', timeout: 60000 });
|
|
211
|
+
rebuilt = true;
|
|
212
|
+
} catch {}
|
|
213
|
+
// 2) Fallback: compile from source via node-gyp
|
|
214
|
+
if (!rebuilt) {
|
|
215
|
+
console.log(chalk.cyan(' ⟳ Prebuilt unavailable, compiling from source...'));
|
|
216
|
+
try {
|
|
217
|
+
execSync('npx --yes node-gyp rebuild --release', { cwd: bsqlDir, stdio: 'pipe', timeout: 120000 });
|
|
218
|
+
rebuilt = true;
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
if (rebuilt) {
|
|
222
|
+
console.log(chalk.green(' ✓ Native module ready'));
|
|
223
|
+
// Clear require cache so the freshly built binding is picked up
|
|
224
|
+
delete require.cache[require.resolve('better-sqlite3')];
|
|
225
|
+
} else {
|
|
226
|
+
console.error(chalk.red(' ✗ Failed to build better-sqlite3.'));
|
|
227
|
+
console.error(chalk.dim(' Ensure build tools are installed (python3, make, g++).'));
|
|
214
228
|
process.exit(1);
|
|
215
229
|
}
|
|
216
230
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.2.
|
|
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": {
|