great-cto 2.77.0 → 2.77.2

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 (41) 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 +241 -0
  4. package/board/packages/board/lib/config.mjs +49 -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 +307 -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 +116 -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/assets/fonts/PROVENANCE.md +21 -0
  24. package/board/packages/board/public/assets/fonts/fonts.css +26 -0
  25. package/board/packages/board/public/assets/fonts/geist-mono-variable.woff2 +0 -0
  26. package/board/packages/board/public/assets/fonts/geist-variable.woff2 +0 -0
  27. package/board/packages/board/public/assets/vendor/PROVENANCE.md +21 -0
  28. package/board/packages/board/public/assets/vendor/marked.min.js +6 -0
  29. package/board/packages/board/public/assets/vendor/purify.min.js +3 -0
  30. package/board/packages/board/public/index.html +5464 -0
  31. package/board/packages/board/public/share.html +1194 -0
  32. package/board/packages/board/public/sw.js +79 -0
  33. package/board/packages/board/push-adapter.mjs +222 -0
  34. package/board/packages/board/server.mjs +133 -0
  35. package/board/packages/cli/dist/archetypes.js +1461 -0
  36. package/board/scripts/lib/change-tier.mjs +119 -0
  37. package/board/scripts/lib/gate-plan.mjs +119 -0
  38. package/board/scripts/lib/judge-model.mjs +42 -0
  39. package/dist/board-path.js +47 -0
  40. package/dist/main.js +3 -31
  41. package/package.json +3 -1
@@ -0,0 +1,50 @@
1
+ import fs from 'fs';
2
+ import crypto from 'node:crypto';
3
+ import { GREAT_CTO_DIR, NOTIF_HISTORY_FILE } from './config.mjs';
4
+ import { notifHistory, MAX_NOTIF_HISTORY } from './state.mjs';
5
+ import { eventSurface } from './util.mjs';
6
+ import { broadcast } from './sse.mjs';
7
+
8
+ function loadNotifHistory() {
9
+ try {
10
+ if (fs.existsSync(NOTIF_HISTORY_FILE)) {
11
+ const parsed = JSON.parse(fs.readFileSync(NOTIF_HISTORY_FILE, 'utf8'));
12
+ if (Array.isArray(parsed)) { notifHistory.length = 0; notifHistory.push(...parsed); }
13
+ }
14
+ } catch { /* start fresh on corrupt file */ }
15
+ }
16
+
17
+ function saveNotifHistory() {
18
+ try {
19
+ fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
20
+ fs.writeFileSync(NOTIF_HISTORY_FILE, JSON.stringify(notifHistory, null, 2));
21
+ } catch { /* best-effort */ }
22
+ }
23
+
24
+ /**
25
+ * Record a notification, broadcast via SSE, and persist.
26
+ * Called alongside fireEmailAlert / firePushAlert at every trigger point.
27
+ */
28
+ function addNotification(event, payload) {
29
+ const notif = {
30
+ id: crypto.randomUUID(),
31
+ event,
32
+ surface: eventSurface(event),
33
+ title: payload.title,
34
+ body: payload.body,
35
+ level: payload.level || 'info',
36
+ project: payload.project || '',
37
+ ts: new Date().toISOString(),
38
+ read: false,
39
+ };
40
+ notifHistory.unshift(notif);
41
+ if (notifHistory.length > MAX_NOTIF_HISTORY) notifHistory.length = MAX_NOTIF_HISTORY;
42
+ broadcast('notification', notif);
43
+ saveNotifHistory();
44
+ return notif;
45
+ }
46
+
47
+ // Load history at server start
48
+ loadNotifHistory();
49
+
50
+ export { loadNotifHistory, saveNotifHistory, addNotification };
@@ -0,0 +1,307 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { spawnSync } from 'child_process';
5
+ import { planGates } from '../../../scripts/lib/gate-plan.mjs';
6
+ import { GREAT_CTO_DIR, PROJECTS_FILE } from './config.mjs';
7
+
8
+ // ── Project registry ───────────────────────────────────────────────────────────
9
+ function readProjectsRegistry() {
10
+ try { if (fs.existsSync(PROJECTS_FILE)) return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); }
11
+ catch {}
12
+ return { projects: [] };
13
+ }
14
+ // Pick the best entry among several sharing a slug: prefer one whose path
15
+ // still exists on disk; among several existing (or several missing), prefer
16
+ // the most recently added_at. Never returns a non-existent path when an
17
+ // existing match is available (great_cto-7xfc).
18
+ function pickBestBySlug(candidates) {
19
+ let best = null;
20
+ for (const p of candidates) {
21
+ if (!best) { best = p; continue; }
22
+ const pExists = existsSafe(p.path);
23
+ const bestExists = existsSafe(best.path);
24
+ if (pExists && !bestExists) { best = p; continue; }
25
+ if (!pExists && bestExists) continue;
26
+ // Both exist or both missing — prefer most recent added_at.
27
+ if (String(p.added_at || '') > String(best.added_at || '')) best = p;
28
+ }
29
+ return best;
30
+ }
31
+ function existsSafe(p) {
32
+ try { return !!p && fs.existsSync(p); } catch { return false; }
33
+ }
34
+ // Dedupe registry entries by slug before writing, so the registry self-heals
35
+ // on every write regardless of how the duplicate got in (great_cto-7xfc).
36
+ function dedupeProjects(projects) {
37
+ const bySlug = new Map();
38
+ for (const p of projects) {
39
+ if (!p || !p.slug) continue;
40
+ const group = bySlug.get(p.slug) || [];
41
+ group.push(p);
42
+ bySlug.set(p.slug, group);
43
+ }
44
+ const out = [];
45
+ for (const group of bySlug.values()) out.push(pickBestBySlug(group));
46
+ return out;
47
+ }
48
+ function writeProjectsRegistry(reg) {
49
+ fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
50
+ const deduped = { ...reg, projects: dedupeProjects(reg.projects || []) };
51
+ fs.writeFileSync(PROJECTS_FILE, JSON.stringify(deduped, null, 2));
52
+ }
53
+ // Map legacy / informal archetype names to canonical archetype slugs.
54
+ // This lets the board correctly badge old PROJECT.md files (mobile, saas-platform,
55
+ // ai-agent, etc.) without forcing users to rewrite them.
56
+ const ARCHETYPE_ALIASES = {
57
+ 'mobile': 'mobile-app',
58
+ 'mobile-android': 'mobile-app',
59
+ 'mobile-ios': 'mobile-app',
60
+ 'saas-platform': 'enterprise-saas',
61
+ 'saas': 'enterprise-saas',
62
+ 'ai-agent': 'agent-product',
63
+ 'agent': 'agent-product',
64
+ 'rag-system': 'ai-system',
65
+ 'llm-app': 'ai-system',
66
+ 'trading-system': 'fintech',
67
+ 'financial-platform': 'fintech',
68
+ 'neobroker': 'fintech',
69
+ 'neobank': 'fintech',
70
+ 'ml-platform': 'mlops',
71
+ 'ml-training': 'mlops',
72
+ 'ml-serving': 'mlops',
73
+ 'web-fullstack': 'web-service',
74
+ 'web-app': 'web-service',
75
+ 'api': 'web-service',
76
+ 'backend': 'web-service',
77
+ 'cli': 'cli-tool',
78
+ 'sdk': 'library',
79
+ 'npm-library': 'library',
80
+ };
81
+
82
+ function normalizeArchetype(raw) {
83
+ if (!raw) return '';
84
+ // Strip backticks, quotes, surrounding whitespace
85
+ const clean = raw.replace(/[`'"]/g, '').trim().toLowerCase();
86
+ // Take first token if comma/plus-separated (e.g. "trading-system + financial-platform")
87
+ const first = clean.split(/[,+]/)[0].trim();
88
+ return ARCHETYPE_ALIASES[first] || first;
89
+ }
90
+
91
+ function extractArchetype(text) {
92
+ // 1. Canonical: `archetype: foo`
93
+ let m = text.match(/^archetype:\s*(.+)$/m);
94
+ if (m) return normalizeArchetype(m[1]);
95
+ // 2. Legacy yaml-like: `primary: foo`
96
+ m = text.match(/^primary:\s*(.+)$/m);
97
+ if (m) return normalizeArchetype(m[1]);
98
+ // 3. Markdown list (bullet): `- Primary: \`foo\`` or `- primary: foo + bar`
99
+ m = text.match(/^[-*]\s*Primary:\s*(.+)$/im);
100
+ if (m) return normalizeArchetype(m[1]);
101
+ return '';
102
+ }
103
+
104
+ function readProjectMd(dir) {
105
+ const p = path.join(dir, '.great_cto', 'PROJECT.md');
106
+ if (!fs.existsSync(p)) return null;
107
+ const text = fs.readFileSync(p, 'utf8');
108
+ const get = k => (text.match(new RegExp(`^${k}:\\s*(.+)$`, 'm')) || [])[1]?.trim() || '';
109
+ return {
110
+ slug: get('project') || get('name') || path.basename(dir),
111
+ archetype: extractArchetype(text) || 'unknown',
112
+ description: get('description') || '',
113
+ path: dir,
114
+ added_at: new Date().toISOString(),
115
+ };
116
+ }
117
+ // change_tier for a project's working-tree diff (gate-tiering, ADR-003/004): which
118
+ // human gates open for the current change + which judge model runs. Pure planGates()
119
+ // over the project's archetype/size + `git diff --name-only`.
120
+ function getChangeTier(dir) {
121
+ try {
122
+ const meta = readProjectMd(dir);
123
+ if (!meta) return { tier: null, error: 'no PROJECT.md' };
124
+ const pm = fs.readFileSync(path.join(dir, '.great_cto', 'PROJECT.md'), 'utf8');
125
+ const size = (pm.match(/^project_size:\s*(\S+)/m) || [])[1] || 'medium';
126
+ const out = spawnSync('git', ['diff', '--name-only', 'HEAD'], { cwd: dir, encoding: 'utf8', timeout: 5000 });
127
+ const changedFiles = String(out.stdout || '').split('\n').map(s => s.trim()).filter(Boolean);
128
+ return planGates({ archetype: meta.archetype, size, changedFiles });
129
+ } catch (e) {
130
+ return { tier: 'T2', error: e.message }; // fail-safe: unknown → full gates
131
+ }
132
+ }
133
+ function autoRegisterProject(dir) {
134
+ const meta = readProjectMd(dir);
135
+ if (!meta) return null;
136
+ const reg = readProjectsRegistry();
137
+ const existingByPath = reg.projects.find(p => p.path === meta.path);
138
+ if (existingByPath) return meta; // already registered at this path, nothing to do
139
+ const existingBySlug = reg.projects.find(p => p.slug === meta.slug);
140
+ if (existingBySlug) {
141
+ // Repo moved: update the existing entry's path in place instead of
142
+ // inserting a second entry for the same slug (great_cto-7xfc).
143
+ existingBySlug.path = meta.path;
144
+ existingBySlug.archetype = meta.archetype;
145
+ existingBySlug.description = meta.description;
146
+ existingBySlug.added_at = meta.added_at;
147
+ } else {
148
+ reg.projects.push(meta);
149
+ }
150
+ writeProjectsRegistry(reg);
151
+ return meta;
152
+ }
153
+
154
+ // Discover great_cto projects across common dev folders + Claude Code's known
155
+ // project list. Runs at server startup AND every /api/projects request, so any
156
+ // project that ran /audit or /start (which writes .great_cto/PROJECT.md) gets
157
+ // auto-registered without the user having to do anything.
158
+ // Fully async — never blocks the event loop.
159
+ async function discoverProjects() {
160
+ const fsAsync = fs.promises;
161
+ const HOME = os.homedir();
162
+ const seen = new Set();
163
+ const found = [];
164
+
165
+ async function scanDir(dir, depth) {
166
+ if (depth < 0 || seen.has(dir)) return;
167
+ seen.add(dir);
168
+ try {
169
+ // Check the dir itself first
170
+ try {
171
+ await fsAsync.access(path.join(dir, '.great_cto', 'PROJECT.md'));
172
+ found.push(dir);
173
+ return; // don't descend into a registered project
174
+ } catch {}
175
+ if (depth === 0) return;
176
+ // Scan children (skip dotfiles + heavyweight dirs)
177
+ const entries = await fsAsync.readdir(dir, { withFileTypes: true });
178
+ for (const e of entries) {
179
+ if (!e.isDirectory()) continue;
180
+ const name = e.name;
181
+ if (name.startsWith('.') || name === 'node_modules' || name === 'dist' ||
182
+ name === 'build' || name === 'target' || name === 'venv' || name === '__pycache__') continue;
183
+ await scanDir(path.join(dir, name), depth - 1);
184
+ }
185
+ } catch {} // permission denied — skip silently
186
+ }
187
+
188
+ // 1) Common dev folders — top-level scan, 1-level deep
189
+ const roots = [
190
+ path.join(HOME, 'work'),
191
+ path.join(HOME, 'dev'),
192
+ path.join(HOME, 'development'),
193
+ path.join(HOME, 'code'),
194
+ path.join(HOME, 'projects'),
195
+ path.join(HOME, 'src'),
196
+ path.join(HOME, 'Documents', 'projects'),
197
+ HOME,
198
+ ];
199
+ for (const root of roots) {
200
+ try { await fsAsync.access(root); await scanDir(root, 1); } catch {}
201
+ }
202
+
203
+ // 2) Claude Code's known project list (~/.claude/projects/<encoded-path>/)
204
+ try {
205
+ const ccProj = path.join(HOME, '.claude', 'projects');
206
+ await fsAsync.access(ccProj);
207
+ const entries = await fsAsync.readdir(ccProj);
208
+ for (const dir of entries) {
209
+ // Claude encodes paths as -Users-foo-projects-bar — decode to /Users/foo/projects/bar
210
+ const decoded = '/' + dir.replace(/^-+/, '').replace(/-/g, '/');
211
+ try {
212
+ await fsAsync.access(path.join(decoded, '.great_cto', 'PROJECT.md'));
213
+ found.push(decoded);
214
+ } catch {}
215
+ }
216
+ } catch {}
217
+
218
+ // Auto-register everything found
219
+ for (const dir of found) autoRegisterProject(dir);
220
+ return found.length;
221
+ }
222
+
223
+ function listProjects() {
224
+ // Auto-register cwd if it has PROJECT.md (cheap)
225
+ autoRegisterProject(process.cwd());
226
+ const reg = readProjectsRegistry();
227
+ // Filter out projects whose paths no longer exist
228
+ reg.projects = reg.projects.filter(p => fs.existsSync(p.path));
229
+ // Re-read metadata in case archetype/description changed
230
+ // Enrich with last_activity (mtime of .beads/interactions.jsonl) so the UI
231
+ // can sort projects by recent activity instead of slug-alpha.
232
+ return reg.projects.map(p => {
233
+ const fresh = readProjectMd(p.path);
234
+ let lastActivity = null;
235
+ try {
236
+ const interactionsFile = path.join(p.path, '.beads', 'interactions.jsonl');
237
+ if (fs.existsSync(interactionsFile)) {
238
+ lastActivity = fs.statSync(interactionsFile).mtime.toISOString();
239
+ }
240
+ } catch {}
241
+ return { ...(fresh ? { ...p, ...fresh } : p), last_activity: lastActivity };
242
+ }).sort((a, b) => {
243
+ // Recent activity first; projects with no activity sink to the bottom (alphabetic)
244
+ const ax = a.last_activity || '';
245
+ const bx = b.last_activity || '';
246
+ if (ax && bx) return bx.localeCompare(ax);
247
+ if (ax) return -1;
248
+ if (bx) return 1;
249
+ return (a.slug || '').localeCompare(b.slug || '');
250
+ });
251
+ }
252
+ function resolveProjectCwd(slugOrPath) {
253
+ if (!slugOrPath) return process.cwd();
254
+ // If absolute path or starts with ~, use directly
255
+ if (slugOrPath.startsWith('/')) return slugOrPath;
256
+ if (slugOrPath.startsWith('~')) return slugOrPath.replace(/^~/, os.homedir());
257
+ // Else look up in registry by slug — among duplicate slugs (great_cto-7xfc),
258
+ // prefer the entry whose path exists on disk; among several existing (or
259
+ // several missing), prefer the most recently added_at.
260
+ const reg = readProjectsRegistry();
261
+ const matches = reg.projects.filter(p => p.slug === slugOrPath);
262
+ const found = pickBestBySlug(matches);
263
+ return found ? found.path : process.cwd();
264
+ }
265
+
266
+ /**
267
+ * Same as resolveProjectCwd but returns { cwd, resolved, fallback? } so
268
+ * callers know whether resolution was authoritative or fell back to the
269
+ * server's working directory. Used by HTTP handlers to set an explicit
270
+ * `X-Project-Fallback` response header (BH-5 fix, 2026-05-15).
271
+ *
272
+ * resolved values:
273
+ * 'cwd' — no project param passed; using server cwd as documented
274
+ * 'path' — absolute / tilde path passed and used directly
275
+ * 'slug' — slug found in registry
276
+ * 'fallback' — slug requested but NOT in registry; using cwd as fallback.
277
+ * Caller should warn the user (header + log).
278
+ */
279
+ function resolveProjectInfo(slugOrPath) {
280
+ if (!slugOrPath) return { cwd: process.cwd(), resolved: 'cwd' };
281
+ if (slugOrPath.startsWith('/')) return { cwd: slugOrPath, resolved: 'path' };
282
+ if (slugOrPath.startsWith('~')) {
283
+ return { cwd: slugOrPath.replace(/^~/, os.homedir()), resolved: 'path' };
284
+ }
285
+ const reg = readProjectsRegistry();
286
+ const matches = reg.projects.filter(p => p.slug === slugOrPath);
287
+ const found = pickBestBySlug(matches);
288
+ if (found) return { cwd: found.path, resolved: 'slug' };
289
+ return { cwd: process.cwd(), resolved: 'fallback', requested: slugOrPath };
290
+ }
291
+
292
+ export {
293
+ readProjectsRegistry,
294
+ writeProjectsRegistry,
295
+ pickBestBySlug,
296
+ dedupeProjects,
297
+ ARCHETYPE_ALIASES,
298
+ normalizeArchetype,
299
+ extractArchetype,
300
+ readProjectMd,
301
+ getChangeTier,
302
+ autoRegisterProject,
303
+ discoverProjects,
304
+ listProjects,
305
+ resolveProjectCwd,
306
+ resolveProjectInfo,
307
+ };