great-cto 2.76.0 → 2.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/board/.claude-plugin/plugin.json +256 -0
  2. package/board/packages/board/lib/alerts.mjs +413 -0
  3. package/board/packages/board/lib/beads.mjs +233 -0
  4. package/board/packages/board/lib/config.mjs +45 -0
  5. package/board/packages/board/lib/data-readers.mjs +340 -0
  6. package/board/packages/board/lib/fleet.mjs +363 -0
  7. package/board/packages/board/lib/metrics.mjs +282 -0
  8. package/board/packages/board/lib/notifications.mjs +50 -0
  9. package/board/packages/board/lib/projects.mjs +256 -0
  10. package/board/packages/board/lib/routes.mjs +1036 -0
  11. package/board/packages/board/lib/share.mjs +143 -0
  12. package/board/packages/board/lib/sse.mjs +20 -0
  13. package/board/packages/board/lib/state.mjs +31 -0
  14. package/board/packages/board/lib/util.mjs +36 -0
  15. package/board/packages/board/lib/verdicts.mjs +168 -0
  16. package/board/packages/board/lib/watchers.mjs +87 -0
  17. package/board/packages/board/mcp-server.mjs +310 -0
  18. package/board/packages/board/public/assets/apple-touch-icon.png +0 -0
  19. package/board/packages/board/public/assets/favicon-16.png +0 -0
  20. package/board/packages/board/public/assets/favicon-32.png +0 -0
  21. package/board/packages/board/public/assets/favicon.ico +0 -0
  22. package/board/packages/board/public/assets/favicon.svg +8 -0
  23. package/board/packages/board/public/index.html +5452 -0
  24. package/board/packages/board/public/share.html +1190 -0
  25. package/board/packages/board/public/sw.js +79 -0
  26. package/board/packages/board/push-adapter.mjs +222 -0
  27. package/board/packages/board/server.mjs +133 -0
  28. package/board/packages/cli/dist/archetypes.js +1461 -0
  29. package/board/scripts/lib/change-tier.mjs +119 -0
  30. package/board/scripts/lib/gate-plan.mjs +119 -0
  31. package/board/scripts/lib/judge-model.mjs +42 -0
  32. package/dist/board-path.js +47 -0
  33. package/dist/main.js +3 -31
  34. package/package.json +3 -1
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Service Worker for great_cto board — Web Push support
3
+ *
4
+ * Strategy: empty-body push (SW fetches content from server on push).
5
+ * This avoids payload encryption complexity while keeping notifications fresh.
6
+ */
7
+
8
+ const BOARD_ORIGIN = self.location.origin;
9
+
10
+ self.addEventListener('activate', (event) => {
11
+ // Take control of all clients immediately — no reload required after install
12
+ event.waitUntil(self.clients.claim());
13
+ });
14
+
15
+ self.addEventListener('push', (event) => {
16
+ event.waitUntil(handlePush());
17
+ });
18
+
19
+ async function handlePush() {
20
+ let notif = null;
21
+ try {
22
+ const res = await fetch(`${BOARD_ORIGIN}/api/notif-history?unread=1&limit=1`);
23
+ if (res.ok) {
24
+ const data = await res.json();
25
+ if (Array.isArray(data) && data.length > 0) {
26
+ notif = data[0];
27
+ }
28
+ }
29
+ } catch { /* network error — show a generic notification */ }
30
+
31
+ const title = notif?.title || 'great_cto';
32
+ const body = notif?.body || 'You have a new notification.';
33
+ const tag = notif?.id || 'gcto-push';
34
+ const notifId = notif?.id || null;
35
+
36
+ const options = {
37
+ body,
38
+ tag,
39
+ // icon may not exist — browsers handle missing icon gracefully
40
+ icon: `${BOARD_ORIGIN}/assets/logo-192.png`,
41
+ badge: `${BOARD_ORIGIN}/assets/favicon-32.png`,
42
+ data: {
43
+ url: `${BOARD_ORIGIN}/`,
44
+ notifId,
45
+ },
46
+ requireInteraction: false,
47
+ };
48
+
49
+ return self.registration.showNotification(title, options);
50
+ }
51
+
52
+ self.addEventListener('notificationclick', (event) => {
53
+ event.notification.close();
54
+ const { url, notifId } = event.notification.data || {};
55
+
56
+ event.waitUntil((async () => {
57
+ // Mark notification as read
58
+ if (notifId) {
59
+ try {
60
+ await fetch(`${BOARD_ORIGIN}/api/notif-history/read`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ id: notifId }),
64
+ });
65
+ } catch { /* best-effort */ }
66
+ }
67
+
68
+ // Focus existing board window or open a new one
69
+ const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
70
+ for (const client of clientList) {
71
+ if (client.url.startsWith(BOARD_ORIGIN) && 'focus' in client) {
72
+ return client.focus();
73
+ }
74
+ }
75
+ if (self.clients.openWindow) {
76
+ return self.clients.openWindow(url || `${BOARD_ORIGIN}/`);
77
+ }
78
+ })());
79
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * push-adapter.mjs — zero-dependency VAPID Web Push helper
3
+ *
4
+ * Uses only Node.js built-in modules: node:crypto, node:https, node:http, node:fs, node:url
5
+ *
6
+ * Why zero-dep: server.mjs is intentionally dependency-free so the board
7
+ * starts with just `node server.mjs` — no npm install required.
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import https from 'node:https';
11
+ import http from 'node:http';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { URL } from 'node:url';
15
+
16
+ // ── Helpers: base64url ────────────────────────────────────────────────────
17
+
18
+ function toBase64url(buf) {
19
+ return Buffer.from(buf).toString('base64url');
20
+ }
21
+
22
+ function fromBase64url(str) {
23
+ return Buffer.from(str, 'base64url');
24
+ }
25
+
26
+ // ── DER → raw R||S (64 bytes) ─────────────────────────────────────────────
27
+ /**
28
+ * Convert DER-encoded ECDSA signature to raw R||S (64 bytes).
29
+ * DER structure: 30 [total_len] 02 [r_len] [r_bytes] 02 [s_len] [s_bytes]
30
+ * Handles both short (1-byte) and long-form (multi-byte) length encodings.
31
+ */
32
+ export function derToRaw(der) {
33
+ let offset = 0;
34
+ // Skip 0x30 (SEQUENCE tag)
35
+ if (der[offset++] !== 0x30) throw new Error('DER: expected SEQUENCE tag 0x30');
36
+ // Skip total length (may be long form)
37
+ if (der[offset] & 0x80) {
38
+ offset += (der[offset] & 0x7f) + 1;
39
+ } else {
40
+ offset++;
41
+ }
42
+ // Read r
43
+ if (der[offset++] !== 0x02) throw new Error('DER: expected INTEGER tag 0x02 for r');
44
+ const rLen = der[offset++];
45
+ const r = der.slice(offset, offset + rLen);
46
+ offset += rLen;
47
+ // Read s
48
+ if (der[offset++] !== 0x02) throw new Error('DER: expected INTEGER tag 0x02 for s');
49
+ const sLen = der[offset++];
50
+ const s = der.slice(offset, offset + sLen);
51
+
52
+ // Pad r and s to 32 bytes each (DER may prepend 0x00 for sign, or be shorter)
53
+ const result = Buffer.alloc(64, 0);
54
+ // r: right-align, ignore leading 0x00 padding
55
+ const rStart = Math.max(0, r.length - 32);
56
+ const rDest = 32 - Math.min(r.length, 32);
57
+ r.copy(result, rDest, rStart);
58
+ // s: right-align
59
+ const sStart = Math.max(0, s.length - 32);
60
+ const sDest = 64 - Math.min(s.length, 32);
61
+ s.copy(result, sDest, sStart);
62
+
63
+ return result;
64
+ }
65
+
66
+ // ── VAPID key management ───────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Generate or load VAPID key pair.
70
+ * Persists as JWK JSON at keyPath. Returns { publicKey: base64url, privateKey: JWK }.
71
+ * The publicKey is the uncompressed P-256 point: 0x04 || x || y (65 bytes, base64url).
72
+ */
73
+ export function getVapidKeys(keyPath) {
74
+ // Load existing keys from disk
75
+ if (fs.existsSync(keyPath)) {
76
+ try {
77
+ const stored = JSON.parse(fs.readFileSync(keyPath, 'utf8'));
78
+ if (stored.publicKey && stored.privateKey) return stored;
79
+ } catch { /* regenerate if corrupt */ }
80
+ }
81
+
82
+ // Generate new P-256 key pair
83
+ const { privateKey: privObj } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
84
+ const jwk = privObj.export({ format: 'jwk' });
85
+ // jwk = { kty: 'EC', crv: 'P-256', x, y, d }
86
+
87
+ // Build uncompressed public key: 0x04 || x || y
88
+ const xBuf = fromBase64url(jwk.x);
89
+ const yBuf = fromBase64url(jwk.y);
90
+ const pubRaw = Buffer.concat([Buffer.from([0x04]), xBuf, yBuf]);
91
+ const publicKey = toBase64url(pubRaw);
92
+
93
+ const keys = { publicKey, privateKey: jwk };
94
+
95
+ // Persist — best effort
96
+ try { fs.mkdirSync(path.dirname(keyPath), { recursive: true }); } catch { /* ignore */ }
97
+ try { fs.writeFileSync(keyPath, JSON.stringify(keys, null, 2)); } catch { /* best-effort */ }
98
+
99
+ return keys;
100
+ }
101
+
102
+ // ── VAPID JWT ─────────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Build an ES256 JWT for VAPID authorization.
106
+ *
107
+ * @param {string} endpoint - push service endpoint URL
108
+ * @param {object} privateKeyJwk - JWK { kty, crv, x, y, d }
109
+ * @param {string} subject - mailto: or https: contact URI
110
+ * @returns {string} compact JWT (header.payload.signature)
111
+ */
112
+ export function createVapidJwt(endpoint, privateKeyJwk, subject) {
113
+ // aud = origin of the push endpoint
114
+ const url = new URL(endpoint);
115
+ const aud = `${url.protocol}//${url.host}`;
116
+ const exp = Math.floor(Date.now() / 1000) + 12 * 3600; // 12h
117
+
118
+ const header = toBase64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));
119
+ const payload = toBase64url(JSON.stringify({ aud, exp, sub: subject }));
120
+ const signingInput = `${header}.${payload}`;
121
+
122
+ // Re-import JWK as crypto private key for signing
123
+ const privKey = crypto.createPrivateKey({ key: privateKeyJwk, format: 'jwk' });
124
+
125
+ // Sign with ECDSA SHA-256 → DER output
126
+ const sign = crypto.createSign('SHA256');
127
+ sign.update(signingInput);
128
+ const derSig = sign.sign(privKey);
129
+
130
+ // Convert DER → raw R||S (64 bytes)
131
+ const rawSig = derToRaw(derSig);
132
+ const signature = toBase64url(rawSig);
133
+
134
+ return `${signingInput}.${signature}`;
135
+ }
136
+
137
+ // ── HTTP POST to push service ─────────────────────────────────────────────
138
+
139
+ /**
140
+ * Send a Web Push notification (empty body — SW fetches content from server).
141
+ *
142
+ * @param {object} subscription - { endpoint, keys: { p256dh, auth } }
143
+ * @param {object} vapidKeys - { publicKey, privateKey }
144
+ * @param {string} subject - VAPID contact URI
145
+ * @returns {Promise<void>}
146
+ * @throws on non-2xx (except 201). 410 = subscription expired — caller removes it.
147
+ */
148
+ export function sendWebPush(subscription, vapidKeys, subject) {
149
+ return new Promise((resolve, reject) => {
150
+ const endpoint = subscription.endpoint;
151
+ const jwt = createVapidJwt(endpoint, vapidKeys.privateKey, subject);
152
+ const authorization = `vapid t=${jwt},k=${vapidKeys.publicKey}`;
153
+
154
+ let parsedUrl;
155
+ try { parsedUrl = new URL(endpoint); }
156
+ catch (e) { return reject(new Error(`Invalid push endpoint URL: ${endpoint}`)); }
157
+
158
+ const options = {
159
+ method: 'POST',
160
+ hostname: parsedUrl.hostname,
161
+ port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
162
+ path: parsedUrl.pathname + parsedUrl.search,
163
+ headers: {
164
+ 'Authorization': authorization,
165
+ 'TTL': '86400',
166
+ 'Content-Length': '0',
167
+ 'Content-Type': 'application/octet-stream',
168
+ },
169
+ };
170
+
171
+ const lib = parsedUrl.protocol === 'https:' ? https : http;
172
+ const req = lib.request(options, (res) => {
173
+ // Drain response body
174
+ res.resume();
175
+ res.on('end', () => {
176
+ const sc = res.statusCode;
177
+ if (sc === 201 || (sc >= 200 && sc < 300)) {
178
+ resolve();
179
+ } else {
180
+ const err = new Error(`Push service returned ${sc}`);
181
+ err.statusCode = sc;
182
+ reject(err);
183
+ }
184
+ });
185
+ });
186
+
187
+ req.on('error', reject);
188
+ req.end();
189
+ });
190
+ }
191
+
192
+ // ── Subscription persistence ──────────────────────────────────────────────
193
+
194
+ export function loadSubscriptions(filePath) {
195
+ try {
196
+ if (!fs.existsSync(filePath)) return [];
197
+ const raw = fs.readFileSync(filePath, 'utf8');
198
+ const parsed = JSON.parse(raw);
199
+ return Array.isArray(parsed) ? parsed : [];
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ export function saveSubscriptions(filePath, subs) {
206
+ try {
207
+ fs.writeFileSync(filePath, JSON.stringify(subs, null, 2));
208
+ } catch { /* best-effort */ }
209
+ }
210
+
211
+ export function addSubscription(filePath, sub) {
212
+ const subs = loadSubscriptions(filePath);
213
+ if (subs.some(s => s.endpoint === sub.endpoint)) return; // dedupe
214
+ subs.push(sub);
215
+ saveSubscriptions(filePath, subs);
216
+ }
217
+
218
+ export function removeSubscription(filePath, endpoint) {
219
+ const subs = loadSubscriptions(filePath);
220
+ const filtered = subs.filter(s => s.endpoint !== endpoint);
221
+ saveSubscriptions(filePath, filtered);
222
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * great_cto board server
4
+ * Serves Kanban + CTO Dashboard on localhost:3141
5
+ * Data source: bd list --json (Beads), verdicts/*.log, docs/
6
+ *
7
+ * Usage: node server.mjs [--port 3141] [--no-open]
8
+ */
9
+
10
+ import http from 'http';
11
+ // The build board. (The separate operator-console runtime lives in its own repo.)
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { spawnSync } from 'child_process';
15
+ import { PORT, PUBLIC, HOST } from './lib/config.mjs';
16
+ import { originAllowed } from './lib/util.mjs';
17
+ import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
18
+ import { startAlertCron } from './lib/alerts.mjs';
19
+ import { watchBeads, watchVerdicts } from './lib/watchers.mjs';
20
+ import { dispatch } from './lib/routes.mjs';
21
+
22
+ // ── Helpers ────────────────────────────────────────────────────────────────────
23
+
24
+ // ── HTTP router ────────────────────────────────────────────────────────────────
25
+ const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB — board payloads are tiny; cap guards against unbounded `body += c` accumulation.
26
+ const server = http.createServer(async (req, res) => {
27
+ // Single-point body-size guard: every route reads `body += c`, so cap cumulative
28
+ // request bytes here and kill the socket on overflow rather than buffering forever.
29
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
30
+ let _seen = 0;
31
+ req.on('data', (c) => {
32
+ _seen += c.length;
33
+ if (_seen > MAX_BODY_BYTES && !res.headersSent) {
34
+ res.writeHead(413, { 'Content-Type': 'application/json' });
35
+ res.end(JSON.stringify({ error: 'request body too large' }));
36
+ req.destroy();
37
+ }
38
+ });
39
+ }
40
+ const url = new URL(req.url, `http://localhost:${PORT}`);
41
+ const pathname = url.pathname;
42
+ const proj = url.searchParams.get('project');
43
+ // BH-5 fix: surface project resolution as a response header. Previously
44
+ // ?project=<unknown> silently returned cwd's data — user thought they
45
+ // were viewing projectX but saw projectY. Now: X-Project-Fallback header
46
+ // tells the client what happened.
47
+ const projInfo = resolveProjectInfo(proj);
48
+ const cwd = projInfo.cwd;
49
+ if (projInfo.resolved === 'fallback') {
50
+ res.setHeader('X-Project-Fallback', `requested='${projInfo.requested}' served='cwd'`);
51
+ res.setHeader('X-Project-Resolved', 'fallback');
52
+ } else {
53
+ res.setHeader('X-Project-Resolved', projInfo.resolved);
54
+ }
55
+
56
+ // CORS
57
+ res.setHeader('Access-Control-Allow-Origin', '*');
58
+ // Expose our debug headers to browsers (CORS hides custom headers by default)
59
+ res.setHeader('Access-Control-Expose-Headers', 'X-Project-Fallback, X-Project-Resolved');
60
+
61
+ // ── CSRF guard (BH-31) ──────────────────────────────────────────────────────
62
+ // The board binds 127.0.0.1, but a page the user visits in their browser can still
63
+ // issue *simple* cross-origin POSTs to localhost (no preflight). Every state-changing
64
+ // request must therefore be SAME-ORIGIN — otherwise a malicious page could approve an
65
+ // autopilot gate (→ run an irreversible write), approve a dev gate, or mutate tasks.
66
+ // (originAllowed() permits requests with no Origin — curl, the CLI, server-to-server —
67
+ // and rejects a foreign browser Origin.)
68
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method) && !originAllowed(req)) {
69
+ res.writeHead(403, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ error: 'cross-origin request blocked — the board only accepts same-origin state changes' }));
71
+ return;
72
+ }
73
+
74
+ // Try all /api/* routes + /api/sse first.
75
+ if (await dispatch(req, res, url, cwd)) return;
76
+
77
+ // Static files
78
+ let filePath = pathname === '/' ? '/index.html' : pathname;
79
+ const fullPath = path.join(PUBLIC, filePath);
80
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
81
+ const ext = path.extname(fullPath);
82
+ const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
83
+ // HTML must never cache — board UI is iterated daily and stale layouts
84
+ // hide new features (period selector, push toggle, etc.) until hard refresh
85
+ const headers = { 'Content-Type': mime[ext] || 'text/plain' };
86
+ if (ext === '.html' || ext === '.js') headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
87
+ res.writeHead(200, headers);
88
+ res.end(fs.readFileSync(fullPath));
89
+ return;
90
+ }
91
+
92
+ // API requests get a JSON 404 so frontends can JSON.parse() the response
93
+ // without crashing. Static-file 404s stay plain text.
94
+ if (pathname.startsWith('/api/')) {
95
+ res.writeHead(404, { 'Content-Type': 'application/json' });
96
+ res.end(JSON.stringify({ error: 'Not found', path: pathname, hint: 'Endpoint missing — restart the board after a great-cto update.' }));
97
+ return;
98
+ }
99
+ res.writeHead(404);
100
+ res.end('Not found');
101
+ });
102
+
103
+ // ── Start ──────────────────────────────────────────────────────────────────────
104
+ server.listen(PORT, HOST, () => {
105
+ console.log(`great_cto board → http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
106
+ if (HOST !== '127.0.0.1' && HOST !== 'localhost') {
107
+ console.log(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
108
+ console.log(` links; put your reverse-proxy auth in front for anything admin-grade.`);
109
+ }
110
+ // Discover all great_cto projects on disk asynchronously — don't block
111
+ // the listening event so /api/tasks is available immediately.
112
+ discoverProjects().then(n => {
113
+ if (n > 0) console.log(` → discovered ${n} project${n === 1 ? '' : 's'} with .great_cto/PROJECT.md`);
114
+ }).catch(() => {}); // non-fatal
115
+ watchBeads();
116
+ startAlertCron();
117
+ watchVerdicts();
118
+
119
+ // Auto-open browser unless --no-open
120
+ if (!process.argv.includes('--no-open')) {
121
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
122
+ spawnSync(opener, [`http://localhost:${PORT}`], { detached: true });
123
+ }
124
+ });
125
+
126
+ server.on('error', e => {
127
+ if (e.code === 'EADDRINUSE') {
128
+ console.log(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
129
+ } else {
130
+ console.error(e);
131
+ }
132
+ process.exit(0);
133
+ });