great-cto 2.77.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,413 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
getVapidKeys,
|
|
5
|
+
sendWebPush,
|
|
6
|
+
loadSubscriptions,
|
|
7
|
+
removeSubscription,
|
|
8
|
+
} from '../push-adapter.mjs';
|
|
9
|
+
import { GREAT_CTO_DIR, PUSH_SUBS_FILE, VAPID_KEYS_FILE, VAPID_SUBJECT } from './config.mjs';
|
|
10
|
+
import { _reportRepublishDedupeSet } from './state.mjs';
|
|
11
|
+
import { listProjects, readProjectMd } from './projects.mjs';
|
|
12
|
+
import { addNotification } from './notifications.mjs';
|
|
13
|
+
import { getMetrics } from './metrics.mjs';
|
|
14
|
+
import { getCostHistory, getInbox } from './data-readers.mjs';
|
|
15
|
+
import { getTasks } from './beads.mjs';
|
|
16
|
+
import { readVerdicts } from './verdicts.mjs';
|
|
17
|
+
import { isFailure } from './fleet.mjs';
|
|
18
|
+
import { getShareState, toggleShare } from './share.mjs';
|
|
19
|
+
|
|
20
|
+
// ── Email alerts (Resend) ─────────────────────────────────────────────────
|
|
21
|
+
// Dispatch model: read webhooks.json on every fire (idempotent if disabled
|
|
22
|
+
// or no Resend hook configured). Each trigger has a dedupe key persisted to
|
|
23
|
+
// ~/.great_cto/alerts-fired.json so we don't email the same event twice.
|
|
24
|
+
|
|
25
|
+
const ALERTS_FIRED_PATH = path.join(GREAT_CTO_DIR, 'alerts-fired.json');
|
|
26
|
+
|
|
27
|
+
function readAlertsFired() {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8')); } catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeAlertsFired(map) {
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(GREAT_CTO_DIR)) fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
|
|
34
|
+
fs.writeFileSync(ALERTS_FIRED_PATH, JSON.stringify(map, null, 2));
|
|
35
|
+
} catch {/* best-effort */}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fire an email alert through the greatcto.systems/notify relay (Cloudflare
|
|
40
|
+
* Worker → Resend). Idempotent per dedupeKey — same key won't email twice.
|
|
41
|
+
*
|
|
42
|
+
* Reads ~/.great_cto/notifications.json for the user's verified email + per-
|
|
43
|
+
* trigger enable flags. Silent no-op if not configured / not verified / event
|
|
44
|
+
* not in the user's selected triggers.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} eventName e.g. "incident.p0", "gate.stale"
|
|
47
|
+
* @param {string} dedupeKey unique per event instance (e.g. "great_cto:GC-42")
|
|
48
|
+
* @param {object} payload { title, body, level, project, link, action, kv }
|
|
49
|
+
*/
|
|
50
|
+
async function fireEmailAlert(eventName, dedupeKey, payload) {
|
|
51
|
+
try {
|
|
52
|
+
const stateFile = path.join(GREAT_CTO_DIR, 'notifications.json');
|
|
53
|
+
if (!fs.existsSync(stateFile)) return;
|
|
54
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
55
|
+
if (!state.enabled || !state.verified || !state.to) return;
|
|
56
|
+
if (!(state.triggers || []).includes(eventName)) return;
|
|
57
|
+
|
|
58
|
+
// Dedupe — never email the same instance twice
|
|
59
|
+
const fired = readAlertsFired();
|
|
60
|
+
if (fired[dedupeKey]) return;
|
|
61
|
+
|
|
62
|
+
// Mark fired optimistically BEFORE the async fetch so that concurrent
|
|
63
|
+
// calls for other projects (same cron tick) see this key immediately
|
|
64
|
+
// and don't fire duplicate sends. Also prevents infinite retry on relay
|
|
65
|
+
// errors — a failed send is still recorded so the next 5-min tick skips.
|
|
66
|
+
fired[dedupeKey] = new Date().toISOString();
|
|
67
|
+
const keys = Object.keys(fired);
|
|
68
|
+
if (keys.length > 500) {
|
|
69
|
+
const trimmed = {};
|
|
70
|
+
keys.slice(-500).forEach(k => trimmed[k] = fired[k]);
|
|
71
|
+
writeAlertsFired(trimmed);
|
|
72
|
+
} else {
|
|
73
|
+
writeAlertsFired(fired);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const relay = process.env.GREATCTO_NOTIFY_URL || 'https://greatcto.systems';
|
|
77
|
+
const res = await fetch(`${relay}/notify`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
to: state.to,
|
|
82
|
+
title: payload.title,
|
|
83
|
+
body: payload.body,
|
|
84
|
+
level: payload.level || 'info',
|
|
85
|
+
project: payload.project || 'great_cto',
|
|
86
|
+
link: payload.link,
|
|
87
|
+
action: payload.action,
|
|
88
|
+
kv: payload.kv || {},
|
|
89
|
+
event: eventName,
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const txt = await res.text().catch(() => '');
|
|
94
|
+
console.warn(`fireEmailAlert ${eventName}: relay HTTP ${res.status} ${txt.slice(0, 200)}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
console.log(`fireEmailAlert: sent ${eventName} (${dedupeKey})`);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.warn(`fireEmailAlert ${eventName} failed:`, e.message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fire Web Push notifications to all registered browser subscriptions.
|
|
105
|
+
* Uses the same alerts-fired.json dedupe map as fireEmailAlert (keyed as
|
|
106
|
+
* "push:<dedupeKey>") so a single event never sends duplicate pushes.
|
|
107
|
+
* Expired subscriptions (HTTP 410) are removed automatically.
|
|
108
|
+
*/
|
|
109
|
+
async function firePushAlert(eventName, dedupeKey, payload) {
|
|
110
|
+
try {
|
|
111
|
+
const subs = loadSubscriptions(PUSH_SUBS_FILE);
|
|
112
|
+
if (!subs.length) return;
|
|
113
|
+
const vapidKeys = getVapidKeys(VAPID_KEYS_FILE);
|
|
114
|
+
const fired = readAlertsFired();
|
|
115
|
+
const pushKey = `push:${dedupeKey}`;
|
|
116
|
+
if (fired[pushKey]) return;
|
|
117
|
+
for (const sub of subs) {
|
|
118
|
+
try { await sendWebPush(sub, vapidKeys, VAPID_SUBJECT); }
|
|
119
|
+
catch (e) {
|
|
120
|
+
// 410 = subscription expired — browser unsubscribed, clean up
|
|
121
|
+
if (e.statusCode === 410) removeSubscription(PUSH_SUBS_FILE, sub.endpoint);
|
|
122
|
+
else console.warn(`firePushAlert send failed for ${sub.endpoint}:`, e.message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
fired[pushKey] = new Date().toISOString();
|
|
126
|
+
writeAlertsFired(fired);
|
|
127
|
+
} catch (e) { console.warn('firePushAlert failed:', e.message); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Cron: scan gates / cost / weekly digest ───────────────────────────────
|
|
131
|
+
// Runs every 5 minutes once the server boots. Each check is idempotent
|
|
132
|
+
// thanks to alerts-fired.json dedupe.
|
|
133
|
+
function startAlertCron() {
|
|
134
|
+
const FIVE_MIN = 5 * 60 * 1000;
|
|
135
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
136
|
+
|
|
137
|
+
// incident.p0: open P0 task with recent activity (last 24h).
|
|
138
|
+
// Older P0s are existing backlog — surfacing them via email is spam.
|
|
139
|
+
// If you want to be reminded about stale P0s, that's gate.stale's job.
|
|
140
|
+
const RECENT_WINDOW_MS = 24 * 3600_000;
|
|
141
|
+
setInterval(() => {
|
|
142
|
+
try {
|
|
143
|
+
const projects = listProjects();
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
for (const proj of projects) {
|
|
146
|
+
const tasks = getTasks(proj.path);
|
|
147
|
+
const p0 = tasks.filter(t => {
|
|
148
|
+
if (t.priority !== 0) return false;
|
|
149
|
+
if (t.raw_status === 'closed' || t.raw_status === 'done') return false;
|
|
150
|
+
const updatedTs = new Date(t.updated_at || t.created_at || 0).getTime();
|
|
151
|
+
// Only fresh activity — silent on old backlog P0s
|
|
152
|
+
return updatedTs > 0 && (now - updatedTs) < RECENT_WINDOW_MS;
|
|
153
|
+
});
|
|
154
|
+
for (const t of p0) {
|
|
155
|
+
const dedupeKey = `incident.p0:${proj.slug}:${t.id}`;
|
|
156
|
+
const p0Payload = {
|
|
157
|
+
title: `P0 — ${t.title.slice(0, 70)} (${proj.slug})`,
|
|
158
|
+
body: `A P0 incident is open and needs your attention.\n\n${t.description || ''}`.slice(0, 600),
|
|
159
|
+
level: 'critical',
|
|
160
|
+
project: proj.slug,
|
|
161
|
+
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}&task=${encodeURIComponent(t.id)}#inbox`,
|
|
162
|
+
action: 'Claim P0 in board',
|
|
163
|
+
kv: {
|
|
164
|
+
id: t.id,
|
|
165
|
+
title: t.title.slice(0, 80),
|
|
166
|
+
status: t.status,
|
|
167
|
+
opened: t.created_at ? new Date(t.created_at).toISOString().slice(0, 16) : 'now',
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
fireEmailAlert('incident.p0', dedupeKey, p0Payload);
|
|
171
|
+
addNotification('incident.p0', p0Payload);
|
|
172
|
+
firePushAlert('incident.p0', dedupeKey, p0Payload);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (e) { console.warn('cron incident.p0 failed:', e.message); }
|
|
176
|
+
}, FIVE_MIN);
|
|
177
|
+
|
|
178
|
+
// gate.blocked: new BLOCKED verdict from security-officer
|
|
179
|
+
setInterval(() => {
|
|
180
|
+
try {
|
|
181
|
+
const verdicts = readVerdicts();
|
|
182
|
+
const recent = verdicts.filter(v => {
|
|
183
|
+
if (v.agent !== 'security-officer') return false;
|
|
184
|
+
if (!isFailure(v.verdict) && !/BLOCKED/i.test(v.verdict || '')) return false;
|
|
185
|
+
if (!v.ts) return false;
|
|
186
|
+
return (Date.now() - new Date(v.ts).getTime()) < 24 * 3600_000;
|
|
187
|
+
});
|
|
188
|
+
for (const v of recent) {
|
|
189
|
+
const dedupeKey = `gate.blocked:${v.ts}:${(v.task || v.reason || '').slice(0, 40)}`;
|
|
190
|
+
const taskParam = v.task ? `&task=${encodeURIComponent(v.task)}` : '';
|
|
191
|
+
const projParam = v.project ? `?project=${encodeURIComponent(v.project)}${taskParam}` : '';
|
|
192
|
+
const blockedPayload = {
|
|
193
|
+
title: `Security BLOCKED — ${(v.reason || v.task || 'unknown').slice(0, 70)}`,
|
|
194
|
+
body: `security-officer rejected a gate. Review the verdict and address the finding before re-submitting.\n\nReason: ${v.reason || '(see verdicts log)'}`,
|
|
195
|
+
level: 'error',
|
|
196
|
+
project: v.project || 'great_cto',
|
|
197
|
+
link: `http://localhost:3141/${projParam}#logs`,
|
|
198
|
+
action: 'Review verdict',
|
|
199
|
+
kv: {
|
|
200
|
+
agent: v.agent,
|
|
201
|
+
verdict: v.verdict,
|
|
202
|
+
ts: v.ts,
|
|
203
|
+
reason: (v.reason || '').slice(0, 120),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
fireEmailAlert('gate.blocked', dedupeKey, blockedPayload);
|
|
207
|
+
addNotification('gate.blocked', blockedPayload);
|
|
208
|
+
firePushAlert('gate.blocked', dedupeKey, blockedPayload);
|
|
209
|
+
}
|
|
210
|
+
} catch (e) { console.warn('cron gate.blocked failed:', e.message); }
|
|
211
|
+
}, FIVE_MIN);
|
|
212
|
+
|
|
213
|
+
// gate.stale: gate task open between 2h and 7 days.
|
|
214
|
+
// Lower bound: 2h is the soonest you'd want a nudge.
|
|
215
|
+
// Upper bound: gates open >7d are abandoned, not stale — don't keep nagging.
|
|
216
|
+
setInterval(() => {
|
|
217
|
+
try {
|
|
218
|
+
const projects = listProjects();
|
|
219
|
+
for (const proj of projects) {
|
|
220
|
+
const tasks = getTasks(proj.path);
|
|
221
|
+
const gates = tasks.filter(t => t.is_gate && t.raw_status !== 'closed' && t.raw_status !== 'blocked');
|
|
222
|
+
for (const g of gates) {
|
|
223
|
+
const created = new Date(g.created_at || g.updated_at || 0).getTime();
|
|
224
|
+
const ageHr = (Date.now() - created) / 3600_000;
|
|
225
|
+
if (ageHr < 2 || ageHr > 24 * 7) continue;
|
|
226
|
+
const dedupeKey = `gate.stale:${proj.slug}:${g.id}`;
|
|
227
|
+
const stalePayload = {
|
|
228
|
+
title: `${proj.slug} — ${g.title.slice(0, 60)} pending ${ageHr.toFixed(1)}h`,
|
|
229
|
+
body: `A gate has been waiting for your approval for ${ageHr.toFixed(1)} hours.\n\nGate: ${g.id}\nProject: ${proj.slug}`,
|
|
230
|
+
level: 'warning',
|
|
231
|
+
project: proj.slug,
|
|
232
|
+
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}&task=${encodeURIComponent(g.id)}#inbox`,
|
|
233
|
+
action: 'Approve in board',
|
|
234
|
+
kv: { gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h` },
|
|
235
|
+
};
|
|
236
|
+
fireEmailAlert('gate.stale', dedupeKey, stalePayload);
|
|
237
|
+
addNotification('gate.stale', stalePayload);
|
|
238
|
+
firePushAlert('gate.stale', dedupeKey, stalePayload);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (e) { console.warn('cron gate.stale failed:', e.message); }
|
|
242
|
+
}, FIVE_MIN);
|
|
243
|
+
|
|
244
|
+
// cost.threshold: monthly LLM spend at 80% / 100% of budget
|
|
245
|
+
setInterval(() => {
|
|
246
|
+
try {
|
|
247
|
+
const projects = listProjects();
|
|
248
|
+
for (const proj of projects) {
|
|
249
|
+
const m = getMetrics(proj.path);
|
|
250
|
+
const meta = readProjectMd(proj.path) || {};
|
|
251
|
+
const budget = parseFloat(meta['monthly-budget']?.replace?.(/[$\s]/g, '') || '0');
|
|
252
|
+
if (!budget) continue;
|
|
253
|
+
const spent = m.cost?.real_llm_usd || m.cost?.llm_usd || 0;
|
|
254
|
+
const pct = (spent / budget) * 100;
|
|
255
|
+
const month = new Date().toISOString().slice(0, 7);
|
|
256
|
+
for (const threshold of [80, 100]) {
|
|
257
|
+
if (pct < threshold) continue;
|
|
258
|
+
const dedupeKey = `cost.threshold:${proj.slug}:${month}:${threshold}`;
|
|
259
|
+
const costPayload = {
|
|
260
|
+
title: `${proj.slug} — $${spent.toFixed(2)} LLM spend, ${pct.toFixed(0)}% of $${budget} monthly budget`,
|
|
261
|
+
body: threshold === 100
|
|
262
|
+
? `Budget exceeded. Consider routing more agents to Haiku/Kimi or raising the cap in PROJECT.md.`
|
|
263
|
+
: `Approaching budget limit. Review top-cost runs before crossing 100%.`,
|
|
264
|
+
level: threshold === 100 ? 'critical' : 'warning',
|
|
265
|
+
project: proj.slug,
|
|
266
|
+
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}#dashboard`,
|
|
267
|
+
action: 'Open cost dashboard',
|
|
268
|
+
kv: {
|
|
269
|
+
spent: `$${spent.toFixed(2)}`,
|
|
270
|
+
budget: `$${budget}`,
|
|
271
|
+
percent: `${pct.toFixed(0)}%`,
|
|
272
|
+
month,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
fireEmailAlert('cost.threshold', dedupeKey, costPayload);
|
|
276
|
+
addNotification('cost.threshold', costPayload);
|
|
277
|
+
firePushAlert('cost.threshold', dedupeKey, costPayload);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch (e) { console.warn('cron cost.threshold failed:', e.message); }
|
|
281
|
+
}, ONE_HOUR);
|
|
282
|
+
|
|
283
|
+
// digest.weekly: Friday 09:00 UTC ± server tick
|
|
284
|
+
setInterval(async () => {
|
|
285
|
+
try {
|
|
286
|
+
const now = new Date();
|
|
287
|
+
// Friday = 5, hour 9, dedupe by ISO-week
|
|
288
|
+
if (now.getUTCDay() !== 5 || now.getUTCHours() !== 9) return;
|
|
289
|
+
// Correct ISO-8601 week number (getUTCDate is day-of-month, not day-of-year)
|
|
290
|
+
const thu = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
291
|
+
thu.setUTCDate(thu.getUTCDate() + 4 - (thu.getUTCDay() || 7));
|
|
292
|
+
const isoWeek = `${thu.getUTCFullYear()}-W${Math.ceil(((thu - new Date(Date.UTC(thu.getUTCFullYear(), 0, 1))) / 86400000 + 1) / 7)}`;
|
|
293
|
+
const projects = listProjects();
|
|
294
|
+
// Sequential loop — each project awaits so file writes complete before the
|
|
295
|
+
// next project reads alerts-fired.json. Prevents the race condition where
|
|
296
|
+
// all N projects read an empty map simultaneously and all fire.
|
|
297
|
+
for (const proj of projects) {
|
|
298
|
+
const m = getMetrics(proj.path);
|
|
299
|
+
const dedupeKey = `digest.weekly:${proj.slug}:${isoWeek}`;
|
|
300
|
+
const weeklyPayload = {
|
|
301
|
+
title: `${proj.slug} weekly — ${m.tasks?.done || 0} shipped, $${(m.cost?.llm_usd || 0).toFixed(2)} spent`,
|
|
302
|
+
body: `Your week at a glance.`,
|
|
303
|
+
level: 'info',
|
|
304
|
+
project: proj.slug,
|
|
305
|
+
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}#dashboard`,
|
|
306
|
+
action: 'Open dashboard',
|
|
307
|
+
kv: {
|
|
308
|
+
'tasks shipped': String(m.tasks?.done || 0),
|
|
309
|
+
'this week': `+${m.velocity?.this_week ?? 0}`,
|
|
310
|
+
'LLM spend': `$${(m.cost?.llm_usd || 0).toFixed(2)}`,
|
|
311
|
+
'human equiv': `$${(m.cost?.human_usd || 0).toFixed(0)}`,
|
|
312
|
+
'savings_x': m.cost?.savings_x ? `${m.cost.savings_x}×` : '—',
|
|
313
|
+
'QA pass rate': m.qa?.pass_rate != null ? `${m.qa.pass_rate}%` : 'no runs',
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
await fireEmailAlert('digest.weekly', dedupeKey, weeklyPayload);
|
|
317
|
+
addNotification('digest.weekly', weeklyPayload);
|
|
318
|
+
await firePushAlert('digest.weekly', dedupeKey, weeklyPayload);
|
|
319
|
+
}
|
|
320
|
+
} catch (e) { console.warn('cron digest.weekly failed:', e.message); }
|
|
321
|
+
}, FIVE_MIN);
|
|
322
|
+
|
|
323
|
+
// digest.daily: morning summary (Mon–Fri, 08:00 UTC).
|
|
324
|
+
// Covers yesterday's activity: spend, features shipped, blocked gates.
|
|
325
|
+
// Idempotent via date-keyed dedupe so re-starts don't re-send.
|
|
326
|
+
setInterval(async () => {
|
|
327
|
+
try {
|
|
328
|
+
const now = new Date();
|
|
329
|
+
// Mon=1 … Fri=5 only; skip weekends
|
|
330
|
+
if (now.getUTCDay() === 0 || now.getUTCDay() === 6) return;
|
|
331
|
+
if (now.getUTCHours() !== 8) return;
|
|
332
|
+
const isoDay = now.toISOString().slice(0, 10);
|
|
333
|
+
const yesterday = new Date(now.getTime() - 86400_000).toISOString().slice(0, 10);
|
|
334
|
+
const projects = listProjects();
|
|
335
|
+
for (const proj of projects) {
|
|
336
|
+
const dedupeKey = `digest.daily:${proj.slug}:${isoDay}`;
|
|
337
|
+
// Pull last 2 days so "yesterday" is always in the window
|
|
338
|
+
const cost = getCostHistory(proj.path, 2);
|
|
339
|
+
const yBucket = (cost.series || []).find(s => s.date === yesterday);
|
|
340
|
+
const ySpend = yBucket ? yBucket.llm : 0;
|
|
341
|
+
// Feature breakdown for yesterday
|
|
342
|
+
const topFeatures = (cost.by_feature || []).slice(0, 3);
|
|
343
|
+
// Inbox for blocked + gates
|
|
344
|
+
const inbox = (() => { try { return getInbox(proj.path); } catch { return {}; } })();
|
|
345
|
+
const blocked = inbox.summary?.blocked ?? 0;
|
|
346
|
+
const gates = inbox.summary?.gates ?? 0;
|
|
347
|
+
// Tasks closed yesterday
|
|
348
|
+
const tasks = (() => { try { return getTasks(proj.path); } catch { return []; } })();
|
|
349
|
+
const doneYesterday = tasks.filter(t => {
|
|
350
|
+
if (!t.closed_at) return false;
|
|
351
|
+
return t.closed_at.slice(0, 10) === yesterday;
|
|
352
|
+
}).length;
|
|
353
|
+
// Skip digest if nothing happened yesterday
|
|
354
|
+
if (ySpend === 0 && doneYesterday === 0 && blocked === 0 && gates === 0) continue;
|
|
355
|
+
const kvObj = {
|
|
356
|
+
date: yesterday,
|
|
357
|
+
'AI spend': `$${ySpend.toFixed(2)}`,
|
|
358
|
+
'tasks shipped': String(doneYesterday),
|
|
359
|
+
'blocked': String(blocked),
|
|
360
|
+
'open gates': String(gates),
|
|
361
|
+
};
|
|
362
|
+
if (topFeatures.length > 0) {
|
|
363
|
+
kvObj['top feature'] = `${topFeatures[0].feature} ($${topFeatures[0].llm.toFixed(2)})`;
|
|
364
|
+
}
|
|
365
|
+
const bodyLines = [
|
|
366
|
+
`Yesterday: $${ySpend.toFixed(2)} AI spend · ${doneYesterday} task${doneYesterday !== 1 ? 's' : ''} shipped`,
|
|
367
|
+
];
|
|
368
|
+
if (blocked > 0) bodyLines.push(`⚠️ ${blocked} blocked task${blocked !== 1 ? 's' : ''} need attention`);
|
|
369
|
+
if (gates > 0) bodyLines.push(`🔒 ${gates} gate${gates !== 1 ? 's' : ''} awaiting approval`);
|
|
370
|
+
if (topFeatures.length > 0) {
|
|
371
|
+
bodyLines.push('', 'Top AI spend by feature:');
|
|
372
|
+
for (const f of topFeatures) bodyLines.push(` • ${f.feature}: $${f.llm.toFixed(2)}`);
|
|
373
|
+
}
|
|
374
|
+
const dailyPayload = {
|
|
375
|
+
title: `${proj.slug} — ${yesterday} · $${ySpend.toFixed(2)} AI · ${doneYesterday} shipped`,
|
|
376
|
+
body: bodyLines.join('\n'),
|
|
377
|
+
level: blocked > 0 || gates > 0 ? 'warning' : 'info',
|
|
378
|
+
project: proj.slug,
|
|
379
|
+
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}#dashboard`,
|
|
380
|
+
action: 'Open board',
|
|
381
|
+
kv: kvObj,
|
|
382
|
+
};
|
|
383
|
+
await fireEmailAlert('digest.daily', dedupeKey, dailyPayload);
|
|
384
|
+
addNotification('digest.daily', dailyPayload);
|
|
385
|
+
await firePushAlert('digest.daily', dedupeKey, dailyPayload);
|
|
386
|
+
}
|
|
387
|
+
} catch (e) { console.warn('cron digest.daily failed:', e.message); }
|
|
388
|
+
}, FIVE_MIN);
|
|
389
|
+
|
|
390
|
+
// report.daily: republish share reports every day at 09:00 UTC
|
|
391
|
+
setInterval(() => {
|
|
392
|
+
try {
|
|
393
|
+
const now = new Date();
|
|
394
|
+
if (now.getUTCHours() !== 9) return;
|
|
395
|
+
const isoDay = now.toISOString().slice(0, 10); // dedupe by date
|
|
396
|
+
const projects = listProjects();
|
|
397
|
+
for (const proj of projects) {
|
|
398
|
+
const state = getShareState(proj.path);
|
|
399
|
+
if (!state.enabled) continue;
|
|
400
|
+
const dedupeKey = `report.daily:${proj.slug}:${isoDay}`;
|
|
401
|
+
if (_reportRepublishDedupeSet.has(dedupeKey)) continue;
|
|
402
|
+
_reportRepublishDedupeSet.add(dedupeKey);
|
|
403
|
+
toggleShare(true, proj.path, true)
|
|
404
|
+
.then(() => console.log(`report.daily: republished ${proj.slug}`))
|
|
405
|
+
.catch(e => console.warn(`report.daily: ${proj.slug} failed: ${e.message}`));
|
|
406
|
+
}
|
|
407
|
+
} catch (e) { console.warn('cron report.daily failed:', e.message); }
|
|
408
|
+
}, FIVE_MIN);
|
|
409
|
+
|
|
410
|
+
console.log('Alert cron started: gate.stale (5min), sla.escalate (5min), connector.health (5min), cost.threshold (1h), digest.daily (Mon–Fri 08:00), digest.weekly (Fri 09:00), report.daily (09:00)');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export { readAlertsFired, writeAlertsFired, fireEmailAlert, firePushAlert, startAlertCron };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { bdCache } from './state.mjs';
|
|
6
|
+
|
|
7
|
+
// ── Beads data ─────────────────────────────────────────────────────────────────
|
|
8
|
+
// Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
|
|
9
|
+
// .beads/interactions.jsonl changes (the file watcher in watchBeads() calls
|
|
10
|
+
// bdCacheInvalidate(cwd) before broadcasting). This avoids spawning `bd list`
|
|
11
|
+
// on every API call when 5+ projects are open in tabs.
|
|
12
|
+
const BD_CACHE_TTL_MS = 2000;
|
|
13
|
+
|
|
14
|
+
function bdCacheInvalidate(cwd) { bdCache.delete(cwd); }
|
|
15
|
+
|
|
16
|
+
// ── bd binary resolution (BH-32) ────────────────────────────────────────────
|
|
17
|
+
// A board launched from a GUI / launchd / a login shell that didn't source the
|
|
18
|
+
// usual profile often has a minimal PATH (`/usr/bin:/bin`) that omits where
|
|
19
|
+
// Homebrew (`/opt/homebrew/bin`) or a user install (`~/.local/bin`) put `bd`.
|
|
20
|
+
// Then `spawnSync('bd', …)` → ENOENT, and a gate Approve fails with the opaque
|
|
21
|
+
// "bd update failed" — even though `bd list` was served from cache. Resolve the
|
|
22
|
+
// binary once against the common locations, and always spawn with an augmented
|
|
23
|
+
// PATH so bd's own child processes (git) are found too.
|
|
24
|
+
const BD_EXTRA_PATHS = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local', 'bin'), '/usr/bin', '/bin'];
|
|
25
|
+
const BD_BIN = (() => {
|
|
26
|
+
// honor an explicit override first
|
|
27
|
+
if (process.env.GREAT_CTO_BD_BIN && fs.existsSync(process.env.GREAT_CTO_BD_BIN)) return process.env.GREAT_CTO_BD_BIN;
|
|
28
|
+
for (const dir of BD_EXTRA_PATHS) {
|
|
29
|
+
const p = path.join(dir, 'bd');
|
|
30
|
+
try { if (fs.existsSync(p)) return p; } catch { /* skip */ }
|
|
31
|
+
}
|
|
32
|
+
return 'bd'; // fall back to PATH lookup
|
|
33
|
+
})();
|
|
34
|
+
function bdEnv() {
|
|
35
|
+
const cur = process.env.PATH || '';
|
|
36
|
+
const have = new Set(cur.split(path.delimiter).filter(Boolean));
|
|
37
|
+
const add = BD_EXTRA_PATHS.filter((d) => !have.has(d));
|
|
38
|
+
return add.length ? { ...process.env, PATH: [...add, cur].filter(Boolean).join(path.delimiter) } : process.env;
|
|
39
|
+
}
|
|
40
|
+
// Centralized bd invocation — resolved binary + augmented PATH for every call site.
|
|
41
|
+
function bd(args, opts = {}) {
|
|
42
|
+
return spawnSync(BD_BIN, args, { encoding: 'utf8', timeout: 8000, ...opts, env: { ...bdEnv(), ...(opts.env || {}) } });
|
|
43
|
+
}
|
|
44
|
+
// Turn a failed bd result into an actionable message (the bare "bd update failed" hid ENOENT).
|
|
45
|
+
function bdErr(r, what) {
|
|
46
|
+
if (r.error && r.error.code === 'ENOENT') return `${what}: 'bd' not found. Install Beads (brew install beads) or set GREAT_CTO_BD_BIN to the bd path, then restart the board.`;
|
|
47
|
+
if (r.error && r.error.code === 'ETIMEDOUT') return `${what}: bd timed out — a stale .beads/.lock can cause this`;
|
|
48
|
+
return (r.stderr && r.stderr.trim()) || (r.stdout && r.stdout.trim()) || what;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check whether `bd` is initialized in the given cwd. Returns null on success,
|
|
52
|
+
// or a structured error object suitable for a 409 Conflict response.
|
|
53
|
+
// Used to give the admin UI a clean signal ("project not initialized") rather
|
|
54
|
+
// than a 500 with a raw stderr dump.
|
|
55
|
+
function checkBeadsAvailable(cwd) {
|
|
56
|
+
// Quick filesystem check first — beads stores its DB under .beads/.
|
|
57
|
+
// Some installs use ~/.beads or env-var BEADS_DIR; respect those too.
|
|
58
|
+
const candidates = [
|
|
59
|
+
path.join(cwd, '.beads'),
|
|
60
|
+
process.env.BEADS_DIR,
|
|
61
|
+
].filter(Boolean);
|
|
62
|
+
if (candidates.some(p => { try { return fs.existsSync(p); } catch { return false; } })) {
|
|
63
|
+
return null; // looks initialized
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
error: 'beads_not_initialized',
|
|
67
|
+
message: `No .beads/ directory found in ${cwd}. Initialize with 'bd init' or set BEADS_DIR.`,
|
|
68
|
+
cwd,
|
|
69
|
+
hint: "Run 'bd init' in the project root, then retry.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── bd write serialisation (BH-12, 2026-05-15) ─────────────────────────────
|
|
74
|
+
//
|
|
75
|
+
// bd uses Dolt-embedded DB with file-level locking. Concurrent `bd create`
|
|
76
|
+
// or `bd update` calls compete for the lock; if one crashes mid-write, it
|
|
77
|
+
// leaves a stale `.beads/.lock` that blocks ALL subsequent operations
|
|
78
|
+
// until manually removed.
|
|
79
|
+
//
|
|
80
|
+
// Server-level fix: serialise bd write operations through this single
|
|
81
|
+
// promise chain. Reads (`bd list`) are unaffected — Dolt's read path
|
|
82
|
+
// doesn't take the write lock.
|
|
83
|
+
//
|
|
84
|
+
// Adds ~100ms per write under burst load; no-op under normal usage.
|
|
85
|
+
let _bdWriteChain = Promise.resolve();
|
|
86
|
+
function bdWriteSerialised(fn) {
|
|
87
|
+
const next = _bdWriteChain.then(() => fn()).catch((e) => {
|
|
88
|
+
console.error('[bd-write-serialised] error:', e?.message || e);
|
|
89
|
+
return null;
|
|
90
|
+
});
|
|
91
|
+
_bdWriteChain = next.then(() => undefined).catch(() => undefined);
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function bdList(cwd = process.cwd()) {
|
|
96
|
+
const cached = bdCache.get(cwd);
|
|
97
|
+
if (cached && Date.now() - cached.ts < BD_CACHE_TTL_MS) return cached.data;
|
|
98
|
+
try {
|
|
99
|
+
const result = bd(['list', '--json', '--all', '--include-gates'], { cwd });
|
|
100
|
+
if (result.status !== 0) {
|
|
101
|
+
bdCache.set(cwd, { ts: Date.now(), data: [] });
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const data = JSON.parse(result.stdout || '[]');
|
|
105
|
+
bdCache.set(cwd, { ts: Date.now(), data });
|
|
106
|
+
return data;
|
|
107
|
+
} catch {
|
|
108
|
+
bdCache.set(cwd, { ts: Date.now(), data: [] });
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fallback: parse .great_cto/tasks.md when Beads isn't initialized.
|
|
114
|
+
// Format: `- [ ] TASK-001: Title [agent] [~42min]\n Description: ...\n Depends: ...`
|
|
115
|
+
function parseTasksMd(cwd) {
|
|
116
|
+
const fp = path.join(cwd, '.great_cto', 'tasks.md');
|
|
117
|
+
if (!fs.existsSync(fp)) return [];
|
|
118
|
+
try {
|
|
119
|
+
const text = fs.readFileSync(fp, 'utf8');
|
|
120
|
+
const tasks = [];
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
for (let i = 0; i < lines.length; i++) {
|
|
123
|
+
const m = lines[i].match(/^-\s+\[([ x])\]\s+([A-Z]+-\d+):\s+(.+?)(?:\s+\[([\w-]+)\])?(?:\s+\[~?([^\]]+)\])?\s*$/);
|
|
124
|
+
if (!m) continue;
|
|
125
|
+
const [, done, id, title, agent, est] = m;
|
|
126
|
+
// Collect indented description lines until next blank or task
|
|
127
|
+
let desc = '';
|
|
128
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
129
|
+
if (/^\s+\S/.test(lines[j])) { desc += lines[j].trim() + ' '; }
|
|
130
|
+
else break;
|
|
131
|
+
}
|
|
132
|
+
const isGate = /^gate:/i.test(title) || (title || '').toLowerCase().includes('gate');
|
|
133
|
+
tasks.push({
|
|
134
|
+
id,
|
|
135
|
+
title: title.trim(),
|
|
136
|
+
description: desc.trim(),
|
|
137
|
+
notes: '',
|
|
138
|
+
design: '',
|
|
139
|
+
acceptance: '',
|
|
140
|
+
status: done === 'x' ? 'done' : (isGate ? 'gate' : 'backlog'),
|
|
141
|
+
raw_status: done === 'x' ? 'closed' : 'open',
|
|
142
|
+
priority: 2,
|
|
143
|
+
labels: agent ? [agent] : [],
|
|
144
|
+
owner: agent || '',
|
|
145
|
+
created_at: null,
|
|
146
|
+
updated_at: null,
|
|
147
|
+
closed_at: null,
|
|
148
|
+
close_reason: '',
|
|
149
|
+
comment_count: 0,
|
|
150
|
+
is_gate: isGate,
|
|
151
|
+
agent: agent || '',
|
|
152
|
+
estimated_minutes: est ? parseInt(est) || null : null,
|
|
153
|
+
source: 'tasks.md',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return tasks;
|
|
157
|
+
} catch { return []; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getTasks(cwd = process.cwd()) {
|
|
161
|
+
const all = bdList(cwd);
|
|
162
|
+
// Fallback to tasks.md when no Beads tasks (project not initialized with bd)
|
|
163
|
+
if (all.length === 0) {
|
|
164
|
+
const mdTasks = parseTasksMd(cwd);
|
|
165
|
+
if (mdTasks.length > 0) return mdTasks;
|
|
166
|
+
}
|
|
167
|
+
return all.map(t => ({
|
|
168
|
+
id: t.id,
|
|
169
|
+
title: t.title,
|
|
170
|
+
description: t.description || '',
|
|
171
|
+
notes: t.notes || '',
|
|
172
|
+
design: t.design || '',
|
|
173
|
+
acceptance: t.acceptance || '',
|
|
174
|
+
status: mapStatus(t.status, t.labels, t.issue_type),
|
|
175
|
+
raw_status: t.status, // bd-native status (open/in_progress/closed/blocked)
|
|
176
|
+
priority: t.priority,
|
|
177
|
+
labels: t.labels || [],
|
|
178
|
+
owner: t.owner || '',
|
|
179
|
+
created_at: t.created_at,
|
|
180
|
+
updated_at: t.updated_at,
|
|
181
|
+
closed_at: t.closed_at || null,
|
|
182
|
+
close_reason: t.close_reason || '',
|
|
183
|
+
comment_count: t.comment_count || 0,
|
|
184
|
+
// Gate detection: explicit 'gate' label OR bd decision type OR title contains 'gate:'
|
|
185
|
+
is_gate: (t.labels || []).includes('gate')
|
|
186
|
+
|| t.issue_type === 'decision'
|
|
187
|
+
|| (t.title || '').toLowerCase().startsWith('gate:'),
|
|
188
|
+
agent: detectAgent(t),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mapStatus(status, labels = [], issue_type = '') {
|
|
193
|
+
// Terminal status takes precedence over the 'gate' classification.
|
|
194
|
+
// Otherwise closed gate tasks would still appear as 'gate' status, which
|
|
195
|
+
// breaks Pending-decisions / P0-open / Active-pipeline aggregates that
|
|
196
|
+
// consider "anything mapped to gate" still actionable.
|
|
197
|
+
// Reported by Codex against /api/inbox showing 3 closed gates as P0 open.
|
|
198
|
+
if (status === 'closed') return 'done';
|
|
199
|
+
if (status === 'blocked') return 'blocked';
|
|
200
|
+
if ((labels || []).includes('gate') || issue_type === 'decision') return 'gate';
|
|
201
|
+
switch (status) {
|
|
202
|
+
case 'open': return 'backlog';
|
|
203
|
+
case 'in_progress': return 'in_progress';
|
|
204
|
+
default: return 'backlog';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function detectAgent(task) {
|
|
209
|
+
const title = (task.title || '').toLowerCase();
|
|
210
|
+
if (title.includes('architect') || title.includes('arch')) return 'architect';
|
|
211
|
+
if (title.includes('pm:') || title.includes('product-manager') || title.includes('plan ')) return 'pm';
|
|
212
|
+
if (title.includes('senior') || title.includes('impl') || title.includes('feat') || title.includes('fix')) return 'senior-dev';
|
|
213
|
+
if (title.includes('qa') || title.includes('test')) return 'qa-engineer';
|
|
214
|
+
if (title.includes('sec') || title.includes('cso')) return 'security-officer';
|
|
215
|
+
if (title.includes('deploy') || title.includes('release')) return 'devops';
|
|
216
|
+
if (title.includes('gate:')) return 'gate';
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export {
|
|
221
|
+
bdCacheInvalidate,
|
|
222
|
+
BD_BIN,
|
|
223
|
+
bdEnv,
|
|
224
|
+
bd,
|
|
225
|
+
bdErr,
|
|
226
|
+
checkBeadsAvailable,
|
|
227
|
+
bdWriteSerialised,
|
|
228
|
+
bdList,
|
|
229
|
+
parseTasksMd,
|
|
230
|
+
getTasks,
|
|
231
|
+
mapStatus,
|
|
232
|
+
detectAgent,
|
|
233
|
+
};
|