great-cto 2.76.0 → 2.77.1
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/board/.claude-plugin/plugin.json +256 -0
- package/board/packages/board/lib/alerts.mjs +413 -0
- package/board/packages/board/lib/beads.mjs +233 -0
- package/board/packages/board/lib/config.mjs +45 -0
- package/board/packages/board/lib/data-readers.mjs +340 -0
- package/board/packages/board/lib/fleet.mjs +363 -0
- package/board/packages/board/lib/metrics.mjs +282 -0
- package/board/packages/board/lib/notifications.mjs +50 -0
- package/board/packages/board/lib/projects.mjs +256 -0
- package/board/packages/board/lib/routes.mjs +1036 -0
- package/board/packages/board/lib/share.mjs +143 -0
- package/board/packages/board/lib/sse.mjs +20 -0
- package/board/packages/board/lib/state.mjs +31 -0
- package/board/packages/board/lib/util.mjs +36 -0
- package/board/packages/board/lib/verdicts.mjs +168 -0
- package/board/packages/board/lib/watchers.mjs +87 -0
- package/board/packages/board/mcp-server.mjs +310 -0
- package/board/packages/board/public/assets/apple-touch-icon.png +0 -0
- package/board/packages/board/public/assets/favicon-16.png +0 -0
- package/board/packages/board/public/assets/favicon-32.png +0 -0
- package/board/packages/board/public/assets/favicon.ico +0 -0
- package/board/packages/board/public/assets/favicon.svg +8 -0
- package/board/packages/board/public/index.html +5452 -0
- package/board/packages/board/public/share.html +1190 -0
- package/board/packages/board/public/sw.js +79 -0
- package/board/packages/board/push-adapter.mjs +222 -0
- package/board/packages/board/server.mjs +133 -0
- package/board/packages/cli/dist/archetypes.js +1461 -0
- package/board/scripts/lib/change-tier.mjs +119 -0
- package/board/scripts/lib/gate-plan.mjs +119 -0
- package/board/scripts/lib/judge-model.mjs +42 -0
- package/dist/board-path.js +47 -0
- package/dist/main.js +3 -31
- 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 };
|