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.
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,143 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { GREAT_CTO_DIR, PUBLIC, SHARE_ENDPOINT } from './config.mjs';
4
+ import { readProjectMd } from './projects.mjs';
5
+ import { getMetrics } from './metrics.mjs';
6
+ import { getTasks } from './beads.mjs';
7
+ import { readVerdicts } from './verdicts.mjs';
8
+ import { readDecisionsLog } from './fleet.mjs';
9
+
10
+ // ── Resume — what happened recently for this project ─────────────────────
11
+ // Returns a compact bundle for the "Resume" inbox card:
12
+ // - last 3 verdicts (APPROVED / DONE / etc.)
13
+ // - open gates (already in inbox, but cheap to include for one-shot fetch)
14
+ // - 3 most-recent WIP tasks (in_progress, sorted by updated_at desc)
15
+ // - last 5 decisions from the global log filtered to this project
16
+ function getResume(cwd = process.cwd()) {
17
+ const tasks = getTasks(cwd);
18
+ const verdicts = readVerdicts(cwd)
19
+ .filter(v => ['APPROVED','DONE','PASS','BLOCKED','FAIL','REJECTED'].includes((v.verdict || '').toUpperCase()))
20
+ .slice(-3)
21
+ .reverse();
22
+ const openGates = tasks
23
+ .filter(t => t.is_gate && t.raw_status !== 'closed' && t.raw_status !== 'blocked')
24
+ .slice(0, 5);
25
+ const wip = tasks
26
+ .filter(t => t.raw_status === 'in_progress' || t.status === 'in_progress')
27
+ .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
28
+ .slice(0, 3);
29
+ const slug = path.basename(cwd);
30
+ const projectDecisions = readDecisionsLog(50)
31
+ .filter(d => d.project === slug || d.project === path.basename(cwd))
32
+ .slice(0, 5);
33
+ return {
34
+ recent_verdicts: verdicts,
35
+ open_gates: openGates,
36
+ wip_tasks: wip,
37
+ decisions: projectDecisions,
38
+ };
39
+ }
40
+
41
+ function shareStatePath(cwd = process.cwd()) {
42
+ // Use slug from PROJECT.md if available, else basename of cwd
43
+ const meta = readProjectMd(cwd);
44
+ const slug = meta?.slug || path.basename(cwd);
45
+ return path.join(GREAT_CTO_DIR, `share-${slug}.json`);
46
+ }
47
+ function getShareState(cwd = process.cwd()) {
48
+ const file = shareStatePath(cwd);
49
+ try { if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8')); }
50
+ catch {}
51
+ return { enabled: false, url: null, hash: null, published_at: null };
52
+ }
53
+ function saveShareState(state, cwd = process.cwd()) {
54
+ fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
55
+ fs.writeFileSync(shareStatePath(cwd), JSON.stringify(state, null, 2));
56
+ }
57
+
58
+ async function publishReport(html) {
59
+ // POST to Cloudflare Worker
60
+ const { default: https } = await import('https');
61
+ return new Promise((resolve, reject) => {
62
+ const body = JSON.stringify({ html, ttl: 2592000 }); // 30 days
63
+ const url = new URL(SHARE_ENDPOINT);
64
+ const req = https.request({
65
+ hostname: url.hostname, path: url.pathname,
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
68
+ }, res => {
69
+ let data = '';
70
+ res.on('data', c => data += c);
71
+ res.on('end', () => {
72
+ try { resolve(JSON.parse(data)); }
73
+ catch { reject(new Error('Invalid response: ' + data)); }
74
+ });
75
+ });
76
+ req.on('error', reject);
77
+ req.write(body);
78
+ req.end();
79
+ });
80
+ }
81
+
82
+ async function toggleShare(enable, cwd = process.cwd(), force = false) {
83
+ const state = getShareState(cwd);
84
+ // (enable && !state.enabled) → first publish
85
+ // (enable && state.enabled && force) → re-publish with fresh data (new URL)
86
+ if (enable && (!state.enabled || force)) {
87
+ // Generate and publish
88
+ // Share report is a marketing artifact — show LIFETIME numbers, not a
89
+ // rolling window. 365 days × 100 = effectively-lifetime cap for any project.
90
+ const html = generateShareHTML(getTasks(cwd), getMetrics(cwd, 36500), cwd);
91
+ try {
92
+ const result = await publishReport(html);
93
+ const newState = { enabled: true, url: result.url, hash: result.hash, published_at: new Date().toISOString() };
94
+ saveShareState(newState, cwd);
95
+ return newState;
96
+ } catch (e) {
97
+ return { error: e.message };
98
+ }
99
+ } else if (!enable && state.enabled) {
100
+ const newState = { ...state, enabled: false };
101
+ saveShareState(newState, cwd);
102
+ // Tell Cloudflare worker to mark this hash as paused
103
+ if (state.hash) {
104
+ try {
105
+ const { default: https } = await import('https');
106
+ await new Promise((resolve) => {
107
+ const body = JSON.stringify({ enabled: false });
108
+ const req = https.request({
109
+ hostname: 'greatcto.systems', path: `/r/${state.hash}`,
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
112
+ }, res => { res.on('data', () => {}); res.on('end', resolve); });
113
+ req.on('error', resolve);
114
+ req.write(body); req.end();
115
+ });
116
+ } catch {}
117
+ }
118
+ return newState;
119
+ }
120
+ return state;
121
+ }
122
+
123
+ function generateShareHTML(tasks, metrics, cwd = process.cwd()) {
124
+ const meta = readProjectMd(cwd);
125
+ const projectName = meta?.slug || path.basename(cwd);
126
+ const date = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
127
+ const done = tasks.filter(t => t.status === 'done' || t.status === 'closed');
128
+ const shareTemplate = fs.readFileSync(path.join(PUBLIC, 'share.html'), 'utf8');
129
+ // Use replaceAll: placeholders appear multiple times (title + script var)
130
+ // BH-22: substitute {{PAUSED}} before publish. The worker can still flip
131
+ // the stored pause flag independently via POST /r/<hash> {enabled:false}
132
+ // from toggleShare, but the published HTML must be valid on its own —
133
+ // shipping `const paused = {{PAUSED}};` literal triggers a SyntaxError
134
+ // and blanks the entire report if the worker forgets to post-process.
135
+ return shareTemplate
136
+ .replaceAll('{{PROJECT}}', projectName)
137
+ .replaceAll('{{DATE}}', date)
138
+ .replaceAll('{{METRICS_JSON}}', JSON.stringify(metrics))
139
+ .replaceAll('{{TASKS_JSON}}', JSON.stringify(done.slice(-20)))
140
+ .replaceAll('{{PAUSED}}', 'false');
141
+ }
142
+
143
+ export { getResume, shareStatePath, getShareState, saveShareState, publishReport, toggleShare, generateShareHTML };
@@ -0,0 +1,20 @@
1
+ import { sseClients } from './state.mjs';
2
+ import { getTasks } from './beads.mjs';
3
+
4
+ function broadcast(event, data) {
5
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
6
+ for (const res of sseClients) {
7
+ try { res.write(msg); } catch { sseClients.delete(res); }
8
+ }
9
+ }
10
+
11
+ function broadcastTasks(cwd) {
12
+ const msg = `event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`;
13
+ for (const res of sseClients) {
14
+ if (res._gctoCwd === cwd) {
15
+ try { res.write(msg); } catch { sseClients.delete(res); }
16
+ }
17
+ }
18
+ }
19
+
20
+ export { broadcast, broadcastTasks };
@@ -0,0 +1,31 @@
1
+ // ── In-process mutable singletons ───────────────────────────────────────────
2
+ // Rule: only this module holds mutable shared state. Every module that needs
3
+ // sseClients, bdCache, or notification history imports from here — no
4
+ // top-level shared state anywhere else.
5
+
6
+ const sseClients = new Set();
7
+ const _reportRepublishDedupeSet = new Set(); // dedupe daily report republish
8
+
9
+ // ── In-app notification history ────────────────────────────────────────────────
10
+ // Persisted to ~/.great_cto/notif-history.json. Capped at 100 entries.
11
+ // Each entry: { id, event, title, body, level, project, ts, read }
12
+ const MAX_NOTIF_HISTORY = 100;
13
+ // NOTE: kept as a mutable array (not a `let` rebind) so importing modules can
14
+ // replace its contents in place (`notifHistory.length = 0; notifHistory.push(...items)`)
15
+ // — ESM live bindings do not allow an importer to reassign an imported `let`.
16
+ let notifHistory = [];
17
+
18
+ // ── Beads data ─────────────────────────────────────────────────────────────────
19
+ // Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
20
+ // .beads/interactions.jsonl changes (the file watcher in watchBeads() calls
21
+ // bdCacheInvalidate(cwd) before broadcasting). This avoids spawning `bd list`
22
+ // on every API call when 5+ projects are open in tabs.
23
+ const bdCache = new Map(); // cwd → { ts, data }
24
+
25
+ export {
26
+ sseClients,
27
+ _reportRepublishDedupeSet,
28
+ MAX_NOTIF_HISTORY,
29
+ notifHistory,
30
+ bdCache,
31
+ };
@@ -0,0 +1,36 @@
1
+ import fs from 'fs';
2
+ import { PORT } from './config.mjs';
3
+
4
+ /**
5
+ * Escape a single value for inclusion in a CSV cell.
6
+ * Quotes the value if it contains comma / quote / newline.
7
+ */
8
+ function csvCell(v) {
9
+ if (v == null) return '';
10
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
11
+ if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
12
+ return s;
13
+ }
14
+
15
+ // Only same-origin (the board's own page) may make a state-changing request — a malicious page must not.
16
+ function originAllowed(req) {
17
+ const o = req.headers.origin || req.headers.referer || '';
18
+ if (!o) return true; // same-origin fetch / curl with no Origin
19
+ // True same-origin: the browser's Origin matches the host this request arrived on
20
+ // (covers a tunnelled/hosted console at console.client.com, http or https).
21
+ const self = req.headers.host ? [`http://${req.headers.host}`, `https://${req.headers.host}`] : [];
22
+ return [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`, ...self].some((e) => o === e || o.startsWith(e + '/'));
23
+ }
24
+
25
+ // Which surface an alert belongs to. Operate-side events (autopilot runtime: dead-letters,
26
+ // connector health, case SLA, gate/safe-mode pushes) are the operator console's concern and
27
+ // must not clutter the builder board's notifications — the two surfaces are separated.
28
+ function eventSurface(event) {
29
+ return /^(autopilot\.|dead-letter|connector\.|sla\.)/.test(String(event || '')) ? 'operate' : 'builder';
30
+ }
31
+
32
+ function readFileSafe(p) {
33
+ try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
34
+ }
35
+
36
+ export { csvCell, originAllowed, eventSurface, readFileSafe };
@@ -0,0 +1,168 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { GREAT_CTO_DIR } from './config.mjs';
4
+
5
+ function readVerdicts(cwd = null) {
6
+ // Verdict attribution model:
7
+ // 1. cwd given → read project-local <cwd>/.great_cto/verdicts/
8
+ // PLUS any global verdict line tagged `project=<slug>` matching cwd
9
+ // 2. cwd absent (cron jobs, fleet view) → read ALL global verdicts
10
+ //
11
+ // Project slug resolution: PROJECT.md `slug:` field, else basename(cwd).
12
+ let projectSlug = null;
13
+ if (cwd) {
14
+ try {
15
+ const md = fs.readFileSync(path.join(cwd, '.great_cto', 'PROJECT.md'), 'utf8');
16
+ const m = md.match(/^slug:\s*(.+)$/m);
17
+ projectSlug = m ? m[1].trim() : path.basename(cwd);
18
+ } catch { projectSlug = path.basename(cwd); }
19
+ }
20
+ // First read project-local verdicts when scoped
21
+ const projectVerdictDir = cwd ? path.join(cwd, '.great_cto', 'verdicts') : null;
22
+ const useProjectDir = projectVerdictDir
23
+ && fs.existsSync(projectVerdictDir)
24
+ && fs.readdirSync(projectVerdictDir).filter(f => f.endsWith('.log')).length > 0;
25
+ // For cwd-scoped reads, we collect from BOTH local AND tagged global lines
26
+ const verdictDirs = [];
27
+ if (useProjectDir) verdictDirs.push(projectVerdictDir);
28
+ if (!cwd) {
29
+ // Unscoped: read everything global
30
+ verdictDirs.push(path.join(GREAT_CTO_DIR, 'verdicts'));
31
+ }
32
+ const results = [];
33
+ // For scoped reads, also iterate global and filter by project= tag
34
+ const globalDir = path.join(GREAT_CTO_DIR, 'verdicts');
35
+ if (cwd && projectSlug && fs.existsSync(globalDir)) {
36
+ verdictDirs.push({ dir: globalDir, filterByProjectTag: projectSlug });
37
+ }
38
+ for (const entry of verdictDirs) {
39
+ const verdictDir = typeof entry === 'string' ? entry : entry.dir;
40
+ const projectTagFilter = typeof entry === 'string' ? null : entry.filterByProjectTag;
41
+ if (!fs.existsSync(verdictDir)) continue;
42
+ for (const file of fs.readdirSync(verdictDir)) {
43
+ const agent = file.replace('.log', '');
44
+ const lines = fs.readFileSync(path.join(verdictDir, file), 'utf8')
45
+ .split('\n').filter(Boolean);
46
+ for (const line of lines) {
47
+ // When reading global with a project filter, only include lines tagged
48
+ // with this project's slug.
49
+ if (projectTagFilter) {
50
+ const tagMatch = line.match(/\bproject=([^\s|]+)/);
51
+ if (!tagMatch || tagMatch[1] !== projectTagFilter) continue;
52
+ }
53
+ // Two formats agents emit in the wild:
54
+ // space-separated: "<ts> <verdict> <details> cost=$X"
55
+ // pipe-separated: "<ts> | <agent> | <verdict> | <details> | cost=$X"
56
+ // Pre-2026-05: parts[1] always took the 2nd whitespace token, which
57
+ // for the pipe form is "|", breaking /api/pipeline status mapping
58
+ // (verdicts displayed as "|" instead of APPROVED/DONE/BLOCKED).
59
+ // Now we detect the pipe form and parse it differently.
60
+ let ts, verdict;
61
+ if (line.includes(' | ')) {
62
+ const pipeParts = line.split('|').map(s => s.trim());
63
+ ts = pipeParts[0].trim();
64
+ // Pipe form: [ts, agent, verdict, details, cost]
65
+ // Verdict is at index 2 (after ts and agent name).
66
+ verdict = pipeParts[2] || '';
67
+ } else {
68
+ const parts = line.split(' ');
69
+ ts = parts[0];
70
+ verdict = parts[1] || '';
71
+ }
72
+ const costMatch = line.match(/\bcost=\$?(\d+\.?\d*)\b/i);
73
+ results.push({
74
+ ts,
75
+ agent,
76
+ verdict,
77
+ cost_usd: costMatch ? parseFloat(costMatch[1]) : null,
78
+ raw: line.replace(/\s*\bcost=\$?\d+\.?\d*\b/i, ''),
79
+ });
80
+ }
81
+ }
82
+ } // end verdictDirs loop
83
+
84
+ // Fallback: enrich verdicts that lack cost_usd from .great_cto/cost-history.log.
85
+ // Format: "<ISO-ts> <agent> <cost_usd>" per line (written by scripts/log-verdict.sh).
86
+ // Match by ts (minute precision) + agent to avoid double-counting.
87
+ const histPath = path.join(GREAT_CTO_DIR, 'cost-history.log');
88
+ if (fs.existsSync(histPath)) {
89
+ const costByKey = new Map();
90
+ const lines = fs.readFileSync(histPath, 'utf8').split('\n').filter(Boolean);
91
+ for (const line of lines) {
92
+ const m = line.match(/^(\S+)\s+(\S+)\s+(\d+\.?\d*)/);
93
+ if (!m) continue;
94
+ const key = `${m[1].slice(0, 16)}|${m[2]}`; // minute + agent
95
+ costByKey.set(key, parseFloat(m[3]));
96
+ }
97
+ for (const v of results) {
98
+ if (v.cost_usd != null) continue;
99
+ const key = `${(v.ts || '').slice(0, 16)}|${v.agent}`;
100
+ if (costByKey.has(key)) v.cost_usd = costByKey.get(key);
101
+ }
102
+ }
103
+
104
+ return results.sort((a, b) => a.ts.localeCompare(b.ts));
105
+ }
106
+
107
+ function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
108
+ const plansDir = path.join(cwd, 'docs/plans');
109
+ let totalLlmMin = 0, totalLlmUsd = 0, totalHumanUsd = 0, count = 0;
110
+ if (!fs.existsSync(plansDir)) return { llm_usd: 0, human_usd: 0, savings_x: 0, count: 0 };
111
+ const cutoff = sinceMsAgo != null ? Date.now() - sinceMsAgo : null;
112
+ for (const file of fs.readdirSync(plansDir).filter(f => f.endsWith('.md'))) {
113
+ const fp = path.join(plansDir, file);
114
+ // Skip plans outside the requested time window (use file mtime, same as
115
+ // getCostHistory — fixes BH-26 where readPlanCosts had no date filter and
116
+ // included all-time plans while getCostHistory only looked at the window).
117
+ if (cutoff != null && fs.statSync(fp).mtimeMs < cutoff) continue;
118
+ const content = fs.readFileSync(fp, 'utf8');
119
+ // Parse cost lines from PLAN-*.md.
120
+ // Use the SAME anchored regex as getCostHistory() so both endpoints agree
121
+ // on what constitutes a valid LLM/Human line. The old regex required a
122
+ // range ("0.5 – $2.30") and silently returned 0 for single-value plans
123
+ // ("LLM: ~$0.30"), causing /api/metrics to fall back to task-estimate and
124
+ // show a different number than /api/cost (BH-26: metrics ≠ cost tile).
125
+ const llmMatch = content.match(/^[\s*_>\-]*LLM[^\n]*?\$(\d+\.?\d*)/im);
126
+ const humanMatch = content.match(/^[\s*_>\-]*Human[^\n]*?\$(\d[\d,]*\.?\d*)/im);
127
+ if (llmMatch) totalLlmUsd += parseFloat(llmMatch[1]);
128
+ // BH-25: /g — replace() with a string only strips the FIRST comma, so
129
+ // "$1,234,567" was silently truncated to 1234. getCostHistory at :413
130
+ // already uses /,/g; this was the divergent twin.
131
+ if (humanMatch) totalHumanUsd += parseFloat(humanMatch[1].replace(/,/g, ''));
132
+ count++;
133
+ }
134
+ return {
135
+ llm_usd: Math.round(totalLlmUsd * 100) / 100,
136
+ human_usd: Math.round(totalHumanUsd),
137
+ savings_x: totalLlmUsd > 0 ? Math.round(totalHumanUsd / totalLlmUsd) : 0,
138
+ count,
139
+ };
140
+ }
141
+
142
+ function readQAStats(cwd = process.cwd()) {
143
+ const qaDir = path.join(cwd, 'docs/qa-reports');
144
+ let passed = 0, failed = 0;
145
+ if (!fs.existsSync(qaDir)) return { pass_rate: null, passed: 0, failed: 0 };
146
+ for (const file of fs.readdirSync(qaDir).filter(f => f.endsWith('.md'))) {
147
+ const content = fs.readFileSync(path.join(qaDir, file), 'utf8');
148
+ // Accept any of: "verdict: pass" / "**Verdict:** PASS" / "Status: PASSED" / "✅ pass" / "result: ✓"
149
+ if (/(?:verdict|status|result)\s*[:=]?\s*[*_`]*\s*(?:✅|✓|pass(?:ed)?)/i.test(content)) passed++;
150
+ else if (/(?:verdict|status|result)\s*[:=]?\s*[*_`]*\s*(?:❌|✗|fail(?:ed)?|block(?:ed)?)/i.test(content)) failed++;
151
+ }
152
+ const total = passed + failed;
153
+ return { pass_rate: total ? Math.round((passed / total) * 100) : null, passed, failed };
154
+ }
155
+
156
+ function readSecStats(cwd = process.cwd()) {
157
+ const secDir = path.join(cwd, 'docs/security');
158
+ let approved = 0, blocked = 0;
159
+ if (!fs.existsSync(secDir)) return { approved: 0, blocked: 0 };
160
+ for (const file of fs.readdirSync(secDir).filter(f => f.endsWith('.md'))) {
161
+ const content = fs.readFileSync(path.join(secDir, file), 'utf8');
162
+ if (/APPROVED/i.test(content)) approved++;
163
+ if (/BLOCKED/i.test(content)) blocked++;
164
+ }
165
+ return { approved, blocked };
166
+ }
167
+
168
+ export { readVerdicts, readPlanCosts, readQAStats, readSecStats };
@@ -0,0 +1,87 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { GREAT_CTO_DIR } from './config.mjs';
4
+ import { sseClients } from './state.mjs';
5
+ import { listProjects } from './projects.mjs';
6
+ import { bdCacheInvalidate, getTasks } from './beads.mjs';
7
+ import { getPipeline, getInbox } from './data-readers.mjs';
8
+
9
+ // ── File watcher ───────────────────────────────────────────────────────────────
10
+ function watchBeads() {
11
+ // Watch every registered project's beads files.
12
+ // Note: bd create only writes to dolt DB, NOT interactions.jsonl. So we must
13
+ // watch BOTH: (a) interactions.jsonl for status/priority changes (from bd
14
+ // update/close), and (b) the dolt manifest/journal for new-issue detection.
15
+ const projects = listProjects();
16
+ const dirs = projects.map(p => p.path);
17
+ if (!dirs.includes(process.cwd())) dirs.push(process.cwd());
18
+
19
+ const broadcast = (dir) => {
20
+ bdCacheInvalidate(dir);
21
+ for (const res of sseClients) {
22
+ if (res._gctoCwd === dir) {
23
+ try {
24
+ res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(dir))}\n\n`);
25
+ res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
26
+ res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
27
+ } catch {}
28
+ }
29
+ }
30
+ };
31
+
32
+ // Debounce per-dir: dolt writes can fire 3-5 events in <50ms during a single
33
+ // bd command. Collapse them into one broadcast 200ms after the last event.
34
+ const debouncers = new Map();
35
+ const schedule = (dir) => {
36
+ if (debouncers.has(dir)) clearTimeout(debouncers.get(dir));
37
+ debouncers.set(dir, setTimeout(() => {
38
+ debouncers.delete(dir);
39
+ broadcast(dir);
40
+ }, 200));
41
+ };
42
+
43
+ for (const dir of dirs) {
44
+ // (a) interactions.jsonl — captures bd update/close
45
+ const interactionsFile = path.join(dir, '.beads', 'interactions.jsonl');
46
+ if (fs.existsSync(interactionsFile)) {
47
+ try { fs.watch(interactionsFile, () => schedule(dir)); } catch {}
48
+ }
49
+ // (b) dolt embeddeddolt directory (recursive) — captures bd create
50
+ const doltDir = path.join(dir, '.beads', 'embeddeddolt');
51
+ if (fs.existsSync(doltDir)) {
52
+ try { fs.watch(doltDir, { recursive: true }, () => schedule(dir)); } catch {}
53
+ }
54
+ }
55
+ }
56
+
57
+ // Watch ~/.great_cto/verdicts/ — push pipeline updates whenever an agent
58
+ // emits a verdict (any project gets the broadcast for its own cwd).
59
+ function watchVerdicts() {
60
+ const verdictDir = path.join(GREAT_CTO_DIR, 'verdicts');
61
+ if (!fs.existsSync(verdictDir)) {
62
+ try { fs.mkdirSync(verdictDir, { recursive: true }); } catch { return; }
63
+ }
64
+ let pushTimer = null;
65
+ const broadcastPipeline = () => {
66
+ if (pushTimer) clearTimeout(pushTimer);
67
+ // debounce: collapse a burst of writes (multiple agents finishing within ~150ms)
68
+ pushTimer = setTimeout(() => {
69
+ for (const res of sseClients) {
70
+ const dir = res._gctoCwd || process.cwd();
71
+ try {
72
+ res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
73
+ res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
74
+ } catch { sseClients.delete(res); }
75
+ }
76
+ }, 150);
77
+ };
78
+ try {
79
+ fs.watch(verdictDir, () => broadcastPipeline());
80
+ // Also watch each existing log file (some agents append to existing)
81
+ for (const f of fs.readdirSync(verdictDir).filter(x => x.endsWith('.log'))) {
82
+ try { fs.watch(path.join(verdictDir, f), () => broadcastPipeline()); } catch {}
83
+ }
84
+ } catch {}
85
+ }
86
+
87
+ export { watchBeads, watchVerdicts };