great-cto 2.82.1 → 2.82.3

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.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "2.82.1",
5
+ "version": "2.82.3",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -171,7 +171,7 @@ function startAlertCron() {
171
171
  },
172
172
  };
173
173
  fireEmailAlert('incident.p0', dedupeKey, p0Payload);
174
- addNotification('incident.p0', p0Payload);
174
+ addNotification('incident.p0', p0Payload, dedupeKey);
175
175
  firePushAlert('incident.p0', dedupeKey, p0Payload);
176
176
  }
177
177
  }
@@ -207,7 +207,7 @@ function startAlertCron() {
207
207
  },
208
208
  };
209
209
  fireEmailAlert('gate.blocked', dedupeKey, blockedPayload);
210
- addNotification('gate.blocked', blockedPayload);
210
+ addNotification('gate.blocked', blockedPayload, dedupeKey);
211
211
  firePushAlert('gate.blocked', dedupeKey, blockedPayload);
212
212
  }
213
213
  } catch (e) { log.warn('cron gate.blocked failed:', e.message); }
@@ -237,7 +237,7 @@ function startAlertCron() {
237
237
  kv: { gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h` },
238
238
  };
239
239
  fireEmailAlert('gate.stale', dedupeKey, stalePayload);
240
- addNotification('gate.stale', stalePayload);
240
+ addNotification('gate.stale', stalePayload, dedupeKey);
241
241
  firePushAlert('gate.stale', dedupeKey, stalePayload);
242
242
  }
243
243
  }
@@ -276,7 +276,7 @@ function startAlertCron() {
276
276
  },
277
277
  };
278
278
  fireEmailAlert('cost.threshold', dedupeKey, costPayload);
279
- addNotification('cost.threshold', costPayload);
279
+ addNotification('cost.threshold', costPayload, dedupeKey);
280
280
  firePushAlert('cost.threshold', dedupeKey, costPayload);
281
281
  }
282
282
  }
@@ -317,7 +317,7 @@ function startAlertCron() {
317
317
  },
318
318
  };
319
319
  await fireEmailAlert('digest.weekly', dedupeKey, weeklyPayload);
320
- addNotification('digest.weekly', weeklyPayload);
320
+ addNotification('digest.weekly', weeklyPayload, dedupeKey);
321
321
  await firePushAlert('digest.weekly', dedupeKey, weeklyPayload);
322
322
  }
323
323
  } catch (e) { log.warn('cron digest.weekly failed:', e.message); }
@@ -384,7 +384,7 @@ function startAlertCron() {
384
384
  kv: kvObj,
385
385
  };
386
386
  await fireEmailAlert('digest.daily', dedupeKey, dailyPayload);
387
- addNotification('digest.daily', dailyPayload);
387
+ addNotification('digest.daily', dailyPayload, dedupeKey);
388
388
  await firePushAlert('digest.daily', dedupeKey, dailyPayload);
389
389
  }
390
390
  } catch (e) { log.warn('cron digest.daily failed:', e.message); }
@@ -423,7 +423,7 @@ function startAlertCron() {
423
423
  notify: (current, latest, dedupeKey) => {
424
424
  const payload = buildUpdatePayload(current, latest);
425
425
  fireEmailAlert('update.available', dedupeKey, payload);
426
- addNotification('update.available', payload);
426
+ addNotification('update.available', payload, dedupeKey);
427
427
  firePushAlert('update.available', dedupeKey, payload);
428
428
  },
429
429
  }).catch(e => log.warn('cron update.available failed:', e.message));
@@ -29,7 +29,7 @@ const PROJECTS_FILE = process.env.GREAT_CTO_PROJECTS_FILE || path.join(GREAT_CTO
29
29
  const SHARE_ENDPOINT = 'https://greatcto.systems/r/';
30
30
  const VAPID_KEYS_FILE = path.join(GREAT_CTO_DIR, 'vapid-keys.json');
31
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');
32
+ const NOTIF_HISTORY_FILE = process.env.GREAT_CTO_NOTIF_HISTORY_FILE || path.join(GREAT_CTO_DIR, 'notif-history.json');
33
33
  const VAPID_SUBJECT = 'mailto:hi@updates.greatcto.systems';
34
34
 
35
35
  export {
@@ -25,7 +25,15 @@ function saveNotifHistory() {
25
25
  * Record a notification, broadcast via SSE, and persist.
26
26
  * Called alongside fireEmailAlert / firePushAlert at every trigger point.
27
27
  */
28
- function addNotification(event, payload) {
28
+ function addNotification(event, payload, dedupeKey = null) {
29
+ // Idempotent per dedupeKey: the alert crons tick every 5 min and a persistent
30
+ // condition (open P0, blocked/stale gate, cost over threshold, the daily
31
+ // digest during its whole UTC hour) would otherwise add a fresh in-app
32
+ // notification on every tick — spamming the bell. Email/push already dedupe
33
+ // via alerts-fired.json; this makes the in-app notification dedupe the same.
34
+ if (dedupeKey && notifHistory.some(n => n.dedupeKey === dedupeKey)) {
35
+ return null;
36
+ }
29
37
  const notif = {
30
38
  id: crypto.randomUUID(),
31
39
  event,
@@ -36,6 +44,7 @@ function addNotification(event, payload) {
36
44
  project: payload.project || '',
37
45
  ts: new Date().toISOString(),
38
46
  read: false,
47
+ ...(dedupeKey ? { dedupeKey } : {}),
39
48
  };
40
49
  notifHistory.unshift(notif);
41
50
  if (notifHistory.length > MAX_NOTIF_HISTORY) notifHistory.length = MAX_NOTIF_HISTORY;
@@ -4039,14 +4039,18 @@ function renderDashboard(m) {
4039
4039
  const avgMin = m.avg_completion_min ?? 0;
4040
4040
  const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
4041
4041
 
4042
- // Honest cost model (matches share report):
4043
- // AI spend = real LLM tokens from verdicts (preferred), else time-based estimate
4044
- // Human est = done × 4h per feature × $150/hr (industry baseline)
4045
- // Savings = humanEst / aiSpend (trustworthy when AI = real tokens, independent of human rate)
4042
+ // Honest cost model the backend is the source of truth:
4043
+ // cost.savings_x = MEASURED savings, only set when real verdict cost data
4044
+ // exists. NULL for the time-based (tasks) estimate, because
4045
+ // that ratio is just the hardcoded rate ratio, not a real
4046
+ // measurement — showing it would fabricate a number.
4047
+ // cost.human_usd = the backend's conservative human-cost estimate.
4048
+ // The frontend must NOT recompute its own human/savings figures (that produced
4049
+ // an inflated "×" that contradicted the API); it displays the backend fields.
4046
4050
  const aiSpend = realLlmUsd > 0 ? realLlmUsd : llmUsd;
4047
- const humanEst = done * 4 * 150;
4048
- const savingsX = (aiSpend > 0 && humanEst > 0)
4049
- ? Math.round(humanEst / aiSpend)
4051
+ const humanEst = m.cost?.human_usd ?? 0;
4052
+ const savingsX = (typeof planSavingsX === 'number' && planSavingsX > 1)
4053
+ ? planSavingsX
4050
4054
  : null;
4051
4055
 
4052
4056
  const llmTrend = realLlmUsd > 0
@@ -4067,28 +4071,21 @@ function renderDashboard(m) {
4067
4071
  v: savingsX.toLocaleString(),
4068
4072
  sub: '×',
4069
4073
  label: 'Cost savings vs FTE',
4070
- trend: `human est. $${humanEst.toLocaleString()} (${done}× 4h × $150)`,
4074
+ trend: humanEst > 0 ? `measured · human est. $${fmtMoney(humanEst)}` : 'measured savings',
4071
4075
  cls: 'green',
4072
4076
  }
4073
4077
  : {
4078
+ // No MEASURED savings yet (no verdict cost data). Don't fabricate a
4079
+ // multiplier from the time-based estimate — show it's not measured.
4074
4080
  v: '—',
4075
4081
  sub: '',
4076
4082
  label: 'Cost savings vs FTE',
4077
- trend: done > 0 ? 'no spend recorded yet' : 'no tasks done yet',
4083
+ trend: aiSpend > 0
4084
+ ? 'estimate only · run pipelines for measured savings'
4085
+ : (done > 0 ? 'no spend recorded yet' : 'no tasks done yet'),
4078
4086
  cls: '',
4079
4087
  },
4080
4088
  ];
4081
-
4082
- // Prefer planSavingsX when available (from PLAN-*.md) — explicit override
4083
- if (planSavingsX != null && planSavingsX > 0) {
4084
- hero[2] = {
4085
- v: planSavingsX,
4086
- sub: '×',
4087
- label: 'Cost savings vs FTE',
4088
- trend: `from PLAN-*.md figures`,
4089
- cls: 'green',
4090
- };
4091
- }
4092
4089
  document.getElementById('mp-hero').innerHTML = hero.map(s => `
4093
4090
  <div class="metric-card ${s.cls || ''}">
4094
4091
  <div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
@@ -310,6 +310,22 @@ const RULES = [
310
310
  s += 9;
311
311
  if (d.readmeKeywords.includes("fintech"))
312
312
  s += 2;
313
+ // tax-prep signals — keep in sync with detect.ts tax-pack terms.
314
+ // Unambiguous regulatory/professional-standard terms get a strong
315
+ // bump; softer generic terms (irs/e-file/tax-prep) get a light one
316
+ // since they can appear in unrelated fixtures.
317
+ const kws = d.readmeKeywords;
318
+ const taxStrong = ["ptin", "circular-230", "form-8879", "mef",
319
+ "pub-4557", "section-7216"];
320
+ const taxSoft = ["irs", "e-file", "tax-prep", "tax-preparation"];
321
+ const taxStrongCount = taxStrong.filter((k) => kws.includes(k)).length;
322
+ const taxSoftCount = taxSoft.filter((k) => kws.includes(k)).length;
323
+ if (taxStrongCount >= 2)
324
+ s += 9;
325
+ else if (taxStrongCount === 1)
326
+ s += 6;
327
+ if (taxSoftCount > 0)
328
+ s += 1;
313
329
  return s;
314
330
  },
315
331
  reason: (d) => {
@@ -330,6 +346,15 @@ const RULES = [
330
346
  bits.push("Flutterwave (Africa)");
331
347
  if (d.stack.includes("mercadopago"))
332
348
  bits.push("MercadoPago (LATAM)");
349
+ const kws = d.readmeKeywords;
350
+ if (kws.includes("ptin"))
351
+ bits.push("PTIN mention");
352
+ if (kws.includes("circular-230"))
353
+ bits.push("Circular 230 mention");
354
+ if (kws.includes("form-8879"))
355
+ bits.push("Form 8879 mention");
356
+ if (kws.includes("mef"))
357
+ bits.push("MeF mention");
333
358
  return `fintech integration: ${bits.join(", ")} — SOX, PCI, KYC/AML compliance gates`;
334
359
  },
335
360
  },
@@ -394,6 +419,34 @@ const RULES = [
394
419
  s += 3;
395
420
  if (d.readmeKeywords.includes("sso") || d.readmeKeywords.includes("saml"))
396
421
  s += 3;
422
+ // procurement signals — keep in sync with detect.ts procurement-pack terms.
423
+ // "three-way-match"/"punchout"/"ofac" are unambiguous procurement/source-
424
+ // to-pay terms; "rfp"/"sourcing" are softer and get a lighter bump.
425
+ const kws = d.readmeKeywords;
426
+ const procurementStrong = ["purchase-order", "three-way-match", "punchout",
427
+ "cxml", "ofac", "requisition"];
428
+ const procurementSoft = ["rfp", "sourcing", "procurement", "spend-management"];
429
+ const procurementStrongCount = procurementStrong.filter((k) => kws.includes(k)).length;
430
+ const procurementSoftCount = procurementSoft.filter((k) => kws.includes(k)).length;
431
+ if (procurementStrongCount >= 2)
432
+ s += 9;
433
+ else if (procurementStrongCount === 1)
434
+ s += 6;
435
+ if (procurementSoftCount > 0)
436
+ s += 1;
437
+ // MSP signals — keep in sync with detect.ts msp-pack terms.
438
+ // "rmm"/"psa"/"credential-vault"/"msp" are unambiguous MSP terms;
439
+ // "msa"/"sla"/"managed-service(s)" are softer, lighter bump.
440
+ const mspStrong = ["rmm", "psa", "credential-vault", "msp"];
441
+ const mspSoft = ["msa", "sla", "managed-service", "managed-services"];
442
+ const mspStrongCount = mspStrong.filter((k) => kws.includes(k)).length;
443
+ const mspSoftCount = mspSoft.filter((k) => kws.includes(k)).length;
444
+ if (mspStrongCount >= 2)
445
+ s += 9;
446
+ else if (mspStrongCount === 1)
447
+ s += 6;
448
+ if (mspSoftCount > 0)
449
+ s += 1;
397
450
  // Stripe billing + multi-tenant signals = SaaS
398
451
  if (s > 0 && d.stack.includes("stripe"))
399
452
  s += 1;
@@ -411,6 +464,15 @@ const RULES = [
411
464
  bits.push("SAML lib");
412
465
  if (d.stack.includes("scim"))
413
466
  bits.push("SCIM");
467
+ const kws = d.readmeKeywords;
468
+ if (kws.includes("three-way-match"))
469
+ bits.push("three-way match mention");
470
+ if (kws.includes("punchout"))
471
+ bits.push("punchout mention");
472
+ if (kws.includes("rmm"))
473
+ bits.push("RMM mention");
474
+ if (kws.includes("psa"))
475
+ bits.push("PSA mention");
414
476
  return `enterprise B2B SaaS (${bits.join(", ")}) — multi-tenant isolation + SSO + audit log + SOC2 mandatory`;
415
477
  },
416
478
  },
@@ -310,6 +310,22 @@ const RULES = [
310
310
  s += 9;
311
311
  if (d.readmeKeywords.includes("fintech"))
312
312
  s += 2;
313
+ // tax-prep signals — keep in sync with detect.ts tax-pack terms.
314
+ // Unambiguous regulatory/professional-standard terms get a strong
315
+ // bump; softer generic terms (irs/e-file/tax-prep) get a light one
316
+ // since they can appear in unrelated fixtures.
317
+ const kws = d.readmeKeywords;
318
+ const taxStrong = ["ptin", "circular-230", "form-8879", "mef",
319
+ "pub-4557", "section-7216"];
320
+ const taxSoft = ["irs", "e-file", "tax-prep", "tax-preparation"];
321
+ const taxStrongCount = taxStrong.filter((k) => kws.includes(k)).length;
322
+ const taxSoftCount = taxSoft.filter((k) => kws.includes(k)).length;
323
+ if (taxStrongCount >= 2)
324
+ s += 9;
325
+ else if (taxStrongCount === 1)
326
+ s += 6;
327
+ if (taxSoftCount > 0)
328
+ s += 1;
313
329
  return s;
314
330
  },
315
331
  reason: (d) => {
@@ -330,6 +346,15 @@ const RULES = [
330
346
  bits.push("Flutterwave (Africa)");
331
347
  if (d.stack.includes("mercadopago"))
332
348
  bits.push("MercadoPago (LATAM)");
349
+ const kws = d.readmeKeywords;
350
+ if (kws.includes("ptin"))
351
+ bits.push("PTIN mention");
352
+ if (kws.includes("circular-230"))
353
+ bits.push("Circular 230 mention");
354
+ if (kws.includes("form-8879"))
355
+ bits.push("Form 8879 mention");
356
+ if (kws.includes("mef"))
357
+ bits.push("MeF mention");
333
358
  return `fintech integration: ${bits.join(", ")} — SOX, PCI, KYC/AML compliance gates`;
334
359
  },
335
360
  },
@@ -394,6 +419,34 @@ const RULES = [
394
419
  s += 3;
395
420
  if (d.readmeKeywords.includes("sso") || d.readmeKeywords.includes("saml"))
396
421
  s += 3;
422
+ // procurement signals — keep in sync with detect.ts procurement-pack terms.
423
+ // "three-way-match"/"punchout"/"ofac" are unambiguous procurement/source-
424
+ // to-pay terms; "rfp"/"sourcing" are softer and get a lighter bump.
425
+ const kws = d.readmeKeywords;
426
+ const procurementStrong = ["purchase-order", "three-way-match", "punchout",
427
+ "cxml", "ofac", "requisition"];
428
+ const procurementSoft = ["rfp", "sourcing", "procurement", "spend-management"];
429
+ const procurementStrongCount = procurementStrong.filter((k) => kws.includes(k)).length;
430
+ const procurementSoftCount = procurementSoft.filter((k) => kws.includes(k)).length;
431
+ if (procurementStrongCount >= 2)
432
+ s += 9;
433
+ else if (procurementStrongCount === 1)
434
+ s += 6;
435
+ if (procurementSoftCount > 0)
436
+ s += 1;
437
+ // MSP signals — keep in sync with detect.ts msp-pack terms.
438
+ // "rmm"/"psa"/"credential-vault"/"msp" are unambiguous MSP terms;
439
+ // "msa"/"sla"/"managed-service(s)" are softer, lighter bump.
440
+ const mspStrong = ["rmm", "psa", "credential-vault", "msp"];
441
+ const mspSoft = ["msa", "sla", "managed-service", "managed-services"];
442
+ const mspStrongCount = mspStrong.filter((k) => kws.includes(k)).length;
443
+ const mspSoftCount = mspSoft.filter((k) => kws.includes(k)).length;
444
+ if (mspStrongCount >= 2)
445
+ s += 9;
446
+ else if (mspStrongCount === 1)
447
+ s += 6;
448
+ if (mspSoftCount > 0)
449
+ s += 1;
397
450
  // Stripe billing + multi-tenant signals = SaaS
398
451
  if (s > 0 && d.stack.includes("stripe"))
399
452
  s += 1;
@@ -411,6 +464,15 @@ const RULES = [
411
464
  bits.push("SAML lib");
412
465
  if (d.stack.includes("scim"))
413
466
  bits.push("SCIM");
467
+ const kws = d.readmeKeywords;
468
+ if (kws.includes("three-way-match"))
469
+ bits.push("three-way match mention");
470
+ if (kws.includes("punchout"))
471
+ bits.push("punchout mention");
472
+ if (kws.includes("rmm"))
473
+ bits.push("RMM mention");
474
+ if (kws.includes("psa"))
475
+ bits.push("PSA mention");
414
476
  return `enterprise B2B SaaS (${bits.join(", ")}) — multi-tenant isolation + SSO + audit log + SOC2 mandatory`;
415
477
  },
416
478
  },
package/dist/detect.js CHANGED
@@ -1158,6 +1158,19 @@ function mineReadmeKeywords(dir) {
1158
1158
  "policy", "underwriting", "premium", "acord", "naic", "actuarial",
1159
1159
  "reinsurance", "solvency", "carrier", "mga", "mgu", "tpa", "insurance",
1160
1160
  "insurtech", "insurer", "insured", "deductible", "coverage", "bordereau",
1161
+ // tax-pack — keep in sync with archetypes.ts fintech score()
1162
+ // "irs"/"e-file"/"tax-prep" deliberately soft signals: generic enough
1163
+ // to appear in unrelated fixtures, so weighted low in the scorer.
1164
+ "ptin", "circular-230", "form-8879", "mef", "pub-4557", "section-7216",
1165
+ "e-file", "tax-prep", "tax-preparation", "irs",
1166
+ // procurement-pack — keep in sync with archetypes.ts enterprise-saas score()
1167
+ // "rfp"/"sourcing" deliberately soft: generic terms, weighted low.
1168
+ "purchase-order", "three-way-match", "rfp", "ofac", "requisition",
1169
+ "punchout", "cxml", "sourcing", "procurement", "spend-management",
1170
+ // msp-pack — keep in sync with archetypes.ts enterprise-saas score()
1171
+ // "sla" deliberately soft: generic term, weighted low.
1172
+ "msa", "sla", "rmm", "psa", "multi-tenant", "managed-service",
1173
+ "credential-vault", "msp", "managed-services",
1161
1174
  ];
1162
1175
  for (const term of packTerms) {
1163
1176
  if (text.includes(term))
@@ -134,7 +134,7 @@ export function isCacheFresh(cache, now = Date.now(), freshMs = CACHE_FRESH_MS)
134
134
  const checkedAtMs = Date.parse(cache.checkedAt);
135
135
  if (Number.isNaN(checkedAtMs))
136
136
  return false;
137
- return now - checkedAtMs < freshMs;
137
+ return now - checkedAtMs <= freshMs;
138
138
  }
139
139
  /** Pure function: does `latest` represent a newer version than `current`? */
140
140
  export function isNewerVersion(current, latest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.82.1",
3
+ "version": "2.82.3",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",