terminalhire 0.3.3 → 0.4.0

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.
@@ -361,11 +361,11 @@ var init_graph_data = __esm({
361
361
  { id: "spark", parents: ["data-engineering"], synonyms: ["apache-spark"] },
362
362
  { id: "airflow", parents: ["data-engineering"], synonyms: ["apache-airflow"] },
363
363
  { id: "dbt", parents: ["data-engineering"] },
364
- { id: "ml", synonyms: ["machine-learning"], related: [{ to: "pytorch", w: 0.5 }, { to: "tensorflow", w: 0.5 }, { to: "scikit-learn", w: 0.5 }] },
364
+ { id: "ml", synonyms: ["machine-learning"], related: [{ to: "pytorch", w: 0.5 }, { to: "tensorflow", w: 0.5 }, { to: "scikit-learn", w: 0.5 }, { to: "data-engineering", w: 0.4 }] },
365
365
  { id: "llm", parents: ["ml"], synonyms: ["llms", "genai", "generative-ai"], related: [{ to: "langchain", w: 0.5 }, { to: "rag", w: 0.55 }, { to: "openai", w: 0.45 }, { to: "anthropic", w: 0.45 }] },
366
366
  { id: "pytorch", parents: ["ml"], synonyms: ["torch"], related: [{ to: "tensorflow", w: 0.5 }] },
367
367
  { id: "tensorflow", parents: ["ml"], synonyms: ["keras", "tf-keras"] },
368
- { id: "pandas", parents: ["python"], related: [{ to: "numpy", w: 0.6 }] },
368
+ { id: "pandas", parents: ["python"], related: [{ to: "numpy", w: 0.6 }, { to: "data-engineering", w: 0.45 }, { to: "spark", w: 0.4 }] },
369
369
  { id: "numpy", parents: ["python"] },
370
370
  { id: "scikit-learn", parents: ["ml"], synonyms: ["sklearn"] },
371
371
  { id: "jupyter", parents: ["python"] },
@@ -540,6 +540,207 @@ var init_types2 = __esm({
540
540
  }
541
541
  });
542
542
 
543
+ // ../../packages/core/src/vocab/extract.ts
544
+ function tokenize(text) {
545
+ return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
546
+ }
547
+ function looksLikeEngRole(title) {
548
+ return !NON_ENG_TITLE.test(title) && ENG_INTENT.test(title);
549
+ }
550
+ function resolveToken(token) {
551
+ const tryOne = (t) => {
552
+ if (GRAPH.ids.has(t)) return { id: t, viaSynonym: false };
553
+ const mapped = GRAPH.synonyms.get(t);
554
+ return mapped ? { id: mapped, viaSynonym: true } : null;
555
+ };
556
+ return tryOne(token) ?? tryOne(token.replace(/^[.\-+#]+|[.\-+#]+$/g, ""));
557
+ }
558
+ function extractSkillTags(title, body = "") {
559
+ if (!looksLikeEngRole(title)) return [];
560
+ const text = `${title}
561
+ ${body}`;
562
+ const tokens = tokenize(text);
563
+ const ids = /* @__PURE__ */ new Set();
564
+ const ambiguousPending = /* @__PURE__ */ new Set();
565
+ for (const tok of tokens) {
566
+ const r = resolveToken(tok);
567
+ if (!r) continue;
568
+ if (NON_EXTRACTABLE.has(r.id)) continue;
569
+ if (SYNONYM_ONLY.has(r.id) && !r.viaSynonym) continue;
570
+ const cue = AMBIGUOUS[r.id];
571
+ if (cue) {
572
+ if (cue.test(text)) ids.add(r.id);
573
+ else ambiguousPending.add(r.id);
574
+ continue;
575
+ }
576
+ ids.add(r.id);
577
+ }
578
+ const hardCount = [...ids].filter((id) => !SOFT_DOMAIN.has(id)).length;
579
+ if (hardCount >= 2) for (const id of ambiguousPending) ids.add(id);
580
+ return [...ids];
581
+ }
582
+ function coreTagsFromTitle(title) {
583
+ return extractSkillTags(title, "").filter((t) => !SOFT_DOMAIN.has(t));
584
+ }
585
+ var SOFT_DOMAIN, SYNONYM_ONLY, NON_EXTRACTABLE, AMBIGUOUS, ENG_INTENT, NON_ENG_TITLE;
586
+ var init_extract = __esm({
587
+ "../../packages/core/src/vocab/extract.ts"() {
588
+ "use strict";
589
+ init_vocab();
590
+ SOFT_DOMAIN = /* @__PURE__ */ new Set([
591
+ "frontend",
592
+ "backend",
593
+ "devops",
594
+ "security",
595
+ "payments",
596
+ "billing",
597
+ "microservices",
598
+ "caching",
599
+ "search",
600
+ "observability",
601
+ "monitoring",
602
+ "testing",
603
+ "accessibility",
604
+ "seo",
605
+ "performance",
606
+ "realtime",
607
+ "authentication",
608
+ "api-design"
609
+ ]);
610
+ SYNONYM_ONLY = /* @__PURE__ */ new Set(["performance", "security", "seo"]);
611
+ NON_EXTRACTABLE = /* @__PURE__ */ new Set(["payments", "billing"]);
612
+ for (const id of SYNONYM_ONLY) {
613
+ if (!SOFT_DOMAIN.has(id)) throw new Error(`extract: SYNONYM_ONLY "${id}" not in SOFT_DOMAIN`);
614
+ }
615
+ AMBIGUOUS = {
616
+ // Accept "go" with an ecosystem cue OR an explicit-skill phrasing ("Go developer",
617
+ // "in Go", "experience with Go"). Rejects prose: "ready to go", "go above", "go live".
618
+ go: /\b(golang|goroutines?|go\.mod|gin framework|gorm)\b|\bgo\b\s+(developer|engineer|programmer|microservices?|backend|services?|lang)|\b(in|with|using|written in|built in|experience (?:in|with)|proficient in|fluent in)\s+go\b/i,
619
+ r: /\b(rstudio|tidyverse|ggplot|shiny|dplyr|cran|r-lang|rlang)\b/i,
620
+ ml: /\b(machine[\s-]?learning|pytorch|tensorflow|scikit|sklearn|keras|neural|model training|deep[\s-]?learning|numpy|pandas|ml\s+(?:engineer|platform|researcher|infrastructure)|(?:ml|ai)\s+research)\b/i
621
+ };
622
+ ENG_INTENT = /\b(engineer|engineering|developer|dev\b|swe|sde|programmer|architect|full[\s-]?stack|front[\s-]?end|back[\s-]?end|devops|sre|software|coding|codebase|technical staff|tech(?:nical)? lead)\b/i;
623
+ NON_ENG_TITLE = /\b(account executive|account manager|sales (?:rep|representative|development|manager|lead)|sdr|bdr|recruiter|recruiting|talent|marketing|administrative|business partner|billing coordinator|operations (?:administrator|coordinator)|customer success|project finance|controller|bookkeeper|graphic|brand)\b/i;
624
+ }
625
+ });
626
+
627
+ // ../../packages/core/src/vocab/idf-background.ts
628
+ var IDF_BACKGROUND;
629
+ var init_idf_background = __esm({
630
+ "../../packages/core/src/vocab/idf-background.ts"() {
631
+ "use strict";
632
+ IDF_BACKGROUND = {
633
+ N: 244,
634
+ df: {
635
+ "backend": 71,
636
+ "python": 57,
637
+ "monitoring": 44,
638
+ "nextjs": 40,
639
+ "testing": 40,
640
+ "observability": 38,
641
+ "llm": 38,
642
+ "go": 36,
643
+ "aws": 36,
644
+ "react": 33,
645
+ "frontend": 30,
646
+ "ml": 28,
647
+ "mobile": 24,
648
+ "realtime": 24,
649
+ "typescript": 23,
650
+ "devops": 22,
651
+ "kubernetes": 22,
652
+ "javascript": 21,
653
+ "java": 20,
654
+ "rag": 20,
655
+ "api-design": 20,
656
+ "linux": 19,
657
+ "postgresql": 19,
658
+ "search": 17,
659
+ "azure": 16,
660
+ "snowflake": 15,
661
+ "spark": 15,
662
+ "kotlin": 14,
663
+ "gcp": 14,
664
+ "accessibility": 14,
665
+ "nodejs": 14,
666
+ "graphql": 14,
667
+ "airflow": 14,
668
+ "docker": 14,
669
+ "ci-cd": 13,
670
+ "android": 12,
671
+ "cpp": 12,
672
+ "gitlab-ci": 11,
673
+ "anthropic": 11,
674
+ "terraform": 11,
675
+ "mysql": 11,
676
+ "r": 10,
677
+ "dbt": 9,
678
+ "langchain": 9,
679
+ "pytorch": 9,
680
+ "ruby": 9,
681
+ "rails": 9,
682
+ "cloudflare": 7,
683
+ "datadog": 7,
684
+ "css": 7,
685
+ "ansible": 7,
686
+ "openai": 6,
687
+ "kafka": 6,
688
+ "rust": 5,
689
+ "grpc": 5,
690
+ "microservices": 5,
691
+ "serverless": 5,
692
+ "scala": 5,
693
+ "prometheus": 5,
694
+ "grafana": 5,
695
+ "php": 5,
696
+ "redis": 5,
697
+ "huggingface": 4,
698
+ "pandas": 4,
699
+ "scikit-learn": 4,
700
+ "html": 4,
701
+ "ios": 4,
702
+ "authentication": 4,
703
+ "vue": 4,
704
+ "mlops": 3,
705
+ "spring": 3,
706
+ "mongodb": 3,
707
+ "csharp": 3,
708
+ "swift": 2,
709
+ "caching": 2,
710
+ "haskell": 2,
711
+ "pulumi": 2,
712
+ "argocd": 2,
713
+ "tensorflow": 2,
714
+ "express": 2,
715
+ "elasticsearch": 2,
716
+ "clickhouse": 2,
717
+ "nestjs": 2,
718
+ "vite": 2,
719
+ "svelte": 2,
720
+ "phoenix": 2,
721
+ "angular": 2,
722
+ "django": 2,
723
+ "dotnet": 2,
724
+ "elixir": 2,
725
+ "bun": 1,
726
+ "oauth": 1,
727
+ "dynamodb": 1,
728
+ "helm": 1,
729
+ "playwright": 1,
730
+ "cypress": 1,
731
+ "jest": 1,
732
+ "mocha": 1,
733
+ "typeorm": 1,
734
+ "tailwind": 1,
735
+ "prisma": 1,
736
+ "expo": 1,
737
+ "rabbitmq": 1,
738
+ "redux": 1
739
+ }
740
+ };
741
+ }
742
+ });
743
+
543
744
  // ../../packages/core/src/vocab/index.ts
544
745
  function normalize(tokens) {
545
746
  const result = /* @__PURE__ */ new Set();
@@ -576,6 +777,8 @@ var init_vocab = __esm({
576
777
  init_types2();
577
778
  init_closure();
578
779
  init_graph_data();
780
+ init_extract();
781
+ init_idf_background();
579
782
  GRAPH = buildGraph(VOCAB_NODES);
580
783
  VOCABULARY = [...GRAPH.ids];
581
784
  SYNONYMS = Object.fromEntries(GRAPH.synonyms);
@@ -590,23 +793,250 @@ var init_vocabulary = __esm({
590
793
  }
591
794
  });
592
795
 
593
- // ../../packages/core/src/matcher.ts
594
- function computeIdf(jobs) {
595
- const docFreq = /* @__PURE__ */ new Map();
596
- const N = jobs.length;
597
- for (const job of jobs) {
598
- const unique = new Set(job.tags);
599
- for (const tag of unique) {
600
- docFreq.set(tag, (docFreq.get(tag) ?? 0) + 1);
796
+ // ../../packages/core/src/github.ts
797
+ function ghHeaders(token) {
798
+ const headers = {
799
+ Accept: "application/vnd.github+json",
800
+ "X-GitHub-Api-Version": "2022-11-28"
801
+ };
802
+ if (token) headers["Authorization"] = `Bearer ${token}`;
803
+ return headers;
804
+ }
805
+ async function ghFetch(path, token) {
806
+ const url = `https://api.github.com${path}`;
807
+ const res = await fetch(url, { headers: ghHeaders(token) });
808
+ if (!res.ok) {
809
+ throw new Error(`GitHub API ${path}: HTTP ${res.status} ${res.statusText}`);
810
+ }
811
+ return res.json();
812
+ }
813
+ async function fetchGitHubProfile(login, token) {
814
+ const user = await ghFetch(`/users/${login}`, token);
815
+ let repos = [];
816
+ try {
817
+ repos = await ghFetch(
818
+ `/users/${login}/repos?sort=pushed&per_page=100`,
819
+ token
820
+ );
821
+ } catch (err) {
822
+ console.warn(`[github] ${login}: repos fetch failed, continuing \u2014`, err);
823
+ }
824
+ const langCount = {};
825
+ for (const repo of repos) {
826
+ if (repo.fork) continue;
827
+ if (repo.language) {
828
+ langCount[repo.language.toLowerCase()] = (langCount[repo.language.toLowerCase()] ?? 0) + 1;
601
829
  }
602
830
  }
603
- const idf = /* @__PURE__ */ new Map();
604
- for (const [tag, df] of docFreq) {
605
- idf.set(tag, Math.log((N + 1) / (df + 1)) + 1);
831
+ const topLanguages = Object.entries(langCount).sort(([, a], [, b]) => b - a).slice(0, 10).map(([lang]) => lang);
832
+ const topicSet = /* @__PURE__ */ new Set();
833
+ for (const repo of repos) {
834
+ if (repo.fork) continue;
835
+ for (const t of repo.topics ?? []) topicSet.add(t.toLowerCase());
836
+ }
837
+ const topics = Array.from(topicSet).slice(0, 30);
838
+ let recentPRorgs;
839
+ try {
840
+ const q = encodeURIComponent(
841
+ `type:pr is:merged author:${login} sort:updated`
842
+ );
843
+ const result = await ghFetch(
844
+ `/search/issues?q=${q}&per_page=30`,
845
+ token
846
+ );
847
+ const orgs = /* @__PURE__ */ new Set();
848
+ for (const item of result.items ?? []) {
849
+ const orgLogin = item.repository?.owner?.login;
850
+ if (orgLogin && orgLogin !== login) orgs.add(orgLogin);
851
+ }
852
+ if (orgs.size > 0) recentPRorgs = Array.from(orgs);
853
+ } catch {
854
+ }
855
+ return {
856
+ login: user.login,
857
+ name: user.name ?? void 0,
858
+ publicEmail: user.email ?? void 0,
859
+ avatarUrl: user.avatar_url,
860
+ accountCreatedAt: user.created_at,
861
+ publicRepos: user.public_repos,
862
+ followers: user.followers,
863
+ topLanguages,
864
+ topics,
865
+ recentPRorgs
866
+ };
867
+ }
868
+ function inferSeniority(p) {
869
+ const ageMs = Date.now() - new Date(p.accountCreatedAt).getTime();
870
+ const ageYears = ageMs / (1e3 * 60 * 60 * 24 * 365.25);
871
+ if (ageYears >= 9 && (p.publicRepos >= 40 || p.followers >= 500)) return "staff";
872
+ if (ageYears >= 5 && (p.publicRepos >= 20 || p.followers >= 100)) return "senior";
873
+ if (ageYears >= 2 && p.publicRepos >= 5) return "mid";
874
+ return "junior";
875
+ }
876
+ function githubToFingerprint(p) {
877
+ const rawTokens = [
878
+ ...p.topLanguages,
879
+ ...p.topics
880
+ // recentPRorgs intentionally excluded — org names are not skill tags
881
+ ];
882
+ const skillTags = normalize(rawTokens);
883
+ const seniorityBand = inferSeniority(p);
884
+ return { skillTags, seniorityBand };
885
+ }
886
+ async function ghFetchRaw(path, token) {
887
+ return fetch(`https://api.github.com${path}`, { headers: ghHeaders(token) });
888
+ }
889
+ function parseRepoUrl(repoUrl) {
890
+ const m = repoUrl.match(/\/repos\/([^/]+)\/([^/]+)\/?$/);
891
+ return m ? { owner: m[1], name: m[2] } : null;
892
+ }
893
+ function isTrivialPRTitle(title) {
894
+ return TRIVIAL_PR_TITLE.test(title);
895
+ }
896
+ async function fetchOwnedOrgs(token) {
897
+ try {
898
+ const memberships = await ghFetch(`/user/memberships/orgs?per_page=100`, token);
899
+ return new Set(
900
+ memberships.filter((m) => m.role === "admin").map((m) => m.organization.login.toLowerCase())
901
+ );
902
+ } catch {
903
+ return /* @__PURE__ */ new Set();
904
+ }
905
+ }
906
+ async function repoContributorCount(owner, name, token) {
907
+ try {
908
+ const res = await ghFetchRaw(
909
+ `/repos/${owner}/${name}/contributors?per_page=1&anon=false`,
910
+ token
911
+ );
912
+ if (!res.ok) return void 0;
913
+ const link = res.headers.get("link");
914
+ const m = link?.match(/[?&]page=(\d+)>;\s*rel="last"/);
915
+ if (m) return Number(m[1]);
916
+ const body = await res.json();
917
+ return Array.isArray(body) ? body.length : 0;
918
+ } catch {
919
+ return void 0;
920
+ }
921
+ }
922
+ async function fetchRepoMeta(owner, name, token, cache) {
923
+ const key = `${owner}/${name}`.toLowerCase();
924
+ const cached = cache.get(key);
925
+ if (cached !== void 0) return cached;
926
+ let meta = null;
927
+ try {
928
+ const r = await ghFetch(`/repos/${owner}/${name}`, token);
929
+ const contributors = await repoContributorCount(owner, name, token);
930
+ meta = {
931
+ stars: r.stargazers_count ?? 0,
932
+ archived: !!r.archived,
933
+ fork: !!r.fork,
934
+ language: r.language ?? null,
935
+ topics: r.topics ?? [],
936
+ contributors
937
+ };
938
+ } catch {
939
+ meta = null;
940
+ }
941
+ cache.set(key, meta);
942
+ return meta;
943
+ }
944
+ async function computeAcceptanceCredential(login, token, cache = /* @__PURE__ */ new Map()) {
945
+ const computedAt = (/* @__PURE__ */ new Date()).toISOString();
946
+ const empty = (status) => ({
947
+ status,
948
+ byDomain: {},
949
+ qualifyingTotal: 0,
950
+ computedAt
951
+ });
952
+ if (!token) return empty("no-token");
953
+ const ownedOrgs = await fetchOwnedOrgs(token);
954
+ const loginLc = login.toLowerCase();
955
+ let items;
956
+ try {
957
+ const q = encodeURIComponent(`type:pr is:merged author:${login} -user:${login} sort:updated`);
958
+ const res = await ghFetch(
959
+ `/search/issues?q=${q}&per_page=${CANDIDATE_PR_PAGE}`,
960
+ token
961
+ );
962
+ items = res.items ?? [];
963
+ } catch (err) {
964
+ const msg = String(err);
965
+ return empty(/HTTP 403|HTTP 429|rate limit/i.test(msg) ? "rate-limited" : "failed");
966
+ }
967
+ const byDomain = {};
968
+ let qualifyingTotal = 0;
969
+ for (const item of items) {
970
+ const repo = parseRepoUrl(item.repository_url);
971
+ if (!repo) continue;
972
+ const ownerLc = repo.owner.toLowerCase();
973
+ if (ownerLc === loginLc) continue;
974
+ if (ownedOrgs.has(ownerLc)) continue;
975
+ if (isTrivialPRTitle(item.title)) continue;
976
+ const meta = await fetchRepoMeta(repo.owner, repo.name, token, cache);
977
+ if (!meta) continue;
978
+ if (meta.archived || meta.fork) continue;
979
+ if (meta.stars < MIN_STARS) continue;
980
+ if (meta.contributors !== void 0 && meta.contributors < MIN_CONTRIBUTORS) continue;
981
+ qualifyingTotal += 1;
982
+ const mergedAt = item.pull_request?.merged_at ?? item.closed_at ?? item.created_at;
983
+ const rawDomains = [meta.language ?? "", ...meta.topics].filter(Boolean);
984
+ for (const d of new Set(normalize(rawDomains))) {
985
+ const b = byDomain[d] ?? (byDomain[d] = { mergedPRs: 0, distinctOrgs: 0, lastMergedAt: mergedAt, orgs: /* @__PURE__ */ new Set() });
986
+ b.mergedPRs += 1;
987
+ b.orgs.add(ownerLc);
988
+ if (mergedAt > b.lastMergedAt) b.lastMergedAt = mergedAt;
989
+ }
990
+ }
991
+ const finalDomains = {};
992
+ for (const [d, b] of Object.entries(byDomain)) {
993
+ finalDomains[d] = {
994
+ mergedPRs: b.mergedPRs,
995
+ distinctOrgs: b.orgs.size,
996
+ lastMergedAt: b.lastMergedAt
997
+ };
606
998
  }
607
- return idf;
999
+ return { status: "ok", byDomain: finalDomains, qualifyingTotal, computedAt };
608
1000
  }
609
- function inferSeniority(title) {
1001
+ function acceptanceCountForDomains(cred, domains) {
1002
+ if (cred.status !== "ok") return 0;
1003
+ let max = 0;
1004
+ for (const d of domains) {
1005
+ const c = cred.byDomain[d]?.mergedPRs ?? 0;
1006
+ if (c > max) max = c;
1007
+ }
1008
+ return max;
1009
+ }
1010
+ function bestAcceptanceDomain(cred, domains) {
1011
+ if (cred.status !== "ok") return null;
1012
+ let best = null;
1013
+ for (const d of domains) {
1014
+ const count = cred.byDomain[d]?.mergedPRs ?? 0;
1015
+ if (count > 0 && (best === null || count > best.count)) best = { domain: d, count };
1016
+ }
1017
+ return best;
1018
+ }
1019
+ var MIN_STARS, MIN_CONTRIBUTORS, CANDIDATE_PR_PAGE, TRIVIAL_PR_TITLE;
1020
+ var init_github = __esm({
1021
+ "../../packages/core/src/github.ts"() {
1022
+ "use strict";
1023
+ init_vocabulary();
1024
+ MIN_STARS = 50;
1025
+ MIN_CONTRIBUTORS = 10;
1026
+ CANDIDATE_PR_PAGE = 50;
1027
+ TRIVIAL_PR_TITLE = /^\s*(fix\s+typo|typo\b|update\s+readme|readme\b|docs?:|docs?\(|chore:|chore\(|style:|ci:|build:|bump\b|update\s+dependenc)/i;
1028
+ }
1029
+ });
1030
+
1031
+ // ../../packages/core/src/matcher.ts
1032
+ function acceptanceDomainsOf(job) {
1033
+ return job.coreTags && job.coreTags.length > 0 ? job.coreTags : job.tags;
1034
+ }
1035
+ function backgroundIdf(tag) {
1036
+ const df = IDF_BACKGROUND.df[tag] ?? 0;
1037
+ return Math.log((IDF_BACKGROUND.N + 1) / (df + 1)) + 1;
1038
+ }
1039
+ function inferSeniority2(title) {
610
1040
  if (!ENG_TITLE.test(title)) return void 0;
611
1041
  for (const [re, level] of SENIORITY_PATTERNS) {
612
1042
  if (re.test(title)) return level;
@@ -615,7 +1045,7 @@ function inferSeniority(title) {
615
1045
  }
616
1046
  function seniorityScore(fp, job) {
617
1047
  if (!fp.seniorityBand) return 1;
618
- const jobLevel = inferSeniority(job.title);
1048
+ const jobLevel = inferSeniority2(job.title);
619
1049
  if (!jobLevel) return 0.85;
620
1050
  const wanted = SENIORITY_RANK[fp.seniorityBand] ?? 1;
621
1051
  const got = SENIORITY_RANK[jobLevel] ?? 1;
@@ -625,8 +1055,10 @@ function seniorityScore(fp, job) {
625
1055
  return 0.4;
626
1056
  }
627
1057
  function recencyScore(postedAt, now) {
628
- if (!postedAt) return 0.75;
629
- const ageDays2 = (now - new Date(postedAt).getTime()) / 864e5;
1058
+ if (!postedAt) return UNKNOWN_RECENCY;
1059
+ const ms = new Date(postedAt).getTime();
1060
+ if (Number.isNaN(ms)) return UNKNOWN_RECENCY;
1061
+ const ageDays2 = (now - ms) / 864e5;
630
1062
  if (ageDays2 < 7) return 1;
631
1063
  if (ageDays2 < 30) return 0.9;
632
1064
  if (ageDays2 < 90) return 0.75;
@@ -657,9 +1089,8 @@ function harmonicMean(a, b) {
657
1089
  if (a <= 0 || b <= 0) return 0;
658
1090
  return 2 * a * b / (a + b);
659
1091
  }
660
- function match(fp, jobs, limit = 5, now = Date.now()) {
661
- const idf = computeIdf(jobs);
662
- const idfOf = (t) => idf.get(t) ?? 0;
1092
+ function match(fp, jobs, limit = 5, now = Date.now(), opts = {}) {
1093
+ const idfOf = backgroundIdf;
663
1094
  const expanded = expandWeighted(fp.skillTags);
664
1095
  const maxDevScore = fp.skillTags.reduce((acc, t) => acc + idfOf(t), 0);
665
1096
  const candidates = jobs.filter((j) => passesFilters(fp, j));
@@ -685,32 +1116,45 @@ function match(fp, jobs, limit = 5, now = Date.now()) {
685
1116
  const jobCov = jobMaxScore > 0 ? Math.min(1, jobMatchScore / jobMaxScore) : 0;
686
1117
  const tagComponent = harmonicMean(devCov, jobCov);
687
1118
  if (tagComponent === 0) return null;
1119
+ const coreTags = job.coreTags ?? coreTagsFromTitle(job.title);
1120
+ let coreComponent = tagComponent;
1121
+ if (coreTags.length > 0) {
1122
+ const coreCov = Math.max(0, ...coreTags.map((ct) => expanded.get(ct)?.weight ?? 0));
1123
+ if (coreCov === 0) coreComponent = tagComponent * CORE_MISS_PENALTY;
1124
+ }
688
1125
  details.sort((a, b) => idfOf(b.tag) * b.weight - idfOf(a.tag) * a.weight);
689
1126
  const sScore = seniorityScore(fp, job);
690
1127
  const rScore = recencyScore(job.postedAt, now);
691
- const score = tagComponent * 0.6 + sScore * 0.25 + rScore * 0.15;
1128
+ const score = coreComponent * 0.6 + sScore * 0.25 + rScore * 0.15;
692
1129
  const matchedTags = [...new Set(details.map((d) => d.via ?? d.tag))];
1130
+ const badge = opts.acceptance ? bestAcceptanceDomain(opts.acceptance, acceptanceDomainsOf(job)) : null;
693
1131
  return {
694
1132
  job,
695
1133
  score: Math.round(score * 1e3) / 1e3,
696
1134
  matchedTags,
697
1135
  matchDetails: details,
1136
+ ...badge ? { acceptance: { status: "ok", domain: badge.domain, count: badge.count } } : {},
698
1137
  reason: buildReason(details)
699
1138
  };
700
1139
  });
701
- return scored.filter((r) => r !== null && r.score >= MIN_SCORE).sort((a, b) => b.score - a.score).slice(0, limit);
702
- }
703
- function matchOne(fp, job) {
704
- const results = match(fp, [job], 1);
705
- return results.length > 0 ? results[0] : null;
706
- }
707
- var MIN_SCORE, SHARPEN, SENIORITY_RANK, SENIORITY_PATTERNS, ENG_TITLE;
1140
+ return scored.filter((r) => r !== null && r.score >= MIN_SCORE).sort((a, b) => {
1141
+ const byScore = b.score - a.score;
1142
+ if (Math.abs(byScore) > TIEBREAK_EPS) return byScore;
1143
+ const byAcceptance = (b.acceptance?.count ?? 0) - (a.acceptance?.count ?? 0);
1144
+ if (byAcceptance !== 0) return byAcceptance;
1145
+ return byScore;
1146
+ }).slice(0, limit);
1147
+ }
1148
+ var MIN_SCORE, TIEBREAK_EPS, SHARPEN, CORE_MISS_PENALTY, SENIORITY_RANK, SENIORITY_PATTERNS, ENG_TITLE, UNKNOWN_RECENCY;
708
1149
  var init_matcher = __esm({
709
1150
  "../../packages/core/src/matcher.ts"() {
710
1151
  "use strict";
711
1152
  init_vocabulary();
1153
+ init_github();
712
1154
  MIN_SCORE = 0.15;
1155
+ TIEBREAK_EPS = 5e-3;
713
1156
  SHARPEN = 1.6;
1157
+ CORE_MISS_PENALTY = 0.4;
714
1158
  SENIORITY_RANK = {
715
1159
  junior: 0,
716
1160
  mid: 1,
@@ -724,24 +1168,19 @@ var init_matcher = __esm({
724
1168
  [/\bmid[\s-]?level\b|\bmid\b/i, "mid"]
725
1169
  ];
726
1170
  ENG_TITLE = /\b(engineer|engineering|developer|dev|swe|sde|programmer|architect)\b/i;
1171
+ UNKNOWN_RECENCY = 0.75;
727
1172
  }
728
1173
  });
729
1174
 
730
1175
  // ../../packages/core/src/feeds/greenhouse.ts
731
- function tokenize(text) {
732
- return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
733
- }
734
1176
  function extractTags(job) {
735
- const texts = [
736
- job.title,
1177
+ const body = [
737
1178
  ...(job.departments ?? []).map((d) => d.name),
738
1179
  job.location?.name ?? "",
739
1180
  ...(job.offices ?? []).map((o) => o.name),
740
- // mine the full HTML description for additional signal when present
741
1181
  ...job.content ? [job.content.replace(/<[^>]*>/g, " ")] : []
742
- ].filter(Boolean);
743
- const tokens = texts.flatMap(tokenize);
744
- return normalize(tokens);
1182
+ ].filter(Boolean).join(" ");
1183
+ return extractSkillTags(job.title, body);
745
1184
  }
746
1185
  function inferRemote(location) {
747
1186
  const l = location.toLowerCase();
@@ -839,17 +1278,15 @@ var init_greenhouse = __esm({
839
1278
  });
840
1279
 
841
1280
  // ../../packages/core/src/feeds/ashby.ts
842
- function tokenize2(text) {
843
- return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
844
- }
845
1281
  function extractTags2(job) {
846
- const texts = [
847
- job.title,
848
- job.teamName ?? "",
849
- job.locationName ?? "",
850
- ...(job.secondaryLocations ?? []).map((l) => l.locationName ?? "")
851
- ];
852
- return normalize(texts.flatMap(tokenize2));
1282
+ const body = [
1283
+ job.team ?? "",
1284
+ job.department ?? "",
1285
+ job.location ?? "",
1286
+ ...(job.secondaryLocations ?? []).map((l) => l.location ?? ""),
1287
+ job.descriptionPlain ?? ""
1288
+ ].join(" ");
1289
+ return extractSkillTags(job.title, body);
853
1290
  }
854
1291
  function mapEmploymentType(raw) {
855
1292
  if (!raw) return "full_time";
@@ -860,7 +1297,7 @@ function mapEmploymentType(raw) {
860
1297
  }
861
1298
  function inferRemote2(job) {
862
1299
  if (job.isRemote === true) return true;
863
- const loc = (job.locationName ?? "").toLowerCase();
1300
+ const loc = (job.location ?? "").toLowerCase();
864
1301
  return loc.includes("remote") || loc.includes("anywhere");
865
1302
  }
866
1303
  async function fetchSlug2(slug) {
@@ -879,14 +1316,14 @@ async function fetchSlug2(slug) {
879
1316
  source: "ashby",
880
1317
  title: j.title,
881
1318
  company: slug,
882
- url: j.applyUrl ?? `https://jobs.ashbyhq.com/${slug}/${j.id}`,
1319
+ url: j.jobUrl ?? j.applyUrl ?? `https://jobs.ashbyhq.com/${slug}/${j.id}`,
883
1320
  remote: inferRemote2(j),
884
- location: j.locationName,
1321
+ location: j.location,
885
1322
  compMin: comp?.minValue,
886
1323
  compMax: comp?.maxValue,
887
1324
  tags: extractTags2(j),
888
1325
  roleType: mapEmploymentType(j.employmentType),
889
- postedAt: j.publishedDate,
1326
+ postedAt: j.publishedAt,
890
1327
  applyMode: "direct",
891
1328
  raw: j
892
1329
  };
@@ -913,20 +1350,16 @@ var init_ashby = __esm({
913
1350
  });
914
1351
 
915
1352
  // ../../packages/core/src/feeds/lever.ts
916
- function tokenize3(text) {
917
- return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
918
- }
919
1353
  function extractTags3(p) {
920
1354
  const cat = p.categories ?? {};
921
- const texts = [
922
- p.text,
1355
+ const body = [
923
1356
  cat.team ?? "",
924
1357
  cat.department ?? "",
925
1358
  cat.location ?? "",
926
1359
  ...cat.allLocations ?? [],
927
1360
  p.descriptionPlain ?? ""
928
- ];
929
- return normalize(texts.flatMap(tokenize3));
1361
+ ].join(" ");
1362
+ return extractSkillTags(p.text, body);
930
1363
  }
931
1364
  function mapCommitment(raw) {
932
1365
  if (!raw) return "full_time";
@@ -999,15 +1432,8 @@ var init_lever = __esm({
999
1432
  });
1000
1433
 
1001
1434
  // ../../packages/core/src/feeds/himalayas.ts
1002
- function tokenize4(text) {
1003
- return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
1004
- }
1005
1435
  function extractTags4(job) {
1006
- const texts = [
1007
- job.title,
1008
- ...job.tags ?? []
1009
- ];
1010
- return normalize(texts.flatMap(tokenize4));
1436
+ return extractSkillTags(job.title, (job.tags ?? []).join(" "));
1011
1437
  }
1012
1438
  function mapJobType(raw) {
1013
1439
  if (!raw) return "full_time";
@@ -1092,9 +1518,6 @@ var init_entities = __esm({
1092
1518
  });
1093
1519
 
1094
1520
  // ../../packages/core/src/feeds/wwr.ts
1095
- function tokenize5(text) {
1096
- return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
1097
- }
1098
1521
  function stripHtml(html) {
1099
1522
  return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
1100
1523
  }
@@ -1119,10 +1542,13 @@ function parseRss(xml) {
1119
1542
  return decodeEntities(plainMatch?.[1].trim() ?? "");
1120
1543
  };
1121
1544
  const rawTitle = get("title");
1122
- const colonIdx = rawTitle.indexOf(":");
1123
- const company = colonIdx !== -1 ? rawTitle.slice(0, colonIdx).trim() : "Unknown";
1124
- const titleAfterColon = colonIdx !== -1 ? rawTitle.slice(colonIdx + 1).trim() : rawTitle;
1545
+ const m = rawTitle.match(/^(.*?):\s+(.*)$/);
1546
+ let company = m ? m[1].trim() : "Unknown";
1547
+ const titleAfterColon = m ? m[2].trim() : rawTitle;
1125
1548
  const title = titleAfterColon.replace(/\s*\([^)]*\)\s*$/, "").trim();
1549
+ if (/^https?:\/\//i.test(company)) {
1550
+ company = company.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").trim() || "Unknown";
1551
+ }
1126
1552
  items.push({
1127
1553
  title,
1128
1554
  link: get("link") || get("guid"),
@@ -1135,8 +1561,8 @@ function parseRss(xml) {
1135
1561
  return items;
1136
1562
  }
1137
1563
  function extractTags5(item) {
1138
- const text = [item.title, item.category, stripHtml(item.description)].join(" ");
1139
- return normalize(tokenize5(text));
1564
+ const body = [item.category, stripHtml(item.description)].join(" ");
1565
+ return extractSkillTags(item.title, body);
1140
1566
  }
1141
1567
  var WWR_RSS_URL, wwr;
1142
1568
  var init_wwr = __esm({
@@ -1178,9 +1604,6 @@ var init_wwr = __esm({
1178
1604
  });
1179
1605
 
1180
1606
  // ../../packages/core/src/feeds/hn.ts
1181
- function tokenize6(text) {
1182
- return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
1183
- }
1184
1607
  function stripHtml2(html) {
1185
1608
  return decodeEntities(html.replace(/<p>/gi, " ").replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
1186
1609
  }
@@ -1211,7 +1634,7 @@ function parseComment(item) {
1211
1634
  return null;
1212
1635
  }
1213
1636
  const url = extractUrl(raw) || `https://news.ycombinator.com/item?id=${item.id}`;
1214
- const tags = extractTags6(raw);
1637
+ const tags = extractTags6(title, raw);
1215
1638
  if (tags.length === 0) return null;
1216
1639
  return {
1217
1640
  id: `hn:${item.id}`,
@@ -1228,8 +1651,8 @@ function parseComment(item) {
1228
1651
  raw: item
1229
1652
  };
1230
1653
  }
1231
- function extractTags6(text) {
1232
- return normalize(tokenize6(text));
1654
+ function extractTags6(title, text) {
1655
+ return extractSkillTags(title, text);
1233
1656
  }
1234
1657
  var ALGOLIA_SEARCH, ALGOLIA_ITEMS, hn;
1235
1658
  var init_hn = __esm({
@@ -1319,7 +1742,7 @@ function authHeaders() {
1319
1742
  if (token) h["Authorization"] = `Bearer ${token}`;
1320
1743
  return h;
1321
1744
  }
1322
- function tokenize7(text) {
1745
+ function tokenize2(text) {
1323
1746
  return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
1324
1747
  }
1325
1748
  function parseAmountUSD(text) {
@@ -1404,7 +1827,7 @@ async function fetchRepoBounties(repoFullName) {
1404
1827
  const body = issue.body ? decodeEntities(issue.body) : "";
1405
1828
  const amountUSD = parseAmountUSD(title) ?? parseAmountUSD(body) ?? await fetchCommentAmount(repoFullName, issue.number);
1406
1829
  const labels = labelNames(issue);
1407
- const tags = normalize(tokenize7([title, labels.join(" "), body.slice(0, 2e3)].join(" ")));
1830
+ const tags = normalize(tokenize2([title, labels.join(" "), body.slice(0, 2e3)].join(" ")));
1408
1831
  return {
1409
1832
  id: `bounty:${repoFullName}#${issue.number}`,
1410
1833
  source: "bounty",
@@ -1721,103 +2144,6 @@ var init_indexer = __esm({
1721
2144
  }
1722
2145
  });
1723
2146
 
1724
- // ../../packages/core/src/github.ts
1725
- function ghHeaders(token) {
1726
- const headers = {
1727
- Accept: "application/vnd.github+json",
1728
- "X-GitHub-Api-Version": "2022-11-28"
1729
- };
1730
- if (token) headers["Authorization"] = `Bearer ${token}`;
1731
- return headers;
1732
- }
1733
- async function ghFetch(path, token) {
1734
- const url = `https://api.github.com${path}`;
1735
- const res = await fetch(url, { headers: ghHeaders(token) });
1736
- if (!res.ok) {
1737
- throw new Error(`GitHub API ${path}: HTTP ${res.status} ${res.statusText}`);
1738
- }
1739
- return res.json();
1740
- }
1741
- async function fetchGitHubProfile(login, token) {
1742
- const user = await ghFetch(`/users/${login}`, token);
1743
- let repos = [];
1744
- try {
1745
- repos = await ghFetch(
1746
- `/users/${login}/repos?sort=pushed&per_page=100`,
1747
- token
1748
- );
1749
- } catch (err) {
1750
- console.warn(`[github] ${login}: repos fetch failed, continuing \u2014`, err);
1751
- }
1752
- const langCount = {};
1753
- for (const repo of repos) {
1754
- if (repo.fork) continue;
1755
- if (repo.language) {
1756
- langCount[repo.language.toLowerCase()] = (langCount[repo.language.toLowerCase()] ?? 0) + 1;
1757
- }
1758
- }
1759
- const topLanguages = Object.entries(langCount).sort(([, a], [, b]) => b - a).slice(0, 10).map(([lang]) => lang);
1760
- const topicSet = /* @__PURE__ */ new Set();
1761
- for (const repo of repos) {
1762
- if (repo.fork) continue;
1763
- for (const t of repo.topics ?? []) topicSet.add(t.toLowerCase());
1764
- }
1765
- const topics = Array.from(topicSet).slice(0, 30);
1766
- let recentPRorgs;
1767
- try {
1768
- const q = encodeURIComponent(
1769
- `type:pr is:merged author:${login} sort:updated`
1770
- );
1771
- const result = await ghFetch(
1772
- `/search/issues?q=${q}&per_page=30`,
1773
- token
1774
- );
1775
- const orgs = /* @__PURE__ */ new Set();
1776
- for (const item of result.items ?? []) {
1777
- const orgLogin = item.repository?.owner?.login;
1778
- if (orgLogin && orgLogin !== login) orgs.add(orgLogin);
1779
- }
1780
- if (orgs.size > 0) recentPRorgs = Array.from(orgs);
1781
- } catch {
1782
- }
1783
- return {
1784
- login: user.login,
1785
- name: user.name ?? void 0,
1786
- publicEmail: user.email ?? void 0,
1787
- avatarUrl: user.avatar_url,
1788
- accountCreatedAt: user.created_at,
1789
- publicRepos: user.public_repos,
1790
- followers: user.followers,
1791
- topLanguages,
1792
- topics,
1793
- recentPRorgs
1794
- };
1795
- }
1796
- function inferSeniority2(p) {
1797
- const ageMs = Date.now() - new Date(p.accountCreatedAt).getTime();
1798
- const ageYears = ageMs / (1e3 * 60 * 60 * 24 * 365.25);
1799
- if (ageYears >= 9 && (p.publicRepos >= 40 || p.followers >= 500)) return "staff";
1800
- if (ageYears >= 5 && (p.publicRepos >= 20 || p.followers >= 100)) return "senior";
1801
- if (ageYears >= 2 && p.publicRepos >= 5) return "mid";
1802
- return "junior";
1803
- }
1804
- function githubToFingerprint(p) {
1805
- const rawTokens = [
1806
- ...p.topLanguages,
1807
- ...p.topics
1808
- // recentPRorgs intentionally excluded — org names are not skill tags
1809
- ];
1810
- const skillTags = normalize(rawTokens);
1811
- const seniorityBand = inferSeniority2(p);
1812
- return { skillTags, seniorityBand };
1813
- }
1814
- var init_github = __esm({
1815
- "../../packages/core/src/github.ts"() {
1816
- "use strict";
1817
- init_vocabulary();
1818
- }
1819
- });
1820
-
1821
2147
  // ../../packages/core/src/index.ts
1822
2148
  var src_exports = {};
1823
2149
  __export(src_exports, {
@@ -1831,17 +2157,23 @@ __export(src_exports, {
1831
2157
  FEEDS: () => FEEDS,
1832
2158
  GRAPH: () => GRAPH,
1833
2159
  GREENHOUSE_SLUGS_BY_TIER: () => GREENHOUSE_SLUGS_BY_TIER,
2160
+ IDF_BACKGROUND: () => IDF_BACKGROUND,
1834
2161
  LEVER_SLUGS_BY_TIER: () => LEVER_SLUGS_BY_TIER,
1835
2162
  SYNONYMS: () => SYNONYMS,
1836
2163
  VOCABULARY: () => VOCABULARY,
1837
2164
  VOCAB_NODES: () => VOCAB_NODES,
2165
+ acceptanceCountForDomains: () => acceptanceCountForDomains,
1838
2166
  aggregate: () => aggregate,
1839
2167
  aggregateBounties: () => aggregateBounties,
1840
2168
  ashby: () => ashby,
2169
+ bestAcceptanceDomain: () => bestAcceptanceDomain,
1841
2170
  buildGraph: () => buildGraph,
1842
2171
  buildIndex: () => buildIndex,
1843
2172
  buildReason: () => buildReason,
2173
+ computeAcceptanceCredential: () => computeAcceptanceCredential,
2174
+ coreTagsFromTitle: () => coreTagsFromTitle,
1844
2175
  expandWeighted: () => expandWeighted,
2176
+ extractSkillTags: () => extractSkillTags,
1845
2177
  fetchGitHubProfile: () => fetchGitHubProfile,
1846
2178
  flattenTiers: () => flattenTiers,
1847
2179
  getBuyer: () => getBuyer,
@@ -1853,10 +2185,11 @@ __export(src_exports, {
1853
2185
  isBounty: () => isBounty,
1854
2186
  lever: () => lever,
1855
2187
  loadPartnerRoles: () => loadPartnerRoles,
2188
+ looksLikeEngRole: () => looksLikeEngRole,
1856
2189
  match: () => match,
1857
- matchOne: () => matchOne,
1858
2190
  normalize: () => normalize,
1859
2191
  passesMaturityGate: () => passesMaturityGate,
2192
+ tokenize: () => tokenize,
1860
2193
  validateGraph: () => validateGraph,
1861
2194
  wwr: () => wwr
1862
2195
  });
@@ -2124,7 +2457,7 @@ async function run() {
2124
2457
  }
2125
2458
  async function runLogin() {
2126
2459
  const { runDeviceFlow: runDeviceFlow2, readGitHubToken: readGitHubToken2 } = await Promise.resolve().then(() => (init_github_auth(), github_auth_exports));
2127
- const { fetchGitHubProfile: fetchGitHubProfile2, githubToFingerprint: githubToFingerprint2 } = await Promise.resolve().then(() => (init_src(), src_exports));
2460
+ const { fetchGitHubProfile: fetchGitHubProfile2, githubToFingerprint: githubToFingerprint2, computeAcceptanceCredential: computeAcceptanceCredential2 } = await Promise.resolve().then(() => (init_src(), src_exports));
2128
2461
  const { readProfile: readProfile2, writeProfile: writeProfile2, accumulateGitHubTags: accumulateGitHubTags2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
2129
2462
  console.log("");
2130
2463
  console.log(" terminalhire \u2014 Sign in with GitHub");
@@ -2140,7 +2473,7 @@ async function runLogin() {
2140
2473
  console.log(`
2141
2474
  Fetching public profile for @${login}...`);
2142
2475
  let ghProfile;
2143
- if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") {
2476
+ if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") {
2144
2477
  const { createRequire } = await import("module");
2145
2478
  const { fileURLToPath: fileURLToPath2 } = await import("url");
2146
2479
  const { join: join4, dirname } = await import("path");
@@ -2166,6 +2499,15 @@ async function runLogin() {
2166
2499
  topLanguages: ghProfile.topLanguages.slice(0, 5),
2167
2500
  publicRepos: ghProfile.publicRepos
2168
2501
  };
2502
+ const isMock = process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1";
2503
+ if (!isMock) {
2504
+ try {
2505
+ console.log(" Computing proof-of-work acceptance credential...");
2506
+ profile.acceptance = await computeAcceptanceCredential2(ghProfile.login, token);
2507
+ } catch (err) {
2508
+ if (process.env["DEBUG"]) console.warn(" [acceptance] credential compute failed:", err);
2509
+ }
2510
+ }
2169
2511
  await writeProfile2(profile);
2170
2512
  console.log("");
2171
2513
  console.log(" GitHub profile merged into local encrypted profile:");
@@ -2182,6 +2524,9 @@ async function runLogin() {
2182
2524
  if (fragment.skillTags.length === 0) {
2183
2525
  console.log(" (No matching vocabulary tags found in public repos/topics)");
2184
2526
  }
2527
+ if (profile.acceptance?.status === "ok" && profile.acceptance.qualifyingTotal > 0) {
2528
+ console.log(` Proof-of-work: ${profile.acceptance.qualifyingTotal} merged PR${profile.acceptance.qualifyingTotal === 1 ? "" : "s"} into external repos`);
2529
+ }
2185
2530
  console.log("");
2186
2531
  console.log(" Profile updated at ~/.terminalhire/profile.enc (encrypted at rest)");
2187
2532
  console.log(" GitHub data stays on your machine unless you consent to share it in a lead.");
@@ -2203,10 +2548,18 @@ async function runLogout() {
2203
2548
  }
2204
2549
  await deleteGitHubToken2();
2205
2550
  const profile = await readProfile2();
2551
+ let changed = false;
2206
2552
  if (profile.github) {
2207
2553
  delete profile.github;
2554
+ changed = true;
2555
+ }
2556
+ if (profile.acceptance) {
2557
+ delete profile.acceptance;
2558
+ changed = true;
2559
+ }
2560
+ if (changed) {
2208
2561
  await writeProfile2(profile);
2209
- console.log("\n GitHub identity cleared from local profile.");
2562
+ console.log("\n GitHub identity + proof-of-work credential cleared from local profile.");
2210
2563
  }
2211
2564
  console.log(" GitHub token deleted from ~/.terminalhire/github-token.enc");
2212
2565
  console.log(" Skill tags accumulated from GitHub remain in your profile.");