@juicedresume/mcp 0.3.0 → 0.3.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.
Files changed (2) hide show
  1. package/dist/index.js +266 -102
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -215,6 +215,23 @@ var TEMPLATE_IDS = [
215
215
  "oneline",
216
216
  "rolodex"
217
217
  ];
218
+ var MULTI_COLUMN_TEMPLATES = [
219
+ "aurora",
220
+ "coral",
221
+ "prism",
222
+ "deedy",
223
+ "nova",
224
+ "axis",
225
+ "kintsugi",
226
+ "portrait",
227
+ "cameo",
228
+ "bauhaus",
229
+ "europass",
230
+ "rolodex"
231
+ ];
232
+ function isMultiColumnTemplate(template) {
233
+ return !!template && MULTI_COLUMN_TEMPLATES.includes(template);
234
+ }
218
235
  var Styling = z.object({
219
236
  template: z.enum(TEMPLATE_IDS).default("aurora"),
220
237
  colorMode: z.enum(["light", "dark"]).default("light"),
@@ -957,8 +974,67 @@ var SKILLS = [
957
974
  ];
958
975
  var SKILL_INDEX = /* @__PURE__ */ new Map();
959
976
  for (const s of SKILLS) SKILL_INDEX.set(s.name.toLowerCase(), s);
977
+ var SKILL_ALIASES = {
978
+ js: "JavaScript",
979
+ ecmascript: "JavaScript",
980
+ ts: "TypeScript",
981
+ "react.js": "React",
982
+ reactjs: "React",
983
+ "vue.js": "Vue",
984
+ vuejs: "Vue",
985
+ "next.js": "Next.js",
986
+ next: "Next.js",
987
+ nextjs: "Next.js",
988
+ "node.js": "Node.js",
989
+ node: "Node.js",
990
+ nodejs: "Node.js",
991
+ k8s: "Kubernetes",
992
+ postgres: "PostgreSQL",
993
+ psql: "PostgreSQL",
994
+ golang: "Go",
995
+ py: "Python",
996
+ "c sharp": "C#",
997
+ csharp: "C#",
998
+ "c plus plus": "C++",
999
+ cpp: "C++",
1000
+ tf: "Terraform",
1001
+ gha: "GitHub Actions",
1002
+ "rest api": "REST APIs",
1003
+ rest: "REST APIs",
1004
+ restful: "REST APIs",
1005
+ "objective c": "Objective-C",
1006
+ "spring-boot": "Spring Boot",
1007
+ "tailwind": "Tailwind CSS",
1008
+ tailwindcss: "Tailwind CSS",
1009
+ gcp: "GCP",
1010
+ "google cloud": "GCP",
1011
+ "google cloud platform": "GCP",
1012
+ aws: "AWS",
1013
+ "amazon web services": "AWS",
1014
+ sklearn: "scikit-learn",
1015
+ "scikit learn": "scikit-learn",
1016
+ hf: "Hugging Face",
1017
+ huggingface: "Hugging Face",
1018
+ pytorch: "PyTorch",
1019
+ tensorflow: "TensorFlow",
1020
+ "power-bi": "Power BI",
1021
+ powerbi: "Power BI"
1022
+ };
1023
+ function normalizeSkill(s) {
1024
+ const key = s.toLowerCase().trim();
1025
+ if (SKILL_INDEX.has(key)) return SKILL_INDEX.get(key).name;
1026
+ const stripped = key.replace(/\.js$/, "");
1027
+ if (SKILL_ALIASES[key]) return SKILL_ALIASES[key];
1028
+ if (SKILL_ALIASES[stripped]) return SKILL_ALIASES[stripped];
1029
+ if (SKILL_INDEX.has(stripped)) return SKILL_INDEX.get(stripped).name;
1030
+ return s.trim();
1031
+ }
960
1032
  function lookupSkill(s) {
961
- return SKILL_INDEX.get(s.toLowerCase().trim());
1033
+ const key = s.toLowerCase().trim();
1034
+ const direct = SKILL_INDEX.get(key);
1035
+ if (direct) return direct;
1036
+ const canonical = normalizeSkill(s).toLowerCase();
1037
+ return SKILL_INDEX.get(canonical);
962
1038
  }
963
1039
  function searchSkills(query, limit = 12) {
964
1040
  const q = query.toLowerCase().trim();
@@ -1327,6 +1403,9 @@ var STOPWORDS = /* @__PURE__ */ new Set([
1327
1403
  "day",
1328
1404
  "days"
1329
1405
  ]);
1406
+ function escapeRe(s) {
1407
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1408
+ }
1330
1409
  function tokens(s) {
1331
1410
  return s.toLowerCase().replace(/[^a-z0-9+./# -]/g, " ").split(/\s+/).filter((w) => w && w.length > 1 && !STOPWORDS.has(w));
1332
1411
  }
@@ -1351,7 +1430,20 @@ function resumeText(resume) {
1351
1430
  }
1352
1431
  function tailorToJob(resume, jdText) {
1353
1432
  const jdToks = tokens(jdText);
1354
- const resumeToks = new Set(tokens(resumeText(resume)));
1433
+ const resumeFullText = resumeText(resume).toLowerCase();
1434
+ const resumeToks = new Set(tokens(resumeFullText));
1435
+ const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
1436
+ function resumeHas(keyword) {
1437
+ const k = keyword.toLowerCase();
1438
+ if (k.includes(" ")) {
1439
+ return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
1440
+ }
1441
+ if (resumeToks.has(k)) return true;
1442
+ const canon = normalizeSkill(keyword).toLowerCase();
1443
+ if (resumeNorm.has(canon)) return true;
1444
+ if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return true;
1445
+ return false;
1446
+ }
1355
1447
  const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
1356
1448
  const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
1357
1449
  const freq = /* @__PURE__ */ new Map();
@@ -1367,17 +1459,22 @@ function tailorToJob(resume, jdText) {
1367
1459
  for (const [k, c] of sortedNonSkill) jdSkills.set(k, { importance: c });
1368
1460
  const matched = [];
1369
1461
  const missing = [];
1462
+ const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
1370
1463
  for (const [k, meta] of jdSkills) {
1371
- const lower = k.toLowerCase();
1372
- if (resumeToks.has(lower) || [...resumeToks].some((t) => t.includes(lower))) {
1373
- matched.push({ keyword: k, kind: meta.kind, count: meta.importance });
1464
+ const w = meta.importance * kindMult(meta.kind);
1465
+ if (resumeHas(k)) {
1466
+ matched.push({ keyword: k, kind: meta.kind, count: w });
1374
1467
  } else {
1375
- missing.push({ keyword: k, kind: meta.kind, importance: meta.importance });
1468
+ missing.push({ keyword: k, kind: meta.kind, importance: w });
1376
1469
  }
1377
1470
  }
1378
1471
  const matchedWeight = matched.reduce((a, m) => a + m.count, 0);
1379
1472
  const totalWeight = matchedWeight + missing.reduce((a, m) => a + m.importance, 0);
1380
- const score = totalWeight === 0 ? 0 : Math.round(matchedWeight / totalWeight * 100);
1473
+ const score = totalWeight === 0 ? 0 : Math.min(95, Math.round(matchedWeight / totalWeight * 100));
1474
+ const titleToks = tokens(resume.personal.title || "");
1475
+ const jdTokSet = new Set(jdToks);
1476
+ const titleHits = titleToks.filter((t) => jdTokSet.has(t)).length;
1477
+ const titleMatch = titleToks.length === 0 ? 0 : Math.round(titleHits / titleToks.length * 100);
1381
1478
  missing.sort((a, b) => b.importance - a.importance);
1382
1479
  matched.sort((a, b) => b.count - a.count);
1383
1480
  return {
@@ -1385,7 +1482,8 @@ function tailorToJob(resume, jdText) {
1385
1482
  matched,
1386
1483
  missing,
1387
1484
  jdSkills: jdSkills.size,
1388
- resumeSkills: resumeToks.size
1485
+ resumeSkills: resumeToks.size,
1486
+ titleMatch
1389
1487
  };
1390
1488
  }
1391
1489
 
@@ -1393,7 +1491,7 @@ function tailorToJob(resume, jdText) {
1393
1491
  function stripHtml(s) {
1394
1492
  return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
1395
1493
  }
1396
- function passSoft(id, label, score, message, suggestion) {
1494
+ function passSoft(id, label, score, message, suggestion, weight) {
1397
1495
  const s = Math.max(0, Math.min(1, score));
1398
1496
  return {
1399
1497
  id,
@@ -1402,12 +1500,10 @@ function passSoft(id, label, score, message, suggestion) {
1402
1500
  status: s >= 0.85 ? "pass" : s >= 0.55 ? "warn" : "fail",
1403
1501
  message,
1404
1502
  suggestion: suggestion ?? null,
1405
- severity: "soft"
1503
+ severity: "soft",
1504
+ weight: weight ?? 1
1406
1505
  };
1407
1506
  }
1408
- function hardCheck(id, label, score, message, suggestion) {
1409
- return { ...passSoft(id, label, score, message, suggestion), severity: "hard" };
1410
- }
1411
1507
  function firstBulletStrongest(resume) {
1412
1508
  const exp = resume.sections.find((s) => s.type === "experience");
1413
1509
  if (!exp?.items?.length) return null;
@@ -1430,7 +1526,8 @@ function firstBulletStrongest(resume) {
1430
1526
  "Lead bullet of recent role is strongest",
1431
1527
  score,
1432
1528
  topQuantified && topLength >= avgOtherLength ? "Top bullet of your most-recent role is quantified and substantive \u2014 recruiter eye lands here first." : "Your strongest bullet should be FIRST in your most-recent role (recruiters skim top-down).",
1433
- "Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first."
1529
+ "Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first.",
1530
+ 0.5
1434
1531
  )
1435
1532
  };
1436
1533
  }
@@ -1498,20 +1595,6 @@ function dobAgePenalty(resume) {
1498
1595
  )
1499
1596
  };
1500
1597
  }
1501
- function singleColumnHard(resume) {
1502
- const layout = resume.styling?.layout;
1503
- const twoCol = resume.sections.some((s) => s.columns === 2) || layout === "two-column" || layout === "sidebar-left" || layout === "sidebar-right";
1504
- return {
1505
- dim: "ats",
1506
- check: hardCheck(
1507
- "AT12.single-column-hard",
1508
- "Single-column layout (ATS-critical)",
1509
- twoCol ? 0 : 1,
1510
- twoCol ? "Two-column / sidebar layout detected \u2014 #1 cause of ATS parse failure. Workday concatenates columns into gibberish." : "Single-column layout \u2014 every ATS parses this cleanly.",
1511
- "Switch to a single-column template (Jake, Harvard, Classic). Multi-column is the top score-destroyer in ATS systems."
1512
- )
1513
- };
1514
- }
1515
1598
  function threeLocationsRule(resume) {
1516
1599
  const summary = resume.sections.find((s) => s.type === "summary");
1517
1600
  const skills = resume.sections.find((s) => s.type === "skills");
@@ -1540,42 +1623,13 @@ function threeLocationsRule(resume) {
1540
1623
  )
1541
1624
  };
1542
1625
  }
1543
- function bulletLengthSweetSpot(resume) {
1544
- const exp = resume.sections.find((s) => s.type === "experience");
1545
- if (!exp?.items?.length) return null;
1546
- const bullets = [];
1547
- for (const it of exp.items) {
1548
- const html = it.description || "";
1549
- const lis = html.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
1550
- for (const li of lis) bullets.push(stripHtml(li));
1551
- }
1552
- if (bullets.length === 0) return null;
1553
- let inRange = 0;
1554
- for (const b of bullets) {
1555
- const w = b.split(/\s+/).filter(Boolean).length;
1556
- if (w >= 12 && w <= 25) inRange++;
1557
- }
1558
- const ratio = inRange / bullets.length;
1559
- return {
1560
- dim: "brevity",
1561
- check: passSoft(
1562
- "B5.sweet-spot",
1563
- "Bullets in 12\u201325 word range",
1564
- ratio,
1565
- `${Math.round(ratio * 100)}% of your bullets fall in the 12\u201325 word sweet spot.`,
1566
- "Aim for bullets between 12 and 25 words \u2014 short enough for the 6-second recruiter scan, long enough to convey impact + metric."
1567
- )
1568
- };
1569
- }
1570
1626
  function additionalChecks(resume) {
1571
1627
  return [
1572
1628
  firstBulletStrongest(resume),
1573
1629
  dateFormatConsistency(resume),
1574
1630
  targetTitle(resume),
1575
1631
  dobAgePenalty(resume),
1576
- singleColumnHard(resume),
1577
- threeLocationsRule(resume),
1578
- bulletLengthSweetSpot(resume)
1632
+ threeLocationsRule(resume)
1579
1633
  ].filter((x) => x !== null);
1580
1634
  }
1581
1635
 
@@ -2103,6 +2157,11 @@ function impactChecks(resume, b) {
2103
2157
  const scaleSignals = E.filter((x) => SCALE_RE.test(x.text));
2104
2158
  const timeBoundRe = /\b(within|in|over)\s+\d+\s*(weeks?|months?|quarters?|years?|days?)\b|\bq[1-4]\s?'?\d{2,4}\b/i;
2105
2159
  const timeBound = E.filter((x) => timeBoundRe.test(x.text));
2160
+ const quantifiedEarly = quantified.filter((x) => {
2161
+ const head = x.text.split(/\s+/).slice(0, 8).join(" ");
2162
+ return QUANT_RE.test(head);
2163
+ });
2164
+ const earlyRatio = quantified.length ? quantifiedEarly.length / quantified.length : 0;
2106
2165
  return [
2107
2166
  mk(
2108
2167
  "A1.quantification",
@@ -2110,15 +2169,20 @@ function impactChecks(resume, b) {
2110
2169
  qRatio >= 0.6 ? 1 : qRatio >= 0.4 ? 0.7 : qRatio / 0.4,
2111
2170
  `${quantified.length} of ${total} experience bullets include numbers, %, $, or counts (${Math.round(qRatio * 100)}%).`,
2112
2171
  nonQ.slice(0, 6).map((x) => x.id),
2113
- qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0
2172
+ qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0,
2173
+ void 0,
2174
+ 3
2114
2175
  ),
2115
2176
  mk(
2116
2177
  "A2.metric-diversity",
2117
2178
  "Variety of metric types",
2118
- Math.min(1, metricTypes.size / 3),
2179
+ // Bonus signal: absence is neutral (0.8), presence lifts toward 1.
2180
+ metricTypes.size === 0 ? 0.8 : Math.min(1, 0.8 + metricTypes.size * 0.1),
2119
2181
  metricTypes.size === 0 ? "No metric types detected (%, $, time, counts, ratios)." : `Uses ${metricTypes.size} metric type(s): ${[...metricTypes].join(", ")}.`,
2120
2182
  [],
2121
- metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0
2183
+ metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0,
2184
+ void 0,
2185
+ 0.5
2122
2186
  ),
2123
2187
  mk(
2124
2188
  "A3.xyz-bullets",
@@ -2126,7 +2190,9 @@ function impactChecks(resume, b) {
2126
2190
  Math.min(1, xyz.length / Math.max(1, total * 0.4)),
2127
2191
  `${xyz.length} of ${total} bullets read as "Accomplished X (measured by Y) by doing Z."`,
2128
2192
  [],
2129
- xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0
2193
+ xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0,
2194
+ void 0,
2195
+ 2
2130
2196
  ),
2131
2197
  mk(
2132
2198
  "A4.responsibility",
@@ -2142,7 +2208,9 @@ function impactChecks(resume, b) {
2142
2208
  strongStart.length / total,
2143
2209
  `${strongStart.length} of ${total} bullets open with a strong verb (Led, Built, Drove, \u2026).`,
2144
2210
  E.filter((x) => !STRONG_VERBS.has(norm(firstWord(x.text)))).slice(0, 5).map((x) => x.id),
2145
- strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0
2211
+ strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0,
2212
+ void 0,
2213
+ 2
2146
2214
  ),
2147
2215
  mk(
2148
2216
  "A6.weak-verbs",
@@ -2163,18 +2231,34 @@ function impactChecks(resume, b) {
2163
2231
  mk(
2164
2232
  "A8.scale-signals",
2165
2233
  "Mentions team size, budget, or org scale",
2166
- scaleSignals.length === 0 ? 0 : Math.min(1, scaleSignals.length / 2),
2167
- scaleSignals.length === 0 ? "No team/budget/scale signals detected." : `${scaleSignals.length} bullet(s) reference team size, budget, or organisation scale.`,
2234
+ // Bonus signal: absence is neutral, presence lifts the score.
2235
+ scaleSignals.length === 0 ? 0.8 : Math.min(1, 0.8 + scaleSignals.length * 0.1),
2236
+ scaleSignals.length === 0 ? "No team/budget/scale signals detected. A nice-to-have, not a deduction." : `${scaleSignals.length} bullet(s) reference team size, budget, or organisation scale.`,
2168
2237
  [],
2169
- scaleSignals.length === 0 ? "Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0
2238
+ scaleSignals.length === 0 ? "Optional boost. Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0,
2239
+ void 0,
2240
+ 0.5
2170
2241
  ),
2171
2242
  mk(
2172
2243
  "A9.time-bound",
2173
2244
  "Time-bound impact statements",
2174
- timeBound.length === 0 ? 0 : Math.min(1, timeBound.length / 2),
2175
- timeBound.length === 0 ? "No time-bound results ('within 6 months', 'in 2 quarters')." : `${timeBound.length} bullet(s) include a timeframe.`,
2245
+ // Bonus signal: absence is neutral, presence lifts the score.
2246
+ timeBound.length === 0 ? 0.8 : Math.min(1, 0.8 + timeBound.length * 0.1),
2247
+ timeBound.length === 0 ? "No time-bound results yet. A nice-to-have, not a deduction." : `${timeBound.length} bullet(s) include a timeframe.`,
2248
+ [],
2249
+ timeBound.length === 0 ? "Optional boost. Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0,
2250
+ void 0,
2251
+ 0.5
2252
+ ),
2253
+ mk(
2254
+ "A10.metric-position",
2255
+ "Metrics appear early in the bullet",
2256
+ quantified.length === 0 ? 0.8 : earlyRatio,
2257
+ quantified.length === 0 ? "No quantified bullets yet to position." : `${quantifiedEarly.length} of ${quantified.length} quantified bullets lead with the number.`,
2176
2258
  [],
2177
- timeBound.length === 0 ? "Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0
2259
+ quantified.length && earlyRatio < 0.6 ? "Move the number toward the front: 'Cut costs 35% by\u2026' scans faster than a metric buried at the end." : void 0,
2260
+ void 0,
2261
+ 1
2178
2262
  )
2179
2263
  ];
2180
2264
  }
@@ -2206,7 +2290,7 @@ function brevityChecks(resume, b) {
2206
2290
  return r.bullets > 5;
2207
2291
  });
2208
2292
  const pages = Math.max(1, Math.ceil(totalWords / 450));
2209
- const targetPages = experienceYears(resume) >= 10 ? 2 : 1;
2293
+ const targetPages = experienceYears(resume) < 5 ? 1 : 2;
2210
2294
  const lengthOK = pages <= targetPages;
2211
2295
  return [
2212
2296
  mk(
@@ -2239,7 +2323,7 @@ function brevityChecks(resume, b) {
2239
2323
  mk(
2240
2324
  "B4.page-length",
2241
2325
  `Resume length fits ${targetPages} page${targetPages > 1 ? "s" : ""}`,
2242
- lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.5),
2326
+ lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.35),
2243
2327
  `Estimated ${pages} page${pages > 1 ? "s" : ""} (${totalWords} words total).`,
2244
2328
  [],
2245
2329
  !lengthOK ? `Cut ~${(pages - targetPages) * 450} words to fit ${targetPages} page${targetPages > 1 ? "s" : ""}.` : void 0
@@ -2337,7 +2421,9 @@ function styleChecks(resume, b) {
2337
2421
  passive.length / total <= 0.15 ? 1 : 1 - (passive.length / total - 0.15) * 2,
2338
2422
  `${passive.length} of ${total} bullets read as passive voice (${Math.round(passive.length / total * 100)}%).`,
2339
2423
  passive.slice(0, 5).map((x) => x.id),
2340
- passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0
2424
+ passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0,
2425
+ void 0,
2426
+ 1.5
2341
2427
  ),
2342
2428
  mk(
2343
2429
  "C3.tense-consistency",
@@ -2377,7 +2463,9 @@ function styleChecks(resume, b) {
2377
2463
  periodConsistent ? 1 : 0.5,
2378
2464
  periodConsistent ? "All bullets agree on whether they end with a period." : `Mixed: ${endsWithPeriod} end with a period, ${endsWithout} don't.`,
2379
2465
  [],
2380
- !periodConsistent ? "Pick one rule for the whole resume." : void 0
2466
+ !periodConsistent ? "Pick one rule for the whole resume." : void 0,
2467
+ void 0,
2468
+ 0.4
2381
2469
  ),
2382
2470
  mk(
2383
2471
  "C8.punctuation-chars",
@@ -2385,7 +2473,9 @@ function styleChecks(resume, b) {
2385
2473
  punctConsistent ? 1 : 0.5,
2386
2474
  punctConsistent ? "Punctuation glyphs are consistent." : "Mixes smart quotes/em-dashes with ASCII equivalents.",
2387
2475
  [],
2388
- !punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0
2476
+ !punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0,
2477
+ void 0,
2478
+ 0.4
2389
2479
  ),
2390
2480
  mk(
2391
2481
  "C9.capitalization",
@@ -2393,7 +2483,9 @@ function styleChecks(resume, b) {
2393
2483
  capConsistent ? 1 : 0.5,
2394
2484
  capConsistent ? "Every bullet starts with the same case." : `${startsCap} of ${total} bullets start with a capital letter.`,
2395
2485
  [],
2396
- !capConsistent ? "Capitalize the first letter of every bullet." : void 0
2486
+ !capConsistent ? "Capitalize the first letter of every bullet." : void 0,
2487
+ void 0,
2488
+ 0.4
2397
2489
  ),
2398
2490
  mk(
2399
2491
  "C10.acronym-density",
@@ -2448,6 +2540,20 @@ function structureChecks(resume, b) {
2448
2540
  }
2449
2541
  const expRev = expSec ? rev(expSec.items || []) : true;
2450
2542
  const eduRev = eduSec ? rev(eduSec.items || []) : true;
2543
+ const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
2544
+ let maxGapMonths = 0;
2545
+ if (expSec) {
2546
+ const ranges = (expSec.items || []).map((it) => ({
2547
+ start: parseMMYYYY(it.startDate || ""),
2548
+ end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "")
2549
+ })).filter((r) => r.start && r.end);
2550
+ ranges.sort((a, b2) => b2.start - a.start);
2551
+ for (let i = 0; i < ranges.length - 1; i++) {
2552
+ const gap = ranges[i].start - ranges[i + 1].end;
2553
+ if (gap > maxGapMonths) maxGapMonths = gap;
2554
+ }
2555
+ }
2556
+ const gapOK = maxGapMonths <= 6;
2451
2557
  function isCanonical(type, title) {
2452
2558
  const t = title.toLowerCase().trim();
2453
2559
  const list = ATS_SECTION_LABELS[type] || [];
@@ -2517,6 +2623,16 @@ function structureChecks(resume, b) {
2517
2623
  expRev && eduRev ? "Both sections are reverse-chronological." : `${!expRev ? "Experience" : ""}${!expRev && !eduRev ? " & " : ""}${!eduRev ? "Education" : ""} out of order.`,
2518
2624
  [],
2519
2625
  !expRev || !eduRev ? "List most recent first." : void 0
2626
+ ),
2627
+ mk(
2628
+ "D9.employment-gaps",
2629
+ "No large unexplained employment gaps",
2630
+ gapOK ? 1 : 0.7,
2631
+ gapOK ? "No employment gap over 6 months between roles." : `A gap of about ${maxGapMonths} months sits between two roles. Some employers screen for gaps over 6 months.`,
2632
+ [],
2633
+ gapOK ? void 0 : "A gap over 6 months is fine, but consider a one-line note (contract work, study, caregiving) or a brief bridging entry so it does not read as unexplained.",
2634
+ void 0,
2635
+ 0.5
2520
2636
  )
2521
2637
  ];
2522
2638
  }
@@ -2524,7 +2640,7 @@ function atsChecks(resume, b) {
2524
2640
  const s = resume.styling;
2525
2641
  const p = resume.personal;
2526
2642
  const allText = b.bullets.map((x) => x.text).join(" ");
2527
- const isSingleCol = s.layout === "single";
2643
+ const isSingleCol = !isMultiColumnTemplate(s.template);
2528
2644
  const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
2529
2645
  const fontSizeOK = s.fontSize >= 9.5 && s.fontSize <= 12;
2530
2646
  const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
@@ -2532,6 +2648,9 @@ function atsChecks(resume, b) {
2532
2648
  const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
2533
2649
  const hasLinkedIn = !!(p.linkedin && /linkedin\.com\/in\//i.test(p.linkedin));
2534
2650
  const locationOK = !!p.location && /,/.test(p.location);
2651
+ const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
2652
+ const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
2653
+ const contactParseable = nameOK && (emailOK || hasPhone);
2535
2654
  const allDates = [];
2536
2655
  for (const sec of resume.sections) for (const it of sec.items || []) {
2537
2656
  if (it.startDate) allDates.push(it.startDate);
@@ -2559,19 +2678,29 @@ function atsChecks(resume, b) {
2559
2678
  return [
2560
2679
  mk(
2561
2680
  "E1.single-column",
2562
- "Single-column layout (ATS-critical)",
2563
- isSingleCol ? 1 : 0,
2564
- isSingleCol ? "Layout is single-column." : "Multi-column layout \u2014 many ATS parsers scramble reading order.",
2681
+ "Single-column layout (ATS-friendly)",
2682
+ // Proportional soft penalty, not an auto-fail. Modern parsers handle
2683
+ // columns (Textkernel reports ~90% correct column extraction vs ~97%
2684
+ // single-column), but ~1 in 10 still scrambles reading order and columns
2685
+ // cost human skimmability. A single, evidence-scaled ding — the overall
2686
+ // score also takes a small proportional multi-column haircut (see below).
2687
+ isSingleCol ? 1 : 0.55,
2688
+ isSingleCol ? "Single-column layout \u2014 every ATS parses this cleanly." : "Multi-column template. Modern parsers usually handle columns, but a minority still scramble reading order, and columns are harder for a recruiter to skim. Single-column is the zero-risk choice.",
2565
2689
  [],
2566
- !isSingleCol ? "Switch to a single-column template (Customize \u2192 Layout \u2192 single)." : void 0,
2567
- !isSingleCol ? "hard" : "soft"
2690
+ !isSingleCol ? "For maximum ATS safety, switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Not required, but it removes all parse risk." : void 0,
2691
+ "soft"
2568
2692
  ),
2569
2693
  mk(
2570
- "E2.contact-in-body",
2571
- "Contact info rendered in body, not header/footer",
2572
- 1,
2573
- "Contact info is rendered in the document body in this app.",
2574
- []
2694
+ "E2.contact-parseable",
2695
+ "Name + contact details are parseable",
2696
+ // Hard dependency: parsers build the candidate record from a name plus at
2697
+ // least one of email/phone (Daxtra: without one of these the parse fails).
2698
+ contactParseable ? 1 : 0,
2699
+ contactParseable ? "Full name and at least one contact channel (email or phone) are present." : !nameOK ? "No full name detected. Parsers need a name plus an email or phone to create a candidate record." : "No email or phone detected. Most parsers reject a record that has neither.",
2700
+ [],
2701
+ !contactParseable ? "Add your full name and at least one of email or phone. Without these, many ATS drop the whole resume." : void 0,
2702
+ contactParseable ? "soft" : "hard",
2703
+ 2
2575
2704
  ),
2576
2705
  mk(
2577
2706
  "E3.email-valid",
@@ -2604,7 +2733,9 @@ function atsChecks(resume, b) {
2604
2733
  hasLinkedIn ? 1 : 0,
2605
2734
  hasLinkedIn ? "LinkedIn URL detected." : "LinkedIn URL missing or non-canonical.",
2606
2735
  [],
2607
- !hasLinkedIn ? "Add a linkedin.com/in/your-handle URL." : void 0
2736
+ !hasLinkedIn ? "Add a linkedin.com/in/your-handle URL. Resumes with one see materially higher recruiter response." : void 0,
2737
+ void 0,
2738
+ 1.5
2608
2739
  ),
2609
2740
  mk(
2610
2741
  "E7.date-format",
@@ -2628,7 +2759,9 @@ function atsChecks(resume, b) {
2628
2759
  safeBullet ? 1 : 0.6,
2629
2760
  safeBullet ? "Bullet glyph is ATS-safe." : `Bullet glyph '${s.bulletGlyph}' may not survive every ATS parser.`,
2630
2761
  [],
2631
- !safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0
2762
+ !safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0,
2763
+ void 0,
2764
+ 0.5
2632
2765
  ),
2633
2766
  mk(
2634
2767
  "E10.font-safe",
@@ -2636,7 +2769,9 @@ function atsChecks(resume, b) {
2636
2769
  isSafeFont ? 1 : 0.5,
2637
2770
  isSafeFont ? "Fonts are ATS-safe." : `Heading '${s.fontHeading}' or body '${s.fontBody}' may not be embedded by some PDF renderers.`,
2638
2771
  [],
2639
- !isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0
2772
+ !isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0,
2773
+ void 0,
2774
+ 0.5
2640
2775
  ),
2641
2776
  mk(
2642
2777
  "E11.font-size",
@@ -2644,7 +2779,9 @@ function atsChecks(resume, b) {
2644
2779
  fontSizeOK ? 1 : 0.5,
2645
2780
  fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
2646
2781
  [],
2647
- !fontSizeOK ? "Set body size to 10\u201312pt." : void 0
2782
+ !fontSizeOK ? "Set body size to 10\u201312pt." : void 0,
2783
+ void 0,
2784
+ 0.5
2648
2785
  ),
2649
2786
  mk(
2650
2787
  "E12.no-killer-chars",
@@ -2669,7 +2806,7 @@ function skillsChecks(resume, b) {
2669
2806
  if (t.length < 3) continue;
2670
2807
  counts.set(t, (counts.get(t) || 0) + 1);
2671
2808
  }
2672
- const stuffed = [...counts.entries()].filter(([k, v]) => v > 6 && /^[a-z]+$/.test(k) && !["with", "from", "into", "over", "under", "across", "by", "to", "for", "the", "and", "of", "on", "in"].includes(k));
2809
+ const stuffed = [...counts.entries()].filter(([k, v]) => v > 8 && /^[a-z]+$/.test(k) && !["with", "from", "into", "over", "under", "across", "by", "to", "for", "the", "and", "of", "on", "in", "a", "an", "that", "this", "was", "were", "are", "have", "has"].includes(k));
2673
2810
  const allText = b.bullets.map((x) => x.text).join(" ");
2674
2811
  const acronyms = [...new Set(Array.from(allText.matchAll(/\b([A-Z]{2,5})\b/g)).map((m) => m[1]))].slice(0, 5);
2675
2812
  const expanded = acronyms.filter((a) => new RegExp(`\\b${a}\\s*\\(`).test(allText));
@@ -2712,7 +2849,9 @@ function skillsChecks(resume, b) {
2712
2849
  acronyms.length === 0 ? 1 : expanded.length / acronyms.length,
2713
2850
  acronyms.length === 0 ? "No acronyms detected." : `${expanded.length} of ${acronyms.length} top acronyms have an inline expansion.`,
2714
2851
  [],
2715
- acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'.` : void 0
2852
+ acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'. Recruiter search is exact-match, so 'ML (Machine Learning)' catches both queries.` : void 0,
2853
+ void 0,
2854
+ 0.5
2716
2855
  )
2717
2856
  ];
2718
2857
  }
@@ -2727,6 +2866,10 @@ function polishChecks(resume, b) {
2727
2866
  const linkedinGeneric = p.linkedin && /\/in\/[a-z0-9]{8,}-[a-z0-9]{4,}/i.test(p.linkedin) && !/[a-zA-Z]/.test(p.linkedin.split("/in/")[1]?.split(/[-?#]/)[0] || "");
2728
2867
  const hasCustomWebsite = !!p.website && !/(linkedin\.com|github\.com|facebook\.com|twitter\.com|x\.com)/i.test(p.website);
2729
2868
  const hasHeadline = !!p.title;
2869
+ const rawHtml = resume.sections.flatMap((sec) => (sec.items || []).flatMap((it) => [it.description, it.body].filter(Boolean))).join(" ");
2870
+ const HIDDEN_STYLE_RE = /color\s*:\s*(#fff(fff)?|#ffffff|white|rgba?\(\s*255\s*,\s*255\s*,\s*255)|font-size\s*:\s*0|opacity\s*:\s*0|visibility\s*:\s*hidden|display\s*:\s*none/i;
2871
+ const PROMPT_INJECT_RE = /\b(ignore (all )?(the )?previous|as an ai|you are an ai|rate (this|the|me|my) (resume|candidate|application) (highly|as|a perfect|10)|you must (rate|score|recommend)|system prompt|disregard (the )?instructions|hiring manager must)\b/i;
2872
+ const hasHiddenText = HIDDEN_STYLE_RE.test(rawHtml) || PROMPT_INJECT_RE.test(stripHtml2(rawHtml).toLowerCase());
2730
2873
  return [
2731
2874
  mk(
2732
2875
  "G1.no-placeholders",
@@ -2771,14 +2914,26 @@ function polishChecks(resume, b) {
2771
2914
  mk(
2772
2915
  "G6.custom-portfolio",
2773
2916
  "Custom portfolio or personal site (nice-to-have)",
2774
- hasCustomWebsite ? 1 : 0.6,
2775
- hasCustomWebsite ? "Custom site present." : "No personal site listed.",
2917
+ hasCustomWebsite ? 1 : 0.9,
2918
+ hasCustomWebsite ? "Custom site present." : "No personal site listed. Optional, not a deduction.",
2919
+ [],
2920
+ hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional.",
2921
+ void 0,
2922
+ 0.5
2923
+ ),
2924
+ mk(
2925
+ "G8.hidden-text",
2926
+ "No hidden text or keyword tricks",
2927
+ hasHiddenText ? 0 : 1,
2928
+ hasHiddenText ? "Hidden or invisible text (white-on-white, zero-size) or an AI-instruction string was detected. ATS convert your file to plain text, so these become visible and read as gaming the system." : "No hidden text or prompt-injection tricks detected.",
2776
2929
  [],
2777
- hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional."
2930
+ hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
2931
+ void 0,
2932
+ 2
2778
2933
  )
2779
2934
  ];
2780
2935
  }
2781
- function mk(id, label, score, message, evidence, suggestion, severity) {
2936
+ function mk(id, label, score, message, evidence, suggestion, severity, weight) {
2782
2937
  const s = Math.max(0, Math.min(1, score));
2783
2938
  return {
2784
2939
  id,
@@ -2788,7 +2943,8 @@ function mk(id, label, score, message, evidence, suggestion, severity) {
2788
2943
  message,
2789
2944
  evidence: evidence ?? [],
2790
2945
  suggestion: suggestion ?? null,
2791
- severity: severity || "soft"
2946
+ severity: severity || "soft",
2947
+ weight: weight ?? 1
2792
2948
  };
2793
2949
  }
2794
2950
  function parseMMYYYY(d) {
@@ -2818,9 +2974,14 @@ function experienceYears(resume) {
2818
2974
  }
2819
2975
  function aggregate(checks, weight) {
2820
2976
  if (!checks.length) return { score: 0, weighted: 0 };
2821
- const sum = checks.reduce((a, c) => a + c.score, 0);
2822
- const score = Math.round(sum / checks.length * 100);
2823
- return { score, weighted: sum / checks.length * weight };
2977
+ let wsum = 0, wtot = 0;
2978
+ for (const c of checks) {
2979
+ const w = c.weight ?? 1;
2980
+ wsum += c.score * w;
2981
+ wtot += w;
2982
+ }
2983
+ const mean = wtot ? wsum / wtot : 0;
2984
+ return { score: Math.round(mean * 100), weighted: mean * weight };
2824
2985
  }
2825
2986
  var WEIGHTS = {
2826
2987
  impact: 22,
@@ -2870,12 +3031,15 @@ function scoreResume(resume) {
2870
3031
  };
2871
3032
  });
2872
3033
  let overall = Math.round(weightedSum / totalWeight * 100);
3034
+ if (isMultiColumnTemplate(resume.styling.template)) {
3035
+ overall = Math.round(overall * 0.93);
3036
+ }
2873
3037
  const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
2874
3038
  if (hardFail) overall = Math.min(overall, 60);
2875
3039
  const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
2876
3040
  const worst = sortedDims[0];
2877
3041
  const best = sortedDims[sortedDims.length - 1];
2878
- const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) => a.score - b2.score);
3042
+ const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) => (1 - b2.score) * b2.weight - (1 - a.score) * a.weight);
2879
3043
  const priorities = allFails.slice(0, 5);
2880
3044
  return {
2881
3045
  overall,
@@ -3325,7 +3489,7 @@ function runOnce(cmd, args, cwd) {
3325
3489
  import { promises as fs3 } from "fs";
3326
3490
  import path3 from "path";
3327
3491
  var server = new Server(
3328
- { name: "juiced-resume", version: "0.3.0" },
3492
+ { name: "juiced-resume", version: "0.3.1" },
3329
3493
  { capabilities: { tools: {} } }
3330
3494
  );
3331
3495
  var idArg = z2.object({ id: z2.string() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicedresume/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Model Context Protocol server for JuicedResume — score, tailor, and edit your resume from any MCP client (Claude Code, Claude Desktop, Cursor).",
5
5
  "keywords": [
6
6
  "mcp",