- Rust 77%
- JavaScript 13%
- CSS 5%
- Python 3%
- HTML 2%
- 3 more languages <1%
dev.permadeath.com #
tally is the deduplicated commit-activity service behind dev.permadeath.com. Repository: tangled.org/permadeath.com/dev.permadeath.com.
Tally answers four questions in one place:
- How much have I committed on Tangled, per identity?
- How much have my didbot agents committed there?
- How much have I committed on GitHub, including private and work repositories?
- What fraction of all Tangled commits is mine?
The site is static. A scheduled Rust worker crawls, writes JSON and HTML, and publishes them to S3 behind CloudFront. There is no public write path and no server process in the request path.
Preview #

The interactive, self-contained snapshot is docs/mockup.html;
open it in a browser to use the period selector, table view and theme toggle.
The screenshots contain real activity data and are intentionally committed.
Rebuild it from a built site with
python3 scripts/build_mockup.py dist/data/site.json. The heatmap's day panel
fetches data/days/YYYY-MM.json, so it needs the built site behind a local
HTTP server (python3 -m http.server -d dist); the mockup can't load it.
Each build also renders the link preview: og.png (by default four of the
page's charts as wordless red shapes: the 90-day calendar, the 24 h wheel,
written-to-landed and project timelines; TALLY_OG_LAYOUT=year brings back
the year's matrix) and its alt text, with the 30-day count of the owner's and
their agents' commits together. The meta tags' title and description are fixed
text. The image URL carries a fingerprint of the PNG, so a crawler that caches
by URL refetches it only when the card changed.
Prior Tangled census #
The earlier network research lives at blogposts/tangled-stats. As of 4 September 2026 its closure crawl reached 182 knots, 14,528 repositories and 17,939 accounts. It counted commits from merged-PR patches: 278,018 all-time, of which permadeath.com was 3,014 (1.1%), and 16,966 / 2,426 (14.3%) for August 2026. Its own notes say commits pushed straight to a branch are not counted and that pull-record shapes changed over the crawl.
Tally takes the branch-walk route instead: every branch is read, direct pushes
are included, and commits collapse by SHA and Change-Id. Its denominator is
therefore larger and its share is not directly comparable to the
patch-derived census. The dashboard labels both the definition and the current
index coverage.
Commit definition #
One definition applies to Tangled, GitHub and the redacted work inbox:
A logical change is a distinct
Change-Id:when the commit carries one (atgc writes these trailers), otherwise a distinct Git object ID (SHA). A commit counts once its repository's default branch reaches it (merged work;TALLY_BRANCH_SCOPE=allcounts every branch instead), and counts once regardless of how many branches, resubmits, rebases, tags, or forges report it. Exact resubmits have the same SHA and are already excluded. A rebase keeps the Change-Id, so the rewritten object collapses into the same logical change. The same SHA on Tangled and GitHub is one change. Merge commits count once. In the share, a rebase without a Change-Id counts once when its author, author time and subject match; elsewhere it remains a separate object. Tags are not counted separately. Attribution uses the Git author, and dates are author dates in UTC. The dashboard also shows the raw unique Git-object count for context.
Tangled's index is built from ref updates, so the same commit can be announced on every branch and again on every resubmit of a pull request. A smoke crawl of 17 repositories read 130,690 branch-reported commits and found 6,680 distinct commits — about 19.6x inflation. Tally walks every branch, stops when a branch joins history it has already indexed, and stores one row per SHA. The GitHub and work crawlers walk branches the same way and deduplicate by SHA; co-authored commits count for their primary author.
- A commit reachable from five branches is one row.
- A commit seen on Tangled and mirrored on GitHub is one row.
- Author dates are used, not push dates; the committer is often
Tangled <noreply@tangled.sh>with the time of the push. - The global denominator is the distinct SHA set across every public Tangled repository the worker has reached. The dashboard shows repository coverage next to the share and labels the figure preliminary until coverage is high.
- The previous self-reported figure (32.6% on 2026-09-04) is shown as context, not silently reproduced.
The share is period-scoped (30d, 90d, 1y, or all time) and is all of your Tangled activity across all repositories — your handles plus your bots — over that period. It is not a per-repository or per-handle ratio. GitHub is tracked separately and is not part of the Tangled denominator. The dashboard's data covers at least the last 365 days.
Identities #
Personal Tangled identities are configured with a stable, validated colour:
| identity | colour |
|---|---|
| permadeath.com | series-red (hard red) |
| lance.blue | series-1 (blue) |
| permadeath.games | series-3 (aqua) |
| atgc.codes | series-4 (yellow) |
| personal GitHub | series-6 (green) |
| did.bot agents | series-5 (magenta) |
| work GitHub | series-7 (violet) |
The date chart and the time wheel stack by identity in these colours, or,
by default, by project: the year's six busiest, owner and agents together,
each keeping its colour between builds while it stays in the six, plus grey
"other". A "project · identity" toggle switches both (?by=identity); the
period pickers filter days and never recolour. The redacted work account
carries no repository names. Project and recent-commit rows
carry a small T (Tangled) or GH (GitHub) source badge, and Tangled project
links use the fully qualified handle/repository form.
Local development #
cargo run -- crawl --db state.sqlite3 --out dist --owned-only --skip-github
cargo run -- serve --dir dist --bind 127.0.0.1:8787
--owned-only scans only the configured personal and bot repositories, which
is the fast feedback loop. Without it the worker enumerates the public Tangled
index and resumes the global backfill across runs.
A run is bounded by TALLY_CRAWL_BUDGET_SECS (default 3000). Repositories and
branches are committed to SQLite as they are read, so the next run resumes
exactly where this one stopped.
Two crawl schedules #
The production deployment uses two tasks and two databases:
- Scheduled global (monthly) —
crawl --global, withTALLY_STATE_KEY=state/global.sqlite3. It walks every public Tangled repository, ignores the time budget, skips GitHub and the inbox, writesTALLY_GLOBAL_SUMMARY_OUT, uploads periodic SQLite checkpoints, and does not publish the site. It can run as N shards (below). - Hourly owned —
crawl --owned-only, with the normalstate/tally.sqlite3. It scans the personal and bot repositories, ingests recent GitHub reports, readsTALLY_GLOBAL_SUMMARY, publishes the site and uploads the hourly snapshot.
The global summary is:
{"version":1,"generated_at":"2026-10-21T03:07:00Z",
"repos_reached":14528,"repos_total":14528,"partial":false,
"days":{"2026-10-20":1234}}
The global pass skips fork records, excludes commits before
TALLY_GLOBAL_IMPORTED_BEFORE and before each repo's creation date minus
TALLY_GLOBAL_REPO_AGE_GRACE_DAYS, and flags a repository whose default branch
exceeds TALLY_GLOBAL_REVIEW_REPO_COMMITS for review. A per-repo page cap is the
backstop for a log whose total is unavailable.
Which repositories count is decided by a versioned rule set (src/rules.rs),
recomputed from scratch on every run, so removing a rule or an exclude brings a
repository back. Each repository gets an evidence vector, signals in tiers:
| tier | for | against |
|---|---|---|
| manual | excludes file, fork record, description says mirror/fork/backup | |
| strong | commits by Tangled identities (DID authors); history born on Tangled | 50+ authors with under 0.5% Tangled-identity commits; stopped at the page cap |
| medium | at most 10 authors; owner keeps other repos; an original description | 10+ authors before the repo existed |
| weak | complete walk; plausible size | history from before the repo existed |
A manual signal excludes. A strong negative excludes, or holds the repository
for review when a strong positive contradicts it; a page-cap stop holds.
Otherwise a repository counts with one strong positive, two medium ones, or one
medium and no medium negative. 10+ authors before the repo existed also drops
its grace period. At the merge, a repository over 5% of any window (and at
least 1,000 changes in it) is held unless its DID is in global-reviewed.txt
(TALLY_GLOBAL_REVIEWED). Rewritten commits without a Change-Id (same author
email, author time and subject) count once, and nothing dated more than a day
after the run counts. The summary records rules (version, parameters),
rules_hash (SHA-256 of them, the excludes and the reviewed list), the verdict
tallies, held_for_review with what each held repository would add,
held_days, the evidence of the largest excluded or held repositories, and
top_repos.
crawl --global --filter-only applies the rule set and the oversized-repo log to
an existing database, rewrites the summary, and exits without walking commits.
It uses indexed commit counts, so a giant repository that was never scanned is
not visible to the audit until a normal run reaches it.
Shards #
TALLY_SHARD_INDEX of TALLY_SHARD_COUNT (or crawl --global --shard i/N)
crawls only the repositories whose DID hashes to that shard, under its own
state key beside the single-task one: state/global.sqlite3 becomes
state/global-shard-2-of-8.sqlite3. A shard with no state yet starts from the
single-task database, trimmed to its repositories, so moving to shards is not
a re-crawl. Instead of the summary, each shard publishes its counted changes
(state/global-shard-2-of-8.counts.sqlite3): one row per logical change with
its earliest day in each count. global-merge (with the same
TALLY_SHARD_COUNT, or --counts files) merges them and writes
TALLY_GLOBAL_SUMMARY_OUT. A change reachable from repositories on two
shards counts once, so the merge equals a single-task summary; adding
per-shard daily counts would not. The merged summary is as old as its oldest
shard and partial if any shard was.
A repository whose history no walk has finished (a branch left unindexed, or
commits none of which is marked as on the default branch) is due whatever
the refresh window says, so a seeded shard walks every default branch once.
A walk saves its place (the head it reads and how far into its log) after
every page, and a branch is indexed only when a walk reaches its end, the
import cutoff or known history; a walk cut short by a timeout, a failed page
or the page cap resumes from that place next run. Until then the repository
is not scanned for coverage: one stopped at the cap is a failure, the rest
count in repos_unwalked and coverage.repos_walk_incomplete.
Before writing a summary, the single task and global-merge refuse one that
looks wrong: coverage under TALLY_GLOBAL_MIN_COVERAGE_PCT, too many
repositories still to walk, or default-branch counts under
TALLY_GLOBAL_MIN_DEFAULT_RATIO of the all-branch ones. The run fails and
the published summary stays.
Discovery and coverage #
The global run's universe is Tangled search, including records from before
repositories had a DID (their knot serves them as <owner DID>/<name>; the
mirror doesn't), plus the repo records of accounts the relay lists and search
doesn't. A sharded run finds it once: global-discover writes the list to
TALLY_GLOBAL_DISCOVERY before the shards start, and each shard keeps its
own repositories from it. Knots on localhost, private addresses and
reserved names are not asked at all. A failure is tried once more after the circuit cooldown.
Coverage is what the run read over what it could read. repos_total is the
reachable repositories, repos_fresh those read without error this run (at or
after coverage.scanned_since, the run's start less the refresh window), and
repos_reached those ever read. A repository is unreachable, and outside
repos_total, when its knot is private, or when its host or the repository
itself has failed three times in a row over at least two days; anything else
that fails stays in as a failure. missing_changes_30d estimates the logical
changes of the last 30 days the unread repositories hold: each one's rate over
the 30 days before its last good read, over the days since. coverage has the
detail per shard and summed: discovered, excluded, reachable, scanned, failed,
not attempted and unreachable counts, failure and unreachable reasons, how
repositories were found, and the same estimate for unreachable ones.
A checkpoint writes the same file with partial:true. The hourly run sums
days for each selected period and hides the share when the summary is
missing, stale, or partial below TALLY_GLOBAL_MIN_COVERAGE_PCT. A partial
summary at or above that coverage is shown with its coverage beside the
number. The age cutoff is TALLY_GLOBAL_SUMMARY_MAX_AGE_DAYS (default 9,
range 1–365); use about 35 for a monthly crawl.
Configuration #
| variable | default | meaning |
|---|---|---|
TALLY_PERSONAL_DIDS |
the four identities | did|handle|series-N comma separated |
TALLY_PERSONAL_EMAILS |
empty | address=did aliases for older commits |
TALLY_DIDBOT_PDS |
https://pds.did.bot |
comma-separated didbot PDS bases |
TALLY_TANGLED_API |
https://api.tangled.org |
appview XRPC |
TALLY_TANGLED_MIRROR |
https://mirror.tangled.network |
commit/branch API |
TALLY_GLOBAL_DISCOVERY |
unset | where global-discover writes the repository list and a sharded run's shards read it (s3://… or a path); unset or older than 36 hours, a run finds the universe itself |
TALLY_TANGLED_RELAY |
https://relay1.us-east.bsky.network |
relay whose sh.tangled.repo writers the global run lists when search lacks them; empty is off |
TALLY_CONCURRENCY |
8 |
repositories scanned in parallel |
TALLY_PER_HOST_RPS |
10 |
per-host request pacing |
TALLY_CRAWL_BUDGET_SECS |
3000 |
hourly budget; --global ignores it |
TALLY_GLOBAL_REFRESH_SECS |
86400 |
how often a global repo is revisited; use 2592000 for monthly |
TALLY_OWNED_REFRESH_SECS |
3600 |
how often an owned repo is revisited |
TALLY_GLOBAL_CONCURRENCY |
4 |
global-run repository parallelism |
TALLY_GLOBAL_PER_HOST_RPS |
2 |
global-run per-host request pacing |
TALLY_GLOBAL_HTTP_ATTEMPTS |
3 |
attempts per global request |
TALLY_GLOBAL_SKIP_FORKS |
true |
exclude fork records from the global denominator |
TALLY_GLOBAL_SKIP_DESCRIBED_COPIES |
true |
also exclude repos whose own description says they are a mirror, fork or backup ([READ-ONLY] Mirror of …, fork of …) |
TALLY_BRANCH_SCOPE |
default |
which branches count: default, what each repository's default branch reaches (merged work), or all, every branch. Both are stored and summarized, so switching needs no recrawl |
TALLY_GLOBAL_IMPORTED_BEFORE |
2025-01-01 |
network-wide floor for imported history |
TALLY_GLOBAL_REPO_AGE_GRACE_DAYS |
30 |
also drop commits older than each repo's Tangled creation date minus this grace |
TALLY_GLOBAL_MAX_PAGES |
40 |
per-repo page cap; stops runaway histories |
TALLY_GLOBAL_REVIEW_REPO_COMMITS |
20000 |
flag repos above this for manual review; not auto-excluded |
TALLY_GLOBAL_EXCLUDES |
global-excludes.txt |
one repository DID per line |
TALLY_GLOBAL_REVIEWED |
global-reviewed.txt |
held repositories the owner has reviewed and released, one DID per line |
TALLY_GLOBAL_PROGRESS_SECS |
600 |
progress log interval |
TALLY_GLOBAL_PROGRESS_REPOS |
250 |
progress log interval in repos |
TALLY_GLOBAL_CHECKPOINT_SECS |
1800 |
seconds of crawling between checkpoint uploads |
TALLY_GLOBAL_CHECKPOINT_REPOS |
0 |
also checkpoint every this many repos; 0 is off |
TALLY_SHARD_INDEX |
0 |
this task's shard of the global crawl |
TALLY_SHARD_COUNT |
1 |
number of global shards; 1 is the whole crawl in one task |
TALLY_REPO_TIMEOUT_SECS |
900 |
give up on one repository after this long; 0 is no limit |
TALLY_GLOBAL_MIN_COVERAGE_PCT |
90 |
minimum coverage for a partial share, and for publishing a global summary at all |
TALLY_GLOBAL_MIN_DEFAULT_RATIO |
0.4 |
refuse to publish when a default-branch count falls below this share of the all-branch one; 0 is off |
TALLY_CIRCUIT_FAILURES |
5 |
consecutive failures before a host circuit opens |
TALLY_CIRCUIT_COOLDOWN_SECS |
60 |
host circuit cooldown |
TALLY_GLOBAL_SUMMARY_MAX_AGE_DAYS |
9 |
1–365; hide the share after this many days (2 nightly, 9 weekly, 16 fortnightly, ~35 monthly) |
TALLY_GLOBAL_SUMMARY_OUT |
unset | global output: s3://… or local path |
TALLY_GLOBAL_SUMMARY |
unset | hourly input: s3://…, https://… or local path |
TALLY_MAX_REPOS_PER_RUN |
unset | optional cap for a run |
TALLY_TIMEZONE |
unset | IANA zone for the time-of-day wheel, such as America/New_York; unset, each viewer sees their own zone. Converted in the browser per date, so daylight saving is right. The link preview's wheel reads hours in it too, in UTC when unset |
TALLY_ACTIVITY_URL |
https://vibescrobble.com |
https vibescrobble service the live "agentic activity" card polls in the browser for the first personal DID; empty turns the card off |
TALLY_ACTIVITY_TEXT |
true |
show each scrobble's text, model and repo in that card; false keeps only emoji, agent and age |
TALLY_OG_LAYOUT |
first in og::LAYOUTS |
the link preview's design by name: dashboard or year |
TALLY_WORK_INBOX |
unset | s3://…, https://… or local path for one inbox payload |
TALLY_INBOXES |
unset | comma-separated inbox payloads for multiple accounts; overrides TALLY_WORK_INBOX |
GITHUB_TOKEN |
unset | personal GitHub token |
TALLY_GITHUB_LOGIN |
token's login | personal login |
TALLY_GITHUB_OWNERS |
permadeath-games |
extra owners to enumerate |
TALLY_GITHUB_REDACT_ALL |
false |
redact every personal-account repo |
TALLY_GITHUB_REDACT_REPOS |
empty | personal repos to redact by full name |
GITHUB_WORK_TOKEN |
unset | optional direct work token; prefer the inbox |
TALLY_WORK_REDACT_ALL |
true |
redact every work repository |
TALLY_WORK_REDACT_REPOS |
empty | comma-separated work repos to redact instead |
TALLY_PUBLISH |
0 |
upload the site for hourly runs, or state + summary for global runs |
TALLY_SITE_BUCKET |
unset | public site bucket |
TALLY_STATE_BUCKET |
unset | index snapshot and inbox bucket |
TALLY_STATE_KEY |
state/tally.sqlite3 |
index snapshot key |
TALLY_S3_PREFIX |
empty | optional key prefix for the site |
GitHub #
Personal account and permadeath-games #
Create a fine-grained PAT with:
- Metadata: Read-only
- Contents: Read-only on the repositories you want counted, including the
private repositories in the
permadeath-gamesorganization. - Organization approval for
permadeath-games, if the org requires it.
The worker lists /user/repos and also enumerates every owner named in
TALLY_GITHUB_OWNERS, so permadeath-games is included even when it is not
returned by the authenticated account's repository list. Commits are filtered
to author=<login>.
Store the token in SSM SecureString and give its parameter name to the infra
stack as github_token_parameter_name.
Either account: upload a report instead of a token #
The service never needs the account tokens. Run the scraper once per account on the machine that holds that token:
# personal account; public repositories are named, private ones are hashed
GITHUB_TOKEN=... python3 scripts/github_activity.py \
--label personal --login <personal-login> \
--s3 s3://tally-state-ACCOUNT/inbox/personal-activity.json
# work account; every name is hashed, including public repositories
GITHUB_WORK_TOKEN=... \
python3 scripts/github_activity.py --label work \
--s3 s3://tally-state-ACCOUNT/inbox/work-activity.json
Private and internal repositories are hashed by default, including when
--no-redact-repos is passed. Public repository names are used unless
--redact-all is passed. A --label work report hashes every repository,
public or private, because a public repository name can still disclose what
the work account was doing. --reveal-private-repos names private
repositories on a personal report and should only be used for repositories you
are willing to publish.
The repo-hash salt is created on first use at ~/.config/tally/repo-salt
with mode 0600, or at $TALLY_SALT_FILE / --salt-file if set. No salt
argument is needed. Keep that file stable and private: changing it changes
every repository hash. --salt and WORK_REPO_SALT still override it for
one-off runs.
On the first run each collects 365 days across every branch; later runs
re-read two days of overlap and the service deduplicates by SHA/Change-Id. The
state file is per label, so the two runs do not overwrite each other. It also
records each repository's pushed_at, so unchanged repositories are skipped
without listing their branches; --full ignores that cache.
Each commit is marked with whether the repository's default branch has it, and
the newest report's mark replaces an older one. Reports from before the mark
existed count every commit; run each label once with --full so history
uploaded earlier is marked too.
--upload-url sends the payload as a plain HTTPS PUT, with no AWS CLI
involved. The bearer token is read from TALLY_UPLOAD_TOKEN, then
TALLY_UPLOAD_TOKEN_FILE, then ~/.config/tally/upload-token. That is the
hook for a stable ingest endpoint; a presigned S3 URL also works but expires
and has to be regenerated.
Point the service at both payloads:
TALLY_INBOXES=s3://tally-state-ACCOUNT/inbox/personal-activity.json,s3://tally-state-ACCOUNT/inbox/work-activity.json
The payload contains only:
{"version":1,"label":"personal","redacted":false,
"generated_at":"…","commits":[
{"sha":"<40 or 64 hex>","authored_at":"2026-10-19T15:00:00Z",
"repo":"owner/public-repo","redacted":false,"change_id":"I…"},
{"sha":"<40 or 64 hex>","authored_at":"2026-10-19T16:00:00Z",
"repo":"h_ab12…","redacted":true,"change_id":""}
]}
Each commit carries its own redacted flag, so one payload can mix named
public repositories with hashed private ones. The top-level redacted is true
only when every repository is hashed; older payloads may omit the per-commit
flag and fall back to it. For --label work, repo is normally a salted
h_ab12… hash. There is no field for a login, commit message, URL, author
name or email. change_id is only the Gerrit Change-Id: trailer value, so
rebases collapse to one logical change just like atgc commits. The service
reads only the fields above and ignores extras.
The work machine's credential needs one permission:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": ["arn:aws:s3:::tally-state-ACCOUNT/inbox/work-activity.json"]
}]
}
The infrastructure repo owns the state bucket and the write-only policy. Point
the scraper at the s3://…/inbox/work-activity.json URI it provides.
A direct GITHUB_WORK_TOKEN is also supported, but it requires the service to
hold the token and see repository names. Prefer the inbox.
Deployment #
This repository is the application only. AWS infrastructure lives in the
private platform Terraform repo (envs/devinfra/site*.tf and modules/site).
See docs/GLOBAL-RERUN.md for the rebuild and global
re-run contract.
The app side of the contract is:
- build the image from
Dockerfileand push it to the platform ECR repo - run the nightly task as
crawl --global, with its ownTALLY_STATE_KEY=state/global.sqlite3,TALLY_GLOBAL_SUMMARY_OUT, no time budget, and no GitHub/inbox configuration - run the hourly task as
crawl --owned-only, withTALLY_STATE_KEYpointing at the hourly snapshot andTALLY_GLOBAL_SUMMARYpointing at the nightly summary - give the task roles the S3 state/site permissions, SSM token access and networking described in the infra repo
The worker is a one-shot Fargate task. CloudFront serves the static site; there is no load balancer or always-on service.
Tests #
cargo test
cargo check --features aws
cargo fmt --check
cargo clippy --all-targets --features aws -- -D warnings
The unit tests cover the de-duplication invariant (same SHA on many branches and in two repositories), identity upgrades without duplication, the Git SHA validator, repository-hash sanitising, the palette's stable bot colours, the S3 prefix/object-key rules and the global-summary period maths.
Limits #
- Private Tangled repositories are not part of the public index and are not counted.
- The global share is only meaningful once the repository coverage shown next to it is high. Until then the site labels it preliminary.
- A work repository is only as hidden as its hash and the local salt. The hash prevents the public site from naming it; it is not encryption.
- GitHub commits are filtered to the authenticated login. Co-authored commits are counted under their primary author.