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,45 @@
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
+ const PROJECTS_FILE = path.join(GREAT_CTO_DIR, 'projects.json');
25
+ const SHARE_ENDPOINT = 'https://greatcto.systems/r/';
26
+ const VAPID_KEYS_FILE = path.join(GREAT_CTO_DIR, 'vapid-keys.json');
27
+ const PUSH_SUBS_FILE = path.join(GREAT_CTO_DIR, 'push-subscriptions.json');
28
+ const NOTIF_HISTORY_FILE = path.join(GREAT_CTO_DIR, 'notif-history.json');
29
+ const VAPID_SUBJECT = 'mailto:hi@updates.greatcto.systems';
30
+
31
+ export {
32
+ __dirname,
33
+ PORT,
34
+ PUBLIC,
35
+ BUILD_VERSION,
36
+ HOST,
37
+ GREAT_CTO_DIR,
38
+ SHARE_STATE_FILE,
39
+ PROJECTS_FILE,
40
+ SHARE_ENDPOINT,
41
+ VAPID_KEYS_FILE,
42
+ PUSH_SUBS_FILE,
43
+ NOTIF_HISTORY_FILE,
44
+ VAPID_SUBJECT,
45
+ };
@@ -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 };