Skip to content

Commit c5ba86c

Browse files
Chirag6722claude
andcommitted
fix(ideas): wire up rule-based idea engine as LLM fallback
generateIdeas() has been fully implemented and imported by server.mjs since v2.0.0, but was never called. Every user without an LLM key saw a permanently empty "Leverageable Ideas" panel, and any transient LLM failure (timeout, 429, malformed JSON) blanked the panel for that sweep — even though all the input signals needed for deterministic ideas were already in the synthesized data. - Add resolveIdeas(), a shared resolver that prefers LLM ideas and falls back to the signal-rule engine when the provider is absent, empty, or throws. Used by both the server sweep cycle and the `npm run inject` CLI path. - synthesize() now seeds baseline ideas, so the instant-load path (existing runs/latest.json) shows a populated panel before the first sweep finishes. - Harden generateIdeas() against degraded sweeps: a failed FRED/EIA/BLS/Telegram source no longer throws, non-finite metric values no longer emit ideas with NaN in user-facing copy, and a zero oldest WTI price no longer divides by zero. - Dashboard: SIGNAL BASED provenance badge for rule-generated ideas, and a source-aware empty state (en/fr locales) instead of always blaming the LLM. - Add `npm test` so the suite is runnable, plus 22 tests covering each rule's trigger, the 8-idea cap, the output contract the dashboard renders, and all four fallback paths. Closes #132 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3db7068 commit c5ba86c

8 files changed

Lines changed: 347 additions & 91 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ The server runs a sweep cycle every 15 minutes (configurable). Each cycle:
146146
1. Queries all 27 sources in parallel (~30s)
147147
2. Synthesizes raw data into dashboard format
148148
3. Computes delta from previous run (what changed, escalated, de-escalated) — visible in the **Sweep Delta** panel on the dashboard
149-
4. Generates LLM trade ideas (if configured)
149+
4. Generates trade ideas — LLM-generated if a provider is configured, otherwise (or if the provider fails) the deterministic signal-rule engine
150150
5. Evaluates breaking news alerts — multi-tier (FLASH / PRIORITY / ROUTINE) with semantic dedup. Sends to Telegram and/or Discord if configured. Works with LLM evaluation or falls back to rule-based alerting when LLM is unavailable.
151151
6. Pushes update to all connected browsers via SSE
152152

dashboard/inject.mjs

Lines changed: 88 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1717
const ROOT = join(__dirname, '..');
1818

1919
// === Helpers ===
20+
const isNum = v => typeof v === 'number' && Number.isFinite(v);
21+
2022
const cyrillic = /[\u0400-\u04FF]/;
2123
function isEnglish(text) {
2224
if (!text) return false;
@@ -257,73 +259,92 @@ export async function fetchAllNews() {
257259
}
258260

259261
// === Leverageable Ideas from Signals ===
262+
/**
263+
* Deterministic, signal-based idea generation. Used as the baseline layer when
264+
* the LLM is disabled or its call fails, so the Ideas panel is never empty just
265+
* because a provider timed out.
266+
*
267+
* Every source is optional: a sweep with a failed FRED/EIA/BLS source must still
268+
* produce whatever ideas the surviving signals support rather than throwing.
269+
*/
260270
export function generateIdeas(V2) {
271+
if (!V2) return [];
261272
const ideas = [];
262-
const vix = V2.fred.find(f => f.id === 'VIXCLS');
263-
const hy = V2.fred.find(f => f.id === 'BAMLH0A0HYM2');
264-
const spread = V2.fred.find(f => f.id === 'T10Y2Y');
265-
266-
if (V2.tg.urgent.length > 3 && V2.energy.wti > 68) {
273+
const fred = Array.isArray(V2.fred) ? V2.fred : [];
274+
const bls = Array.isArray(V2.bls) ? V2.bls : [];
275+
const thermal = Array.isArray(V2.thermal) ? V2.thermal : [];
276+
const urgent = Array.isArray(V2.tg?.urgent) ? V2.tg.urgent : [];
277+
const energy = V2.energy || {};
278+
const wtiRecent = Array.isArray(energy.wtiRecent) ? energy.wtiRecent : [];
279+
280+
const vix = fred.find(f => f.id === 'VIXCLS');
281+
const hy = fred.find(f => f.id === 'BAMLH0A0HYM2');
282+
const spread = fred.find(f => f.id === 'T10Y2Y');
283+
284+
if (urgent.length > 3 && energy.wti > 68) {
267285
ideas.push({
268286
title: 'Conflict-Energy Nexus Active',
269-
text: `${V2.tg.urgent.length} urgent conflict signals with WTI at $${V2.energy.wti}. Geopolitical risk premium may expand. Consider energy exposure.`,
287+
text: `${urgent.length} urgent conflict signals with WTI at $${energy.wti}. Geopolitical risk premium may expand. Consider energy exposure.`,
270288
type: 'long', confidence: 'Medium', horizon: 'swing'
271289
});
272290
}
273-
if (vix && vix.value > 20) {
291+
if (isNum(vix?.value) && vix.value > 20) {
274292
ideas.push({
275293
title: 'Elevated Volatility Regime',
276294
text: `VIX at ${vix.value} — fear premium elevated. Portfolio hedges justified. Short-term equity upside is capped.`,
277295
type: 'hedge', confidence: vix.value > 25 ? 'High' : 'Medium', horizon: 'tactical'
278296
});
279297
}
280-
if (vix && vix.value > 20 && hy && hy.value > 3) {
298+
if (isNum(vix?.value) && vix.value > 20 && isNum(hy?.value) && hy.value > 3) {
281299
ideas.push({
282300
title: 'Safe Haven Demand Rising',
283301
text: `VIX ${vix.value} + HY spread ${hy.value}% = risk-off building. Gold, treasuries, quality dividends may outperform.`,
284302
type: 'hedge', confidence: 'Medium', horizon: 'tactical'
285303
});
286304
}
287-
if (V2.energy.wtiRecent.length > 1) {
288-
const latest = V2.energy.wtiRecent[0];
289-
const oldest = V2.energy.wtiRecent[V2.energy.wtiRecent.length - 1];
290-
const pct = ((latest - oldest) / oldest * 100).toFixed(1);
291-
if (Math.abs(pct) > 3) {
292-
ideas.push({
293-
title: pct > 0 ? 'Oil Momentum Building' : 'Oil Under Pressure',
294-
text: `WTI moved ${pct > 0 ? '+' : ''}${pct}% recently to $${V2.energy.wti}/bbl. ${pct > 0 ? 'Energy and commodity names benefit.' : 'Demand concerns may be emerging.'}`,
295-
type: pct > 0 ? 'long' : 'watch', confidence: 'Medium', horizon: 'swing'
296-
});
305+
if (wtiRecent.length > 1) {
306+
const latest = wtiRecent[0];
307+
const oldest = wtiRecent[wtiRecent.length - 1];
308+
// Guard against a zero/absent oldest price — otherwise pct is Infinity/NaN.
309+
if (isNum(latest) && isNum(oldest) && oldest !== 0) {
310+
const pct = (latest - oldest) / oldest * 100;
311+
if (Math.abs(pct) > 3) {
312+
ideas.push({
313+
title: pct > 0 ? 'Oil Momentum Building' : 'Oil Under Pressure',
314+
text: `WTI moved ${pct > 0 ? '+' : ''}${pct.toFixed(1)}% recently to $${energy.wti}/bbl. ${pct > 0 ? 'Energy and commodity names benefit.' : 'Demand concerns may be emerging.'}`,
315+
type: pct > 0 ? 'long' : 'watch', confidence: 'Medium', horizon: 'swing'
316+
});
317+
}
297318
}
298319
}
299-
if (spread) {
320+
if (isNum(spread?.value)) {
300321
ideas.push({
301322
title: spread.value > 0 ? 'Yield Curve Normalizing' : 'Yield Curve Inverted',
302323
text: `10Y-2Y spread at ${spread.value.toFixed(2)}. ${spread.value > 0 ? 'Recession signal fading — cyclical rotation possible.' : 'Inversion persists — defensive positioning warranted.'}`,
303324
type: 'watch', confidence: 'Medium', horizon: 'strategic'
304325
});
305326
}
306-
const debt = parseFloat(V2.treasury.totalDebt);
307-
if (debt > 35e12) {
327+
const debt = parseFloat(V2.treasury?.totalDebt);
328+
if (isNum(debt) && debt > 35e12) {
308329
ideas.push({
309330
title: 'Fiscal Trajectory Supports Hard Assets',
310331
text: `National debt at $${(debt / 1e12).toFixed(1)}T. Long-term gold, bitcoin, and real asset appreciation thesis intact.`,
311332
type: 'long', confidence: 'High', horizon: 'strategic'
312333
});
313334
}
314-
const totalThermal = V2.thermal.reduce((s, t) => s + t.det, 0);
315-
if (totalThermal > 30000 && V2.tg.urgent.length > 2) {
335+
const totalThermal = thermal.reduce((s, t) => s + (t?.det || 0), 0);
336+
if (totalThermal > 30000 && urgent.length > 2) {
316337
ideas.push({
317338
title: 'Satellite Confirms Conflict Intensity',
318-
text: `${totalThermal.toLocaleString()} thermal detections + ${V2.tg.urgent.length} urgent OSINT flags. Defense sector procurement may accelerate.`,
339+
text: `${totalThermal.toLocaleString()} thermal detections + ${urgent.length} urgent OSINT flags. Defense sector procurement may accelerate.`,
319340
type: 'watch', confidence: 'Medium', horizon: 'swing'
320341
});
321342
}
322343

323344
// Yield Curve + Labor Interaction
324-
const unemployment = V2.bls.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE');
325-
const payrolls = V2.bls.find(b => b.id === 'CES0000000001' || b.id === 'PAYEMS');
326-
if (spread && unemployment && payrolls) {
345+
const unemployment = bls.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE');
346+
const payrolls = bls.find(b => b.id === 'CES0000000001' || b.id === 'PAYEMS');
347+
if (isNum(spread?.value) && isNum(unemployment?.value) && payrolls) {
327348
const weakLabor = (unemployment.value > 4.3) || (payrolls.momChange && payrolls.momChange < -50);
328349
if (spread.value > 0.3 && weakLabor) {
329350
ideas.push({
@@ -336,9 +357,9 @@ export function generateIdeas(V2) {
336357

337358
// ACLED Conflict + Energy Momentum
338359
const conflictEvents = V2.acled?.totalEvents || 0;
339-
if (conflictEvents > 50 && V2.energy.wtiRecent.length > 1) {
340-
const wtiMove = V2.energy.wtiRecent[0] - V2.energy.wtiRecent[V2.energy.wtiRecent.length - 1];
341-
if (wtiMove > 2) {
360+
if (conflictEvents > 50 && wtiRecent.length > 1) {
361+
const wtiMove = wtiRecent[0] - wtiRecent[wtiRecent.length - 1];
362+
if (isNum(wtiMove) && wtiMove > 2) {
342363
ideas.push({
343364
title: 'Conflict Fueling Energy Momentum',
344365
text: `${conflictEvents} ACLED events this week + WTI up $${wtiMove.toFixed(1)}. Conflict-energy transmission channel active.`,
@@ -349,7 +370,7 @@ export function generateIdeas(V2) {
349370

350371
// Defense + Conflict Intensity
351372
const totalFatalities = V2.acled?.totalFatalities || 0;
352-
const totalThermalAll = V2.thermal.reduce((s, t) => s + t.det, 0);
373+
const totalThermalAll = totalThermal;
353374
if (totalFatalities > 500 && totalThermalAll > 20000) {
354375
ideas.push({
355376
title: 'Defense Procurement Acceleration Signal',
@@ -359,7 +380,7 @@ export function generateIdeas(V2) {
359380
}
360381

361382
// HY Spread + VIX Divergence
362-
if (hy && vix) {
383+
if (isNum(hy?.value) && isNum(vix?.value)) {
363384
const hyWide = hy.value > 3.5;
364385
const vixLow = vix.value < 18;
365386
const hyTight = hy.value < 2.5;
@@ -380,9 +401,9 @@ export function generateIdeas(V2) {
380401
}
381402

382403
// Supply Chain + Inflation Pipeline
383-
const ppi = V2.bls.find(b => b.id === 'WPUFD49104' || b.id === 'PCU--PCU--');
384-
const cpi = V2.bls.find(b => b.id === 'CUUR0000SA0' || b.id === 'CPIAUCSL');
385-
if (ppi && cpi && V2.gscpi) {
404+
const ppi = bls.find(b => b.id === 'WPUFD49104' || b.id === 'PCU--PCU--');
405+
const cpi = bls.find(b => b.id === 'CUUR0000SA0' || b.id === 'CPIAUCSL');
406+
if (ppi && cpi && isNum(V2.gscpi?.value)) {
386407
const supplyPressure = V2.gscpi.value > 0.5;
387408
const ppiRising = ppi.momChangePct > 0.3;
388409
if (supplyPressure && ppiRising) {
@@ -397,6 +418,28 @@ export function generateIdeas(V2) {
397418
return ideas.slice(0, 8);
398419
}
399420

421+
/**
422+
* Resolve the Ideas panel contents for a synthesized sweep.
423+
*
424+
* The LLM is an *enhancement* layer, not a prerequisite: whenever it is absent,
425+
* returns nothing, or throws, we fall back to the deterministic signal-based
426+
* engine so the panel is still populated.
427+
*
428+
* @returns {Promise<{ideas: Array, ideasSource: 'llm'|'rules'}>}
429+
*/
430+
export async function resolveIdeas(llmProvider, V2, delta = null, previousIdeas = []) {
431+
if (llmProvider?.isConfigured) {
432+
try {
433+
const llmIdeas = await generateLLMIdeas(llmProvider, V2, delta, previousIdeas);
434+
if (llmIdeas?.length) return { ideas: llmIdeas, ideasSource: 'llm' };
435+
console.warn('[Ideas] LLM returned no ideas — falling back to signal rules');
436+
} catch (err) {
437+
console.warn('[Ideas] LLM idea generation failed, falling back to signal rules:', err.message);
438+
}
439+
}
440+
return { ideas: generateIdeas(V2), ideasSource: 'rules' };
441+
}
442+
400443
// === Synthesize raw sweep data into dashboard format ===
401444
export async function synthesize(data) {
402445
const liveAirHotspots = data.sources.OpenSky?.hotspots || [];
@@ -610,11 +653,15 @@ export async function synthesize(data) {
610653
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
611654
who, fred, energy, metals, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
612655
markets, // Live Yahoo Finance market data
613-
ideas: [], ideasSource: 'disabled',
656+
ideas: [], ideasSource: 'rules',
614657
// newsFeed for ticker (merged RSS + GDELT + Telegram)
615658
newsFeed: buildNewsFeed(news, gdeltData, tgUrgent, tgTop),
616659
};
617660

661+
// Baseline signal-based ideas so the panel is populated even before (or without)
662+
// an LLM pass. Callers that run the LLM overwrite these via resolveIdeas().
663+
V2.ideas = generateIdeas(V2);
664+
618665
return V2;
619666
}
620667

@@ -696,29 +743,11 @@ async function cliInject() {
696743
const V2 = await synthesize(data);
697744
const llmProvider = createLLMProvider(config.llm);
698745

699-
if (llmProvider?.isConfigured) {
700-
try {
701-
console.log(`[LLM] Generating ideas via ${llmProvider.name}...`);
702-
const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []);
703-
if (llmIdeas?.length) {
704-
V2.ideas = llmIdeas;
705-
V2.ideasSource = 'llm';
706-
console.log(`[LLM] Generated ${llmIdeas.length} ideas`);
707-
} else {
708-
V2.ideas = [];
709-
V2.ideasSource = 'llm-failed';
710-
console.log('[LLM] No ideas returned');
711-
}
712-
} catch (err) {
713-
V2.ideas = [];
714-
V2.ideasSource = 'llm-failed';
715-
console.log('[LLM] Idea generation failed:', err.message);
716-
}
717-
} else {
718-
V2.ideas = [];
719-
V2.ideasSource = 'disabled';
720-
}
721-
console.log(`Generated ${V2.ideas.length} leverageable ideas`);
746+
if (llmProvider?.isConfigured) console.log(`[LLM] Generating ideas via ${llmProvider.name}...`);
747+
const resolved = await resolveIdeas(llmProvider, V2, null, []);
748+
V2.ideas = resolved.ideas;
749+
V2.ideasSource = resolved.ideasSource;
750+
console.log(`Generated ${V2.ideas.length} leverageable ideas (${V2.ideasSource})`);
722751

723752
const json = JSON.stringify(V2);
724753
console.log('\n--- Synthesis ---');

dashboard/public/jarvis.html

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,7 +1433,8 @@
14331433
}).join('');
14341434
const tickerDuration = Math.max(20, feed.length * 2.5);
14351435

1436-
// Leverageable Ideas (LLM-only feature)
1436+
// Leverageable Ideas — LLM-generated when a provider is configured, otherwise
1437+
// the deterministic signal-rule engine.
14371438
const hasIdeas = D.ideas && D.ideas.length > 0;
14381439
const ideasHtml = hasIdeas ? (D.ideas||[]).map(idea=>`
14391440
<div class="idea-card">
@@ -1446,8 +1447,8 @@
14461447
${idea.risk ? `<div class="idea-text" style="color:var(--warn);margin-top:3px">Risk: ${idea.risk}</div>` : ''}
14471448
</div>`).join('') : `<div style="padding:20px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:11px">
14481449
<div style="font-size:24px;margin-bottom:8px;opacity:0.3">&#9888;</div>
1449-
<div>LLM NOT CONFIGURED</div>
1450-
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
1450+
<div>${t('ideas.noTriggers','NO SIGNAL TRIGGERS')}</div>
1451+
<div style="font-size:9px;margin-top:6px;opacity:0.6">${t('ideas.noTriggersHint','No signal thresholds crossed this sweep. Set LLM_PROVIDER + credentials in .env for AI-generated ideas.')}</div>
14511452
</div>`;
14521453

14531454

@@ -1481,7 +1482,7 @@
14811482
</div>
14821483
</div>`;
14831484
const ideasPanel = `<div class="g-panel lp-ideas">
1484-
<div class="sec-head"><h3>${t('panels.tradeIdeas','Leverageable Ideas')}</h3>${D.ideasSource==='llm'?'<span class="ideas-src llm">'+t('ideas.aiEnhanced','AI ENHANCED')+'</span>':D.ideasSource==='disabled'?'<span class="ideas-src static">'+t('ideas.llmOff','LLM OFF')+'</span>':'<span class="ideas-src static">'+t('ideas.pending','PENDING')+'</span>'}</div>
1485+
<div class="sec-head"><h3>${t('panels.tradeIdeas','Leverageable Ideas')}</h3>${ideasBadge(D.ideasSource)}</div>
14851486
${ideasHtml}
14861487
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
14871488
</div>`;
@@ -1552,6 +1553,13 @@
15521553
// === HELPERS ===
15531554
function getAge(d){const ms=Date.now()-new Date(d).getTime();const h=Math.floor(ms/3600000);if(h<1)return 'just now';if(h<24)return h+'h ago';return Math.floor(h/24)+'d ago'}
15541555
function cleanText(t){return t.replace(/&#39;/g,"'").replace(/&#33;/g,"!").replace(/&amp;/g,"&").replace(/<[^>]+>/g,'')}
1556+
// Provenance badge for the Ideas panel. 'llm' = AI-generated, 'rules' = deterministic
1557+
// signal engine (the fallback when no LLM is configured or the provider failed).
1558+
function ideasBadge(src){
1559+
if(src==='llm') return '<span class="ideas-src llm">'+t('ideas.aiEnhanced','AI ENHANCED')+'</span>';
1560+
if(src==='rules') return '<span class="ideas-src static">'+t('ideas.signalBased','SIGNAL BASED')+'</span>';
1561+
return '<span class="ideas-src static">'+t('ideas.pending','PENDING')+'</span>';
1562+
}
15551563
function safeExternalUrl(raw){try{const u=new URL(raw,location.href);return u.protocol==='http:'||u.protocol==='https:'?u.toString():null}catch{return null}}
15561564

15571565
// === BOOT SEQUENCE ===

locales/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@
121121
"pending": "PENDING",
122122
"llmNotConfigured": "LLM NOT CONFIGURED",
123123
"llmHelp": "Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas",
124-
"disclosure": "FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results."
124+
"disclosure": "FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.",
125+
"signalBased": "SIGNAL BASED",
126+
"noTriggers": "NO SIGNAL TRIGGERS",
127+
"noTriggersHint": "No signal thresholds crossed this sweep. Set LLM_PROVIDER + credentials in .env for AI-generated ideas."
125128
},
126129
"regions": {
127130
"world": "World",

locales/fr.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@
121121
"pending": "EN ATTENTE",
122122
"llmNotConfigured": "LLM NON CONFIGURÉ",
123123
"llmHelp": "Définir LLM_PROVIDER + identifiants dans .env pour activer les idées de trade IA",
124-
"disclosure": "À TITRE INFORMATIF UNIQUEMENT. Ceci ne constitue pas un conseil financier, une recommandation d'achat ou de vente de titre, ni une sollicitation quelconque. Toutes les observations basées sur les signaux sont dérivées de données OSINT publiques et ne doivent pas être utilisées pour prendre des décisions d'investissement. Consultez un conseiller financier agréé avant tout investissement. Les performances passées ne garantissent pas les résultats futurs."
124+
"disclosure": "À TITRE INFORMATIF UNIQUEMENT. Ceci ne constitue pas un conseil financier, une recommandation d'achat ou de vente de titre, ni une sollicitation quelconque. Toutes les observations basées sur les signaux sont dérivées de données OSINT publiques et ne doivent pas être utilisées pour prendre des décisions d'investissement. Consultez un conseiller financier agréé avant tout investissement. Les performances passées ne garantissent pas les résultats futurs.",
125+
"signalBased": "BASE SIGNAUX",
126+
"noTriggers": "AUCUN SEUIL FRANCHI",
127+
"noTriggersHint": "Aucun seuil de signal franchi lors de ce balayage. Definissez LLM_PROVIDER + identifiants dans .env pour des idees generees par IA."
125128
},
126129
"regions": {
127130
"world": "Monde",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"start": "node server.mjs",
8+
"test": "node --test \"test/*.test.mjs\"",
89
"dev": "node --trace-warnings server.mjs",
910
"sweep": "node apis/briefing.mjs",
1011
"inject": "node dashboard/inject.mjs",

0 commit comments

Comments
 (0)