/ scripts / release.py
release.py
   1  #!/usr/bin/env python3
   2  """Hermes Agent Release Script
   3  
   4  Generates changelogs and creates GitHub releases with CalVer tags.
   5  
   6  Usage:
   7      # Preview changelog (dry run)
   8      python scripts/release.py
   9  
  10      # Preview with semver bump
  11      python scripts/release.py --bump minor
  12  
  13      # Create the release
  14      python scripts/release.py --bump minor --publish
  15  
  16      # First release (no previous tag)
  17      python scripts/release.py --bump minor --publish --first-release
  18  
  19      # Override CalVer date (e.g. for a belated release)
  20      python scripts/release.py --bump minor --publish --date 2026.3.15
  21  """
  22  
  23  import argparse
  24  import re
  25  import shutil
  26  import subprocess
  27  import sys
  28  from collections import defaultdict
  29  from datetime import datetime
  30  from pathlib import Path
  31  
  32  REPO_ROOT = Path(__file__).resolve().parent.parent
  33  VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
  34  PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
  35  
  36  # ──────────────────────────────────────────────────────────────────────
  37  # Git email → GitHub username mapping
  38  # ──────────────────────────────────────────────────────────────────────
  39  
  40  # Auto-extracted from noreply emails + manual overrides
  41  AUTHOR_MAP = {
  42      # teknium (multiple emails)
  43      "teknium1@gmail.com": "teknium1",
  44      "m@mobrienv.dev": "mikeyobrien",
  45      "qiyin.zuo@pcitc.com": "qiyin-code",
  46      "leone.parise@gmail.com": "leoneparise",
  47      "teknium@nousresearch.com": "teknium1",
  48      "127238744+teknium1@users.noreply.github.com": "teknium1",
  49      "159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
  50      "aludwin+gh@gmail.com": "adamludwin",
  51      "ngusev@astralinux.ru": "NikolayGusev-astra",
  52      "2093036+exiao@users.noreply.github.com": "exiao",
  53      "rylen.anil@gmail.com": "rylena",
  54      "godnanijatin@gmail.com": "jatingodnani",
  55      "14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",
  56      "657290301@qq.com": "IMHaoyan",
  57      "revar@users.noreply.github.com": "revaraver",
  58      # Matrix parity salvage batch (April 2026)
  59      "sr@samirusani": "samrusani",
  60      "angelclaw@AngelMacBook.local": "angel12",
  61      "charles@cryptoassetrecovery.com": "charles-brooks",
  62      # DeepSeek v4 + Kimi thinking-mode reasoning_content salvage (April 2026)
  63      "luwinyang@deepseek.com": "lsdsjy",
  64      "season.saw@gmail.com": "season179",
  65      "heathley@Heathley-MacBook-Air.local": "heathley",
  66      "vlad19@gmail.com": "dandaka",
  67      "adamrummer@gmail.com": "cyclingwithelephants",
  68      "nbot@liizfq.top": "liizfq",
  69      "274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
  70      "dejie.guo@gmail.com": "JayGwod",
  71      "133716830+0xKingBack@users.noreply.github.com": "0xKingBack",
  72      "daixin1204@gmail.com": "SimbaKingjoe",
  73      "maxence@groine.fr": "MaxyMoos",
  74      "61830395+leprincep35700@users.noreply.github.com": "leprincep35700",
  75      # OpenViking viking_read salvage (April 2026)
  76      "hitesh@gmail.com": "htsh",
  77      "pty819@outlook.com": "pty819",
  78      "pty819@users.noreply.github.com": "pty819",
  79      "517024110@qq.com": "chennest",
  80      # Curator fixes (Apr 30 2026)
  81      "yuxiangl490@gmail.com": "y0shua1ee",
  82      "manmit0x@gmail.com": "0xDevNinja",
  83      "aamirjawaid@microsoft.com": "heyitsaamir",
  84      "johnnncenaaa77@gmail.com": "johnncenae",
  85      "thomasjhon6666@gmail.com": "ThomassJonax",
  86      "focusflow.app.help@gmail.com": "yes999zc",
  87      "rob@atlas.lan": "rmoen",
  88      # Slack ephemeral slash-ack salvage (May 2026)
  89      "probepark@users.noreply.github.com": "probepark",
  90      # Slack batch salvage (May 2026)
  91      "280484231+prive-fe-bot@users.noreply.github.com": "priveperfumes",
  92      "amr@ghanem.sa": "amroessam",
  93      "paperlantern.agent@gmail.com": "Hinotoi-agent",
  94      "valda@underscore.jp": "valda",
  95      "162235745+0z1-ghb@users.noreply.github.com": "0z1-ghb",
  96      "yes999zc@163.com": "yes999zc",
  97      "343873859@qq.com": "DrStrangerUJN",
  98      "252818347@qq.com": "hejuntt1014",
  99      "uzmpsk.dilekakbas@gmail.com": "dlkakbs",
 100      "beliefanx@gmail.com": "BeliefanX",
 101      "changchun989@proton.me": "changchun989",
 102      "jefferson@heimdallstrategy.com": "Mind-Dragon",
 103      "44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
 104      "steve.westerhouse@origami-analytics.com": "westers",
 105      "yeyitech@users.noreply.github.com": "yeyitech",
 106      "260878550+beenherebefore@users.noreply.github.com": "beenherebefore",
 107      "79389617+txbxxx@users.noreply.github.com": "txbxxx",
 108      "liuhao03@bilibili.com": "liuhao1024",
 109      "130918800+devorun@users.noreply.github.com": "devorun",
 110      "surat.s@itm.kmutnb.ac.th": "beesrsj2500",
 111      "beesr@bee.localdomain": "beesrsj2500",
 112      "mind-dragon@nous.research": "Mind-Dragon",
 113      "juntingpublic@gmail.com": "JustinUssuri",
 114      "mtf201013@gmail.com": "ma-pony",
 115      "sonoyuncudmr@gmail.com": "Sonoyunchu",
 116      "43525405+yatesjalex@users.noreply.github.com": "yatesjalex",
 117      "maks.mir@yahoo.com": "say8hi",
 118      "27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky",
 119      "web3blind@users.noreply.github.com": "web3blind",
 120      "julia@alexland.us": "alexg0bot",
 121      "christian@scheid.tech": "scheidti",
 122      # Moonshot schema anyOf+enum salvage (May 2026)
 123      "git@local.invalid": "hendrixfreire",
 124      "1060770+benjaminsehl@users.noreply.github.com": "benjaminsehl",
 125      "nerijusn76@gmail.com": "Nerijusas",
 126      "itonov@proton.me": "Ito-69",
 127      "glesstech@gmail.com": "georgeglessner",
 128      "maxim.smetanin@gmail.com": "maxims-oss",
 129      "nazirulhafiy@gmail.com": "nazirulhafiy",
 130      "CREWorx@users.noreply.github.com": "BadTechBandit",
 131      "yoimexex@gmail.com": "Yoimex",
 132      "6548898+romanornr@users.noreply.github.com": "romanornr",
 133      "foxion37@gmail.com": "foxion37",
 134      "bloodcarter@gmail.com": "bloodcarter",
 135      "scott@scotttrinh.com": "scotttrinh",
 136      "quocanh261997@gmail.com": "quocanh261997",
 137      # contributors (from noreply pattern)
 138      "david.vv@icloud.com": "davidvv",
 139      "wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
 140      "snreynolds2506@gmail.com": "snreynolds",
 141      "35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
 142      "71184274+MassiveMassimo@users.noreply.github.com": "MassiveMassimo",
 143      "massivemassimo@users.noreply.github.com": "MassiveMassimo",
 144      "82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
 145      "keifergu@tencent.com": "keifergu",
 146      "kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
 147      "SHL0MS@users.noreply.github.com": "SHL0MS",
 148      "abner.the.foreman@agentmail.to": "Abnertheforeman",
 149      "adam.manning@pro-serveinc.com": "amanning3390",
 150      "thomasgeorgevii09@gmail.com": "tochukwuada",
 151      "sb@wmc.sh": "zicochaos",
 152      "harryykyle1@gmail.com": "hharry11",
 153      "kshitijk4poor@gmail.com": "kshitijk4poor",
 154      "1294707+Tosko4@users.noreply.github.com": "Tosko4",
 155      "keira.voss94@gmail.com": "keiravoss94",
 156      "16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
 157      "fqsy1416@gmail.com": "EKKOLearnAI",
 158      "octo-patch@github.com": "octo-patch",
 159      "math0r-be@github.com": "math0r-be",
 160      "simbamax99@gmail.com": "simbam99",
 161      "iris@growthpillars.co": "irispillars",
 162      "185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
 163      "101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
 164      "255305877+ismell0992-afk@users.noreply.github.com": "ismell0992-afk",
 165      "cyprian@ironin.pl": "iRonin",
 166      "valdi.jorge@gmail.com": "jvcl",
 167      "q19dcp@gmail.com": "aj-nt",
 168      "ebukau84@gmail.com": "UgwujaGeorge",
 169      "francip@gmail.com": "francip",
 170      "omni@comelse.com": "omnissiah-comelse",
 171      "oussama.redcode@gmail.com": "mavrickdeveloper",
 172      "126368201+vilkasdev@users.noreply.github.com": "vilkasdev",
 173      "137614867+cutepawss@users.noreply.github.com": "cutepawss",
 174      "96793918+memosr@users.noreply.github.com": "memosr",
 175      "mehmet.sr35@gmail.com": "memosr",
 176      "milkoor@users.noreply.github.com": "milkoor",
 177      "xuerui911@gmail.com": "Fatty911",
 178      "131039422+SHL0MS@users.noreply.github.com": "SHL0MS",
 179      "77628552+raulvidis@users.noreply.github.com": "raulvidis",
 180      "145567217+Aum08Desai@users.noreply.github.com": "Aum08Desai",
 181      "256820943+kshitij-eliza@users.noreply.github.com": "kshitij-eliza",
 182      "jiechengwu@pony.ai": "Jason2031",
 183      "44278268+shitcoinsherpa@users.noreply.github.com": "shitcoinsherpa",
 184      "104278804+Sertug17@users.noreply.github.com": "Sertug17",
 185      "112503481+caentzminger@users.noreply.github.com": "caentzminger",
 186      "258577966+voidborne-d@users.noreply.github.com": "voidborne-d",
 187      "liusway405@gmail.com": "voidborne-d",
 188      "xydarcher@uestc.edu.cn": "Readon",
 189      "sir_even@icloud.com": "sirEven",
 190      "36056348+sirEven@users.noreply.github.com": "sirEven",
 191      "70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
 192      "jezzahehn@gmail.com": "JezzaHehn",
 193      "254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
 194      "259807879+Bartok9@users.noreply.github.com": "Bartok9",
 195      "270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
 196      "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
 197      "268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
 198      "27917469+nosleepcassette@users.noreply.github.com": "nosleepcassette",
 199      "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
 200      "109555139+davetist@users.noreply.github.com": "davetist",
 201      "39405770+yyq4193@users.noreply.github.com": "yyq4193",
 202      "Asunfly@users.noreply.github.com": "Asunfly",
 203      "2500400+honghua@users.noreply.github.com": "honghua",
 204      "462836+jplew@users.noreply.github.com": "jplew",
 205      "nish3451@users.noreply.github.com": "nish3451",
 206      "Mibayy@users.noreply.github.com": "Mibayy",
 207      "mibayy@users.noreply.github.com": "Mibayy",
 208      "135070653+sgaofen@users.noreply.github.com": "sgaofen",
 209      "lzy.dev@gmail.com": "zhiyanliu",
 210      "me@janstepanovsky.cz": "hhhonzik",
 211      "139848623+hhuang91@users.noreply.github.com": "hhuang91",
 212      "s.ozaki@ebinou.net": "Satoshi-agi",
 213      "10774721+kunlabs@users.noreply.github.com": "kunlabs",
 214      "110560187+Wang-tianhao@users.noreply.github.com": "Wang-tianhao",
 215      "170458616+ghostmfr@users.noreply.github.com": "ghostmfr",
 216      "1848670+mewwts@users.noreply.github.com": "mewwts",
 217      "1930707+haru398801@users.noreply.github.com": "haru398801",
 218      "rapabelias@gmail.com": "badgerbees",
 219      "xnb888@proton.me": "xnbi",
 220      "xiahu889889@proton.me": "xiahu88988",
 221      "nocoo@users.noreply.github.com": "nocoo",
 222      "30841158+n-WN@users.noreply.github.com": "n-WN",
 223      "tsuijinglei@gmail.com": "hiddenpuppy",
 224      "buraysandro9@gmail.com": "ygd58",
 225      "jerome@clawwork.ai": "HiddenPuppy",
 226      "jerome.benoit@sap.com": "jerome-benoit",
 227      "wysie@users.noreply.github.com": "Wysie",
 228      "leoyuan0099@gmail.com": "keyuyuan",
 229      "bxzt2006@163.com": "Only-Code-A",
 230      "i@troy-y.org": "TroyMitchell911",
 231      "mygamez@163.com": "zhongyueming1121",
 232      "hansnow@users.noreply.github.com": "hansnow",
 233      "134848055+UNLINEARITY@users.noreply.github.com": "UNLINEARITY",
 234      "ben.burtenshaw@gmail.com": "burtenshaw",
 235      "roopaknijhara@gmail.com": "rnijhara",
 236      "josephzcan@gmail.com": "j0sephz",
 237      # contributors (manual mapping from git names)
 238      "ahmedsherif95@gmail.com": "asheriif",
 239      "dyxushuai@gmail.com": "dyxushuai",
 240      "33860762+etcircle@users.noreply.github.com": "etcircle",
 241      "liujinkun@bytedance.com": "liujinkun2025",
 242      "dmayhem93@gmail.com": "dmahan93",
 243      "fr@tecompanytea.com": "ifrederico",
 244      "cdanis@gmail.com": "cdanis",
 245      "samherring99@gmail.com": "samherring99",
 246      "desaiaum08@gmail.com": "Aum08Desai",
 247      "shannon.sands.1979@gmail.com": "shannonsands",
 248      "shannon@nousresearch.com": "shannonsands",
 249      "abdi.moya@gmail.com": "AxDSan",
 250      "eri@plasticlabs.ai": "Erosika",
 251      "hjcpuro@gmail.com": "hjc-puro",
 252      "xaydinoktay@gmail.com": "aydnOktay",
 253      "abdullahfarukozden@gmail.com": "Farukest",
 254      "lovre.pesut@gmail.com": "rovle",
 255      "xjtumj@gmail.com": "mengjian-github",
 256      "kevinskysunny@gmail.com": "kevinskysunny",
 257      "xiewenxuan462@gmail.com": "yule975",
 258      "yiweimeng.dlut@hotmail.com": "meng93",
 259      "hakanerten02@hotmail.com": "teyrebaz33",
 260      "linux2010@users.noreply.github.com": "Linux2010",
 261      "elmatadorgh@users.noreply.github.com": "elmatadorgh",
 262      "alexazzjjtt@163.com": "alexzhu0",
 263      "1180176+Swift42@users.noreply.github.com": "Swift42",
 264      "ruzzgarcn@gmail.com": "Ruzzgar",
 265      "yukipukikedy@gmail.com": "Yukipukii1",
 266      "alireza78.crypto@gmail.com": "alireza78a",
 267      "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson",
 268      "withapurpose37@gmail.com": "StefanIsMe",
 269      "4317663+helix4u@users.noreply.github.com": "helix4u",
 270      "ifkellx@users.noreply.github.com": "Ifkellx",
 271      "331214+counterposition@users.noreply.github.com": "counterposition",
 272      "blspear@gmail.com": "BrennerSpear",
 273      "akhater@gmail.com": "akhater",
 274      "Cos_Admin@PTG-COS.lodluvup4uaudnm3ycd14giyug.xx.internal.cloudapp.net": "akhater",
 275      "239876380+handsdiff@users.noreply.github.com": "handsdiff",
 276      "hesapacicam112@gmail.com": "etherman-os",
 277      "mark.ramsell@rivermounts.com": "mark-ramsell",
 278      "taeng02@icloud.com": "taeng0204",
 279      "gpickett00@gmail.com": "gpickett00",
 280      "mcosma@gmail.com": "wakamex",
 281      "clawdia.nash@proton.me": "clawdia-nash",
 282      "pickett.austin@gmail.com": "austinpickett",
 283      "dangtc94@gmail.com": "dieutx",
 284      "jaisehgal11299@gmail.com": "jaisup",
 285      "percydikec@gmail.com": "PercyDikec",
 286      "noonou7@gmail.com": "HenkDz",
 287      # Azure Foundry salvage (PRs #9029, #4599, #10086, #8766)
 288      "tech@smartlogics.net": "TechPrototyper",
 289      "637186+HangGlidersRule@users.noreply.github.com": "HangGlidersRule",
 290      "pein892@gmail.com": "pein892",
 291      "dean.kerr@gmail.com": "deankerr",
 292      "socrates1024@gmail.com": "socrates1024",
 293      "seanalt555@gmail.com": "Salt-555",
 294      "satelerd@gmail.com": "satelerd",
 295      "dan@danlynn.com": "danklynn",
 296      "mattmaximo@hotmail.com": "MattMaximo",
 297      "MatthewRHardwick@gmail.com": "mrhwick",
 298      "149063006+j3ffffff@users.noreply.github.com": "j3ffffff",
 299      "A-FdL-Prog@users.noreply.github.com": "A-FdL-Prog",
 300      "l0hde@users.noreply.github.com": "l0hde",
 301      "difujia@users.noreply.github.com": "difujia",
 302      "vominh1919@gmail.com": "vominh1919",
 303      "yue.gu2023@gmail.com": "YueLich",
 304      "51783311+andyylin@users.noreply.github.com": "andyylin",
 305      "me@jakubkrcmar.cz": "jakubkrcmar",
 306      "prasadus92@gmail.com": "prasadus92",
 307      "michael@make.software": "mssteuer",
 308      "der@konsi.org": "konsisumer",
 309      "abogale2@gmail.com": "amanuel2",
 310      "alexazzjjtt@163.com": "alexzhu0",
 311      "pub_forgreatagent@antgroup.com": "AntAISecurityLab",
 312      "252620095+briandevans@users.noreply.github.com": "briandevans",
 313      "danielrpike9@gmail.com": "Bartok9",
 314      "skozyuk@cruxexperts.com": "CruxExperts",
 315      "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
 316      "12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
 317      "mgparkprint@gmail.com": "vlwkaos",
 318      "1317078257maroon@gmail.com": "Oxidane-bot",
 319      "tranquil_flow@protonmail.com": "Tranquil-Flow",
 320      "LyleLengyel@gmail.com": "mcndjxlefnd",
 321      "wangshengyang2004@163.com": "Wangshengyang2004",
 322      "hasan.ali13381@gmail.com": "H-Ali13381",
 323      "xienb@proton.me": "XieNBi",
 324      "139681654+maymuneth@users.noreply.github.com": "maymuneth",
 325      "zengwei@nightq.cn": "nightq",
 326      "1434494126@qq.com": "5park1e",
 327      "158153005+5park1e@users.noreply.github.com": "5park1e",
 328      "innocarpe@gmail.com": "innocarpe",
 329      "noreply@ked.com": "qike-ms",
 330      "andrekurait@gmail.com": "AndreKurait",
 331      "bsgdigital@users.noreply.github.com": "bsgdigital",
 332      "numman.ali@gmail.com": "nummanali",
 333      "rohithsaimidigudla@gmail.com": "whitehatjr1001",
 334      "0xNyk@users.noreply.github.com": "0xNyk",
 335      "0xnykcd@googlemail.com": "0xNyk",
 336      "buraysandro9@gmail.com": "buray",
 337      "contact@jomar.fr": "joshmartinelle",
 338      "camilo@tekelala.com": "tekelala",
 339      "vincentcharlebois@gmail.com": "vincentcharlebois",
 340      "aryan@synvoid.com": "aryansingh",
 341      "johnsonblake1@gmail.com": "blakejohnson",
 342      "hcn518@gmail.com": "pedh",
 343      "haileymarshall005@gmail.com": "haileymarshall",
 344      "greer.guthrie@gmail.com": "g-guthrie",
 345      "kennyx102@gmail.com": "bobashopcashier",
 346      "77253505+bobashopcashier@users.noreply.github.com": "bobashopcashier",
 347      "25355950+megastary@users.noreply.github.com": "megastary",  # PR #18325
 348      "shokatalishaikh95@gmail.com": "areu01or00",
 349      "bryan@intertwinesys.com": "bryanyoung",
 350      "christo.mitov@gmail.com": "christomitov",
 351      "hermes@nousresearch.com": "NousResearch",
 352      "reginaldasr@gmail.com": "ReginaldasR",
 353      "ntconguit@gmail.com": "0xharryriddle",
 354      "agent@wildcat.local": "ericnicolaides",
 355      "georgex8001@gmail.com": "georgex8001",
 356      "stefan@dimagents.ai": "dimitrovi",
 357      "hermes@noushq.ai": "benbarclay",
 358      "chinmingcock@gmail.com": "ChimingLiu",
 359      "allard.quek@singtel.com": "AllardQuek",
 360      "openclaw@sparklab.ai": "openclaw",
 361      "semihcvlk53@gmail.com": "Himess",
 362      "erenkar950@gmail.com": "erenkarakus",
 363      "adavyasharma@gmail.com": "adavyas",
 364      "acaayush1111@gmail.com": "aayushchaudhary",
 365      "jason@outland.art": "jasonoutland",
 366      "73175452+Magaav@users.noreply.github.com": "Magaav",
 367      "mrflu1918@proton.me": "SPANISHFLU",
 368      "morganemoss@gmai.com": "mormio",
 369      "kopjop926@gmail.com": "cesareth",
 370      "fuleinist@gmail.com": "fuleinist",
 371      "jack.47@gmail.com": "JackTheGit",
 372      "dalvidjr2022@gmail.com": "Jr-kenny",
 373      "m@statecraft.systems": "mbierling",
 374      "balyan.sid@gmail.com": "alt-glitch",
 375      "52913345+alt-glitch@users.noreply.github.com": "alt-glitch",
 376      "oluwadareab12@gmail.com": "bennytimz",
 377      "simon@simonmarcus.org": "simon-marcus",
 378      "xowiekk@gmail.com": "Xowiek",
 379      "1243352777@qq.com": "zons-zhaozhy",
 380      "e.silacandmr@gmail.com": "Es1la",
 381      "h3057183414@gmail.com": "CoreyNoDream",
 382      "franksong2702@gmail.com": "franksong2702",
 383      "673088860@qq.com": "ambition0802",
 384      "beibei1988@proton.me": "beibi9966",
 385      # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
 386      #    crossref, and GH contributor list matching (April 2026 audit) ──
 387      "1115117931@qq.com": "aaronagent",
 388      "1506751656@qq.com": "hqhq1025",
 389      "364939526@qq.com": "luyao618",
 390      "hgk324@gmail.com": "houziershi",
 391      "176644217+PStarH@users.noreply.github.com": "PStarH",
 392      "51058514+Sanjays2402@users.noreply.github.com": "Sanjays2402",
 393      "16577466+andy825@user.noreply.gitee.com": "Andy283",
 394      "906014227@qq.com": "bingo906",
 395      "aaronwong1999@icloud.com": "AaronWong1999",
 396      "agents@kylefrench.dev": "DeployFaith",
 397      "angelos@oikos.lan.home.malaiwah.com": "angelos",
 398      "aptx4561@gmail.com": "cokemine",
 399      "arilotter@gmail.com": "ethernet8023",
 400      "ben@nousresearch.com": "benbarclay",
 401      "birdiegyal@gmail.com": "yyovil",
 402      "boschi1997@gmail.com": "nicoloboschi",
 403      "chef.ya@gmail.com": "cherifya",
 404      "chlqhdtn98@gmail.com": "BongSuCHOI",
 405      "coffeemjj@gmail.com": "Cafexss",
 406      "dalianmao0107@gmail.com": "dalianmao000",
 407      "der@konsi.org": "konsisumer",
 408      "dgrieco@redhat.com": "DomGrieco",
 409      "dhicham.pro@gmail.com": "spideystreet",
 410      "dipp.who@gmail.com": "dippwho",
 411      "don.rhm@gmail.com": "donrhmexe",
 412      "dorukardahan@hotmail.com": "dorukardahan",
 413      "dsocolobsky@gmail.com": "dsocolobsky",
 414      "dylan.socolobsky@lambdaclass.com": "dsocolobsky",
 415      "ignacio.avecilla@lambdaclass.com": "IAvecilla",
 416      "duerzy@gmail.com": "duerzy",
 417      "emozilla@nousresearch.com": "emozilla",
 418      "fancydirty@gmail.com": "fancydirty",
 419      "farion1231@gmail.com": "farion1231",
 420      "floptopbot33@gmail.com": "flobo3",
 421      "fontana.pedro93@gmail.com": "pefontana",
 422      "francis.x.fitzpatrick@gmail.com": "fxfitz",
 423      "frank@helmschrott.de": "Helmi",
 424      "gaixg94@gmail.com": "gaixianggeng",
 425      "geoff.wellman@gmail.com": "geoffwellman",
 426      "han.shan@live.cn": "jamesarch",
 427      "haolong@microsoft.com": "LongOddCode",
 428      "hata1234@gmail.com": "hata1234",
 429      "hmbown@gmail.com": "Hmbown",
 430      "iacobs@m0n5t3r.info": "m0n5t3r",
 431      "jiayuw794@gmail.com": "JiayuuWang",
 432      "jonny@nousresearch.com": "jquesnelle",
 433      "juan.ovalle@mistral.ai": "jjovalle99",
 434      "julien.talbot@ergonomia.re": "Julientalbot",
 435      "kagura.chen28@gmail.com": "kagura-agent",
 436      "1342088860@qq.com": "youngDoo",
 437      "kamil@gwozdz.me": "kamil-gwozdz",
 438      "skmishra1991@gmail.com": "bugkill3r",
 439      "karamusti912@gmail.com": "MustafaKara7",
 440      "kira@ariaki.me": "kira-ariaki",
 441      "knopki@duck.com": "knopki",
 442      "limars874@gmail.com": "limars874",
 443      "lisicheng168@gmail.com": "lesterli",
 444      "mingjwan@microsoft.com": "MagicRay1217",
 445      "orangeko@gmail.com": "GenKoKo",
 446      "82095453+iacker@users.noreply.github.com": "iacker",
 447      "sontianye@users.noreply.github.com": "sontianye",
 448      "jackjin1997@users.noreply.github.com": "jackjin1997",
 449      "1037461232@qq.com": "jackjin1997",
 450      "danieldoderlein@users.noreply.github.com": "danieldoderlein",
 451      "lrawnsley@users.noreply.github.com": "lrawnsley",
 452      "taeuk178@users.noreply.github.com": "taeuk178",
 453      "ogzerber@users.noreply.github.com": "ogzerber",
 454      "cola-runner@users.noreply.github.com": "cola-runner",
 455      "ygd58@users.noreply.github.com": "ygd58",
 456      "45554392+warabe1122@users.noreply.github.com": "warabe1122",
 457      "187001140+willy-scr@users.noreply.github.com": "willy-scr",
 458      "vominh1919@users.noreply.github.com": "vominh1919",
 459      "iamagenius00@users.noreply.github.com": "iamagenius00",
 460      "9219265+cresslank@users.noreply.github.com": "cresslank",
 461      "trevmanthony@gmail.com": "trevthefoolish",
 462      "ziliangpeng@users.noreply.github.com": "ziliangpeng",
 463      "centripetal-star@users.noreply.github.com": "centripetal-star",
 464      "LeonSGP43@users.noreply.github.com": "LeonSGP43",
 465      "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
 466      "cine.dreamer.one@gmail.com": "LeonSGP43",
 467      "Lubrsy706@users.noreply.github.com": "Lubrsy706",
 468      "niyant@spicefi.xyz": "spniyant",
 469      "olafthiele@gmail.com": "olafthiele",
 470      "oncuevtv@gmail.com": "sprmn24",
 471      "programming@olafthiele.com": "olafthiele",
 472      "r2668940489@gmail.com": "r266-tech",
 473      "s5460703@gmail.com": "BlackishGreen33",
 474      "saul.jj.wu@gmail.com": "SaulJWu",
 475      "shenhaocheng19990111@gmail.com": "hcshen0111",
 476      "sjtuwbh@gmail.com": "Cygra",
 477      "srhtsrht17@gmail.com": "Sertug17",
 478      "stephenschoettler@gmail.com": "stephenschoettler",
 479      "tanishq231003@gmail.com": "yyovil",
 480      "taosiyuan163@153.com": "taosiyuan163",
 481      "tesseracttars@gmail.com": "tesseracttars-creator",
 482      "tianliangjay@gmail.com": "xingkongliang",
 483      "1317078257maroon@gmail.com": "Oxidane-bot",
 484      "tranquil_flow@protonmail.com": "Tranquil-Flow",
 485      "LyleLengyel@gmail.com": "mcndjxlefnd",
 486      "unayung@gmail.com": "Unayung",
 487      "vorvul.danylo@gmail.com": "WorldInnovationsDepartment",
 488      "win4r@outlook.com": "win4r",
 489      "xush@xush.org": "KUSH42",
 490      "yangzhi.see@gmail.com": "SeeYangZhi",
 491      "yongtenglei@gmail.com": "yongtenglei",
 492      "young@YoungdeMacBook-Pro.local": "YoungYang963",
 493      "ysfalweshcan@gmail.com": "Junass1",
 494      "ysfwaxlycan@gmail.com": "WAXLYY",
 495      "yusufalweshdemir@gmail.com": "Dusk1e",
 496      "zhouboli@gmail.com": "zhouboli",
 497      "zqiao@microsoft.com": "tomqiaozc",
 498      "zzn+pa@zzn.im": "xinbenlv",
 499      "zaynjarvis@gmail.com": "ZaynJarvis",
 500      "zhiheng.liu@bytedance.com": "ZaynJarvis",
 501      "izhaolongfei@gmail.com": "loongfay",
 502      "296659110@qq.com": "lrt4836",
 503      "fe.daniel91@gmail.com": "beforeload",
 504      "libo1106@foxmail.com": "libo1106",
 505      "295367131@qq.com": "295367131",
 506      "295367132@qq.com": "IxAres",
 507      "danieldliu@tencent.com": "danieldliu",
 508      "loongzhao@tencent.com": "loongzhao",
 509      "Bartok9@users.noreply.github.com": "Bartok9",
 510      "LeonSGP43@users.noreply.github.com": "LeonSGP43",
 511      "kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
 512      "mbelleau@Michels-MacBook-Pro.local": "malaiwah",
 513      "michel.belleau@malaiwah.com": "malaiwah",
 514      "gnanasekaran.sekareee@gmail.com": "gnanam1990",
 515      "jz.pentest@gmail.com": "0xyg3n",
 516      "7093928+0xyg3n@users.noreply.github.com": "0xyg3n",
 517      "nftpoetrist@gmail.com": "nftpoetrist",  # PR #18982
 518      "millerc79@users.noreply.github.com": "millerc79",  # PR #19033
 519      "hermes@example.com": "shellybotmoyer",  # PR #18915 (bot-committed)
 520      "exx@example.com": "exxmen",  # PR #19555
 521      "hypnosis.mda@gmail.com": "Hypn0sis",
 522      "ywt000818@gmail.com": "OwenYWT",
 523      "dhandhalyabhavik@gmail.com": "v1k22",
 524      "rucchizhao@zhaochenfeideMacBook-Pro.local": "RucchiZ",
 525      "tannerfokkens@Mac.attlocal.net": "tannerfokkens-maker",
 526      "lehaolin98@outlook.com": "LehaoLin",
 527      "yuewang1@microsoft.com": "imink",
 528      "1736355688@qq.com": "hedgeho9X",
 529      "bernylinville@devopsthink.org": "bernylinville",
 530      "brian@bde.io": "briandevans",
 531      "hubin_ll@qq.com": "LLQWQ",
 532      "memosr_email@gmail.com": "memosr",
 533      "jperlow@gmail.com": "perlowja",
 534      "jasonpette1783@gmail.com": "web-dev0521",
 535      "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
 536      "harryplusplus@gmail.com": "harryplusplus",
 537      "anthhub@163.com": "anthhub",
 538      "allard.quek@singtel.com": "AllardQuek",
 539      "shenuu@gmail.com": "shenuu",
 540      "xiayh17@gmail.com": "xiayh0107",
 541      "zhujianxyz@gmail.com": "opriz",
 542      "asurla@nvidia.com": "anniesurla",
 543      "limkuan24@gmail.com": "WideLee",
 544      "aviralarora002@gmail.com": "AviArora02-commits",
 545      "draixagent@gmail.com": "draix",
 546      "junminliu@gmail.com": "JimLiu",
 547      "jarvischer@gmail.com": "maxchernin",
 548      "levantam.98.2324@gmail.com": "LVT382009",
 549      "zhurongcheng@rcrai.com": "heykb",
 550      "withapurpose37@gmail.com": "StefanIsMe",
 551      "261797239+lumenradley@users.noreply.github.com": "lumenradley",
 552      "166376523+sjz-ks@users.noreply.github.com": "sjz-ks",
 553      "haileymarshall005@gmail.com": "haileymarshall",
 554      "aniruddhaadak80@users.noreply.github.com": "aniruddhaadak80",
 555      "zheng.jerilyn@gmail.com": "jerilynzheng",
 556      "asslaenn5@gmail.com": "Aslaaen",
 557      "shalompmc0505@naver.com": "pinion05",
 558      "105142614+VTRiot@users.noreply.github.com": "VTRiot",
 559      "vivien000812@gmail.com": "iamagenius00",
 560      "89228157+Feranmi10@users.noreply.github.com": "Feranmi10",
 561      "oluwadareferanmi11@gmail.com": "Feranmi10",
 562      "simon@gtcl.us": "simon-gtcl",
 563      "suzukaze.haduki@gmail.com": "houko",
 564      "cliff@cigii.com": "cgarwood82",
 565      "anna@oa.ke": "anna-oake",
 566      "jaffarkeikei@gmail.com": "jaffarkeikei",
 567      "hxp@hxp.plus": "hxp-plus",
 568      "3580442280@qq.com": "Tianworld",
 569      "wujianxu91@gmail.com": "wujhsu",
 570      "zhrh120@gmail.com": "niyoh120",
 571      "vrinek@hey.com": "vrinek",
 572      "268198004+xandersbell@users.noreply.github.com": "xandersbell",
 573      "somme4096@gmail.com": "Somme4096",
 574      "brian@tiuxo.com": "brianclemens",
 575      "25944632+yudaiyan@users.noreply.github.com": "yudaiyan",
 576      "chayton@sina.com": "ycbai",
 577      "longsizhuo@gmail.com": "longsizhuo",
 578      "chenb19870707@gmail.com": "ms-alan",
 579      "276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123",
 580      "22549957+li0near@users.noreply.github.com": "li0near",
 581      "23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi",
 582      "haimu0x0@proton.me": "haimu0x",
 583      "abdelmajidnidnasser1@gmail.com": "NIDNASSER-Abdelmajid",
 584      "projectadmin@wit.id": "projectadmin-dev",
 585      "mrigankamondal10@gmail.com": "Dev-Mriganka",
 586      "132275809+shushuzn@users.noreply.github.com": "shushuzn",
 587      "ibrahimozsarac@gmail.com": "iborazzi",
 588      "130149563+A-afflatus@users.noreply.github.com": "A-afflatus",
 589      "huangkwell@163.com": "huangke19",
 590      "tanishq@exa.ai": "10ishq",
 591      "363708+christopherwoodall@users.noreply.github.com": "christopherwoodall",
 592      "zhang9w0v5@qq.com": "zhang9w0v5",
 593      "fuleinist@outlook.com": "fuleinist",
 594      "43494187+Llugaes@users.noreply.github.com": "Llugaes",
 595      "fengtianyu88@users.noreply.github.com": "fengtianyu88",
 596      "l.moncany@gmail.com": "lmoncany",
 597      "fatinghenji@users.noreply.github.com": "fatinghenji",
 598      "xin.peng.dr@gmail.com": "xinpengdr",
 599      "mike@mikewaters.net": "mikewaters",
 600      "65117428+WadydX@users.noreply.github.com": "WadydX",
 601      "216480837+isaachuangGMICLOUD@users.noreply.github.com": "isaachuangGMICLOUD",
 602      "nukuom976228@gmail.com": "hsy5571616",
 603      "11462216+Nan93@users.noreply.github.com": "Nan93",
 604      "l973401489@126.com": "zhouxiaoya12",
 605      "373119611@qq.com": "roytian1217",
 606      "brett@brettbrewer.com": "minorgod",
 607      "67779267+wenhao7@users.noreply.github.com": "wenhao7",
 608      "git@yzx9.xyz": "yzx9",
 609      "nilesh@cloudgeni.us": "lvnilesh",
 610      "63502660+azhengbot@users.noreply.github.com": "azhengbot",
 611      "sharvil.saxena@gmail.com": "sharziki",
 612      "yuanhe@minimaxi.com": "RyanLee-Dev",
 613      "curtis992250@gmail.com": "TaroballzChen",
 614      "92638503+Lind3ey@users.noreply.github.com": "Lind3ey",
 615      "1352808998@qq.com": "phpoh",
 616      "caliberoviv@gmail.com": "vivganes",
 617      "michaelfackerell@gmail.com": "MikeFac",
 618      "18024642@qq.com": "GuyCui",
 619      "eumael.mkt@gmail.com": "maelrx",
 620      # v0.11.0 additions
 621      "benbarclay@gmail.com": "benbarclay",
 622      "lijiawen@umich.edu": "Jiawen-lee",
 623      "oleksiy@kovyrin.net": "kovyrin",
 624      "kovyrin.claw@gmail.com": "kovyrin",
 625      "kaiobarb@gmail.com": "liftaris",
 626      "me@arihantsethia.com": "arihantsethia",
 627      "zhuofengwang2003@gmail.com": "coekfung",
 628      "teknium@noreply.github.com": "teknium1",
 629      "2114364329@qq.com": "cuyua9",
 630      "2557058999@qq.com": "Disaster-Terminator",
 631      "cine.dreamer.one@gmail.com": "LeonSGP43",
 632      "zyprothh@gmail.com": "Zyproth",
 633      "amitgaur@gmail.com": "amitgaur",
 634      "albuquerque.abner@gmail.com": "mrbob-git",
 635      "kiala@users.noreply.github.com": "kiala9",
 636      "alanxchen@gmail.com": "alanxchen85",
 637      "clawbot@clawbots-Mac-mini.local": "John-tip",
 638      "der@konsi.org": "konsisumer",
 639      "cirwel@The-CIRWEL-Group.local": "CIRWEL",
 640      "molvikar8@gmail.com": "molvikar",
 641      "nftpoetrist@gmail.com": "nftpoetrist",
 642      "dodofun@126.com": "colorcross",
 643      "1615063567@qq.com": "zhao0112",
 644      "ethanguo.2003@gmail.com": "EthanGuo-coder",
 645      "dev0jsh@gmail.com": "tmdgusya",
 646      "leavr@163.com": "leavrcn",
 647      "17683456+wanazhar@users.noreply.github.com": "wanazhar",
 648      "26782336+cixuuz@users.noreply.github.com": "cixuuz",
 649      "aleksandr.pasevin@openzeppelin.com": "pasevin",
 650      "ubuntu@localhost.localdomain": "holynn-q",
 651      "holynn@placeholder.local": "holynn-q",
 652      "agent@hermes.local": "jacdevos",
 653      "sunsky.lau@gmail.com": "liuhao1024",
 654      "qiuqfang98@qq.com": "keepcalmqqf",
 655      "261867348+ai-ag2026@users.noreply.github.com": "ai-ag2026",
 656      "yanzh.su@gmail.com": "YanzhongSu",
 657      "wanderwang@users.noreply.github.com": "WanderWang",
 658      "yueheime@gmail.com": "yuehei",
 659      "emidomh@gmail.com": "Emidomenge",
 660      "2642448440@qq.com": "BlackJulySnow",
 661      "4317663+helix4u@users.noreply.github.com": "helix4u",
 662      "floptopbot33@gmail.com": "flobo3",
 663      "dpaluy@users.noreply.github.com": "dpaluy",
 664      "psikonetik@gmail.com": "el-analista",
 665      "chenb19870707@gmail.com": "ms-alan",
 666      "hex-clawd@users.noreply.github.com": "hex-clawd",
 667      "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
 668      "barteq@hacknotes.local": "barteqpl",
 669      "pama0227@gmail.com": "pama0227",
 670      "52785845+ee-blog@users.noreply.github.com": "ee-blog",
 671      "simplenamebox@gmail.com": "simplenamebox-ops",
 672      "balyan.sid@gmail.com": "alt-glitch",
 673      "xdord@xdorddeMac-mini.local": "foreverxdord",
 674      "k2767567815@gmail.com": "QifengKuang",
 675      "88077783+jjjojoj@users.noreply.github.com": "jjjojoj",
 676      "valda@underscore.jp": "valda",
 677      "lling486@163.com": "M3RCUR2Y",
 678      "buraysandro9@gmail.com": "ygd58",
 679      "ideathinklab01-source@users.noreply.github.com": "ideathinklab01-source",
 680      "27987889@qq.com": "zng8418",
 681      "daniuxie88@proton.me": "DaniuXie",
 682      "panchanler@gmail.com": "ChanlerDev",
 683      "252620095+briandevans@users.noreply.github.com": "briandevans",
 684      "141889580+h0tp-ftw@users.noreply.github.com": "h0tp-ftw",
 685      "chinadbo@foxmail.com": "chinadbo",
 686      "82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
 687      "xyywtt@gmail.com": "xyiy001",
 688      "charliekerfoot@gmail.com": "CharlieKerfoot",
 689      "grey0202@users.noreply.github.com": "Grey0202",
 690      "vominh1919@gmail.com": "vominh1919",
 691      "giwavictor9@gmail.com": "giwaov",
 692      "yoimexex@gmail.com": "Yoimex",
 693      "76803960+atongrun@users.noreply.github.com": "atongrun",
 694      "michaeldanko@icloud.com": "MichaelWDanko",
 695      "xudavid429@gmail.com": "YX234",
 696      "kathy@Kathy.local": "julysir",
 697      "274902531@qq.com": "JanCong",
 698      "225304168+e-shizz@users.noreply.github.com": "e-shizz",
 699      "vincent_hh@users.noreply.github.com": "VinVC",
 700      "1243352777@qq.com": "zons-zhaozhy",
 701      "dejie.guo@gmail.com": "JayGwod",
 702      "52840391+swithek@users.noreply.github.com": "swithek",
 703      "raipratik0101@gmail.com": "PratikRai0101",
 704      "code@sasha.id": "sasha-id",
 705      "chen.yunbo@xydigit.com": "chenyunbo411",
 706      "openclaw@local": "Asce66",
 707      "59465365+0xsir0000@users.noreply.github.com": "0xsir0000",
 708      "lisanhu2014@hotmail.com": "lisanhu",
 709      "0668001438@zte.com.cn": "chenyunbo411",
 710      "leozeli@qq.com": "leozeli",
 711      "linlehao@cuhk.edu.cn": "LehaoLin",
 712      "liutong@isacas.ac.cn": "I3eg1nner",
 713      "peterberthelsen@Peters-MacBook-Air.local": "PeterBerthelsen",
 714      "root@debian.debian": "lengxii",
 715      "roque@priveperfumeshn.com": "priveperfumes",
 716      "shijianzhi@shijianzhideMacBook-Pro.local": "sjz-ks",
 717      "topcheer@me.com": "topcheer",
 718      "walli@tencent.com": "walli",
 719      "zhuofengwang@tencent.com": "Zhuofeng-Wang",
 720      "simonweng@tencent.com": "Contentment003111",
 721      # April 2026 salvage-PR batch (#14920, #14986, #14966)
 722      "mrunmayeerane17@gmail.com": "mrunmayee17",
 723      "69489633+camaragon@users.noreply.github.com": "camaragon",
 724      "shamork@outlook.com": "shamork",
 725      # April 2026 Discord Copilot /model salvage (#15030)
 726      "cshong2017@outlook.com": "Nicecsh",
 727      # no-github-match — keep as display names
 728      "clio-agent@sisyphuslabs.ai": "Sisyphus",
 729      "marco@rutimka.de": "Marco Rutsch",
 730      "paul@gamma.app": "Paul Bergeron",
 731      "zhangxicen@example.com": "zhangxicen",
 732      "codex@openai.invalid": "teknium1",
 733      "screenmachine@gmail.com": "teknium1",
 734      "chenzeshi@live.com": "chen1749144759",
 735      "mor.aleksandr@yahoo.com": "MorAlekss",
 736      "276649498+ztexydt-cqh@users.noreply.github.com": "ztexydt-cqh",
 737      "ash@users.noreply.github.com": "ash",
 738      "andrewho.sf@gmail.com": "andrewhosf",
 739      # April 2026 Honcho bug-fix consolidation (#15381)
 740      "HiddenPuppy@users.noreply.github.com": "HiddenPuppy",
 741      "code@sasha.id": "sasha-id",
 742      "dontcallmejames@users.noreply.github.com": "dontcallmejames",
 743      "hekaru.agent@gmail.com": "hekaru-agent",
 744      "jas9000@gmail.com": "twozle",
 745      "r.filgueiras@apheris.com": "rfilgueiras",
 746      "leihaibo1992@gmail.com": "Leihb",
 747      # ACP streaming fix salvage (PR #9428 + #16273)
 748      "nfb0408@163.com": "ningfangbin",
 749      "164839249+Joseph19820124@users.noreply.github.com": "Joseph19820124",
 750      "rugved@lmstudio.ai": "rugvedS07",
 751      "44333070+Heltman@users.noreply.github.com": "Heltman",
 752      # v0.12.0 additions
 753      "ching@kachingappz.com": "ching-kaching",
 754      "codezhujr@gmail.com": "Zjianru",  # salvage chain: code by codez, PR #15749 author @Zjianru
 755      "daimon@noreply.github.com": "Siddharth Balyan",  # co-author only
 756      "i@zkl2333.com": "zkl2333",
 757      "isaachuang@Isaacs-MacBook-Pro.local": "isaachuangGMICLOUD",
 758      "isaachuang@Mac.localdomain": "isaachuangGMICLOUD",  # salvage of PR #11955 → #16663
 759      "liyuan851277048@icloud.com": "Octopus",  # co-author only
 760      "me+github7604@versun.org": "Versun",  # co-author only
 761      "my.vesper.nine@gmail.com": "kevin-ho",  # salvage: PR #15488 author @kevin-ho
 762      "noreply@paperclip.ing": "Paperclip",  # co-author only
 763      "teknium@hermes-agent": "teknium1",
 764      "web3blind@gmail.com": "web3blind",
 765      "ztzheng@163.com": "chengoak",  # PR #17467
 766      "24110240104@m.fudan.edu.cn": "YuShu",  # co-author only
 767      "charliekerfoot@gmail.com": "CharlieKerfoot",  # PR #18951
 768      # Debug share upload-time redaction (May 2026)
 769      "dhuysamen@gmail.com": "GodsBoy",  # PR #19318
 770  }
 771  
 772  
 773  def git(*args, cwd=None):
 774      """Run a git command and return stdout."""
 775      result = subprocess.run(
 776          ["git"] + list(args),
 777          capture_output=True, text=True,
 778          cwd=cwd or str(REPO_ROOT),
 779      )
 780      if result.returncode != 0:
 781          print(f"git {' '.join(args)} failed: {result.stderr}", file=sys.stderr)
 782          return ""
 783      return result.stdout.strip()
 784  
 785  
 786  def git_result(*args, cwd=None):
 787      """Run a git command and return the full CompletedProcess."""
 788      return subprocess.run(
 789          ["git"] + list(args),
 790          capture_output=True,
 791          text=True,
 792          cwd=cwd or str(REPO_ROOT),
 793      )
 794  
 795  
 796  def get_last_tag():
 797      """Get the most recent CalVer tag."""
 798      tags = git("tag", "--list", "v20*", "--sort=-v:refname")
 799      if tags:
 800          return tags.split("\n")[0]
 801      return None
 802  
 803  
 804  def next_available_tag(base_tag: str) -> tuple[str, str]:
 805      """Return a tag/calver pair, suffixing same-day releases when needed."""
 806      if not git("tag", "--list", base_tag):
 807          return base_tag, base_tag.removeprefix("v")
 808  
 809      suffix = 2
 810      while git("tag", "--list", f"{base_tag}.{suffix}"):
 811          suffix += 1
 812      tag_name = f"{base_tag}.{suffix}"
 813      return tag_name, tag_name.removeprefix("v")
 814  
 815  
 816  def get_current_version():
 817      """Read current semver from __init__.py."""
 818      content = VERSION_FILE.read_text()
 819      match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
 820      return match.group(1) if match else "0.0.0"
 821  
 822  
 823  def bump_version(current: str, part: str) -> str:
 824      """Bump a semver version string."""
 825      parts = current.split(".")
 826      if len(parts) != 3:
 827          parts = ["0", "0", "0"]
 828      major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
 829  
 830      if part == "major":
 831          major += 1
 832          minor = 0
 833          patch = 0
 834      elif part == "minor":
 835          minor += 1
 836          patch = 0
 837      elif part == "patch":
 838          patch += 1
 839      else:
 840          raise ValueError(f"Unknown bump part: {part}")
 841  
 842      return f"{major}.{minor}.{patch}"
 843  
 844  
 845  def update_version_files(semver: str, calver_date: str):
 846      """Update version strings in source files."""
 847      # Update __init__.py
 848      content = VERSION_FILE.read_text()
 849      content = re.sub(
 850          r'__version__\s*=\s*"[^"]+"',
 851          f'__version__ = "{semver}"',
 852          content,
 853      )
 854      content = re.sub(
 855          r'__release_date__\s*=\s*"[^"]+"',
 856          f'__release_date__ = "{calver_date}"',
 857          content,
 858      )
 859      VERSION_FILE.write_text(content)
 860  
 861      # Update pyproject.toml
 862      pyproject = PYPROJECT_FILE.read_text()
 863      pyproject = re.sub(
 864          r'^version\s*=\s*"[^"]+"',
 865          f'version = "{semver}"',
 866          pyproject,
 867          flags=re.MULTILINE,
 868      )
 869      PYPROJECT_FILE.write_text(pyproject)
 870  
 871  
 872  def build_release_artifacts(semver: str) -> list[Path]:
 873      """Build sdist/wheel artifacts for the current release.
 874  
 875      Returns the artifact paths when the local environment has ``python -m build``
 876      available. If build tooling is missing or the build fails, returns an empty
 877      list and lets the release proceed without attached Python artifacts.
 878      """
 879      dist_dir = REPO_ROOT / "dist"
 880      shutil.rmtree(dist_dir, ignore_errors=True)
 881  
 882      result = subprocess.run(
 883          [sys.executable, "-m", "build", "--sdist", "--wheel"],
 884          cwd=str(REPO_ROOT),
 885          capture_output=True,
 886          text=True,
 887      )
 888      if result.returncode != 0:
 889          print("  ⚠ Could not build Python release artifacts.")
 890          stderr = result.stderr.strip()
 891          stdout = result.stdout.strip()
 892          if stderr:
 893              print(f"    {stderr.splitlines()[-1]}")
 894          elif stdout:
 895              print(f"    {stdout.splitlines()[-1]}")
 896          print("    Install the 'build' package to attach semver-named sdist/wheel assets.")
 897          return []
 898  
 899      artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
 900      matching = [p for p in artifacts if semver in p.name]
 901      if not matching:
 902          print("  ⚠ Built artifacts did not match the expected release version.")
 903          return []
 904      return matching
 905  
 906  
 907  def resolve_author(name: str, email: str) -> str:
 908      """Resolve a git author to a GitHub @mention."""
 909      # Try email lookup first
 910      gh_user = AUTHOR_MAP.get(email)
 911      if gh_user:
 912          return f"@{gh_user}"
 913  
 914      # Try noreply pattern
 915      noreply_match = re.match(r"(\d+)\+(.+)@users\.noreply\.github\.com", email)
 916      if noreply_match:
 917          return f"@{noreply_match.group(2)}"
 918  
 919      # Try username@users.noreply.github.com
 920      noreply_match2 = re.match(r"(.+)@users\.noreply\.github\.com", email)
 921      if noreply_match2:
 922          return f"@{noreply_match2.group(1)}"
 923  
 924      # Fallback to git name
 925      return name
 926  
 927  
 928  def categorize_commit(subject: str) -> str:
 929      """Categorize a commit by its conventional commit prefix."""
 930      subject_lower = subject.lower()
 931  
 932      # Match conventional commit patterns
 933      patterns = {
 934          "breaking": [r"^breaking[\s:(]", r"^!:", r"BREAKING CHANGE"],
 935          "features": [r"^feat[\s:(]", r"^feature[\s:(]", r"^add[\s:(]"],
 936          "fixes": [r"^fix[\s:(]", r"^bugfix[\s:(]", r"^bug[\s:(]", r"^hotfix[\s:(]"],
 937          "improvements": [r"^improve[\s:(]", r"^perf[\s:(]", r"^enhance[\s:(]",
 938                           r"^refactor[\s:(]", r"^cleanup[\s:(]", r"^clean[\s:(]",
 939                           r"^update[\s:(]", r"^optimize[\s:(]"],
 940          "docs": [r"^doc[\s:(]", r"^docs[\s:(]"],
 941          "tests": [r"^test[\s:(]", r"^tests[\s:(]"],
 942          "chore": [r"^chore[\s:(]", r"^ci[\s:(]", r"^build[\s:(]",
 943                    r"^deps[\s:(]", r"^bump[\s:(]"],
 944      }
 945  
 946      for category, regexes in patterns.items():
 947          for regex in regexes:
 948              if re.match(regex, subject_lower):
 949                  return category
 950  
 951      # Heuristic fallbacks
 952      if any(w in subject_lower for w in ["add ", "new ", "implement", "support "]):
 953          return "features"
 954      if any(w in subject_lower for w in ["fix ", "fixed ", "resolve", "patch "]):
 955          return "fixes"
 956      if any(w in subject_lower for w in ["refactor", "cleanup", "improve", "update "]):
 957          return "improvements"
 958  
 959      return "other"
 960  
 961  
 962  def clean_subject(subject: str) -> str:
 963      """Clean up a commit subject for display."""
 964      # Remove conventional commit prefix
 965      cleaned = re.sub(r"^(feat|fix|docs|chore|refactor|test|perf|ci|build|improve|add|update|cleanup|hotfix|breaking|enhance|optimize|bugfix|bug|feature|tests|deps|bump)[\s:(!]+\s*", "", subject, flags=re.IGNORECASE)
 966      # Remove trailing issue refs that are redundant with PR links
 967      cleaned = cleaned.strip()
 968      # Capitalize first letter
 969      if cleaned:
 970          cleaned = cleaned[0].upper() + cleaned[1:]
 971      return cleaned
 972  
 973  
 974  def parse_coauthors(body: str) -> list:
 975      """Extract Co-authored-by trailers from a commit message body.
 976  
 977      Returns a list of {'name': ..., 'email': ...} dicts.
 978      Filters out AI assistants and bots (Claude, Copilot, Cursor, etc.).
 979      """
 980      if not body:
 981          return []
 982      # AI/bot emails to ignore in co-author trailers
 983      _ignored_emails = {"noreply@anthropic.com", "noreply@github.com",
 984                         "cursoragent@cursor.com", "hermes@nousresearch.com"}
 985      _ignored_names = re.compile(r"^(Claude|Copilot|Cursor Agent|GitHub Actions?|dependabot|renovate)", re.IGNORECASE)
 986      pattern = re.compile(r"Co-authored-by:\s*(.+?)\s*<([^>]+)>", re.IGNORECASE)
 987      results = []
 988      for m in pattern.finditer(body):
 989          name, email = m.group(1).strip(), m.group(2).strip()
 990          if email in _ignored_emails or _ignored_names.match(name):
 991              continue
 992          results.append({"name": name, "email": email})
 993      return results
 994  
 995  
 996  def get_commits(since_tag=None):
 997      """Get commits since a tag (or all commits if None)."""
 998      if since_tag:
 999          range_spec = f"{since_tag}..HEAD"
1000      else:
1001          range_spec = "HEAD"
1002  
1003      # Format: hash|author_name|author_email|subject\0body
1004      # Using %x00 (null) as separator between subject and body
1005      log = git(
1006          "log", range_spec,
1007          "--format=%H|%an|%ae|%s%x00%b%x00",
1008          "--no-merges",
1009      )
1010  
1011      if not log:
1012          return []
1013  
1014      commits = []
1015      # Split on double-null to get each commit entry, since body ends with \0
1016      # and format ends with \0, each record ends with \0\0 between entries
1017      for entry in log.split("\0\0"):
1018          entry = entry.strip()
1019          if not entry:
1020              continue
1021          # Split on first null to separate "hash|name|email|subject" from "body"
1022          if "\0" in entry:
1023              header, body = entry.split("\0", 1)
1024              body = body.strip()
1025          else:
1026              header = entry
1027              body = ""
1028          parts = header.split("|", 3)
1029          if len(parts) != 4:
1030              continue
1031          sha, name, email, subject = parts
1032          coauthor_info = parse_coauthors(body)
1033          coauthors = [resolve_author(ca["name"], ca["email"]) for ca in coauthor_info]
1034          commits.append({
1035              "sha": sha,
1036              "short_sha": sha[:8],
1037              "author_name": name,
1038              "author_email": email,
1039              "subject": subject,
1040              "category": categorize_commit(subject),
1041              "github_author": resolve_author(name, email),
1042              "coauthors": coauthors,
1043          })
1044  
1045      return commits
1046  
1047  
1048  def get_pr_number(subject: str) -> str:
1049      """Extract PR number from commit subject if present."""
1050      match = re.search(r"#(\d+)", subject)
1051      if match:
1052          return match.group(1)
1053      return None
1054  
1055  
1056  def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/NousResearch/hermes-agent",
1057                         prev_tag=None, first_release=False):
1058      """Generate markdown changelog from categorized commits."""
1059      lines = []
1060  
1061      # Header
1062      now = datetime.now()
1063      date_str = now.strftime("%B %d, %Y")
1064      lines.append(f"# Hermes Agent v{semver} ({tag_name})")
1065      lines.append("")
1066      lines.append(f"**Release Date:** {date_str}")
1067      lines.append("")
1068  
1069      if first_release:
1070          lines.append("> 🎉 **First official release!** This marks the beginning of regular weekly releases")
1071          lines.append("> for Hermes Agent. See below for everything included in this initial release.")
1072          lines.append("")
1073  
1074      # Group commits by category
1075      categories = defaultdict(list)
1076      all_authors = set()
1077      teknium_aliases = {"@teknium1"}
1078  
1079      for commit in commits:
1080          categories[commit["category"]].append(commit)
1081          author = commit["github_author"]
1082          if author not in teknium_aliases:
1083              all_authors.add(author)
1084          for coauthor in commit.get("coauthors", []):
1085              if coauthor not in teknium_aliases:
1086                  all_authors.add(coauthor)
1087  
1088      # Category display order and emoji
1089      category_order = [
1090          ("breaking", "⚠️ Breaking Changes"),
1091          ("features", "✨ Features"),
1092          ("improvements", "🔧 Improvements"),
1093          ("fixes", "🐛 Bug Fixes"),
1094          ("docs", "📚 Documentation"),
1095          ("tests", "🧪 Tests"),
1096          ("chore", "🏗️ Infrastructure"),
1097          ("other", "📦 Other Changes"),
1098      ]
1099  
1100      for cat_key, cat_title in category_order:
1101          cat_commits = categories.get(cat_key, [])
1102          if not cat_commits:
1103              continue
1104  
1105          lines.append(f"## {cat_title}")
1106          lines.append("")
1107  
1108          for commit in cat_commits:
1109              subject = clean_subject(commit["subject"])
1110              pr_num = get_pr_number(commit["subject"])
1111              author = commit["github_author"]
1112  
1113              # Build the line
1114              parts = [f"- {subject}"]
1115              if pr_num:
1116                  parts.append(f"([#{pr_num}]({repo_url}/pull/{pr_num}))")
1117              else:
1118                  parts.append(f"([`{commit['short_sha']}`]({repo_url}/commit/{commit['sha']}))")
1119  
1120              if author not in teknium_aliases:
1121                  parts.append(f"— {author}")
1122  
1123              lines.append(" ".join(parts))
1124  
1125          lines.append("")
1126  
1127      # Contributors section
1128      if all_authors:
1129          # Sort contributors by commit count
1130          author_counts = defaultdict(int)
1131          for commit in commits:
1132              author = commit["github_author"]
1133              if author not in teknium_aliases:
1134                  author_counts[author] += 1
1135              for coauthor in commit.get("coauthors", []):
1136                  if coauthor not in teknium_aliases:
1137                      author_counts[coauthor] += 1
1138  
1139          sorted_authors = sorted(author_counts.items(), key=lambda x: -x[1])
1140  
1141          lines.append("## 👥 Contributors")
1142          lines.append("")
1143          lines.append("Thank you to everyone who contributed to this release!")
1144          lines.append("")
1145          for author, count in sorted_authors:
1146              commit_word = "commit" if count == 1 else "commits"
1147              lines.append(f"- {author} ({count} {commit_word})")
1148          lines.append("")
1149  
1150      # Full changelog link
1151      if prev_tag:
1152          lines.append(f"**Full Changelog**: [{prev_tag}...{tag_name}]({repo_url}/compare/{prev_tag}...{tag_name})")
1153      else:
1154          lines.append(f"**Full Changelog**: [{tag_name}]({repo_url}/commits/{tag_name})")
1155      lines.append("")
1156  
1157      return "\n".join(lines)
1158  
1159  
1160  def main():
1161      parser = argparse.ArgumentParser(description="Hermes Agent Release Tool")
1162      parser.add_argument("--bump", choices=["major", "minor", "patch"],
1163                          help="Which semver component to bump")
1164      parser.add_argument("--publish", action="store_true",
1165                          help="Actually create the tag and GitHub release (otherwise dry run)")
1166      parser.add_argument("--date", type=str,
1167                          help="Override CalVer date (format: YYYY.M.D)")
1168      parser.add_argument("--first-release", action="store_true",
1169                          help="Mark as first release (no previous tag expected)")
1170      parser.add_argument("--output", type=str,
1171                          help="Write changelog to file instead of stdout")
1172      args = parser.parse_args()
1173  
1174      # Determine CalVer date
1175      if args.date:
1176          calver_date = args.date
1177      else:
1178          now = datetime.now()
1179          calver_date = f"{now.year}.{now.month}.{now.day}"
1180  
1181      base_tag = f"v{calver_date}"
1182      tag_name, calver_date = next_available_tag(base_tag)
1183      if tag_name != base_tag:
1184          print(f"Note: Tag {base_tag} already exists, using {tag_name}")
1185  
1186      # Determine semver
1187      current_version = get_current_version()
1188      if args.bump:
1189          new_version = bump_version(current_version, args.bump)
1190      else:
1191          new_version = current_version
1192  
1193      # Get previous tag
1194      prev_tag = get_last_tag()
1195      if not prev_tag and not args.first_release:
1196          print("No previous tags found. Use --first-release for the initial release.")
1197          print(f"Would create tag: {tag_name}")
1198          print(f"Would set version: {new_version}")
1199  
1200      # Get commits
1201      commits = get_commits(since_tag=prev_tag)
1202      if not commits:
1203          print("No new commits since last tag.")
1204          if not args.first_release:
1205              return
1206  
1207      print(f"{'='*60}")
1208      print(f"  Hermes Agent Release Preview")
1209      print(f"{'='*60}")
1210      print(f"  CalVer tag:      {tag_name}")
1211      print(f"  SemVer:          v{current_version} → v{new_version}")
1212      print(f"  Previous tag:    {prev_tag or '(none — first release)'}")
1213      print(f"  Commits:         {len(commits)}")
1214      print(f"  Unique authors:  {len(set(c['github_author'] for c in commits))}")
1215      print(f"  Mode:            {'PUBLISH' if args.publish else 'DRY RUN'}")
1216      print(f"{'='*60}")
1217      print()
1218  
1219      # Generate changelog
1220      changelog = generate_changelog(
1221          commits, tag_name, new_version,
1222          prev_tag=prev_tag,
1223          first_release=args.first_release,
1224      )
1225  
1226      if args.output:
1227          Path(args.output).write_text(changelog)
1228          print(f"Changelog written to {args.output}")
1229      else:
1230          print(changelog)
1231  
1232      if args.publish:
1233          print(f"\n{'='*60}")
1234          print("  Publishing release...")
1235          print(f"{'='*60}")
1236  
1237          # Update version files
1238          if args.bump:
1239              update_version_files(new_version, calver_date)
1240              print(f"  ✓ Updated version files to v{new_version} ({calver_date})")
1241  
1242              # Commit version bump
1243              add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE))
1244              if add_result.returncode != 0:
1245                  print(f"  ✗ Failed to stage version files: {add_result.stderr.strip()}")
1246                  return
1247  
1248              commit_result = git_result(
1249                  "commit", "-m", f"chore: bump version to v{new_version} ({calver_date})"
1250              )
1251              if commit_result.returncode != 0:
1252                  print(f"  ✗ Failed to commit version bump: {commit_result.stderr.strip()}")
1253                  return
1254              print(f"  ✓ Committed version bump")
1255  
1256          # Create annotated tag
1257          tag_result = git_result(
1258              "tag", "-a", tag_name, "-m",
1259              f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release"
1260          )
1261          if tag_result.returncode != 0:
1262              print(f"  ✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}")
1263              return
1264          print(f"  ✓ Created tag {tag_name}")
1265  
1266          # Push
1267          push_result = git_result("push", "origin", "HEAD", "--tags")
1268          if push_result.returncode == 0:
1269              print(f"  ✓ Pushed to origin")
1270          else:
1271              print(f"  ✗ Failed to push to origin: {push_result.stderr.strip()}")
1272              print("    Continue manually after fixing access:")
1273              print("    git push origin HEAD --tags")
1274  
1275          # Build semver-named Python artifacts so downstream packagers
1276          # (e.g. Homebrew) can target them without relying on CalVer tag names.
1277          artifacts = build_release_artifacts(new_version)
1278          if artifacts:
1279              print("  ✓ Built release artifacts:")
1280              for artifact in artifacts:
1281                  print(f"    - {artifact.relative_to(REPO_ROOT)}")
1282  
1283          # Create GitHub release
1284          changelog_file = REPO_ROOT / ".release_notes.md"
1285          changelog_file.write_text(changelog)
1286  
1287          gh_cmd = [
1288              "gh", "release", "create", tag_name,
1289              "--title", f"Hermes Agent v{new_version} ({calver_date})",
1290              "--notes-file", str(changelog_file),
1291          ]
1292          gh_cmd.extend(str(path) for path in artifacts)
1293  
1294          gh_bin = shutil.which("gh")
1295          if gh_bin:
1296              result = subprocess.run(
1297                  gh_cmd,
1298                  capture_output=True, text=True,
1299                  cwd=str(REPO_ROOT),
1300              )
1301          else:
1302              result = None
1303  
1304          if result and result.returncode == 0:
1305              changelog_file.unlink(missing_ok=True)
1306              print(f"  ✓ GitHub release created: {result.stdout.strip()}")
1307              print(f"\n  🎉 Release v{new_version} ({tag_name}) published!")
1308          else:
1309              if result is None:
1310                  print("  ✗ GitHub release skipped: `gh` CLI not found.")
1311              else:
1312                  print(f"  ✗ GitHub release failed: {result.stderr.strip()}")
1313              print(f"    Release notes kept at: {changelog_file}")
1314              print(f"    Tag was created locally. Create the release manually:")
1315              print(
1316                  f"    gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' "
1317                  f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}"
1318              )
1319              print(f"\n  ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
1320      else:
1321          print(f"\n{'='*60}")
1322          print(f"  Dry run complete. To publish, add --publish")
1323          print(f"  Example: python scripts/release.py --bump minor --publish")
1324          print(f"{'='*60}")
1325  
1326  
1327  if __name__ == "__main__":
1328      main()