great-cto 2.4.0 → 2.5.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.
@@ -54,21 +54,46 @@ function* walk(root, exclude) {
54
54
  function fileMatchesGlobs(file, globs) {
55
55
  if (!globs || globs.length === 0)
56
56
  return true;
57
- // Tiny glob regex. Convert globs in two passes:
58
- // 1. Replace ** and * with sentinel placeholders.
59
- // 2. Escape remaining regex metachars.
60
- // 3. Replace placeholders with their regex equivalents.
57
+ // Normalize path separators for cross-platform matching
58
+ const normalized = file.replace(/\\/g, '/');
61
59
  return globs.some((g) => {
62
- const pattern = g
63
- .replace(/\*\*/g, '') // ** SOH
64
- .replace(/\*/g, '') // * → STX
65
- .replace(/\?/g, '') // ? → ETX
66
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
67
- .replace(//g, '.*')
68
- .replace(//g, '[^/]*')
69
- .replace(//g, '.');
60
+ // Token-based glob → regex conversion. Walks character-by-character to
61
+ // avoid the substitution-order pitfalls of multiple replace passes.
62
+ // Treats `**/` as "zero or more path segments" — so `**/*.ts` matches
63
+ // both `foo.ts` (root) and `src/lib/foo.ts` (nested).
64
+ let re = '';
65
+ for (let i = 0; i < g.length; i++) {
66
+ const c = g[i];
67
+ if (c === '*' && g[i + 1] === '*') {
68
+ // ** consumes the trailing /, so `**/x` becomes `(?:.*\/)?x` not `.*\/x`
69
+ if (g[i + 2] === '/') {
70
+ re += '(?:.*\\/)?';
71
+ i += 2;
72
+ }
73
+ else {
74
+ re += '.*';
75
+ i++;
76
+ }
77
+ }
78
+ else if (c === '*') {
79
+ re += '[^/]*';
80
+ }
81
+ else if (c === '?') {
82
+ re += '.';
83
+ }
84
+ else if ('.+^${}()|[]\\'.includes(c)) {
85
+ re += '\\' + c;
86
+ }
87
+ else if (c === '/') {
88
+ re += '/';
89
+ }
90
+ else {
91
+ re += c;
92
+ }
93
+ }
70
94
  try {
71
- return new RegExp(pattern).test(file);
95
+ // Match suffix — `src/foo.ts` matches `**/*.ts` regardless of cwd
96
+ return new RegExp('(?:^|/)' + re + '$').test(normalized);
72
97
  }
73
98
  catch {
74
99
  return false;
package/dist/main.js CHANGED
@@ -91,6 +91,10 @@ function parseArgs(argv) {
91
91
  args.command = "adapt";
92
92
  else if (a === "serve")
93
93
  args.command = "serve";
94
+ else if (a === "webhook")
95
+ args.command = "webhook";
96
+ else if (a === "report")
97
+ args.command = "report";
94
98
  else if (a.startsWith("--dir="))
95
99
  args.dir = a.slice("--dir=".length);
96
100
  else if (a === "--dir")
@@ -823,6 +827,7 @@ async function main() {
823
827
  const code = await runServe({
824
828
  port: args.boardPort === 3141 ? 3142 : args.boardPort,
825
829
  noLog: rawArgv.includes("--no-log"),
830
+ insecure: rawArgv.includes("--insecure"),
826
831
  });
827
832
  process.exit(code);
828
833
  }
@@ -831,6 +836,37 @@ async function main() {
831
836
  process.exit(2);
832
837
  }
833
838
  }
839
+ if (args.command === "webhook") {
840
+ try {
841
+ const { runWebhookCli, parseWebhookArgs } = await import("./webhook-cli.js");
842
+ const parsed = parseWebhookArgs(rawArgv);
843
+ if (!parsed) {
844
+ error("usage: great-cto webhook list | add-incoming <name> --secret <s> | add-outgoing <name> --url <u> --format <f> --triggers <t1,t2> | remove <name> | test <name>");
845
+ process.exit(2);
846
+ }
847
+ const code = await runWebhookCli(parsed);
848
+ process.exit(code);
849
+ }
850
+ catch (e) {
851
+ error(e.message);
852
+ process.exit(2);
853
+ }
854
+ }
855
+ if (args.command === "report") {
856
+ try {
857
+ const { runReport, parseReportArgs } = await import("./report.js");
858
+ const parsed = parseReportArgs(rawArgv, args.dir);
859
+ if (!parsed) {
860
+ process.exit(2);
861
+ }
862
+ const code = await runReport(parsed);
863
+ process.exit(code);
864
+ }
865
+ catch (e) {
866
+ error(e.message);
867
+ process.exit(2);
868
+ }
869
+ }
834
870
  if (args.command === "version") {
835
871
  // Version resolved in index.mjs or from package.json at runtime
836
872
  try {
package/dist/mcp.js CHANGED
@@ -245,6 +245,75 @@ async function handle(req) {
245
245
  return fail(-32603, `Internal error: ${e.message}`);
246
246
  }
247
247
  }
248
+ // ── SSE transport ──────────────────────────────────────────────────────────
249
+ async function runSse(port, version) {
250
+ const { createServer } = await import("node:http");
251
+ // Each connection gets a unique session id and an open SSE stream.
252
+ // Inbound JSON-RPC arrives via POST /message?sessionId=<id>; responses are
253
+ // pushed back over the SSE stream. This matches the standard MCP SSE
254
+ // transport (https://spec.modelcontextprotocol.io/specification/transports).
255
+ const sessions = new Map();
256
+ const server = createServer(async (req, res) => {
257
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
258
+ if (req.method === "GET" && url.pathname === "/sse") {
259
+ const sessionId = `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
260
+ res.writeHead(200, {
261
+ "Content-Type": "text/event-stream",
262
+ "Cache-Control": "no-cache, no-transform",
263
+ Connection: "keep-alive",
264
+ "X-Accel-Buffering": "no",
265
+ });
266
+ // Initial endpoint event — tells client where to POST messages
267
+ res.write(`event: endpoint\ndata: /message?sessionId=${sessionId}\n\n`);
268
+ sessions.set(sessionId, res);
269
+ req.on("close", () => sessions.delete(sessionId));
270
+ return;
271
+ }
272
+ if (req.method === "POST" && url.pathname === "/message") {
273
+ const sessionId = url.searchParams.get("sessionId") ?? "";
274
+ const sse = sessions.get(sessionId);
275
+ if (!sse) {
276
+ res.writeHead(404, { "Content-Type": "application/json" });
277
+ res.end(JSON.stringify({ error: "unknown sessionId" }));
278
+ return;
279
+ }
280
+ const chunks = [];
281
+ req.on("data", c => chunks.push(Buffer.from(c)));
282
+ req.on("end", async () => {
283
+ const body = Buffer.concat(chunks).toString("utf8");
284
+ try {
285
+ const reqJson = JSON.parse(body);
286
+ const reply = await handle(reqJson);
287
+ if (reply) {
288
+ sse.write(`event: message\ndata: ${JSON.stringify(reply)}\n\n`);
289
+ }
290
+ res.writeHead(202).end();
291
+ }
292
+ catch (e) {
293
+ res.writeHead(400, { "Content-Type": "application/json" });
294
+ res.end(JSON.stringify({ error: e.message }));
295
+ }
296
+ });
297
+ return;
298
+ }
299
+ if (req.method === "GET" && url.pathname === "/healthz") {
300
+ res.writeHead(200, { "Content-Type": "application/json" });
301
+ res.end(JSON.stringify({ ok: true, version, sessions: sessions.size, transport: "sse" }));
302
+ return;
303
+ }
304
+ res.writeHead(404).end();
305
+ });
306
+ return new Promise(resolve => {
307
+ server.listen(port, "127.0.0.1", () => {
308
+ process.stderr.write(`great-cto mcp v${version} (sse) → http://localhost:${port}/sse\n`);
309
+ process.stderr.write(` GET /sse open event stream\n`);
310
+ process.stderr.write(` POST /message?sessionId=... send JSON-RPC\n`);
311
+ process.stderr.write(` GET /healthz liveness\n`);
312
+ });
313
+ process.on("SIGINT", () => { server.close(); resolve(0); });
314
+ process.on("SIGTERM", () => { server.close(); resolve(0); });
315
+ });
316
+ }
248
317
  // ── stdio transport ────────────────────────────────────────────────────────
249
318
  async function runStdio() {
250
319
  // Read newline-delimited JSON from stdin, write to stdout. This is the
@@ -278,8 +347,7 @@ async function runStdio() {
278
347
  export async function runMcp(args) {
279
348
  SERVER_INFO.version = args.version;
280
349
  if (args.mode === "sse") {
281
- process.stderr.write("great-cto mcp: SSE mode not yet implemented (use --stdio)\n");
282
- return 2;
350
+ return runSse(args.port, args.version);
283
351
  }
284
352
  // Notify clients we're ready (some hosts log this)
285
353
  process.stderr.write(`great-cto mcp v${args.version} (stdio) — ${TOOLS.length} tools\n`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }
package/dist/serve.js CHANGED
@@ -1,26 +1,30 @@
1
- // great-cto serve — webhook receiver + outbound notifier (scaffolding).
1
+ // great-cto serve — webhook receiver with HMAC verification, retry, DLQ.
2
+ // v2.5.0 production-grade upgrade.
2
3
  //
3
- // v2.4.0 ships scaffolding + a single working endpoint (POST /webhook/github
4
- // run scan, log event). Signature verification, retry/DLQ, and outbound
5
- // integrations land in v2.5.0.
4
+ // Incoming endpoints:
5
+ // POST /webhook/github GitHub events with X-Hub-Signature-256 verification
6
+ // POST /webhook/sentry Sentry alerts (HMAC via X-Sentry-Signature-256)
7
+ // POST /webhook/generic Generic JSON, optional shared-secret verification
8
+ // GET /events Recent event log (last 50)
9
+ // GET /healthz Liveness probe
10
+ // GET /dlq Recent dead-lettered outbound deliveries
6
11
  //
7
- // Usage:
8
- // great-cto serve [--port 3142] [--no-log]
12
+ // HMAC verification is REQUIRED unless GREATCTO_WEBHOOK_INSECURE=1 is set
13
+ // (intended only for local dev). Configure secrets via:
14
+ // great-cto webhook add-incoming github --secret <hmac-secret>
9
15
  //
10
- // Endpoints:
11
- // POST /webhook/github GitHub event receiver (pull_request.opened
12
- // runs scan on PR head, persists summary)
13
- // POST /webhook/generic Catch-all for ad-hoc integrations. Body persisted
14
- // to ~/.great_cto/webhook-events.log (JSONL).
15
- // GET /healthz Liveness probe
16
- // GET /events Recent event log (last 50)
17
- //
18
- // All events are appended to ~/.great_cto/webhook-events.log as JSONL with
19
- // fields: ts, source, event_type, payload_summary, action_taken.
16
+ // Outbound dispatch fires automatically on certain incoming events:
17
+ // github.pull_request.opened "pr.opened" event
18
+ // github.issues.opened → "issue.opened" event
19
+ // sentry.event_alert → "incident.p0" event (severity-mapped)
20
+ // Each registered outgoing hook listens to a subset via its triggers list.
20
21
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
22
+ import { createHmac, timingSafeEqual } from "node:crypto";
21
23
  import { createServer } from "node:http";
22
24
  import { homedir } from "node:os";
23
25
  import { join } from "node:path";
26
+ import { dispatch, getDlqPath } from "./webhook-dispatch.js";
27
+ import { getIncoming } from "./webhook-config.js";
24
28
  const EVENTS_LOG = join(homedir(), ".great_cto", "webhook-events.log");
25
29
  function logEvent(ev, noLog) {
26
30
  if (noLog)
@@ -37,9 +41,9 @@ function logEvent(ev, noLog) {
37
41
  }
38
42
  function readBody(req) {
39
43
  return new Promise((resolve, reject) => {
40
- let data = "";
41
- req.on("data", chunk => (data += chunk));
42
- req.on("end", () => resolve(data));
44
+ const chunks = [];
45
+ req.on("data", chunk => chunks.push(Buffer.from(chunk)));
46
+ req.on("end", () => resolve(Buffer.concat(chunks)));
43
47
  req.on("error", reject);
44
48
  });
45
49
  }
@@ -47,40 +51,149 @@ function json(res, status, body) {
47
51
  res.writeHead(status, { "Content-Type": "application/json" });
48
52
  res.end(JSON.stringify(body));
49
53
  }
54
+ // ── HMAC verification ──────────────────────────────────────────────────────
55
+ /**
56
+ * Constant-time HMAC-SHA256 verification. Returns true if signatures match.
57
+ * GitHub format: "sha256=<hex>"
58
+ * Sentry format: "<hex>" (just the digest)
59
+ * Generic format: either accepted
60
+ */
61
+ function verifyHmac(secret, body, headerValue) {
62
+ if (!headerValue)
63
+ return false;
64
+ // Strip "sha256=" prefix if present
65
+ const expected = headerValue.startsWith("sha256=") ? headerValue.slice(7) : headerValue;
66
+ const computed = createHmac("sha256", secret).update(body).digest("hex");
67
+ if (expected.length !== computed.length)
68
+ return false;
69
+ try {
70
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(computed, "hex"));
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
50
76
  // ── Endpoint handlers ──────────────────────────────────────────────────────
51
77
  async function handleGitHub(req, res, args) {
52
78
  const body = await readBody(req);
53
79
  const eventType = req.headers["x-github-event"] ?? "unknown";
54
80
  const deliveryId = req.headers["x-github-delivery"] ?? "no-id";
81
+ const signature = req.headers["x-hub-signature-256"];
82
+ // HMAC verification
83
+ if (!args.insecure) {
84
+ const cfg = getIncoming("github");
85
+ if (!cfg?.secret) {
86
+ json(res, 401, {
87
+ error: "github webhook not configured",
88
+ hint: "run: great-cto webhook add-incoming github --secret <hmac-secret>",
89
+ });
90
+ return;
91
+ }
92
+ if (!verifyHmac(cfg.secret, body, signature)) {
93
+ json(res, 401, { error: "invalid signature" });
94
+ return;
95
+ }
96
+ }
55
97
  let payload;
56
98
  try {
57
- payload = JSON.parse(body);
99
+ payload = JSON.parse(body.toString("utf8"));
58
100
  }
59
101
  catch {
60
102
  json(res, 400, { error: "invalid JSON" });
61
103
  return;
62
104
  }
63
- // Currently we just record the event. Scan-on-PR lands in v2.5.0 with
64
- // proper signature verification and clone-and-scan flow.
105
+ const action = payload.action ?? "?";
65
106
  const summary = eventType === "pull_request"
66
- ? `${payload.action ?? "?"} PR #${payload.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
67
- : `${eventType} delivery=${deliveryId}`;
68
- const ev = {
107
+ ? `${action} PR #${payload.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
108
+ : eventType === "issues"
109
+ ? `${action} issue #${payload.issue?.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
110
+ : `${eventType} delivery=${deliveryId}`;
111
+ // Outbound dispatch — map GitHub events to internal trigger names
112
+ let outboundFired = 0;
113
+ if (eventType === "pull_request" && action === "opened") {
114
+ outboundFired = dispatch({
115
+ name: "pr.opened",
116
+ level: "info",
117
+ title: `PR opened: #${payload.number} in ${payload.repository?.full_name}`,
118
+ body: payload.pull_request?.title ?? "",
119
+ meta: { url: payload.pull_request?.html_url, author: payload.sender?.login },
120
+ }).fired;
121
+ }
122
+ else if (eventType === "issues" && action === "opened") {
123
+ outboundFired = dispatch({
124
+ name: "issue.opened",
125
+ level: "info",
126
+ title: `Issue opened: #${payload.issue.number} in ${payload.repository?.full_name}`,
127
+ body: payload.issue?.title ?? "",
128
+ meta: { url: payload.issue?.html_url, author: payload.sender?.login },
129
+ }).fired;
130
+ }
131
+ logEvent({
69
132
  ts: new Date().toISOString(),
70
133
  source: "github",
71
134
  event_type: eventType,
72
135
  summary,
73
- action_taken: "logged",
136
+ action_taken: outboundFired > 0 ? `dispatched to ${outboundFired} outbound hook(s)` : "logged",
74
137
  meta: { delivery_id: deliveryId, pr_number: payload.number, action: payload.action },
75
- };
76
- logEvent(ev, args.noLog);
77
- json(res, 200, { ok: true, event_type: eventType, recorded: true });
138
+ }, args.noLog);
139
+ json(res, 200, { ok: true, event_type: eventType, dispatched_to: outboundFired });
140
+ }
141
+ async function handleSentry(req, res, args) {
142
+ const body = await readBody(req);
143
+ const signature = req.headers["x-sentry-signature-256"];
144
+ if (!args.insecure) {
145
+ const cfg = getIncoming("sentry");
146
+ if (!cfg?.secret) {
147
+ json(res, 401, { error: "sentry webhook not configured" });
148
+ return;
149
+ }
150
+ if (!verifyHmac(cfg.secret, body, signature)) {
151
+ json(res, 401, { error: "invalid signature" });
152
+ return;
153
+ }
154
+ }
155
+ let payload;
156
+ try {
157
+ payload = JSON.parse(body.toString("utf8"));
158
+ }
159
+ catch {
160
+ json(res, 400, { error: "invalid JSON" });
161
+ return;
162
+ }
163
+ // Sentry events typically include event.level: 'fatal' | 'error' | 'warning'
164
+ const level = payload?.event?.level ?? payload?.level ?? "warning";
165
+ const title = payload?.event?.title ?? payload?.title ?? "Sentry alert";
166
+ const isP0 = level === "fatal" || level === "critical";
167
+ const fired = dispatch({
168
+ name: isP0 ? "incident.p0" : "incident.alert",
169
+ level: isP0 ? "critical" : "error",
170
+ title,
171
+ body: payload?.event?.metadata?.value ?? "",
172
+ meta: { url: payload?.url, project: payload?.project_slug },
173
+ }).fired;
174
+ logEvent({
175
+ ts: new Date().toISOString(),
176
+ source: "sentry",
177
+ event_type: isP0 ? "p0" : "alert",
178
+ summary: title,
179
+ action_taken: `dispatched to ${fired} outbound hook(s)`,
180
+ meta: { level, url: payload?.url },
181
+ }, args.noLog);
182
+ json(res, 200, { ok: true, dispatched_to: fired });
78
183
  }
79
184
  async function handleGeneric(req, res, args) {
80
185
  const body = await readBody(req);
81
- let payload = body;
186
+ const signature = req.headers["x-greatcto-signature-256"];
187
+ if (!args.insecure) {
188
+ const cfg = getIncoming("generic");
189
+ if (cfg?.secret && !verifyHmac(cfg.secret, body, signature)) {
190
+ json(res, 401, { error: "invalid signature" });
191
+ return;
192
+ }
193
+ }
194
+ let payload = body.toString("utf8");
82
195
  try {
83
- payload = JSON.parse(body);
196
+ payload = JSON.parse(payload);
84
197
  }
85
198
  catch {
86
199
  /* keep as raw string */
@@ -91,7 +204,7 @@ async function handleGeneric(req, res, args) {
91
204
  event_type: "incoming",
92
205
  summary: `payload ${body.length} bytes`,
93
206
  action_taken: "logged",
94
- meta: { payload_preview: String(body).slice(0, 200) },
207
+ meta: { payload_preview: String(body.toString("utf8")).slice(0, 200) },
95
208
  };
96
209
  logEvent(ev, args.noLog);
97
210
  json(res, 200, { ok: true, recorded: true });
@@ -106,53 +219,71 @@ function handleEvents(_req, res) {
106
219
  .filter(Boolean)
107
220
  .slice(-50);
108
221
  const events = lines
109
- .map(l => {
110
- try {
111
- return JSON.parse(l);
112
- }
113
- catch {
114
- return null;
115
- }
116
- })
222
+ .map(l => { try {
223
+ return JSON.parse(l);
224
+ }
225
+ catch {
226
+ return null;
227
+ } })
117
228
  .filter(Boolean);
118
229
  json(res, 200, { events });
119
230
  }
231
+ function handleDlq(_req, res) {
232
+ const dlq = getDlqPath();
233
+ if (!existsSync(dlq)) {
234
+ json(res, 200, { dlq: [] });
235
+ return;
236
+ }
237
+ const lines = readFileSync(dlq, "utf8").split("\n").filter(Boolean).slice(-50);
238
+ const events = lines
239
+ .map(l => { try {
240
+ return JSON.parse(l);
241
+ }
242
+ catch {
243
+ return null;
244
+ } })
245
+ .filter(Boolean);
246
+ json(res, 200, { dlq: events });
247
+ }
120
248
  // ── Main entry ─────────────────────────────────────────────────────────────
121
249
  export async function runServe(args) {
250
+ const insecure = args.insecure ?? process.env.GREATCTO_WEBHOOK_INSECURE === "1";
251
+ const finalArgs = { ...args, insecure };
122
252
  const server = createServer(async (req, res) => {
123
253
  const url = new URL(req.url ?? "/", `http://localhost:${args.port}`);
124
254
  const path = url.pathname;
125
- // Healthz
126
255
  if (req.method === "GET" && path === "/healthz") {
127
- return json(res, 200, { ok: true, service: "great-cto serve", events_log: EVENTS_LOG });
256
+ return json(res, 200, { ok: true, service: "great-cto serve", insecure });
128
257
  }
129
258
  if (req.method === "GET" && path === "/events") {
130
259
  return handleEvents(req, res);
131
260
  }
261
+ if (req.method === "GET" && path === "/dlq") {
262
+ return handleDlq(req, res);
263
+ }
132
264
  if (req.method === "POST" && path === "/webhook/github") {
133
- return handleGitHub(req, res, args);
265
+ return handleGitHub(req, res, finalArgs);
266
+ }
267
+ if (req.method === "POST" && path === "/webhook/sentry") {
268
+ return handleSentry(req, res, finalArgs);
134
269
  }
135
270
  if (req.method === "POST" && path === "/webhook/generic") {
136
- return handleGeneric(req, res, args);
271
+ return handleGeneric(req, res, finalArgs);
137
272
  }
138
273
  json(res, 404, { error: "not found", path });
139
274
  });
140
275
  return new Promise(resolve => {
141
276
  server.listen(args.port, "127.0.0.1", () => {
142
- console.error(`great-cto serve → http://localhost:${args.port}`);
143
- console.error(` POST /webhook/github GitHub event receiver`);
144
- console.error(` POST /webhook/generic Catch-all`);
145
- console.error(` GET /events Recent event log`);
146
- console.error(` GET /healthz Liveness probe`);
277
+ console.error(`great-cto serve → http://localhost:${args.port}${insecure ? " [INSECURE: HMAC OFF]" : ""}`);
278
+ console.error(` POST /webhook/github GitHub (HMAC SHA-256)`);
279
+ console.error(` POST /webhook/sentry Sentry (HMAC SHA-256)`);
280
+ console.error(` POST /webhook/generic Generic (optional HMAC)`);
281
+ console.error(` GET /events Recent event log`);
282
+ console.error(` GET /dlq Dead-letter queue`);
283
+ console.error(` GET /healthz Liveness probe`);
147
284
  console.error(` log: ${EVENTS_LOG}`);
148
285
  });
149
- process.on("SIGINT", () => {
150
- server.close();
151
- resolve(0);
152
- });
153
- process.on("SIGTERM", () => {
154
- server.close();
155
- resolve(0);
156
- });
286
+ process.on("SIGINT", () => { server.close(); resolve(0); });
287
+ process.on("SIGTERM", () => { server.close(); resolve(0); });
157
288
  });
158
289
  }
@@ -0,0 +1,150 @@
1
+ // great-cto webhook — manage incoming/outgoing webhook configuration.
2
+ //
3
+ // Usage:
4
+ // great-cto webhook list
5
+ // great-cto webhook add-incoming <name> --secret <hmac> [--events e1,e2]
6
+ // great-cto webhook add-outgoing <name> --url <url> --format slack|discord|pagerduty|generic --triggers t1,t2
7
+ // great-cto webhook remove <name>
8
+ // great-cto webhook test <name> (sends a test event through dispatcher)
9
+ import { loadConfig, addIncoming, addOutgoing, removeHook, getConfigPath, } from "./webhook-config.js";
10
+ import { dispatch } from "./webhook-dispatch.js";
11
+ export async function runWebhookCli(args) {
12
+ switch (args.action) {
13
+ case "list": {
14
+ const cfg = loadConfig();
15
+ console.log(`config: ${getConfigPath()}\n`);
16
+ console.log(`Incoming hooks (${cfg.incoming.length}):`);
17
+ if (cfg.incoming.length === 0)
18
+ console.log(" (none)");
19
+ for (const h of cfg.incoming) {
20
+ const sec = h.secret ? `[secret: ${h.secret.slice(0, 4)}...${h.secret.slice(-2)}]` : "[NO SECRET — INSECURE]";
21
+ console.log(` - ${h.name} ${sec} events=${h.events?.join(",") || "all"}`);
22
+ }
23
+ console.log(`\nOutgoing hooks (${cfg.outgoing.length}):`);
24
+ if (cfg.outgoing.length === 0)
25
+ console.log(" (none)");
26
+ for (const h of cfg.outgoing) {
27
+ const url = h.url.length > 60 ? h.url.slice(0, 57) + "..." : h.url;
28
+ console.log(` - ${h.name} [${h.format}] triggers=${h.triggers.join(",")}\n ${url}`);
29
+ }
30
+ return 0;
31
+ }
32
+ case "add-incoming": {
33
+ if (!args.name) {
34
+ console.error("FAIL: --name required");
35
+ return 2;
36
+ }
37
+ if (!args.secret) {
38
+ console.error("WARN: no --secret provided. Webhooks will only work in --insecure mode.");
39
+ }
40
+ addIncoming({
41
+ name: args.name,
42
+ secret: args.secret,
43
+ events: args.events,
44
+ });
45
+ console.log(`✓ added incoming hook "${args.name}"`);
46
+ return 0;
47
+ }
48
+ case "add-outgoing": {
49
+ if (!args.name) {
50
+ console.error("FAIL: --name required");
51
+ return 2;
52
+ }
53
+ if (!args.url) {
54
+ console.error("FAIL: --url required");
55
+ return 2;
56
+ }
57
+ if (!args.format) {
58
+ console.error("FAIL: --format required");
59
+ return 2;
60
+ }
61
+ if (!args.triggers || args.triggers.length === 0) {
62
+ console.error("FAIL: --triggers required (comma-separated event names)");
63
+ return 2;
64
+ }
65
+ const headers = {};
66
+ if (args.routingKey)
67
+ headers.routing_key = args.routingKey;
68
+ addOutgoing({
69
+ name: args.name,
70
+ url: args.url,
71
+ format: args.format,
72
+ triggers: args.triggers,
73
+ headers: Object.keys(headers).length ? headers : undefined,
74
+ });
75
+ console.log(`✓ added outgoing hook "${args.name}" (${args.format}) → ${args.triggers.join(", ")}`);
76
+ return 0;
77
+ }
78
+ case "remove": {
79
+ if (!args.name) {
80
+ console.error("FAIL: --name required");
81
+ return 2;
82
+ }
83
+ const removed = removeHook(args.name);
84
+ if (removed) {
85
+ console.log(`✓ removed hook "${args.name}"`);
86
+ return 0;
87
+ }
88
+ console.error(`hook not found: ${args.name}`);
89
+ return 1;
90
+ }
91
+ case "test": {
92
+ if (!args.name) {
93
+ console.error("FAIL: --name required");
94
+ return 2;
95
+ }
96
+ const cfg = loadConfig();
97
+ const hook = cfg.outgoing.find(h => h.name === args.name);
98
+ if (!hook) {
99
+ console.error(`outgoing hook not found: ${args.name}`);
100
+ return 1;
101
+ }
102
+ const result = dispatch({
103
+ name: hook.triggers[0] ?? "test.event",
104
+ level: "info",
105
+ title: "great-cto webhook test",
106
+ body: "If you see this, your webhook is correctly configured.",
107
+ meta: { test: true, timestamp: new Date().toISOString() },
108
+ });
109
+ console.log(`✓ test event dispatched to ${result.fired} hook(s)`);
110
+ console.log(` (delivery is async — check destination shortly; check ~/.great_cto/webhook-dlq.log if it doesn't arrive)`);
111
+ // Give the in-flight request a moment before exit
112
+ await new Promise(r => setTimeout(r, 500));
113
+ return 0;
114
+ }
115
+ }
116
+ console.error(`unknown action: ${args.action}`);
117
+ return 2;
118
+ }
119
+ export function parseWebhookArgs(rawArgv) {
120
+ const idx = rawArgv.indexOf("webhook");
121
+ if (idx === -1)
122
+ return null;
123
+ const action = rawArgv[idx + 1];
124
+ if (!["list", "add-incoming", "add-outgoing", "remove", "test"].includes(action)) {
125
+ return null;
126
+ }
127
+ const flag = (n) => {
128
+ const i = rawArgv.indexOf(`--${n}`);
129
+ return i >= 0 && i < rawArgv.length - 1 ? rawArgv[i + 1] : undefined;
130
+ };
131
+ // First positional after action = name
132
+ let name;
133
+ for (let i = idx + 2; i < rawArgv.length; i++) {
134
+ const a = rawArgv[i];
135
+ if (!a.startsWith("--")) {
136
+ name = a;
137
+ break;
138
+ }
139
+ }
140
+ return {
141
+ action,
142
+ name,
143
+ secret: flag("secret"),
144
+ url: flag("url"),
145
+ format: flag("format"),
146
+ triggers: flag("triggers")?.split(",").map(s => s.trim()).filter(Boolean),
147
+ events: flag("events")?.split(",").map(s => s.trim()).filter(Boolean),
148
+ routingKey: flag("routing-key"),
149
+ };
150
+ }
@@ -0,0 +1,65 @@
1
+ // Webhook configuration store — persisted to ~/.great_cto/webhooks.json.
2
+ // Used by `serve` (incoming) and the dispatcher (outgoing).
3
+ //
4
+ // Schema:
5
+ // {
6
+ // "incoming": [
7
+ // { "name": "github", "secret": "<hmac-secret>", "events": ["pull_request"] }
8
+ // ],
9
+ // "outgoing": [
10
+ // { "name": "ops-slack", "url": "https://hooks.slack.com/services/...",
11
+ // "format": "slack", "triggers": ["gate.approved", "incident.p0"] }
12
+ // ]
13
+ // }
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ const CONFIG_PATH = join(homedir(), ".great_cto", "webhooks.json");
18
+ const DEFAULT_CONFIG = { incoming: [], outgoing: [] };
19
+ export function getConfigPath() {
20
+ return CONFIG_PATH;
21
+ }
22
+ export function loadConfig() {
23
+ if (!existsSync(CONFIG_PATH))
24
+ return { ...DEFAULT_CONFIG };
25
+ try {
26
+ const raw = readFileSync(CONFIG_PATH, "utf8");
27
+ const parsed = JSON.parse(raw);
28
+ return {
29
+ incoming: parsed.incoming ?? [],
30
+ outgoing: parsed.outgoing ?? [],
31
+ };
32
+ }
33
+ catch {
34
+ return { ...DEFAULT_CONFIG };
35
+ }
36
+ }
37
+ export function saveConfig(cfg) {
38
+ const dir = dirname(CONFIG_PATH);
39
+ if (!existsSync(dir))
40
+ mkdirSync(dir, { recursive: true });
41
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
42
+ }
43
+ export function addIncoming(hook) {
44
+ const cfg = loadConfig();
45
+ cfg.incoming = cfg.incoming.filter(h => h.name !== hook.name);
46
+ cfg.incoming.push({ enabled: true, ...hook });
47
+ saveConfig(cfg);
48
+ }
49
+ export function addOutgoing(hook) {
50
+ const cfg = loadConfig();
51
+ cfg.outgoing = cfg.outgoing.filter(h => h.name !== hook.name);
52
+ cfg.outgoing.push({ enabled: true, ...hook });
53
+ saveConfig(cfg);
54
+ }
55
+ export function removeHook(name) {
56
+ const cfg = loadConfig();
57
+ const before = cfg.incoming.length + cfg.outgoing.length;
58
+ cfg.incoming = cfg.incoming.filter(h => h.name !== name);
59
+ cfg.outgoing = cfg.outgoing.filter(h => h.name !== name);
60
+ saveConfig(cfg);
61
+ return cfg.incoming.length + cfg.outgoing.length < before;
62
+ }
63
+ export function getIncoming(name) {
64
+ return loadConfig().incoming.find(h => h.name === name) ?? null;
65
+ }
@@ -0,0 +1,132 @@
1
+ // Webhook dispatcher — outbound notifications with retry + DLQ.
2
+ //
3
+ // Reliability model:
4
+ // - In-memory retry queue with exponential backoff (1s, 4s, 16s, 64s)
5
+ // - Max 4 attempts per delivery
6
+ // - On final failure: append to dead-letter log (~/.great_cto/webhook-dlq.log)
7
+ // - Dispatcher is fire-and-forget for the caller — we never block the
8
+ // incoming-webhook handler on outbound success
9
+ //
10
+ // Format adapters:
11
+ // - slack: posts as Slack incoming-webhook JSON ({text, blocks?})
12
+ // - discord: Discord webhook JSON ({content, embeds?})
13
+ // - pagerduty: Events API v2 ({routing_key, event_action, payload})
14
+ // - generic: arbitrary JSON POST
15
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { loadConfig } from "./webhook-config.js";
19
+ const DLQ_PATH = join(homedir(), ".great_cto", "webhook-dlq.log");
20
+ const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000]; // 4 attempts total
21
+ // ── Format adapters ────────────────────────────────────────────────────────
22
+ function formatSlack(ev) {
23
+ const emoji = ev.level === "critical" ? ":rotating_light:"
24
+ : ev.level === "error" ? ":x:"
25
+ : ev.level === "warning" ? ":warning:"
26
+ : ":information_source:";
27
+ return {
28
+ text: `${emoji} *${ev.title}*`,
29
+ attachments: ev.body ? [{ text: ev.body, color: ev.level === "critical" ? "danger" : "good" }] : undefined,
30
+ };
31
+ }
32
+ function formatDiscord(ev) {
33
+ const color = ev.level === "critical" ? 0xff0000
34
+ : ev.level === "error" ? 0xff6600
35
+ : ev.level === "warning" ? 0xffcc00
36
+ : 0x00aa66;
37
+ return {
38
+ content: ev.title,
39
+ embeds: ev.body ? [{ description: ev.body, color }] : undefined,
40
+ };
41
+ }
42
+ function formatPagerDuty(ev, routingKey) {
43
+ // PagerDuty Events API v2
44
+ const severity = ev.level === "critical" ? "critical"
45
+ : ev.level === "error" ? "error"
46
+ : ev.level === "warning" ? "warning"
47
+ : "info";
48
+ return {
49
+ routing_key: routingKey,
50
+ event_action: "trigger",
51
+ payload: {
52
+ summary: ev.title,
53
+ source: "great-cto",
54
+ severity,
55
+ custom_details: ev.body ? { details: ev.body, ...(ev.meta ?? {}) } : ev.meta,
56
+ },
57
+ };
58
+ }
59
+ function buildPayload(hook, ev) {
60
+ switch (hook.format) {
61
+ case "slack": return formatSlack(ev);
62
+ case "discord": return formatDiscord(ev);
63
+ case "pagerduty": {
64
+ // PagerDuty uses routing_key from headers config: headers.routing_key
65
+ const key = hook.headers?.routing_key ?? "";
66
+ return formatPagerDuty(ev, key);
67
+ }
68
+ case "generic":
69
+ default: return { event: ev.name, ...ev };
70
+ }
71
+ }
72
+ // ── Retry / DLQ ────────────────────────────────────────────────────────────
73
+ async function deliver(hook, ev, attempt = 0) {
74
+ try {
75
+ const body = JSON.stringify(buildPayload(hook, ev));
76
+ const headers = {
77
+ "Content-Type": "application/json",
78
+ ...hook.headers,
79
+ };
80
+ // Don't leak routing_key as HTTP header — PagerDuty wants it in body
81
+ delete headers.routing_key;
82
+ const res = await fetch(hook.url, { method: "POST", headers, body });
83
+ if (!res.ok) {
84
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
85
+ }
86
+ }
87
+ catch (err) {
88
+ const next = attempt + 1;
89
+ if (next < RETRY_DELAYS_MS.length) {
90
+ setTimeout(() => { void deliver(hook, ev, next); }, RETRY_DELAYS_MS[next]);
91
+ return;
92
+ }
93
+ // Final failure — write to DLQ
94
+ writeToDlq(hook, ev, err);
95
+ }
96
+ }
97
+ function writeToDlq(hook, ev, err) {
98
+ try {
99
+ const dir = dirname(DLQ_PATH);
100
+ if (!existsSync(dir))
101
+ mkdirSync(dir, { recursive: true });
102
+ const entry = {
103
+ ts: new Date().toISOString(),
104
+ hook: hook.name,
105
+ url: hook.url,
106
+ event: ev,
107
+ error: err.message,
108
+ };
109
+ appendFileSync(DLQ_PATH, JSON.stringify(entry) + "\n");
110
+ process.stderr.write(`webhook-dispatch: ${hook.name} dead-lettered: ${err.message}\n`);
111
+ }
112
+ catch {
113
+ /* even DLQ failed — no recovery */
114
+ }
115
+ }
116
+ // ── Public API ─────────────────────────────────────────────────────────────
117
+ /**
118
+ * Fire-and-forget dispatch to all outbound webhooks whose triggers include
119
+ * the event's name. The caller does not await delivery — we run all
120
+ * dispatchers in parallel with their own retry queues.
121
+ */
122
+ export function dispatch(ev) {
123
+ const cfg = loadConfig();
124
+ const targets = cfg.outgoing.filter(h => (h.enabled !== false) && h.triggers.includes(ev.name));
125
+ for (const hook of targets) {
126
+ void deliver(hook, ev, 0);
127
+ }
128
+ return { fired: targets.length };
129
+ }
130
+ export function getDlqPath() {
131
+ return DLQ_PATH;
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",