Before I wrote "Claude Code ships five or six versions a week" into my update notes, I decided to count once. A number written from feel has a way of quietly bending later decisions.
As an indie developer who uses Claude Code between app releases, I treat its update rhythm as a planning number. So I wrote a small script that counts the publish history. All it needs is python3 and a network path to the npm registry. Below are the steps in order, and the two traps I hit when I actually ran it.
Today's numbers first
The thing I most want to pass on: the median and the maximum gap describe the cadence better than an average does. This is what the script printed at midday today (a run at 03:xx UTC).
| ISO week | Releases |
|---|---|
| 2026-W39 | 4 |
| 2026-W40 | 6 |
| 2026-W41 | 7 |
| 2026-W42 | 5 |
| 2026-W43 | 7 |
| 2026-W44 (as of Tuesday) | 1 |
Across the last 60 gaps, the median was 23.2 hours and the maximum was 103.4 hours. In plain terms: about one release a day, and even the longest pause was a little over four days. My "five or six a week" guess sits inside the 5 to 7 range of the complete weeks W40 through W43.
Step 1: pull the publish times from the registry
The npm registry returns JSON when you request the package path. Its time key holds one timestamp per version.
def fetch(pkg):
url = f"https://registry.npmjs.org/{pkg}"
with urllib.request.urlopen(url, timeout=30) as r:
return json.load(r)Scoped names such as @anthropic-ai/claude-code work as-is in the URL. For a package with over 500 versions the document is large — 1,564,015 bytes in today's fetch — so I would not poll it every minute.
Step 2: drop what you do not want to count
time mixes two non-version keys, created and modified, in with the versions. Prereleases such as 2.2.0-beta.1 appear too. I filter out both, then sort by timestamp.
def stable_releases(doc):
out = []
for ver, stamp in doc["time"].items():
if ver in ("created", "modified") or "-" in ver:
continue
out.append((ver, datetime.fromisoformat(stamp.replace("Z", "+00:00"))))
return sorted(out, key=lambda x: x[1])The word stable in that name means "no prereleases", not the stable channel. It is confusing, so rename it when you reuse the code. For counting how far the stable channel itself lags, I wrote a separate piece on the stable channel gap.
Step 3: count by week, take the median gap
I bucket by ISO week (%G-W%V). Mixing %Y with %W can misplace days around New Year.
per_week = Counter(t.strftime("%G-W%V") for _, t in rel)
gaps = [(b[1] - a[1]).total_seconds() / 3600 for a, b in zip(rel, rel[1:])][-RECENT:]
print(f"median {statistics.median(gaps):.1f} h / max {max(gaps):.1f} h")zip(rel, rel[1:]) pairs each release with the next one, and the last RECENT gaps are kept. The median beats the mean here because one long pause over a weekend drags a mean upward.
The whole script
Here is everything in 37 lines. Run python3 cadence.py for the default package, or add arguments, as in python3 cadence.py lodash 20.
#!/usr/bin/env python3
"""Count weekly publishes and the median/max gap between publishes from npm history."""
import json, sys, statistics, urllib.request
from collections import Counter
from datetime import datetime, timedelta, timezone
PKG = sys.argv[1] if len(sys.argv) > 1 else "@anthropic-ai/claude-code"
RECENT = int(sys.argv[2]) if len(sys.argv) > 2 else 60
def fetch(pkg):
url = f"https://registry.npmjs.org/{pkg}"
with urllib.request.urlopen(url, timeout=30) as r:
return json.load(r)
def stable_releases(doc):
out = []
for ver, stamp in doc["time"].items():
if ver in ("created", "modified") or "-" in ver: # skip prereleases (x.y.z-beta)
continue
out.append((ver, datetime.fromisoformat(stamp.replace("Z", "+00:00"))))
return sorted(out, key=lambda x: x[1])
def main():
rel = stable_releases(fetch(PKG))
per_week = Counter(t.strftime("%G-W%V") for _, t in rel)
print(f"package={PKG} total={len(rel)}")
now = datetime.now(timezone.utc)
for back in range(5, -1, -1): # walk back week by week so empty weeks are not dropped
week = (now - timedelta(weeks=back)).strftime("%G-W%V")
print(f" {week}: {per_week.get(week, 0)} releases")
gaps = [(b[1] - a[1]).total_seconds() / 3600 for a, b in zip(rel, rel[1:])][-RECENT:]
print(f"last {len(gaps)} gaps: median {statistics.median(gaps):.1f} h / max {max(gaps):.1f} h")
idle = (now - rel[-1][1]).total_seconds() / 3600
print(f"latest {rel[-1][0]} was published {idle:.1f} h ago")
if __name__ == "__main__":
main()Two traps I hit while running it
Empty weeks vanish silently
My first version printed the last six keys of the Counter. It looked fine for Claude Code, which publishes every week. Then I tried lodash, and weeks from 2019 and 2020 showed up as "recent". A week with zero releases has no key in a Counter, so it disappears the moment you list the keys.
The fix was to walk back from today one week at a time and read each count with per_week.get(week, 0). With that version, lodash prints six weeks of zeros, and you can also see that its last publish (4.18.1) was about 5,000 hours ago.
A Tuesday run makes this week look like one release
The second trap is about reading the output. W44 shows 1 not because publishing slowed, but because the week was barely a day old. The latest publish was in the evening of October 26 (UTC), 7.4 hours before the run.
My rule now: compare only complete weeks (W39 to W43) and treat the week of the run separately. Since adding that rule, I no longer jump to "this week is slow."
One thing to try next
Save cadence.py and run it once with no arguments. Checking whether the median matches your sense that "a new version appears daily" is enough to set a sensible rhythm for following updates.
For my own indie-development tools, I plan to decide between updating right after a release or waiting a few days by this median rather than by feel. Once you have counted, you have a reason for the number of days you wait.