great-cto 2.3.4 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapt.js +342 -0
- package/dist/ci.js +258 -0
- package/dist/main.js +135 -0
- package/dist/mcp.js +355 -0
- package/dist/report.js +410 -0
- package/dist/serve.js +289 -0
- package/dist/webhook-cli.js +150 -0
- package/dist/webhook-config.js +65 -0
- package/dist/webhook-dispatch.js +132 -0
- package/package.json +1 -1
package/dist/report.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// great-cto report — shareable cost / agents / compliance reports.
|
|
2
|
+
//
|
|
3
|
+
// Three report types, two output formats (HTML self-contained, JSON):
|
|
4
|
+
//
|
|
5
|
+
// great-cto report cost --period 30d --format html > cost.html
|
|
6
|
+
// great-cto report agents --since-last-release --format json
|
|
7
|
+
// great-cto report compliance --archetype fintech --format html
|
|
8
|
+
//
|
|
9
|
+
// HTML output is fully self-contained (no external CSS/JS) so it can be
|
|
10
|
+
// emailed to a CFO, attached to a PR, or hosted as a GitHub Pages artifact.
|
|
11
|
+
// JSON is for downstream automation.
|
|
12
|
+
//
|
|
13
|
+
// Data sources:
|
|
14
|
+
// cost → ~/.great_cto/verdicts/*.log (LLM cost ledger) +
|
|
15
|
+
// .great_cto/PROJECT.md (monthly-budget) +
|
|
16
|
+
// bd tasks (closed_at-created_at timing)
|
|
17
|
+
// agents → ~/.great_cto/verdicts/*.log + plugin agents/*.md
|
|
18
|
+
// compliance → .great_cto/PROJECT.md (compliance gates) +
|
|
19
|
+
// docs/security/CSO-*.md + docs/qa-reports/QA-*.md +
|
|
20
|
+
// gates closed via bd
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
// ── Data collectors ────────────────────────────────────────────────────────
|
|
25
|
+
function readAllVerdicts() {
|
|
26
|
+
const dir = join(homedir(), ".great_cto", "verdicts");
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
return [];
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const f of readdirSync(dir)) {
|
|
31
|
+
if (!f.endsWith(".log"))
|
|
32
|
+
continue;
|
|
33
|
+
const agent = f.replace(/\.log$/, "");
|
|
34
|
+
const lines = readFileSync(join(dir, f), "utf8").split("\n").filter(Boolean);
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const ts = line.split(/\s+/)[0] ?? "";
|
|
37
|
+
const verdict = line.split(/\s+/)[1] ?? "";
|
|
38
|
+
const costMatch = line.match(/\bcost(?:_usd)?[=:]?\s*\$?(\d+\.?\d*)/i);
|
|
39
|
+
out.push({
|
|
40
|
+
ts, agent, verdict,
|
|
41
|
+
cost_usd: costMatch ? parseFloat(costMatch[1]) : null,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
46
|
+
}
|
|
47
|
+
function periodToCutoff(period) {
|
|
48
|
+
const m = period.match(/^(\d+)d$/);
|
|
49
|
+
if (!m)
|
|
50
|
+
return "";
|
|
51
|
+
const days = parseInt(m[1], 10);
|
|
52
|
+
return new Date(Date.now() - days * 86_400_000).toISOString();
|
|
53
|
+
}
|
|
54
|
+
// ── Cost report ────────────────────────────────────────────────────────────
|
|
55
|
+
function buildCostReport(args) {
|
|
56
|
+
const cutoff = periodToCutoff(args.period);
|
|
57
|
+
const verdicts = readAllVerdicts().filter(v => v.ts >= cutoff);
|
|
58
|
+
const HUMAN_RATE_PER_HR = 150;
|
|
59
|
+
const LLM_RATE_PER_HR = 0.02;
|
|
60
|
+
const RATIO = HUMAN_RATE_PER_HR / LLM_RATE_PER_HR;
|
|
61
|
+
// Per-agent aggregation
|
|
62
|
+
const byAgent = new Map();
|
|
63
|
+
let totalLlm = 0;
|
|
64
|
+
for (const v of verdicts) {
|
|
65
|
+
const cost = v.cost_usd ?? 0;
|
|
66
|
+
totalLlm += cost;
|
|
67
|
+
const cur = byAgent.get(v.agent) ?? { llm: 0, runs: 0 };
|
|
68
|
+
cur.llm += cost;
|
|
69
|
+
cur.runs += 1;
|
|
70
|
+
byAgent.set(v.agent, cur);
|
|
71
|
+
}
|
|
72
|
+
// Day-level series for chart
|
|
73
|
+
const byDay = new Map();
|
|
74
|
+
for (const v of verdicts) {
|
|
75
|
+
const day = v.ts.slice(0, 10);
|
|
76
|
+
byDay.set(day, (byDay.get(day) ?? 0) + (v.cost_usd ?? 0));
|
|
77
|
+
}
|
|
78
|
+
const series = Array.from(byDay.entries())
|
|
79
|
+
.map(([date, llm]) => ({ date, llm }))
|
|
80
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
81
|
+
// Read budget
|
|
82
|
+
const projectMd = join(args.cwd, ".great_cto", "PROJECT.md");
|
|
83
|
+
const budgetMatch = existsSync(projectMd)
|
|
84
|
+
? readFileSync(projectMd, "utf8").match(/monthly[-_]budget:\s*\$?(\d[\d,]+)/i)
|
|
85
|
+
: null;
|
|
86
|
+
const budget = budgetMatch ? parseFloat(budgetMatch[1].replace(/,/g, "")) : null;
|
|
87
|
+
const totalHuman = totalLlm * RATIO;
|
|
88
|
+
return {
|
|
89
|
+
type: "cost",
|
|
90
|
+
period: args.period,
|
|
91
|
+
generated_at: new Date().toISOString(),
|
|
92
|
+
summary: {
|
|
93
|
+
total_llm_usd: +totalLlm.toFixed(4),
|
|
94
|
+
total_human_equivalent_usd: +totalHuman.toFixed(2),
|
|
95
|
+
savings_x: Math.round(RATIO),
|
|
96
|
+
savings_usd: +(totalHuman - totalLlm).toFixed(2),
|
|
97
|
+
runs: verdicts.length,
|
|
98
|
+
monthly_budget: budget,
|
|
99
|
+
pct_of_budget: budget ? +((totalLlm / budget) * 100).toFixed(1) : null,
|
|
100
|
+
},
|
|
101
|
+
by_agent: Array.from(byAgent.entries())
|
|
102
|
+
.map(([agent, { llm, runs }]) => ({
|
|
103
|
+
agent,
|
|
104
|
+
llm_usd: +llm.toFixed(4),
|
|
105
|
+
human_equivalent_usd: +(llm * RATIO).toFixed(2),
|
|
106
|
+
runs,
|
|
107
|
+
}))
|
|
108
|
+
.sort((a, b) => b.llm_usd - a.llm_usd),
|
|
109
|
+
series,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// ── Agents report ──────────────────────────────────────────────────────────
|
|
113
|
+
function buildAgentsReport(args) {
|
|
114
|
+
const cutoff = periodToCutoff(args.period);
|
|
115
|
+
const verdicts = readAllVerdicts().filter(v => v.ts >= cutoff);
|
|
116
|
+
const byAgent = new Map();
|
|
117
|
+
for (const v of verdicts) {
|
|
118
|
+
const cur = byAgent.get(v.agent) ?? { runs: 0, ok: 0, fail: 0, lastTs: "", cost: 0 };
|
|
119
|
+
cur.runs += 1;
|
|
120
|
+
const u = (v.verdict || "").toUpperCase();
|
|
121
|
+
if (["OK", "APPROVED", "DONE", "PASS", "PASSED"].includes(u))
|
|
122
|
+
cur.ok += 1;
|
|
123
|
+
else if (["FAIL", "FAILED", "BLOCKED", "REJECTED"].includes(u))
|
|
124
|
+
cur.fail += 1;
|
|
125
|
+
if (v.ts > cur.lastTs)
|
|
126
|
+
cur.lastTs = v.ts;
|
|
127
|
+
cur.cost += v.cost_usd ?? 0;
|
|
128
|
+
byAgent.set(v.agent, cur);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
type: "agents",
|
|
132
|
+
period: args.period,
|
|
133
|
+
generated_at: new Date().toISOString(),
|
|
134
|
+
summary: {
|
|
135
|
+
total_agents: byAgent.size,
|
|
136
|
+
total_runs: verdicts.length,
|
|
137
|
+
total_cost_usd: +Array.from(byAgent.values()).reduce((s, a) => s + a.cost, 0).toFixed(4),
|
|
138
|
+
},
|
|
139
|
+
agents: Array.from(byAgent.entries())
|
|
140
|
+
.map(([name, m]) => ({
|
|
141
|
+
agent: name,
|
|
142
|
+
runs: m.runs,
|
|
143
|
+
ok: m.ok,
|
|
144
|
+
fail: m.fail,
|
|
145
|
+
success_rate: m.runs ? +((m.ok / m.runs) * 100).toFixed(1) : null,
|
|
146
|
+
cost_usd: +m.cost.toFixed(4),
|
|
147
|
+
last_seen: m.lastTs,
|
|
148
|
+
}))
|
|
149
|
+
.sort((a, b) => b.runs - a.runs),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ── Compliance report ──────────────────────────────────────────────────────
|
|
153
|
+
function buildComplianceReport(args) {
|
|
154
|
+
const projectMd = join(args.cwd, ".great_cto", "PROJECT.md");
|
|
155
|
+
const meta = existsSync(projectMd) ? readFileSync(projectMd, "utf8") : "";
|
|
156
|
+
const declaredArchetype = (meta.match(/^primary:\s*(\S+)/m)?.[1] ?? "unknown").trim();
|
|
157
|
+
const archetype = args.archetype ?? declaredArchetype;
|
|
158
|
+
const compliance = (meta.match(/^compliance:\s*(.+)$/m)?.[1] ?? "")
|
|
159
|
+
.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
|
160
|
+
// Count gates from docs/security and docs/qa-reports
|
|
161
|
+
const securityDir = join(args.cwd, "docs", "security");
|
|
162
|
+
const qaDir = join(args.cwd, "docs", "qa-reports");
|
|
163
|
+
let secApproved = 0, secBlocked = 0, secTotal = 0;
|
|
164
|
+
if (existsSync(securityDir)) {
|
|
165
|
+
for (const f of readdirSync(securityDir).filter(x => x.endsWith(".md"))) {
|
|
166
|
+
secTotal += 1;
|
|
167
|
+
const text = readFileSync(join(securityDir, f), "utf8");
|
|
168
|
+
if (/APPROVED/i.test(text))
|
|
169
|
+
secApproved += 1;
|
|
170
|
+
if (/BLOCKED/i.test(text))
|
|
171
|
+
secBlocked += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let qaPass = 0, qaFail = 0, qaTotal = 0;
|
|
175
|
+
if (existsSync(qaDir)) {
|
|
176
|
+
for (const f of readdirSync(qaDir).filter(x => x.endsWith(".md"))) {
|
|
177
|
+
qaTotal += 1;
|
|
178
|
+
const text = readFileSync(join(qaDir, f), "utf8");
|
|
179
|
+
if (/(?:verdict|status|result)\s*[:=]?\s*[*_`]*\s*(?:✅|✓|pass(?:ed)?)/i.test(text))
|
|
180
|
+
qaPass += 1;
|
|
181
|
+
else if (/(?:verdict|status|result)\s*[:=]?\s*[*_`]*\s*(?:❌|✗|fail|block)/i.test(text))
|
|
182
|
+
qaFail += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
type: "compliance",
|
|
187
|
+
archetype,
|
|
188
|
+
generated_at: new Date().toISOString(),
|
|
189
|
+
declared_compliance: compliance,
|
|
190
|
+
security_gates: { total: secTotal, approved: secApproved, blocked: secBlocked },
|
|
191
|
+
qa_reports: { total: qaTotal, passed: qaPass, failed: qaFail,
|
|
192
|
+
pass_rate: qaTotal ? +((qaPass / qaTotal) * 100).toFixed(1) : null },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ── HTML rendering ─────────────────────────────────────────────────────────
|
|
196
|
+
const HTML_STYLE = `
|
|
197
|
+
:root { color-scheme: light dark; }
|
|
198
|
+
body { font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 960px; margin: 32px auto; padding: 0 24px; color: #111; background: #fafafa; }
|
|
199
|
+
@media (prefers-color-scheme: dark) { body { background: #0d0e10; color: #d6d6d6; } }
|
|
200
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
201
|
+
h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: #888; margin: 28px 0 8px; }
|
|
202
|
+
.meta { color: #888; margin-bottom: 24px; font-size: 12px; }
|
|
203
|
+
.tile-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
204
|
+
.tile { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 14px 16px; }
|
|
205
|
+
@media (prefers-color-scheme: dark) { .tile { background: #1a1c1f; border-color: #2a2c30; } }
|
|
206
|
+
.tile-num { font-size: 26px; font-weight: 600; }
|
|
207
|
+
.tile-lbl { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.06em; margin-top: 4px; }
|
|
208
|
+
.tile-sub { font-size: 12px; color: #666; margin-top: 4px; }
|
|
209
|
+
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
|
210
|
+
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }
|
|
211
|
+
@media (prefers-color-scheme: dark) { th, td { border-color: #2a2c30; } }
|
|
212
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #888; font-weight: 500; }
|
|
213
|
+
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
|
214
|
+
.bar { height: 6px; background: #eee; border-radius: 99px; overflow: hidden; min-width: 60px; }
|
|
215
|
+
@media (prefers-color-scheme: dark) { .bar { background: #2a2c30; } }
|
|
216
|
+
.bar > span { display: block; height: 100%; background: #16a34a; min-width: 2px; }
|
|
217
|
+
.footer { color: #999; font-size: 11px; margin-top: 40px; padding-top: 16px; border-top: 1px solid #eee; }
|
|
218
|
+
@media (prefers-color-scheme: dark) { .footer { border-color: #2a2c30; } }
|
|
219
|
+
.svg-chart { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 14px; margin: 8px 0 24px; }
|
|
220
|
+
@media (prefers-color-scheme: dark) { .svg-chart { background: #1a1c1f; border-color: #2a2c30; } }
|
|
221
|
+
`;
|
|
222
|
+
function fmtMoney(n) {
|
|
223
|
+
return "$" + Math.round(n).toLocaleString().replace(/,/g, " ");
|
|
224
|
+
}
|
|
225
|
+
function renderCostHtml(report) {
|
|
226
|
+
const s = report.summary;
|
|
227
|
+
const series = report.series;
|
|
228
|
+
const maxLlm = Math.max(...series.map(p => p.llm), 0.001);
|
|
229
|
+
const chartH = 120;
|
|
230
|
+
const chartW = 800;
|
|
231
|
+
const padL = 40, padR = 12, padB = 24, padT = 8;
|
|
232
|
+
const usableW = chartW - padL - padR;
|
|
233
|
+
const usableH = chartH - padT - padB;
|
|
234
|
+
const bars = series.map((p, i) => {
|
|
235
|
+
const barW = Math.max(2, usableW / Math.max(series.length, 1) - 2);
|
|
236
|
+
const x = padL + i * (usableW / Math.max(series.length, 1));
|
|
237
|
+
const h = (p.llm / maxLlm) * usableH;
|
|
238
|
+
const y = padT + (usableH - h);
|
|
239
|
+
return `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" fill="#16a34a" />`;
|
|
240
|
+
}).join("");
|
|
241
|
+
const chartSvg = `
|
|
242
|
+
<svg class="svg-chart" viewBox="0 0 ${chartW} ${chartH}" width="100%" preserveAspectRatio="none">
|
|
243
|
+
<line x1="${padL}" y1="${padT + usableH}" x2="${chartW - padR}" y2="${padT + usableH}" stroke="#999" stroke-width="0.5" />
|
|
244
|
+
${bars}
|
|
245
|
+
<text x="${padL}" y="${chartH - 4}" font-family="ui-monospace" font-size="9" fill="#888">${series[0]?.date ?? ""}</text>
|
|
246
|
+
<text x="${chartW - padR}" y="${chartH - 4}" font-family="ui-monospace" font-size="9" fill="#888" text-anchor="end">${series[series.length - 1]?.date ?? ""}</text>
|
|
247
|
+
<text x="${padL - 6}" y="${padT + 8}" font-family="ui-monospace" font-size="9" fill="#888" text-anchor="end">${fmtMoney(maxLlm)}</text>
|
|
248
|
+
<text x="${padL - 6}" y="${chartH - padB + 4}" font-family="ui-monospace" font-size="9" fill="#888" text-anchor="end">$0</text>
|
|
249
|
+
</svg>`;
|
|
250
|
+
const agentRows = report.by_agent.map(a => `
|
|
251
|
+
<tr>
|
|
252
|
+
<td>${escapeHtml(a.agent)}</td>
|
|
253
|
+
<td class="num">${a.runs}</td>
|
|
254
|
+
<td class="num">${fmtMoney(a.llm_usd)}</td>
|
|
255
|
+
<td class="num">${fmtMoney(a.human_equivalent_usd)}</td>
|
|
256
|
+
<td><span class="bar"><span style="width:${(a.llm_usd / Math.max(report.by_agent[0]?.llm_usd || 1, 0.0001) * 100).toFixed(1)}%"></span></span></td>
|
|
257
|
+
</tr>`).join("");
|
|
258
|
+
return `<!doctype html>
|
|
259
|
+
<html lang="en">
|
|
260
|
+
<head>
|
|
261
|
+
<meta charset="utf-8">
|
|
262
|
+
<title>great-cto cost report — ${escapeHtml(report.period)}</title>
|
|
263
|
+
<style>${HTML_STYLE}</style>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<h1>Cost report — last ${escapeHtml(report.period)}</h1>
|
|
267
|
+
<div class="meta">Generated ${escapeHtml(report.generated_at)} · ${s.runs} agent run(s) · period: ${escapeHtml(report.period)}</div>
|
|
268
|
+
|
|
269
|
+
<div class="tile-row">
|
|
270
|
+
<div class="tile"><div class="tile-num">${fmtMoney(s.total_llm_usd)}</div><div class="tile-lbl">LLM spend</div></div>
|
|
271
|
+
<div class="tile"><div class="tile-num">${fmtMoney(s.total_human_equivalent_usd)}</div><div class="tile-lbl">vs human team</div></div>
|
|
272
|
+
<div class="tile"><div class="tile-num">${s.savings_x}×</div><div class="tile-lbl">cost ratio</div><div class="tile-sub">saved ${fmtMoney(s.savings_usd)}</div></div>
|
|
273
|
+
<div class="tile"><div class="tile-num">${s.pct_of_budget != null ? s.pct_of_budget + "%" : "—"}</div><div class="tile-lbl">of monthly budget</div><div class="tile-sub">${s.monthly_budget != null ? "budget: " + fmtMoney(s.monthly_budget) : "(no budget set)"}</div></div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<h2>Daily LLM spend</h2>
|
|
277
|
+
${chartSvg}
|
|
278
|
+
|
|
279
|
+
<h2>Per-agent breakdown</h2>
|
|
280
|
+
<table>
|
|
281
|
+
<thead><tr><th>Agent</th><th class="num">Runs</th><th class="num">LLM cost</th><th class="num">Human equiv.</th><th>Share</th></tr></thead>
|
|
282
|
+
<tbody>${agentRows}</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
|
|
285
|
+
<div class="footer">
|
|
286
|
+
Report generated by great-cto. LLM cost ratio model: $0.02/AI-hour vs $150/human-hour (~7500×).
|
|
287
|
+
Source: ~/.great_cto/verdicts/*.log + .great_cto/PROJECT.md.
|
|
288
|
+
</div>
|
|
289
|
+
</body>
|
|
290
|
+
</html>
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
function renderAgentsHtml(report) {
|
|
294
|
+
const s = report.summary;
|
|
295
|
+
const rows = report.agents.map(a => `
|
|
296
|
+
<tr>
|
|
297
|
+
<td>${escapeHtml(a.agent)}</td>
|
|
298
|
+
<td class="num">${a.runs}</td>
|
|
299
|
+
<td class="num">${a.success_rate != null ? a.success_rate + "%" : "—"}</td>
|
|
300
|
+
<td class="num">${a.ok}</td>
|
|
301
|
+
<td class="num">${a.fail}</td>
|
|
302
|
+
<td class="num">${fmtMoney(a.cost_usd)}</td>
|
|
303
|
+
<td>${a.last_seen ? escapeHtml(a.last_seen.slice(0, 10)) : "—"}</td>
|
|
304
|
+
</tr>`).join("");
|
|
305
|
+
return `<!doctype html>
|
|
306
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
307
|
+
<title>great-cto agents report</title><style>${HTML_STYLE}</style></head>
|
|
308
|
+
<body>
|
|
309
|
+
<h1>Agents performance — last ${escapeHtml(report.period)}</h1>
|
|
310
|
+
<div class="meta">Generated ${escapeHtml(report.generated_at)} · ${s.total_agents} agent(s) · ${s.total_runs} run(s)</div>
|
|
311
|
+
<div class="tile-row">
|
|
312
|
+
<div class="tile"><div class="tile-num">${s.total_agents}</div><div class="tile-lbl">Active agents</div></div>
|
|
313
|
+
<div class="tile"><div class="tile-num">${s.total_runs}</div><div class="tile-lbl">Total runs</div></div>
|
|
314
|
+
<div class="tile"><div class="tile-num">${fmtMoney(s.total_cost_usd)}</div><div class="tile-lbl">Total cost</div></div>
|
|
315
|
+
</div>
|
|
316
|
+
<table>
|
|
317
|
+
<thead><tr><th>Agent</th><th class="num">Runs</th><th class="num">Success rate</th><th class="num">OK</th><th class="num">Fail</th><th class="num">Cost</th><th>Last seen</th></tr></thead>
|
|
318
|
+
<tbody>${rows}</tbody>
|
|
319
|
+
</table>
|
|
320
|
+
<div class="footer">Source: ~/.great_cto/verdicts/*.log</div>
|
|
321
|
+
</body></html>`;
|
|
322
|
+
}
|
|
323
|
+
function renderComplianceHtml(report) {
|
|
324
|
+
const sg = report.security_gates;
|
|
325
|
+
const qa = report.qa_reports;
|
|
326
|
+
const compRows = report.declared_compliance
|
|
327
|
+
.map(c => `<li>${escapeHtml(c)}</li>`)
|
|
328
|
+
.join("");
|
|
329
|
+
return `<!doctype html>
|
|
330
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
331
|
+
<title>great-cto compliance report</title><style>${HTML_STYLE}</style></head>
|
|
332
|
+
<body>
|
|
333
|
+
<h1>Compliance posture — ${escapeHtml(report.archetype)}</h1>
|
|
334
|
+
<div class="meta">Generated ${escapeHtml(report.generated_at)}</div>
|
|
335
|
+
|
|
336
|
+
<div class="tile-row">
|
|
337
|
+
<div class="tile"><div class="tile-num">${sg.total}</div><div class="tile-lbl">Security signoffs</div><div class="tile-sub">${sg.approved} approved · ${sg.blocked} blocked</div></div>
|
|
338
|
+
<div class="tile"><div class="tile-num">${qa.total}</div><div class="tile-lbl">QA reports</div><div class="tile-sub">${qa.pass_rate != null ? qa.pass_rate + "% pass rate" : "(no data)"}</div></div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<h2>Declared compliance gates</h2>
|
|
342
|
+
${compRows ? `<ul>${compRows}</ul>` : "<p>No compliance gates declared in PROJECT.md.</p>"}
|
|
343
|
+
|
|
344
|
+
<h2>Audit trail</h2>
|
|
345
|
+
<table>
|
|
346
|
+
<tr><td>Security signoffs</td><td class="num">${sg.approved}/${sg.total} approved</td></tr>
|
|
347
|
+
<tr><td>QA reports</td><td class="num">${qa.passed}/${qa.total} passed</td></tr>
|
|
348
|
+
</table>
|
|
349
|
+
|
|
350
|
+
<div class="footer">Source: docs/security/CSO-*.md, docs/qa-reports/QA-*.md, .great_cto/PROJECT.md</div>
|
|
351
|
+
</body></html>`;
|
|
352
|
+
}
|
|
353
|
+
function escapeHtml(s) {
|
|
354
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
355
|
+
}
|
|
356
|
+
// ── Main entry ─────────────────────────────────────────────────────────────
|
|
357
|
+
export async function runReport(args) {
|
|
358
|
+
let report;
|
|
359
|
+
try {
|
|
360
|
+
if (args.type === "cost")
|
|
361
|
+
report = buildCostReport(args);
|
|
362
|
+
else if (args.type === "agents")
|
|
363
|
+
report = buildAgentsReport(args);
|
|
364
|
+
else if (args.type === "compliance")
|
|
365
|
+
report = buildComplianceReport(args);
|
|
366
|
+
else {
|
|
367
|
+
console.error(`unknown report type: ${args.type}`);
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
console.error(`report failed: ${e.message}`);
|
|
373
|
+
return 2;
|
|
374
|
+
}
|
|
375
|
+
if (args.format === "json") {
|
|
376
|
+
console.log(JSON.stringify(report, null, 2));
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
let html;
|
|
380
|
+
if (args.type === "cost")
|
|
381
|
+
html = renderCostHtml(report);
|
|
382
|
+
else if (args.type === "agents")
|
|
383
|
+
html = renderAgentsHtml(report);
|
|
384
|
+
else
|
|
385
|
+
html = renderComplianceHtml(report);
|
|
386
|
+
console.log(html);
|
|
387
|
+
}
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
export function parseReportArgs(rawArgv, cwd) {
|
|
391
|
+
const idx = rawArgv.indexOf("report");
|
|
392
|
+
if (idx === -1)
|
|
393
|
+
return null;
|
|
394
|
+
const type = rawArgv[idx + 1];
|
|
395
|
+
if (!["cost", "agents", "compliance"].includes(type)) {
|
|
396
|
+
console.error(`great-cto report: type must be cost|agents|compliance (got: ${type ?? "<missing>"})`);
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const flag = (n, def) => {
|
|
400
|
+
const i = rawArgv.indexOf(`--${n}`);
|
|
401
|
+
return i >= 0 && i < rawArgv.length - 1 ? rawArgv[i + 1] : def;
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
type,
|
|
405
|
+
format: flag("format", "html"),
|
|
406
|
+
period: flag("period", "30d"),
|
|
407
|
+
archetype: flag("archetype") ?? null,
|
|
408
|
+
cwd,
|
|
409
|
+
};
|
|
410
|
+
}
|