great-cto 2.77.0 → 2.77.2

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 (41) hide show
  1. package/board/.claude-plugin/plugin.json +256 -0
  2. package/board/packages/board/lib/alerts.mjs +413 -0
  3. package/board/packages/board/lib/beads.mjs +241 -0
  4. package/board/packages/board/lib/config.mjs +49 -0
  5. package/board/packages/board/lib/data-readers.mjs +340 -0
  6. package/board/packages/board/lib/fleet.mjs +363 -0
  7. package/board/packages/board/lib/metrics.mjs +282 -0
  8. package/board/packages/board/lib/notifications.mjs +50 -0
  9. package/board/packages/board/lib/projects.mjs +307 -0
  10. package/board/packages/board/lib/routes.mjs +1036 -0
  11. package/board/packages/board/lib/share.mjs +143 -0
  12. package/board/packages/board/lib/sse.mjs +20 -0
  13. package/board/packages/board/lib/state.mjs +31 -0
  14. package/board/packages/board/lib/util.mjs +36 -0
  15. package/board/packages/board/lib/verdicts.mjs +168 -0
  16. package/board/packages/board/lib/watchers.mjs +116 -0
  17. package/board/packages/board/mcp-server.mjs +310 -0
  18. package/board/packages/board/public/assets/apple-touch-icon.png +0 -0
  19. package/board/packages/board/public/assets/favicon-16.png +0 -0
  20. package/board/packages/board/public/assets/favicon-32.png +0 -0
  21. package/board/packages/board/public/assets/favicon.ico +0 -0
  22. package/board/packages/board/public/assets/favicon.svg +8 -0
  23. package/board/packages/board/public/assets/fonts/PROVENANCE.md +21 -0
  24. package/board/packages/board/public/assets/fonts/fonts.css +26 -0
  25. package/board/packages/board/public/assets/fonts/geist-mono-variable.woff2 +0 -0
  26. package/board/packages/board/public/assets/fonts/geist-variable.woff2 +0 -0
  27. package/board/packages/board/public/assets/vendor/PROVENANCE.md +21 -0
  28. package/board/packages/board/public/assets/vendor/marked.min.js +6 -0
  29. package/board/packages/board/public/assets/vendor/purify.min.js +3 -0
  30. package/board/packages/board/public/index.html +5464 -0
  31. package/board/packages/board/public/share.html +1194 -0
  32. package/board/packages/board/public/sw.js +79 -0
  33. package/board/packages/board/push-adapter.mjs +222 -0
  34. package/board/packages/board/server.mjs +133 -0
  35. package/board/packages/cli/dist/archetypes.js +1461 -0
  36. package/board/scripts/lib/change-tier.mjs +119 -0
  37. package/board/scripts/lib/gate-plan.mjs +119 -0
  38. package/board/scripts/lib/judge-model.mjs +42 -0
  39. package/dist/board-path.js +47 -0
  40. package/dist/main.js +3 -31
  41. package/package.json +3 -1
@@ -0,0 +1,363 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { GREAT_CTO_DIR } from './config.mjs';
5
+ import { readFileSafe } from './util.mjs';
6
+ import { readVerdicts } from './verdicts.mjs';
7
+
8
+ // ── Agent fleet view (DESIGN-agents-fleet-view §3) ─────────────────────────
9
+ //
10
+ // Single source of truth for the /agents tab. Composes:
11
+ // • canonical agent files at ~/.claude/agents/great_cto-*.md
12
+ // • verdict log at ~/.great_cto/verdicts/<agent>.log
13
+ // • retire sidecar at ~/.claude/agents/great_cto-<slug>.md.retired
14
+ //
15
+ // Domain taxonomy is slug-keyword based (founder Q#3 — picked: derived, not
16
+ // frontmatter, to avoid pipeline-wide migration). Founder may flip to
17
+ // frontmatter-driven later — encapsulated in this function only.
18
+
19
+ const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
20
+
21
+ function deriveDomain(slug) {
22
+ const s = slug.toLowerCase();
23
+ if (/architect|adr|design|prompt/.test(s)) return 'arch';
24
+ if (/security|sec-|threat|pci|gdpr|hipaa/.test(s)) return 'security';
25
+ if (/qa|test|eval|review/.test(s)) return 'qa';
26
+ if (/devops|deploy|infra|l3|support|oncall/.test(s)) return 'ops';
27
+ if (/reviewer$/.test(s)) return 'domain';
28
+ if (/pm|plan|product/.test(s)) return 'pm';
29
+ if (/learn|memory|continuous/.test(s)) return 'memory';
30
+ return 'other';
31
+ }
32
+
33
+ // Founder Q#5 — picked: baked-in regex set (start small, expand later).
34
+ const FAILURE_PATTERNS = [
35
+ { key: 'rate-limit', re: /rate[ -]?limit|HTTP 429|too many requests/i },
36
+ { key: 'precondition', re: /BLOCKED:.*no\s+(ARCH|PLAN|PROJECT)/i },
37
+ { key: 'timeout', re: /timeout|timed out|exceeded.*window/i },
38
+ { key: 'parse-fail', re: /JSON\.parse|invalid_json|parse.*fail/i },
39
+ { key: 'spawn-fail', re: /spawn(Sync)?\b.*ENOENT|command not found/i },
40
+ ];
41
+
42
+ function clusterFailureModes(verdicts) {
43
+ const counts = new Map();
44
+ for (const v of verdicts) {
45
+ const text = v.raw || '';
46
+ for (const p of FAILURE_PATTERNS) {
47
+ if (p.re.test(text)) {
48
+ const entry = counts.get(p.key) || { key: p.key, count: 0, last_seen: null };
49
+ entry.count += 1;
50
+ if (!entry.last_seen || v.ts > entry.last_seen) entry.last_seen = v.ts;
51
+ counts.set(p.key, entry);
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ return [...counts.values()].sort((a, b) => b.count - a.count);
57
+ }
58
+
59
+ function isRetired(slug) {
60
+ return fs.existsSync(path.join(AGENTS_DIR, `great_cto-${slug}.md.retired`));
61
+ }
62
+
63
+ // Verdict status mapping — agents emit a mix of vocabularies:
64
+ // APPROVED / OK / DONE / PASS → success
65
+ // BLOCKED / FAIL / FAILED / REJECTED → failure
66
+ // anything else → neutral
67
+ function isSuccess(verdict) {
68
+ return /^(APPROVED|OK|DONE|PASS|PASSED)$/i.test(verdict || '');
69
+ }
70
+ function isFailure(verdict) {
71
+ return /^(BLOCKED|FAIL|FAILED|REJECTED)$/i.test(verdict || '');
72
+ }
73
+
74
+ function getAgentsFleet(projectCwd) {
75
+ const agents = [];
76
+ let files = [];
77
+ try {
78
+ files = fs.readdirSync(AGENTS_DIR)
79
+ .filter(f => f.startsWith('great_cto-') && f.endsWith('.md'))
80
+ .sort();
81
+ } catch { /* dir missing → empty fleet */ }
82
+
83
+ const verdicts = readVerdicts(projectCwd);
84
+ const now = Date.now();
85
+ const day30Ms = 30 * 86400_000;
86
+ const day7Ms = 7 * 86400_000;
87
+
88
+ // Group verdicts by agent for one pass.
89
+ const byAgent = new Map();
90
+ for (const v of verdicts) {
91
+ if (!v.agent) continue;
92
+ if (!byAgent.has(v.agent)) byAgent.set(v.agent, []);
93
+ byAgent.get(v.agent).push(v);
94
+ }
95
+
96
+ // Fleet metrics from getMetrics agents_cost (estimate-based).
97
+ // Currently project-scoped; for fleet view we want global, so recompute
98
+ // a quick aggregate. Time-based estimate using same rates as getMetrics.
99
+ const HUMAN_RATE_PER_HR = parseFloat(process.env.GREATCTO_HUMAN_RATE_PER_HR || '150');
100
+ const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
101
+ const DEFAULT_TASK_MIN = 30;
102
+
103
+ for (const f of files) {
104
+ const slug = f.replace(/^great_cto-/, '').replace(/\.md$/, '');
105
+ const fp = path.join(AGENTS_DIR, f);
106
+ const raw = readFileSafe(fp) || '';
107
+ const descM = raw.match(/^description:\s*"?([^"\n]+)"?/m);
108
+ const modelM = raw.match(/^model:\s*(\S+)/m);
109
+ const colorM = raw.match(/^color:\s*(\S+)/m);
110
+
111
+ const vs = byAgent.get(slug) || [];
112
+ const vs30d = vs.filter(v => v.ts && (now - new Date(v.ts).getTime()) < day30Ms);
113
+ const vs7d = vs.filter(v => v.ts && (now - new Date(v.ts).getTime()) < day7Ms);
114
+
115
+ const okCount30d = vs30d.filter(v => isSuccess(v.verdict)).length;
116
+ const failCount30d = vs30d.filter(v => isFailure(v.verdict)).length;
117
+ const failCount7d = vs7d.filter(v => isFailure(v.verdict)).length;
118
+ const decided = okCount30d + failCount30d;
119
+ const successRate = decided > 0 ? Math.round((okCount30d / decided) * 100) : null;
120
+
121
+ const lastRun = vs[0]?.ts || null; // verdicts already sorted by readVerdicts caller? if not, scan:
122
+ let lastRunActual = null;
123
+ for (const v of vs) {
124
+ if (v.ts && (!lastRunActual || v.ts > lastRunActual)) lastRunActual = v.ts;
125
+ }
126
+
127
+ // Estimated cost — DEFAULT_TASK_MIN per verdict (no real timing data here).
128
+ const estLlmUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * LLM_RATE_PER_HR;
129
+ const estHumanUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * HUMAN_RATE_PER_HR;
130
+ const savingsX = estLlmUsd > 0 ? Math.round(estHumanUsd / estLlmUsd) : null;
131
+ const realLlmUsd = vs30d.reduce((s, v) => s + (v.cost_usd || 0), 0);
132
+
133
+ // Health classification.
134
+ let health = 'ok';
135
+ if (vs.length === 0) health = 'unused';
136
+ else if (!lastRunActual || (now - new Date(lastRunActual).getTime()) > day30Ms) health = 'idle';
137
+ if (failCount7d >= 3) health = 'failing';
138
+
139
+ agents.push({
140
+ slug,
141
+ description: descM?.[1]?.trim() || '',
142
+ model: modelM?.[1]?.trim() || 'sonnet',
143
+ color: colorM?.[1]?.trim() || null,
144
+ domain: deriveDomain(slug),
145
+ runs_total: vs.length,
146
+ runs_30d: vs30d.length,
147
+ runs_7d: vs7d.length,
148
+ fail_30d: failCount30d,
149
+ fail_7d: failCount7d,
150
+ ok_30d: okCount30d,
151
+ success_rate: successRate,
152
+ last_run: lastRunActual,
153
+ llm_usd_30d_est: Math.round(estLlmUsd * 100) / 100,
154
+ human_usd_30d_est: Math.round(estHumanUsd),
155
+ llm_usd_30d_real: realLlmUsd > 0 ? Math.round(realLlmUsd * 100) / 100 : null,
156
+ savings_x: savingsX,
157
+ health,
158
+ retired: isRetired(slug),
159
+ });
160
+ }
161
+
162
+ // Summary tiles.
163
+ const total = agents.length;
164
+ const active30d = agents.filter(a => a.runs_30d > 0 && !a.retired).length;
165
+ const retireCandidates = agents.filter(a => a.runs_30d === 0 && !a.retired).length;
166
+ const failing = agents.filter(a => a.health === 'failing' && !a.retired).length;
167
+ const totalLlm30d = agents.reduce((s, a) => s + (a.llm_usd_30d_est || 0), 0);
168
+
169
+ return {
170
+ agents,
171
+ total,
172
+ summary: {
173
+ installed: total,
174
+ active_30d: active30d,
175
+ retire_candidates: retireCandidates,
176
+ failing_7d: failing,
177
+ llm_usd_30d: Math.round(totalLlm30d * 100) / 100,
178
+ },
179
+ };
180
+ }
181
+
182
+ function getAgentProfile(slug) {
183
+ const fp = path.join(AGENTS_DIR, `great_cto-${slug}.md`);
184
+ if (!fs.existsSync(fp)) return null;
185
+
186
+ const raw = readFileSafe(fp) || '';
187
+ const descM = raw.match(/^description:\s*"?([^"\n]+)"?/m);
188
+ const modelM = raw.match(/^model:\s*(\S+)/m);
189
+ const colorM = raw.match(/^color:\s*(\S+)/m);
190
+ const appliesM = raw.match(/^applies_to:\s*\[([^\]]+)\]/m);
191
+ const applies_to = appliesM
192
+ ? appliesM[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)
193
+ : [];
194
+ // tools: either an inline CSV (`tools: Read, Bash, Grep`) or a YAML list.
195
+ const toolsM = raw.match(/^tools:\s*(.+)$/m);
196
+ let tools = [];
197
+ if (toolsM && toolsM[1].trim() && !toolsM[1].trim().startsWith('#')) {
198
+ tools = toolsM[1].split(',').map(s => s.trim()).filter(Boolean)
199
+ .map(t => /^Bash\(/.test(t) ? 'Bash' : t); // collapse Bash(...) allowlist to one chip
200
+ tools = [...new Set(tools)];
201
+ } else {
202
+ const listM = raw.match(/^tools:\s*\n((?:\s*-\s*.+\n?)+)/m);
203
+ if (listM) tools = listM[1].split('\n').map(l => (l.match(/^\s*-\s*(.+)$/) || [])[1]).filter(Boolean).map(s => s.trim());
204
+ }
205
+ // skills: a YAML list under `skills:`. Stop before the closing `---` frontmatter delimiter
206
+ // (otherwise `---` parses as a bogus `--` entry).
207
+ const skills = [];
208
+ const skillsM = raw.match(/^skills:\s*\n((?:[ \t]*-[ \t]*.+\n?)+)/m);
209
+ if (skillsM) for (const line of skillsM[1].split('\n')) { const v = (line.match(/^[ \t]*-[ \t]*(.+)$/) || [])[1]; const t = v && v.trim(); if (t && !/^-+$/.test(t)) skills.push(t); }
210
+
211
+ const verdicts = readVerdicts();
212
+ const all = verdicts.filter(v => v.agent === slug);
213
+ const now = Date.now();
214
+ const day30Ms = 30 * 86400_000;
215
+ const day7Ms = 7 * 86400_000;
216
+ const vs30d = all.filter(v => v.ts && (now - new Date(v.ts).getTime()) < day30Ms);
217
+
218
+ const okCount30d = vs30d.filter(v => isSuccess(v.verdict)).length;
219
+ const failCount30d = vs30d.filter(v => isFailure(v.verdict)).length;
220
+ const failCount7d = all.filter(v => v.ts
221
+ && (now - new Date(v.ts).getTime()) < day7Ms
222
+ && isFailure(v.verdict)).length;
223
+ const decided = okCount30d + failCount30d;
224
+
225
+ let lastRun = null;
226
+ for (const v of all) {
227
+ if (v.ts && (!lastRun || v.ts > lastRun)) lastRun = v.ts;
228
+ }
229
+
230
+ const HUMAN_RATE_PER_HR = parseFloat(process.env.GREATCTO_HUMAN_RATE_PER_HR || '150');
231
+ const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
232
+ const DEFAULT_TASK_MIN = 30;
233
+ const estLlmUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * LLM_RATE_PER_HR;
234
+ const estHumanUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * HUMAN_RATE_PER_HR;
235
+ const realLlmUsd = vs30d.reduce((s, v) => s + (v.cost_usd || 0), 0);
236
+
237
+ // Recent runs — last 20, newest first.
238
+ const recent = [...all]
239
+ .sort((a, b) => (b.ts || '').localeCompare(a.ts || ''))
240
+ .slice(0, 20)
241
+ .map(v => ({
242
+ ts: v.ts,
243
+ verdict: v.verdict,
244
+ cost_usd: v.cost_usd,
245
+ raw: (v.raw || '').slice(0, 200),
246
+ }));
247
+
248
+ // Failure modes — regex-cluster verdicts that look like failures.
249
+ const failures = all.filter(v => isFailure(v.verdict));
250
+ const failure_modes = clusterFailureModes(failures);
251
+
252
+ let health = 'ok';
253
+ if (all.length === 0) health = 'unused';
254
+ else if (!lastRun || (now - new Date(lastRun).getTime()) > day30Ms) health = 'idle';
255
+ if (failCount7d >= 3) health = 'failing';
256
+
257
+ return {
258
+ slug,
259
+ description: descM?.[1]?.trim() || '',
260
+ model: modelM?.[1]?.trim() || 'sonnet',
261
+ color: colorM?.[1]?.trim() || null,
262
+ applies_to,
263
+ tools,
264
+ skills,
265
+ domain: deriveDomain(slug),
266
+ health,
267
+ retired: isRetired(slug),
268
+ runs_total: all.length,
269
+ runs_30d: vs30d.length,
270
+ ok_30d: okCount30d,
271
+ fail_30d: failCount30d,
272
+ success_rate: decided > 0 ? Math.round((okCount30d / decided) * 100) : null,
273
+ last_run: lastRun,
274
+ llm_usd_30d_est: Math.round(estLlmUsd * 100) / 100,
275
+ human_usd_30d_est: Math.round(estHumanUsd),
276
+ llm_usd_30d_real: realLlmUsd > 0 ? Math.round(realLlmUsd * 100) / 100 : null,
277
+ savings_x: estLlmUsd > 0 ? Math.round(estHumanUsd / estLlmUsd) : null,
278
+ recent_runs: recent,
279
+ failure_modes,
280
+ file_path: fp,
281
+ };
282
+ }
283
+
284
+ function retireAgent(slug) {
285
+ const fp = path.join(AGENTS_DIR, `great_cto-${slug}.md`);
286
+ if (!fs.existsSync(fp)) return { ok: false, error: 'agent_not_found' };
287
+ const marker = `${fp}.retired`;
288
+ fs.writeFileSync(marker, new Date().toISOString() + '\n');
289
+ return { ok: true, slug, retired_at: new Date().toISOString() };
290
+ }
291
+
292
+ function restoreAgent(slug) {
293
+ const fp = path.join(AGENTS_DIR, `great_cto-${slug}.md`);
294
+ if (!fs.existsSync(fp)) return { ok: false, error: 'agent_not_found' };
295
+ const marker = `${fp}.retired`;
296
+ if (fs.existsSync(marker)) fs.unlinkSync(marker);
297
+ return { ok: true, slug, restored_at: new Date().toISOString() };
298
+ }
299
+
300
+ // ── decisions.md (global ADR log) ──────────────────────────────────────────
301
+ // Append-only architectural decisions log. Triggered on gate approve/reject.
302
+ // One line per decision; pure markdown so users can `cat` / `grep` / view in
303
+ // their editor without tooling.
304
+ function decisionsLogPath() {
305
+ return path.join(GREAT_CTO_DIR, 'decisions.md');
306
+ }
307
+
308
+ function appendDecisionLog({ ts, project, action, id, title, reason }) {
309
+ const file = decisionsLogPath();
310
+ try { fs.mkdirSync(GREAT_CTO_DIR, { recursive: true }); } catch {}
311
+ // Initialize header if file doesn't exist
312
+ if (!fs.existsSync(file)) {
313
+ const header =
314
+ `# great_cto — decisions log
315
+
316
+ Append-only architectural decisions across all projects. One line per
317
+ gate approve/reject. Agents and humans can grep this for "have we decided
318
+ this before?" lookups.
319
+
320
+ Format: \`- [TIMESTAMP] [PROJECT] [APPROVED|REJECTED] gate-id — title — reason\`
321
+
322
+ `;
323
+ fs.writeFileSync(file, header);
324
+ }
325
+ const verdict = action === 'approve' ? 'APPROVED' : 'REJECTED';
326
+ const safeTitle = (title || '').replace(/\n/g, ' ').slice(0, 120);
327
+ const safeReason = (reason || '').replace(/\n/g, ' ').slice(0, 200);
328
+ const line = `- [${ts}] [${project}] [${verdict}] ${id} — ${safeTitle}${safeReason ? ` — ${safeReason}` : ''}\n`;
329
+ fs.appendFileSync(file, line);
330
+ }
331
+
332
+ function readDecisionsLog(limit = 20) {
333
+ const file = decisionsLogPath();
334
+ if (!fs.existsSync(file)) return [];
335
+ try {
336
+ const text = fs.readFileSync(file, 'utf-8');
337
+ const lines = text.split('\n').filter(l => l.startsWith('- ['));
338
+ // Newest last → reverse and take last `limit`
339
+ return lines.slice(-limit).reverse().map(line => {
340
+ // Parse: - [TS] [PROJECT] [VERDICT] id — title — reason
341
+ const m = line.match(/^- \[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] ([^\s]+)\s+—\s+(.+?)(?:\s+—\s+(.+))?$/);
342
+ if (!m) return null;
343
+ return { ts: m[1], project: m[2], verdict: m[3], id: m[4], title: m[5], reason: m[6] || '' };
344
+ }).filter(Boolean);
345
+ } catch {
346
+ return [];
347
+ }
348
+ }
349
+
350
+ export {
351
+ deriveDomain,
352
+ clusterFailureModes,
353
+ isRetired,
354
+ isSuccess,
355
+ isFailure,
356
+ getAgentsFleet,
357
+ getAgentProfile,
358
+ retireAgent,
359
+ restoreAgent,
360
+ decisionsLogPath,
361
+ appendDecisionLog,
362
+ readDecisionsLog,
363
+ };
@@ -0,0 +1,282 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { sseClients, bdCache } from './state.mjs';
5
+ import { getTasks } from './beads.mjs';
6
+ import { readVerdicts, readPlanCosts, readQAStats, readSecStats } from './verdicts.mjs';
7
+
8
+ // ── Metrics ────────────────────────────────────────────────────────────────────
9
+ function getMetrics(cwd = process.cwd(), days = 30) {
10
+ // `days` controls the window for cost/agents_cost and for "shipped in window".
11
+ // Tasks shipped within the window are returned in `tasks.done_in_window`.
12
+ // Full lifetime `tasks.done` is still returned for backwards compatibility.
13
+ const tasks = getTasks(cwd);
14
+ // BH-28: resolved gates (approve→closed, reject→blocked) get mapped to
15
+ // status='done' by mapStatus(). Those are governance decisions, not shipped
16
+ // features — counting them inflates velocity / tasks-shipped on the report.
17
+ const done = tasks.filter(t => t.status === 'done' && !t.is_gate);
18
+ const inProgress = tasks.filter(t => t.status === 'in_progress');
19
+ const backlog = tasks.filter(t => t.status === 'backlog');
20
+
21
+ // Velocity: features closed per week
22
+ const now = Date.now();
23
+ const week = 7 * 24 * 60 * 60 * 1000;
24
+ const doneThisWeek = done.filter(t => t.closed_at && (now - new Date(t.closed_at).getTime()) < week);
25
+ const doneThisMonth = done.filter(t => t.closed_at && (now - new Date(t.closed_at).getTime()) < 30 * 24 * 60 * 60 * 1000);
26
+
27
+ // Cycle time (median, last 30 days, cap individual cycles at 30 days).
28
+ // Previously: arithmetic mean over ALL tasks ever, no cap — stuck tasks
29
+ // (created months earlier, finally closed) pulled the average into the
30
+ // tens of thousands of minutes. Median + 30d cap matches how the cost
31
+ // tile bounds cycles ([server.mjs:816](server.mjs:816)).
32
+ const cycleCap = 30 * 86400_000;
33
+ const completionTimes = done
34
+ .filter(t => t.created_at && t.closed_at && (now - new Date(t.closed_at).getTime()) < cycleCap)
35
+ .map(t => new Date(t.closed_at).getTime() - new Date(t.created_at).getTime())
36
+ .filter(ms => ms > 0 && ms < cycleCap)
37
+ .sort((a, b) => a - b);
38
+ const medianCompletionMs = completionTimes.length
39
+ ? completionTimes[Math.floor(completionTimes.length / 2)]
40
+ : 0;
41
+
42
+ // Verdicts (global verdicts log lives in ~/.great_cto/verdicts/)
43
+ const verdicts = readVerdicts(cwd);
44
+
45
+ // Cost from plans (per-project) — filter to the same window as task/verdict
46
+ // loops below so /api/metrics and /api/cost agree on the headline AI spend.
47
+ const costData = readPlanCosts(cwd, days * 86400_000);
48
+
49
+ // QA/Security (per-project)
50
+ const qaStats = readQAStats(cwd);
51
+ const secStats = readSecStats(cwd);
52
+
53
+ // Agent utilization from verdicts.
54
+ // Filter against canonical list of installed agents (~/.claude/agents/great_cto-*.md)
55
+ // so a typo in a verdict line does not produce a phantom agent in the dashboard.
56
+ //
57
+ // Previously: non-canonical agents bucketed into `unknown` — which became
58
+ // the TOP agent in production dashboards because legacy verdict log files
59
+ // (backend.log, frontend.log, docs.log, ops.log, qa.log, security.log,
60
+ // test-agent.log) from older great_cto versions were all aggregated there.
61
+ // That hid real agent activity under a misleading label.
62
+ //
63
+ // Now: non-canonical agents are tracked separately (legacy_agent_runs)
64
+ // and surfaced as a single summary count, NOT individually polluting the
65
+ // agent-runs map. Users see honest specialist metrics + a cleanup hint.
66
+ const canonicalAgents = getCanonicalAgents();
67
+ const agentRuns = {};
68
+ const legacyAgentRuns = {};
69
+ for (const v of verdicts) {
70
+ if (!v.agent) continue;
71
+ if (canonicalAgents.has(v.agent)) {
72
+ agentRuns[v.agent] = (agentRuns[v.agent] || 0) + 1;
73
+ } else {
74
+ legacyAgentRuns[v.agent] = (legacyAgentRuns[v.agent] || 0) + 1;
75
+ }
76
+ }
77
+ const legacyAgentCount = Object.values(legacyAgentRuns).reduce((a, b) => a + b, 0);
78
+
79
+ // Agent cost + time breakdown.
80
+ //
81
+ // Cost model derivation (LLM_RATE_PER_HR):
82
+ // Sonnet 4.6: input $3/1M, output $15/1M.
83
+ // Typical agent task: ~30K in + 5K out → ~$0.165 per task.
84
+ // At 30 min/task → ~$0.33/hour.
85
+ // Haiku 4.5: input $1/1M, output $5/1M.
86
+ // Same task shape → ~$0.055 → ~$0.11/hour.
87
+ // Mixed pipeline (architect Sonnet, qa Haiku, ...): ~$0.30/hour avg.
88
+ //
89
+ // Previous default of $0.02/hour produced an unbelievable 7500× ratio in
90
+ // the UI. v2.5.9: realistic default $0.30/hour gives ~500× — still a
91
+ // huge advantage, but defensible.
92
+ //
93
+ // Override via env var:
94
+ // GREATCTO_LLM_RATE_PER_HR=0.50 (e.g. all-Sonnet pipeline)
95
+ // GREATCTO_HUMAN_RATE_PER_HR=200 (e.g. SF senior engineer fully-loaded)
96
+ //
97
+ // When verdict logs contain real `cost=$X` tags, those override the
98
+ // time-based estimate per agent (see "real cost overlay" loop below).
99
+ const HUMAN_RATE_PER_HR = parseFloat(process.env.GREATCTO_HUMAN_RATE_PER_HR || '150');
100
+ const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
101
+ const DEFAULT_TASK_MIN = 30; // fallback when no timing data
102
+ // Window: count only tasks closed in the last `days` (or still in progress).
103
+ // Previously: lifetime — produced LLM-spend tile ($1749) wildly out of step
104
+ // with the "Last 30 days" panel ($6.42) shown directly below it. Now both
105
+ // sit on the same N-day window so the dashboard numbers reconcile.
106
+ const costWindowMs = days * 86400_000;
107
+ // AI active time per task: use estimated_minutes if set, else DEFAULT_TASK_MIN (30m).
108
+ // We deliberately DO NOT use wall-clock (closed_at - created_at) because that
109
+ // includes idle time — tasks that sit in backlog for days before being closed
110
+ // in a single commit would inflate "AI time" to weeks/months. This is the only
111
+ // honest model without per-agent-run timing data from verdicts.
112
+ const agentCostMap = {};
113
+ for (const t of tasks) {
114
+ if (!t.agent) continue;
115
+ // Only count completed tasks — in-progress tasks have no closed_at and
116
+ // should not inflate the LLM cost estimate (they match the old
117
+ // `if (t.closed_at && outside_window) continue` guard which
118
+ // inadvertently passed through !closed_at tasks). Mirrors getCostHistory.
119
+ if (!t.closed_at) continue;
120
+ if ((now - new Date(t.closed_at).getTime()) > costWindowMs) continue;
121
+ const mins = t.estimated_minutes || DEFAULT_TASK_MIN;
122
+ const llmCost = mins / 60 * LLM_RATE_PER_HR;
123
+ const humanCost = mins / 60 * HUMAN_RATE_PER_HR;
124
+ if (!agentCostMap[t.agent]) agentCostMap[t.agent] = { agent: t.agent, llm_usd: 0, human_usd: 0, time_min: 0, tasks_total: 0, tasks_done: 0, real_llm_usd: 0 };
125
+ agentCostMap[t.agent].llm_usd += llmCost;
126
+ agentCostMap[t.agent].human_usd += humanCost;
127
+ agentCostMap[t.agent].time_min += mins;
128
+ agentCostMap[t.agent].tasks_total += 1;
129
+ if (t.status === 'done') agentCostMap[t.agent].tasks_done += 1;
130
+ }
131
+ // Real cost overlay — sum verdict cost=$X tags per agent. We expose this
132
+ // as a separate field (`real_llm_usd`) for transparency, but DON'T
133
+ // overwrite the time-based estimate. Reason: verdict data is often
134
+ // synthetic test fixtures or partial (only some agents log cost), which
135
+ // would distort the savings ratio with implausibly low numbers.
136
+ //
137
+ // Heuristic for trusted production verdicts (future): require >= 50%
138
+ // of agent runs to have cost_usd, AND sum/time hourly rate >= $0.05/hr.
139
+ // Until that's implemented, time-based estimate is the canonical number.
140
+ // Window verdicts by timestamp — same window as agents_cost / tasks.
141
+ // Without this, "AI spend" stayed at lifetime $93 even when period=7D
142
+ // showed only 12 tasks worth ~$0.30 — making savings ratios nonsensical.
143
+ for (const v of verdicts) {
144
+ if (v.cost_usd == null) continue;
145
+ if (!agentCostMap[v.agent]) continue;
146
+ if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) continue;
147
+ agentCostMap[v.agent].real_llm_usd += v.cost_usd;
148
+ }
149
+ for (const a of Object.values(agentCostMap)) {
150
+ a.cost_source = 'estimate'; // time-based is canonical
151
+ if (a.real_llm_usd > 0) {
152
+ a.real_llm_usd = Math.round(a.real_llm_usd * 10000) / 10000;
153
+ } else {
154
+ delete a.real_llm_usd;
155
+ }
156
+ }
157
+ const agentsCost = Object.values(agentCostMap)
158
+ .map(a => ({
159
+ ...a,
160
+ llm_usd: Math.round(a.llm_usd * 100) / 100,
161
+ human_usd: Math.round(a.human_usd * 100) / 100,
162
+ time_min: Math.round(a.time_min),
163
+ }))
164
+ .sort((a, b) => b.time_min - a.time_min);
165
+
166
+ // Cost source priority (v2.5.9 — flipped to put time-based estimate
167
+ // before verdict-totals; verdict cost data is often synthetic test
168
+ // fixtures or partial coverage, which produced unbelievable 25,000×
169
+ // ratios in earlier versions):
170
+ //
171
+ // 1) PLAN-*.md files (real planned cost figures) — costData
172
+ // 2) Task-based estimation (canonical — uses realistic $0.30/$150 rates)
173
+ // 3) Verdict totals expose as `real_llm_usd` for transparency, NOT
174
+ // used as the headline number unless plans / tasks are absent
175
+ const taskLlmTotal = agentsCost.reduce((s, a) => s + a.llm_usd, 0);
176
+ const taskHumanTotal = agentsCost.reduce((s, a) => s + a.human_usd, 0);
177
+ // Filter verdicts to the same window for consistent total
178
+ const verdictLlmTotal = verdicts.reduce((s, v) => {
179
+ if (v.cost_usd == null) return s;
180
+ if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) return s;
181
+ return s + v.cost_usd;
182
+ }, 0);
183
+
184
+ let cost;
185
+ if (costData.llm_usd > 0 || costData.human_usd > 0) {
186
+ cost = { ...costData, real_llm_usd: verdictLlmTotal > 0 ? Math.round(verdictLlmTotal * 10000) / 10000 : null };
187
+ } else if (taskLlmTotal > 0) {
188
+ // savings_x intentionally NULL for source='tasks': it would always equal
189
+ // HUMAN_RATE_PER_HR / LLM_RATE_PER_HR (e.g. 500) because both legs share
190
+ // the same task-minute base. That's the rate ratio, not measured savings —
191
+ // putting it on a dashboard tile misleads. Only plans/verdicts have an
192
+ // independent human number worth comparing.
193
+ cost = {
194
+ llm_usd: Math.round(taskLlmTotal * 100) / 100,
195
+ human_usd: Math.round(taskHumanTotal),
196
+ savings_x: null,
197
+ rate_ratio: Math.round(HUMAN_RATE_PER_HR / LLM_RATE_PER_HR),
198
+ window_days: 30,
199
+ count: 0,
200
+ source: 'tasks',
201
+ real_llm_usd: verdictLlmTotal > 0 ? Math.round(verdictLlmTotal * 10000) / 10000 : null,
202
+ };
203
+ } else if (verdictLlmTotal > 0) {
204
+ // Last-resort: no tasks, only verdict data. Pair with a token human
205
+ // baseline (verdict count × DEFAULT_TASK_MIN × $150/hr) so the ratio
206
+ // stays meaningful.
207
+ const verdictHuman = verdicts.length * (30 / 60) * HUMAN_RATE_PER_HR;
208
+ cost = {
209
+ llm_usd: Math.round(verdictLlmTotal * 100) / 100,
210
+ human_usd: Math.round(verdictHuman),
211
+ savings_x: Math.round(verdictHuman / verdictLlmTotal),
212
+ count: verdicts.filter(v => v.cost_usd != null).length,
213
+ source: 'verdicts',
214
+ real_llm_usd: Math.round(verdictLlmTotal * 10000) / 10000,
215
+ };
216
+ } else {
217
+ cost = { llm_usd: 0, human_usd: 0, savings_x: 0, count: 0, source: 'none', real_llm_usd: null };
218
+ }
219
+
220
+ // Count tasks completed in selected window (for period-scoped reports)
221
+ const doneInWindow = done.filter(t => t.closed_at && (now - new Date(t.closed_at).getTime()) <= costWindowMs);
222
+
223
+ return {
224
+ window_days: days,
225
+ tasks: {
226
+ total: tasks.length,
227
+ done: done.length,
228
+ done_in_window: doneInWindow.length,
229
+ in_progress: inProgress.length,
230
+ backlog: backlog.length,
231
+ },
232
+ // BH-22 fix: these are ROLLING windows (last 7 days, last 30 days from
233
+ // 'now') — not calendar week/month. Old keys this_week/this_month are
234
+ // kept for backward compat but the canonical names are last_7d/last_30d.
235
+ velocity: {
236
+ last_7d: doneThisWeek.length,
237
+ last_30d: doneThisMonth.length,
238
+ this_week: doneThisWeek.length, // alias, deprecated — remove in v3.0
239
+ this_month: doneThisMonth.length, // alias, deprecated — remove in v3.0
240
+ },
241
+ avg_completion_min: Math.round(medianCompletionMs / 60000),
242
+ cycle_time_stat: 'median_30d',
243
+ cost,
244
+ qa: qaStats,
245
+ security: secStats,
246
+ agents: agentRuns,
247
+ agents_cost: agentsCost,
248
+ legacy_agent_runs: legacyAgentRuns,
249
+ legacy_agent_count: legacyAgentCount,
250
+ verdicts: verdicts.slice(-20),
251
+ recent_done: done.slice(-10).reverse(),
252
+ // Observability counters (BH-13, 2026-05-15): surface internal queues
253
+ // so users + monitoring can spot leaks / runaway state.
254
+ server: {
255
+ sse_clients: sseClients.size,
256
+ bd_cache_entries: bdCache.size,
257
+ },
258
+ };
259
+ }
260
+
261
+ // Canonical list of installed agents from ~/.claude/agents/great_cto-*.md.
262
+ // Cached with a 30s TTL to avoid stat'ing on every metrics request.
263
+ let _canonicalAgentsCache = { agents: null, ts: 0 };
264
+ function getCanonicalAgents() {
265
+ const now = Date.now();
266
+ if (_canonicalAgentsCache.agents && now - _canonicalAgentsCache.ts < 30_000) {
267
+ return _canonicalAgentsCache.agents;
268
+ }
269
+ const agentsDir = path.join(os.homedir(), '.claude', 'agents');
270
+ const set = new Set();
271
+ try {
272
+ for (const f of fs.readdirSync(agentsDir)) {
273
+ if (f.startsWith('great_cto-') && f.endsWith('.md')) {
274
+ set.add(f.replace(/^great_cto-/, '').replace(/\.md$/, ''));
275
+ }
276
+ }
277
+ } catch { /* dir missing — empty set is fine */ }
278
+ _canonicalAgentsCache = { agents: set, ts: now };
279
+ return set;
280
+ }
281
+
282
+ export { getMetrics, getCanonicalAgents };