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.
- package/board/.claude-plugin/plugin.json +256 -0
- package/board/packages/board/lib/alerts.mjs +413 -0
- package/board/packages/board/lib/beads.mjs +241 -0
- package/board/packages/board/lib/config.mjs +49 -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 +307 -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 +116 -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/assets/fonts/PROVENANCE.md +21 -0
- package/board/packages/board/public/assets/fonts/fonts.css +26 -0
- package/board/packages/board/public/assets/fonts/geist-mono-variable.woff2 +0 -0
- package/board/packages/board/public/assets/fonts/geist-variable.woff2 +0 -0
- package/board/packages/board/public/assets/vendor/PROVENANCE.md +21 -0
- package/board/packages/board/public/assets/vendor/marked.min.js +6 -0
- package/board/packages/board/public/assets/vendor/purify.min.js +3 -0
- package/board/packages/board/public/index.html +5464 -0
- package/board/packages/board/public/share.html +1194 -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,49 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PORT = parseInt(process.env.BOARD_PORT || process.env.PORT || '3141', 10);
|
|
8
|
+
const PUBLIC = path.join(__dirname, '..', 'public');
|
|
9
|
+
// Build version — read the plugin manifest this board ships inside (packages/board → ../../.claude-plugin).
|
|
10
|
+
const BUILD_VERSION = (() => {
|
|
11
|
+
for (const rel of ['../../../.claude-plugin/plugin.json', '../../cli/package.json']) {
|
|
12
|
+
try { const v = JSON.parse(fs.readFileSync(path.join(__dirname, rel), 'utf8')).version; if (v) return v; } catch { /* try next */ }
|
|
13
|
+
}
|
|
14
|
+
return 'unknown';
|
|
15
|
+
})();
|
|
16
|
+
// Bind host. Default loopback. --host/GREAT_CTO_HOST allows binding the board to a
|
|
17
|
+
// non-loopback address for tunnelling; put your reverse-proxy auth in front.
|
|
18
|
+
const HOST = (() => {
|
|
19
|
+
const i = process.argv.indexOf('--host');
|
|
20
|
+
return String(i > -1 ? process.argv[i + 1] : process.env.GREAT_CTO_HOST || '127.0.0.1');
|
|
21
|
+
})();
|
|
22
|
+
const GREAT_CTO_DIR = path.join(os.homedir(), '.great_cto');
|
|
23
|
+
const SHARE_STATE_FILE = path.join(GREAT_CTO_DIR, 'board-share.json');
|
|
24
|
+
// Test seam: honor an explicit override so tests can point the registry at a
|
|
25
|
+
// tmp fixture without touching the real ~/.great_cto/projects.json (same
|
|
26
|
+
// convention as GREAT_CTO_BD_BIN in lib/beads.mjs). Unset in production —
|
|
27
|
+
// zero behavior change at runtime.
|
|
28
|
+
const PROJECTS_FILE = process.env.GREAT_CTO_PROJECTS_FILE || path.join(GREAT_CTO_DIR, 'projects.json');
|
|
29
|
+
const SHARE_ENDPOINT = 'https://greatcto.systems/r/';
|
|
30
|
+
const VAPID_KEYS_FILE = path.join(GREAT_CTO_DIR, 'vapid-keys.json');
|
|
31
|
+
const PUSH_SUBS_FILE = path.join(GREAT_CTO_DIR, 'push-subscriptions.json');
|
|
32
|
+
const NOTIF_HISTORY_FILE = path.join(GREAT_CTO_DIR, 'notif-history.json');
|
|
33
|
+
const VAPID_SUBJECT = 'mailto:hi@updates.greatcto.systems';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
__dirname,
|
|
37
|
+
PORT,
|
|
38
|
+
PUBLIC,
|
|
39
|
+
BUILD_VERSION,
|
|
40
|
+
HOST,
|
|
41
|
+
GREAT_CTO_DIR,
|
|
42
|
+
SHARE_STATE_FILE,
|
|
43
|
+
PROJECTS_FILE,
|
|
44
|
+
SHARE_ENDPOINT,
|
|
45
|
+
VAPID_KEYS_FILE,
|
|
46
|
+
PUSH_SUBS_FILE,
|
|
47
|
+
NOTIF_HISTORY_FILE,
|
|
48
|
+
VAPID_SUBJECT,
|
|
49
|
+
};
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { GREAT_CTO_DIR } from './config.mjs';
|
|
5
|
+
import { readFileSafe } from './util.mjs';
|
|
6
|
+
import { getTasks } from './beads.mjs';
|
|
7
|
+
import { readVerdicts, readSecStats } from './verdicts.mjs';
|
|
8
|
+
|
|
9
|
+
// ── Memory: 4-layer file contents ─────────────────────────────────────────────
|
|
10
|
+
function getMemory(cwd = process.cwd()) {
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
const layers = [
|
|
13
|
+
// Project-local (.great_cto/) — L1 archetype + L2 codebase + L3 retros
|
|
14
|
+
{ id: 'project', scope: 'project', layer: 'L1', name: 'PROJECT.md', desc: 'Archetype, size, compliance, owners', path: path.join(cwd, '.great_cto', 'PROJECT.md') },
|
|
15
|
+
{ id: 'archetypes', scope: 'project', layer: 'L1', name: 'ARCHETYPES.md', desc: 'Archetype catalogue used by /start + agents', path: path.join(cwd, '.great_cto', 'ARCHETYPES.md') },
|
|
16
|
+
{ id: 'skill', scope: 'project', layer: 'L1', name: 'SKILL.md', desc: 'Pipeline skill — synced from plugin', path: path.join(cwd, '.great_cto', 'SKILL.md') },
|
|
17
|
+
{ id: 'codebase', scope: 'project', layer: 'L2', name: 'CODEBASE.md', desc: 'God nodes, entry points, public API, routes', path: path.join(cwd, '.great_cto', 'CODEBASE.md') },
|
|
18
|
+
{ id: 'brain', scope: 'project', layer: 'L3', name: 'brain.md', desc: 'Patterns in use, what failed, team patterns', path: path.join(cwd, '.great_cto', 'brain.md') },
|
|
19
|
+
{ id: 'lessons', scope: 'project', layer: 'L3', name: 'lessons.md', desc: 'Per-project lessons (extracted by /learn)', path: path.join(cwd, '.great_cto', 'lessons.md') },
|
|
20
|
+
{ id: 'handoff', scope: 'project', layer: 'L3', name: 'HANDOFF.md', desc: 'Auto-written on context compaction', path: path.join(cwd, '.great_cto', 'HANDOFF.md') },
|
|
21
|
+
{ id: 'local', scope: 'project', layer: 'L3', name: 'local.md', desc: 'Project-local notes (gitignored)', path: path.join(cwd, '.great_cto', 'local.md') },
|
|
22
|
+
// Cross-project (~/.great_cto/) — L4 global memory shared across all projects
|
|
23
|
+
{ id: 'g-decisions', scope: 'global', layer: 'L4', name: 'decisions.md', desc: 'Append-only ADR log — every gate approval', path: path.join(home, '.great_cto', 'decisions.md') },
|
|
24
|
+
{ id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(home, '.great_cto', 'preferences.md') },
|
|
25
|
+
{ id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(home, '.great_cto', 'lessons.md') },
|
|
26
|
+
];
|
|
27
|
+
const result = layers.map(l => ({
|
|
28
|
+
...l,
|
|
29
|
+
content: readFileSafe(l.path),
|
|
30
|
+
exists: fs.existsSync(l.path),
|
|
31
|
+
size: fs.existsSync(l.path) ? fs.statSync(l.path).size : 0,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Cross-project global patterns (~/.great_cto/global-patterns/)
|
|
35
|
+
const gpDir = path.join(GREAT_CTO_DIR, 'global-patterns');
|
|
36
|
+
let patterns = [];
|
|
37
|
+
if (fs.existsSync(gpDir)) {
|
|
38
|
+
patterns = fs.readdirSync(gpDir)
|
|
39
|
+
.filter(f => f.startsWith('GP-') && f.endsWith('.md'))
|
|
40
|
+
.map(f => {
|
|
41
|
+
const fp = path.join(gpDir, f);
|
|
42
|
+
const content = readFileSafe(fp) || '';
|
|
43
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
44
|
+
return {
|
|
45
|
+
id: f.replace(/\.md$/, ''),
|
|
46
|
+
name: f,
|
|
47
|
+
title: titleMatch ? titleMatch[1] : f,
|
|
48
|
+
path: fp,
|
|
49
|
+
size: fs.statSync(fp).size,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { layers: result, patterns, cwd };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Pipeline state ────────────────────────────────────────────────────────────
|
|
57
|
+
function getPipeline(cwd = process.cwd()) {
|
|
58
|
+
const stages = ['product-owner', 'architect', 'pm', 'senior-dev', 'reviewers', 'qa-engineer', 'security-officer', 'devops', 'l3-support'];
|
|
59
|
+
const verdicts = readVerdicts(cwd);
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const ACTIVE_WINDOW = 30 * 60 * 1000; // 30 min
|
|
62
|
+
|
|
63
|
+
// Map agents → most recent verdict
|
|
64
|
+
const lastByAgent = {};
|
|
65
|
+
for (const v of verdicts) {
|
|
66
|
+
const a = (v.agent || '').toLowerCase();
|
|
67
|
+
if (!lastByAgent[a] || lastByAgent[a].ts < v.ts) lastByAgent[a] = v;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tasks in_progress give us "active" agents
|
|
71
|
+
const tasks = getTasks(cwd);
|
|
72
|
+
const activeAgents = new Set(
|
|
73
|
+
tasks.filter(t => t.status === 'in_progress').map(t => (t.agent || '').toLowerCase()).filter(Boolean)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const agentStages = stages.map(stage => {
|
|
77
|
+
// agent log file naming convention: shortened agent name
|
|
78
|
+
const aliases = {
|
|
79
|
+
'product-owner': ['product-owner', 'product_owner', 'product-manager', 'po'],
|
|
80
|
+
'architect': ['architect'],
|
|
81
|
+
'pm': ['pm', 'product-manager', 'project-manager', 'planner'],
|
|
82
|
+
'senior-dev': ['senior-dev', 'senior_dev', 'backend', 'frontend'],
|
|
83
|
+
'reviewers': ['reviewer', 'review', 'code-reviewer'],
|
|
84
|
+
'qa-engineer': ['qa-engineer', 'qa'],
|
|
85
|
+
'security-officer': ['security-officer', 'security'],
|
|
86
|
+
'devops': ['devops', 'ops'],
|
|
87
|
+
'l3-support': ['l3-support', 'l3', 'support'],
|
|
88
|
+
};
|
|
89
|
+
const cands = aliases[stage] || [stage];
|
|
90
|
+
let last = null;
|
|
91
|
+
for (const c of cands) {
|
|
92
|
+
if (lastByAgent[c] && (!last || last.ts < lastByAgent[c].ts)) last = lastByAgent[c];
|
|
93
|
+
}
|
|
94
|
+
const isActive = cands.some(c => activeAgents.has(c));
|
|
95
|
+
const ageMs = last ? (now - new Date(last.ts).getTime()) : null;
|
|
96
|
+
const recent = ageMs != null && ageMs < ACTIVE_WINDOW;
|
|
97
|
+
let status = 'idle';
|
|
98
|
+
if (isActive || (recent && (last?.verdict || '').toUpperCase() === 'DONE' === false && recent)) status = 'active';
|
|
99
|
+
if (last && (last.verdict || '').toUpperCase().match(/BLOCKED|FAIL/)) status = 'failed';
|
|
100
|
+
if (last && !isActive && (last.verdict || '').toUpperCase().match(/APPROVED|DONE|PASS/)) status = 'done';
|
|
101
|
+
return {
|
|
102
|
+
stage,
|
|
103
|
+
status,
|
|
104
|
+
verdict: last?.verdict || null,
|
|
105
|
+
last_message: last ? (last.raw || '').slice(last.ts.length + 1).split(' ').slice(1).join(' ').slice(0, 80) : null,
|
|
106
|
+
ts: last?.ts || null,
|
|
107
|
+
age_min: ageMs != null ? Math.round(ageMs / 60000) : null,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Human gate ──────────────────────────────────────────────────────────
|
|
112
|
+
// The product's whole premise: no irreversible action ships without a human
|
|
113
|
+
// signature. Surface that checkpoint AS A STAGE in the pipeline, sitting just
|
|
114
|
+
// before the irreversible steps (devops/ship). It lights up when a gate is
|
|
115
|
+
// awaiting a human — so the operator always sees where the rails are.
|
|
116
|
+
const openGates = tasks.filter(t => t.is_gate && t.status !== 'done' && t.status !== 'closed' && t.raw_status !== 'closed');
|
|
117
|
+
const pending = openGates.length;
|
|
118
|
+
const newestGate = openGates.reduce((acc, t) => {
|
|
119
|
+
const ts = t.updated_at || t.created_at; return (!acc || (ts && ts > acc)) ? ts : acc;
|
|
120
|
+
}, null);
|
|
121
|
+
const gateAgeMs = newestGate ? (now - new Date(newestGate).getTime()) : null;
|
|
122
|
+
const gateNode = {
|
|
123
|
+
stage: 'human-gate',
|
|
124
|
+
is_human_gate: true,
|
|
125
|
+
status: pending > 0 ? 'active' : 'idle',
|
|
126
|
+
pending,
|
|
127
|
+
last_message: pending > 0
|
|
128
|
+
? `${pending} gate${pending > 1 ? 's' : ''} awaiting signature`
|
|
129
|
+
: 'no gate pending',
|
|
130
|
+
verdict: null,
|
|
131
|
+
ts: newestGate,
|
|
132
|
+
age_min: gateAgeMs != null ? Math.round(gateAgeMs / 60000) : null,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Insert the gate immediately before the first irreversible stage (devops).
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const s of agentStages) {
|
|
138
|
+
if (s.stage === 'devops') out.push(gateNode);
|
|
139
|
+
out.push(s);
|
|
140
|
+
}
|
|
141
|
+
if (!out.includes(gateNode)) out.push(gateNode); // fallback if devops absent
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Cost history (daily LLM burn) ────────────────────────────────────────────
|
|
146
|
+
function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
147
|
+
// Build a map<dateISO, { llm, human, plans, verdictCost }>.
|
|
148
|
+
// Inclusive window: `days=30` means [today-30 ... today] = 31 buckets.
|
|
149
|
+
// Previous behaviour (days buckets only) created a one-day gap on the
|
|
150
|
+
// far edge — tasks closed exactly on `today - days` fell out of the
|
|
151
|
+
// history while still being valid "last 30 days" data per user expectation.
|
|
152
|
+
const buckets = new Map();
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
for (let i = 0; i <= days; i++) {
|
|
155
|
+
const d = new Date(now - i * 86400000);
|
|
156
|
+
const key = d.toISOString().slice(0, 10);
|
|
157
|
+
buckets.set(key, { date: key, llm: 0, human: 0, plans: 0, runs: 0 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Plans: file mtime as date
|
|
161
|
+
// Cost extraction from LLM-written plan docs is intentionally lenient about
|
|
162
|
+
// shape — agents emit "LLM time: ~5 min · ~$0.30" as often as the old
|
|
163
|
+
// "LLM cost: 5–10 min" range form. We match a $-amount near the word
|
|
164
|
+
// "LLM" and another near "Human", and FIRE the sanity check below to
|
|
165
|
+
// reject pathological pairs (the 7,638× regression — total_human present
|
|
166
|
+
// but total_llm fell to zero because the LLM regex was too strict).
|
|
167
|
+
const plansDir = path.join(cwd, 'docs/plans');
|
|
168
|
+
if (fs.existsSync(plansDir)) {
|
|
169
|
+
for (const f of fs.readdirSync(plansDir).filter(x => x.endsWith('.md'))) {
|
|
170
|
+
const fp = path.join(plansDir, f);
|
|
171
|
+
const stat = fs.statSync(fp);
|
|
172
|
+
const dayKey = stat.mtime.toISOString().slice(0, 10);
|
|
173
|
+
if (!buckets.has(dayKey)) continue;
|
|
174
|
+
const content = fs.readFileSync(fp, 'utf8');
|
|
175
|
+
// Anchor LLM/Human at START of line (with optional markdown emphasis)
|
|
176
|
+
// so we never mis-match cases like:
|
|
177
|
+
// "**Cost**: $0.50 LLM | $240 human" ← would have grabbed $240 as LLM
|
|
178
|
+
// The label MUST be the first non-emphasis token on the line. Examples
|
|
179
|
+
// that correctly match:
|
|
180
|
+
// "**LLM**: $0.50–1.20"
|
|
181
|
+
// "LLM time: ~$0.30"
|
|
182
|
+
// "- **LLM cost:** $0.75 – $1.85"
|
|
183
|
+
// Examples correctly skipped:
|
|
184
|
+
// "**Cost**: $0.50–1.20 LLM | $240–360 human" (LLM mid-line)
|
|
185
|
+
// "Savings = Human/LLM" (LLM mid-line)
|
|
186
|
+
const llmMatch = content.match(/^[\s*_>\-]*LLM[^\n]*?\$(\d+\.?\d*)/im);
|
|
187
|
+
const humanMatch = content.match(/^[\s*_>\-]*Human[^\n]*?\$(\d[\d,]*\.?\d*)/im);
|
|
188
|
+
const b = buckets.get(dayKey);
|
|
189
|
+
if (llmMatch) b.llm += parseFloat(llmMatch[1]);
|
|
190
|
+
if (humanMatch) b.human += parseFloat(humanMatch[1].replace(/,/g, ''));
|
|
191
|
+
// SANITY GUARD: if Human matched but LLM regex missed, suppress Human
|
|
192
|
+
// for THIS plan rather than emit an implausible ratio. This is the
|
|
193
|
+
// production safety net for the 7,638× bug class.
|
|
194
|
+
if (humanMatch && !llmMatch && b.human > 0) {
|
|
195
|
+
// Reverse the suppression — drop the bogus single-sided Human entry.
|
|
196
|
+
b.human -= parseFloat(humanMatch[1].replace(/,/g, ''));
|
|
197
|
+
}
|
|
198
|
+
b.plans++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// hasRealCostData: true only when actual dollar figures exist — from plan
|
|
203
|
+
// files with parseable $ amounts OR from verdicts with cost_usd tags.
|
|
204
|
+
// A plan FILE existing (b.plans > 0) without a $ match does NOT count —
|
|
205
|
+
// that was the original BH-26 bug: plans without $ data blocked task
|
|
206
|
+
// estimates, causing /api/cost and /api/metrics to diverge.
|
|
207
|
+
let hasRealCostData = false;
|
|
208
|
+
for (const b of buckets.values()) { if (b.llm > 0) { hasRealCostData = true; break; } }
|
|
209
|
+
|
|
210
|
+
// Verdicts: cost=$X tag (from ~/.great_cto/verdicts/)
|
|
211
|
+
const verdicts = readVerdicts(cwd);
|
|
212
|
+
// feature=X aggregation — answers "how much did stripe-webhook cost?"
|
|
213
|
+
const featureMap = new Map(); // feature → { llm, runs }
|
|
214
|
+
for (const v of verdicts) {
|
|
215
|
+
if (v.cost_usd == null) continue;
|
|
216
|
+
const dayKey = (v.ts || '').slice(0, 10);
|
|
217
|
+
if (!buckets.has(dayKey)) continue;
|
|
218
|
+
const b = buckets.get(dayKey);
|
|
219
|
+
b.llm += v.cost_usd;
|
|
220
|
+
b.runs++;
|
|
221
|
+
// Extract feature= tag from raw verdict line
|
|
222
|
+
const featMatch = v.raw && v.raw.match(/\bfeature=([^\s|]+)/);
|
|
223
|
+
if (featMatch) {
|
|
224
|
+
const feat = featMatch[1];
|
|
225
|
+
const f = featureMap.get(feat) || { llm: 0, runs: 0 };
|
|
226
|
+
f.llm += v.cost_usd;
|
|
227
|
+
f.runs++;
|
|
228
|
+
featureMap.set(feat, f);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Human cost: ALWAYS compute as `closed_tasks_per_day × 4h × $150/hr`.
|
|
233
|
+
// This is the industry-baseline per-feature estimate and is INDEPENDENT
|
|
234
|
+
// of LLM cost source — so even when AI cost comes from real verdict data,
|
|
235
|
+
// human comparison is meaningful. Fallback LLM estimate engages only when
|
|
236
|
+
// no real cost data exists anywhere (verdicts or PLAN files).
|
|
237
|
+
const HUMAN_PER_TASK_USD = 4 * 150; // 4 hours × $150/hr
|
|
238
|
+
const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
|
|
239
|
+
const DEFAULT_TASK_MIN = 30;
|
|
240
|
+
try {
|
|
241
|
+
const tasks = getTasks(cwd);
|
|
242
|
+
for (const t of tasks) {
|
|
243
|
+
if (!t.closed_at) continue;
|
|
244
|
+
if (!t.agent) continue;
|
|
245
|
+
const dayKey = new Date(t.closed_at).toISOString().slice(0, 10);
|
|
246
|
+
if (!buckets.has(dayKey)) continue;
|
|
247
|
+
const b = buckets.get(dayKey);
|
|
248
|
+
b.human += HUMAN_PER_TASK_USD;
|
|
249
|
+
// Add LLM estimate only for days that have no real cost data (plans or
|
|
250
|
+
// verdicts). Using a per-bucket check rather than a global flag so that
|
|
251
|
+
// days WITH plan/verdict data keep their real numbers while days without
|
|
252
|
+
// still get an estimate — consistent with /api/metrics behaviour.
|
|
253
|
+
if (b.llm === 0) {
|
|
254
|
+
const mins = t.estimated_minutes || DEFAULT_TASK_MIN;
|
|
255
|
+
b.llm += mins / 60 * LLM_RATE_PER_HR;
|
|
256
|
+
b.runs++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch { /* getTasks failure is non-fatal */ }
|
|
260
|
+
|
|
261
|
+
const series = Array.from(buckets.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
262
|
+
let totalLlm = series.reduce((a, b) => a + b.llm, 0);
|
|
263
|
+
let totalHuman = series.reduce((a, b) => a + b.human, 0);
|
|
264
|
+
const totalPlans = series.reduce((a, b) => a + b.plans, 0);
|
|
265
|
+
|
|
266
|
+
// SANITY GUARD — anti-7,638× regression. If ratio > 1000×, one of the
|
|
267
|
+
// numbers is wrong. Almost always: total_llm collapsed to ~0 because plan
|
|
268
|
+
// parsing missed the LLM value, while total_human matched a "$7,500 saved"
|
|
269
|
+
// marketing line. Better to under-report than show an implausible 7,500×
|
|
270
|
+
// savings on the dashboard. Caller can still see the raw `series`.
|
|
271
|
+
if (totalLlm > 0 && totalHuman > 0 && totalPlans > 0 && (totalHuman / totalLlm) > 1000) {
|
|
272
|
+
totalHuman = 0;
|
|
273
|
+
for (const b of series) b.human = 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Read budget from PROJECT.md (monthly-budget: $X)
|
|
277
|
+
const projMd = readFileSafe(path.join(cwd, '.great_cto', 'PROJECT.md')) || '';
|
|
278
|
+
const budgetMatch = projMd.match(/monthly[-_]budget:\s*\$?(\d[\d,]+)/i);
|
|
279
|
+
const budget = budgetMatch ? parseFloat(budgetMatch[1].replace(/,/g, '')) : null;
|
|
280
|
+
// Burn projection: assume same daily rate, project to 30-day month
|
|
281
|
+
const dayRate = totalLlm / Math.max(1, days);
|
|
282
|
+
const projectedMonthly = Math.round(dayRate * 30 * 100) / 100;
|
|
283
|
+
return {
|
|
284
|
+
days,
|
|
285
|
+
series,
|
|
286
|
+
total_llm: Math.round(totalLlm * 100) / 100,
|
|
287
|
+
total_human: Math.round(totalHuman),
|
|
288
|
+
total_plans: totalPlans,
|
|
289
|
+
daily_avg: Math.round(dayRate * 100) / 100,
|
|
290
|
+
projected_monthly: projectedMonthly,
|
|
291
|
+
monthly_budget: budget,
|
|
292
|
+
over_budget: budget != null && projectedMonthly > budget,
|
|
293
|
+
// savings_x semantics:
|
|
294
|
+
// null → cannot compute (no human estimate available — distinct from "no savings")
|
|
295
|
+
// 0+ → real ratio, total_human / total_llm
|
|
296
|
+
// Pre-fix this returned 0 in both cases, conflating "no human estimate"
|
|
297
|
+
// with "human cost is identical to LLM cost" — misleading on dashboards.
|
|
298
|
+
savings_x: (totalLlm > 0 && totalHuman > 0)
|
|
299
|
+
? Math.round(totalHuman / totalLlm)
|
|
300
|
+
: null,
|
|
301
|
+
// Top features by LLM spend — sorted desc, top 10
|
|
302
|
+
by_feature: Array.from(featureMap.entries())
|
|
303
|
+
.map(([feature, f]) => ({ feature, llm: Math.round(f.llm * 100) / 100, runs: f.runs }))
|
|
304
|
+
.sort((a, b) => b.llm - a.llm)
|
|
305
|
+
.slice(0, 10),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Inbox: what needs the user's decision right now ──────────────────────────
|
|
310
|
+
function getInbox(cwd = process.cwd()) {
|
|
311
|
+
const tasks = getTasks(cwd);
|
|
312
|
+
// Use raw_status here: mapStatus() rewrites status to 'gate' for any task with the
|
|
313
|
+
// 'gate' label, regardless of bd-native state. Filtering on the mapped value would
|
|
314
|
+
// leave closed/blocked gates in the inbox forever.
|
|
315
|
+
const pendingGates = tasks.filter(t => t.is_gate && t.raw_status !== 'closed' && t.raw_status !== 'blocked');
|
|
316
|
+
const blocked = tasks.filter(t => t.status === 'blocked');
|
|
317
|
+
const p0 = tasks.filter(t => t.priority === 0 && t.status !== 'done' && t.status !== 'closed');
|
|
318
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
319
|
+
const stale = inProgress.filter(t => {
|
|
320
|
+
if (!t.updated_at) return false;
|
|
321
|
+
const ageH = (Date.now() - new Date(t.updated_at).getTime()) / 3600_000;
|
|
322
|
+
return ageH > 48;
|
|
323
|
+
});
|
|
324
|
+
const sec = readSecStats(cwd);
|
|
325
|
+
return {
|
|
326
|
+
pending_gates: pendingGates.slice(0, 20),
|
|
327
|
+
blocked: blocked.slice(0, 10),
|
|
328
|
+
p0_open: p0.slice(0, 10),
|
|
329
|
+
stale_in_progress: stale.slice(0, 10),
|
|
330
|
+
security: { blocked: sec.blocked, approved: sec.approved },
|
|
331
|
+
summary: {
|
|
332
|
+
gates: pendingGates.length,
|
|
333
|
+
blocked: blocked.length,
|
|
334
|
+
p0: p0.length,
|
|
335
|
+
stale: stale.length,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export { getMemory, getPipeline, getCostHistory, getInbox };
|