SMAD PickleBot · Jan 14, 2026 – Oct 3, 2026 · Build b22df13 · 22:35 PT

Picklebot Release Dashboard

Picklebot was built to manage my now 60-strong group of picklers playing at the Caltech Athenaeum — I wrote about it on Substack. This Release Dashboard contains a lot of CI/CD and DORA metrics, and has the Commit Log at the end. This page republishes automatically on every push to main.

Co-authored with Claude Code: Sonnet 4.5 (18) → Opus 4.5 (171) → Opus 4.6 (266) → Sonnet 4.6 (88) → Fable 5 (36) → Opus 5 (301) → Claude (44) → Opus 5 (1M context) (15) → Fable 5.1 (149) → Opus 5.5 (1M context) (167) → Opus 5.5 (40) — 1295 of 1384 commits (94%).

Velocity
140k
lines · 424 tracked files
1384
commits · 145 active days
102
deploys · last 7d
105s
lead time, commit to live · median, p90 28m · 105 commits (last 7d)
Pipeline
85s
deploy time mean · last 7d, 250 runs, p90 100s
87s
tests workflow p90 · last 7d, 94 runs
7,471
tests · 172 suites (+1 local-only), all passing · 49s
98.5%
statement coverage · +75.4 pts since 2026-09-12
Reliability
1.8%
change failure rate · 4 of 225 deploys (last 30d)
9%
fix commits · 131 of 1384
36.1h
mean time to restore · 5 outages (last 30d)
23%
found by monitoring · 3 of 13 outages (all time) · 38m mean to detect, 5 timed
Production & cost
11.1s
/pb reply p90 · last 7d, 208 commands, 4 failed
0
WhatsApp sends failed · of 248 sent, last 7d
0
pages (ERROR lines) · last 7d, 3 alert issues opened
617m
Actions minutes this month of 2,000 · account

Production Outages

Every outage since logging began, with time to restore (TTR). Recorded in ops/incidents.json in the same commit that fixes the break, so the log cannot drift from the repair. Upstream outages are listed but excluded from change failure rate. The newest 3 are shown; the button below the table lists the rest.

DateServiceImpactTTRCause
092926sync-members (Sync Group Members workflow, smad-whatsapp.py sync-members)New group members got no Player-sheet row and no welcome DM: ND from 9/22, John Stowell from 9/27, cunningham dan from 9/28; their votes went untracked and they got no reminders post-mortem10072 minself-inflicted
092726GREEN-API token (whatsapp-message-sender, smad-picklebot, smad-whatsapp-webhook)Every WhatsApp send and GREEN-API call would have failed for 4 minutes; none was attempted in the window, so no player saw it post-mortem4 minoperational
092026Daily Reminder Runner (GitHub Actions)The 8 AM run never started: no Sunday games poll for the week, no Last Call scheduling, no Games This Week report, no vote/payment/survey reminders, no Venmo sync, no court cache refresh and not the every-other-day Gmail watch renewal (9/20 is an even day; the watch renewed on 9/18 ran to 9/25, so nothing was at risk that day) post-mortem595 minself-inflicted
091126smad-picklebot (Cloud Run)/pb match answered 'No player found matching' for every name on two commands sent after a 26-minute lull; both matches were lost until backfilled by hand (Win Loss Log 20260911-7pm-9 and -10)46 minself-inflicted
090426Daily Reminder Runner (GitHub Actions)The 8 AM run never started: no game day reminder, no vote/payment/survey reminders, no Venmo sync and no court cache refresh that morning (a Thursday; not a poll day)118 minself-inflicted
090126greenapi instance (all WhatsApp in and out)Every /pb command and every outgoing WhatsApp message stopped. Gene found it when he tried to record a match at tonight's game.364 minupstream
083026smad-picklebot/pb shyam from a DM returned HTTP 500 and replied nothing. The command was unusable for its only two intended DM callers, Shyam and the adminsnot measuredself-inflicted
083026venmo-sync-triggerOne Venmo/Zelle sync run failed outright — no payment was pending, so nothing was lost9 minupstream
082726court-bookingEvery court WhatsApp notification with no explicit reply_chat_id was dropped without trace - nightly booking results, cancellation results, reservation listings. Email notifications were unaffected, which is why nobody noticed3994 minself-inflicted
082726whatsapp-message-senderWhatsApp message delivery stopped — the function crash-looped at import11 minself-inflicted
082526GREEN-API instanceBot unreachable — WhatsApp session dropped to notAuthorized10 minoperational
082426whatsapp-message-senderWhatsApp message delivery stopped — every Firestore call failed15 minself-inflicted
082126cancel-game workflow (smad-whatsapp.py cancel-game)Every player who had voted yes for a game that was then cancelled got no DM and no email: 8/22, 8/28, 8/30 and 9/8 cancellations. The summary sent to the requester said 'nobody voted yes'26012 minself-inflicted

10 older outages folded

092926 · sync-members (Sync Group Members workflow, smad-whatsapp.py sync-members)

Post-mortem read the full post-mortem

When 09/22/26 12:20:40 → 09/29/26 12:13:06 PT

How it was timed measured from real timestamps

Cause deploy

Detected by Gene, on 9/29: new players told him they had no welcome DM. Every failing run exited 0 (success), so the workflow watchdog never alerted: a monitoring gap, closed by a9873cf (a refused member now fails the run)

Introduced by Join Date, Referrer and Join Source on the Player sheet, backfilled from history and stamped by sync-members

Fixed by New members get their row and welcome DM again: a player row and its range come from one column map, and a failed insert fails the sync

9428645 (9/9 21:24 PT) widened sync-members' new-member row to every static column (Join Date, Referrer, Join Source added after L) but left its write range ending at W/L; Sheets refuses a row wider than its range ('tried writing to column [W]'), so every insert failed. The defect was live from 9/9; nobody joined until 9/22, so `started` is the first refused member, from the run log. 14 runs refused 20 attempts, all green; each inserted a row before its write was refused; Snow White found 18 nameless rows, each with the formulas of the row above (not 20, unexplained), and deleted them with Gene's approval (66f2c31). The insert had no test. Fix: row and range from one column map (ColumnMapper.static_row/static_range), a refused member fails the run, a refused write deletes its row, tests/test-sync-members.py with a fake that enforces the range rule. Restored by Snow White's manual sync-members --execute on the fixed code at Gene's request: the three welcome DMs went out 12:13:04-06 PT (GREEN-API outgoing log). Financial impact none: only Andy Tien voted (can't play), nobody played. Follow-ups by Snow White: names from Google Contacts (8f7dfae), a shared phone number fails the run (66f2c31), a post-run integrity check (8b92ced).

092726 · GREEN-API token (whatsapp-message-sender, smad-picklebot, smad-whatsapp-webhook)

Post-mortem read the full post-mortem

When 09/27/26 10:40:04 → 10:43:41 PT

How it was timed measured from real timestamps

Cause operational

Detected by Snow White's own getStateInstance check (401 on three methods) after the third rotation attempt, a few minutes before the first logged failure; its time was not recorded. The webhook's instance-state poll logged the 401 at 10:40:04 PT as a WARNING (debounced by design: one failed read is not an outage)

Fixed by rotate-greenapi-token.ps1 -Paste: start it, then copy the console's new token, so copying a command cannot overwrite the token on the clipboard

A planned token rotation (the old token's first half had leaked into a test fixture and the whole token into a session transcript). GREEN-API switches to a console-regenerated token minutes after showing it; scripts/rotate-greenapi-token.ps1 checked the new token at once, took the 401 as a bad copy and stopped with nothing written, and the old token then died. The session misread the 401 as 'some other key' and shipped an updateApiToken path (4a1e8ea) that also got 401. Restored by the -Paste run (df583ba) at 10:41:41 PT; first confirmed send 10:43:41 PT. `started` is the first LOGGED failure; the session saw 401s earlier, unrecorded. Action items in the post-mortem: the check now waits out a 401 for 3 minutes, and the console-paste flow is the default.

092026 · Daily Reminder Runner (GitHub Actions)

Post-mortem read the full post-mortem

When 09/20/26 08:00:04 → 17:55:10 PT

How it was timed measured from real timestamps

Cause config

Detected by The Workflow Watchdog's hourly sweep (cron :41, which GitHub ran 47 minutes late at 9:28 AM PT) filed issue #69 at 16:28Z from the run's startup_failure conclusion; detection 88 minutes after the failure. The Alert Investigator triaged it at 10:14 AM PT with the one-line fix and the dispatch needed. Nothing woke a session that could act: the desktop was off (Sunday morning), the cloud session cannot dispatch and workflows are the desktop's by rule, so repair waited 8 hours for a human handover

Introduced by Actions minutes: retire GREEN-API Watch, gate close-on-green on an OPEN_ALERTS variable

Fixed by The 8am runner starts again: a caller must grant what the workflow it calls asks for

A called workflow is bounded by the job that calls it, and GitHub refuses to start the run rather than degrading it. 720b9df (2026-09-19 22:03 PT) added actions:write to fifteen workflow files for the OPEN_ALERTS gate, including release-notes.yml, but not to daily-reminder-runner.yml's release-digest job -- the repo's only `uses: ./.github/workflows/` edge. THE SAME FILE FAILED THE SAME WAY ON 2026-09-04 (issues:write that time), and the comment documenting that outage sits six lines above the block that was wrong again; that earlier outage has no entry in this file, so the MTTR and change-failure tiles do not count it. restored is null on purpose: the fix is pushed but the runner is dispatched by hand or at the next 8 AM, and restoration is the first successful run, not the push. It is filled in from that run rather than estimated. RESTORED by the hand dispatch of run 35549088614 at 5:53 PM PT (Gene chose reminder_type=all): poll created 5:54 PM PT, every step green, release-digest started. Ten hours late. Why the refactor shipped without its caller is written up in session-notes.md, 2026-09-20 PT (Snow White).

091126 · smad-picklebot (Cloud Run)

When 09/11/26 21:28:10 → 22:14:27 PT

How it was timed measured from real timestamps

Cause deploy

Detected by Gene, reading 'No player found matching gene' about himself. No monitor: the ERROR line reached Cloud Logging as plain stdout text with no severity, so neither a severity filter nor the hourly Error Reporting sweep could see it

Introduced by Build the Sheets service once per thread: one /pb match built it ten times and OOM-killed picklebot at 992 MiB (#50)

Fixed by 39d1f86 (idle reset of the cached service's connection pool, honest reply); the reply gained the exception text in the commit adding this restored time

596ba70 (2026-09-09 PT, the #50 memory fix) caches the built Sheets service per thread. The Resource carries httplib2's keep-alive sockets in Http.connections; the far end closes an idle one after twenty-odd minutes, and httplib2 0.32.0 re-raises the resulting socket.error from the request phase without closing the connection, so the dead socket is tried again on every later call. Both 9:28 PM commands failed in under 3 ms with 'EOF occurred in violation of protocol'. get_full_player_data() swallowed it and returned [], and an empty roster fed to the match parser reads back as every name unknown -- a confident wrong diagnosis, sent to the scorekeeper. Gaps of 14-22 minutes earlier in the evening survived; 26 did not. Fix: a hand-out after 60 s idle closes and clears the pool (one TLS handshake per burst, no rebuild), and handle_record_match() refuses an empty roster with a message that says the sheet could not be read and nothing was written.

090426 · Daily Reminder Runner (GitHub Actions)

When 09/04/26 08:00:04 → 09:58:28 PT

How it was timed measured from real timestamps

Cause config

Detected by Noticed by hand: the 8 AM reminders did not arrive and the run was found as startup_failure in the Actions list, then fixed and re-dispatched within two hours. No monitor existed for it then (a startup_failure has zero jobs, so the watchdog had nothing to read, and no failure issue was filed)

Introduced by Send the whole day's list, to three chats, at 8am from Cloud Scheduler

Fixed by Let the 8am runner start again: grant the digest job the scopes it calls for

daily-reminder-runner.yml's release-digest job called release-notes.yml with no permissions block of its own, while the callee's job asked for issues: write; a called workflow is bounded by its caller's job, so GitHub refused to start the run (run 33886955002, startup_failure at 15:00:04Z). Introduced by bf14474 (2026-09-03, 'Send the whole day's list, to three chats, at 8am from Cloud Scheduler'), which added the release-digest call with no permissions block; fixed by 85a51ec (2026-09-04 9:56 AM PT, 'Let the 8am runner start again: grant the digest job the scopes it calls for'); restored by run 33898063769, a hand dispatch on the fixed caller at 16:58:28Z. Recorded 2026-09-20 PT, sixteen days late, when the same file failed the same way; timestamps are the runs API's. The comment written into the workflow that day described the failure exactly and did not prevent its repeat -- the class is now caught by scripts/check-workflow-reporting.py check 3 (c32a3e6).

090126 · greenapi instance (all WhatsApp in and out)

When 09/01/26 18:01:10 → 09/02/26 00:05:20 PT

How it was timed measured from real timestamps

Cause external

Detected by a human noticed - Gene, 1h41m in, when a /pb command did not answer. Nothing alerted; see the detection note below.

WHATSAPP applied the restriction, not GREEN-API and not a device problem. Confirmed from the vendor's own docs rather than inferred from the word: the suspended state 'indicates that WhatsApp has applied temporary restrictions to your account', is 'the initial stage of a WhatsApp account suspension', and replaced the deprecated yellowCard status. getWaSettings returned suspendedUntil = 1788332470 = 2026-09-02 00:01:10 PDT, exactly six hours after the start. restored is NOT that value: the instance was still suspended at 00:03:22 and authorized at 00:05:20, which is the observed figure recorded here. The vendor's own expiry ran four minutes optimistic, so a restored time taken from it would have been a guess that happened to look precise -- the exact failure this file's rules forbid. A watcher polling every 20s supplied the real one. Gene re-linked at 23:18:13 PDT and it authorized, then suspended again 47 seconds later at 23:19:00. Re-linking inside the window does not hold, and knowing that is the difference between waiting 34 minutes and repeatedly rescanning a QR. LIKELY TRIGGER, ours: release-notes.yml sends one WhatsApp message per push to main, calling GREEN-API directly from the runner via shared.greenapi.send_message, which bypasses whatsapp-message-sender and therefore its dedup, its queue and its logs -- the sender logged 6 messages in three days while the real figure was far higher. Successful release-note runs: 30 on 08-31, 57 on 09-01, 13 on 09-02 (UTC), 10 in the two hours before the suspension, overwhelmingly one per session-note reply from a single session. WhatsApp does not state a reason, so this is the leading candidate and not a confirmed cause. cause is external because a vendor restriction is what broke it; self_inflicted stays false because change failure rate is measured against deploys and no deploy caused this -- but the sending rate was ours, and commit rule 7's push-batching advice was written about republishing the dashboard, not about this larger cost. DETECTION: the webhook logged [INSTANCE STATE] notAuthorized as an ERROR immediately and nothing consumed it. error-reporting.yml swept two minutes later and correctly said nothing -- it reads GCP Error Reporting GROUPS, built from exceptions, and a logger.error() line never becomes one. Closed by .github/workflows/greenapi-watch.yml, which polls getWaSettings every 15 minutes, reports stateInstance and suspendedUntil, and opens an issue on anything that is not authorized. Verified against this live outage: it opened issue #39.

083026 · smad-picklebot

When 08/30/26 15:38:00 PT, restore time not recorded

How it was timed measured from real timestamps

Cause deploy

Detected by Shyam ran the command during a live test with Gene and got silence

Introduced by feat: derive session fees from SESSION_HOURS; let admins DM /pb shyam

_is_admin_dinkers_member() read GREENAPI_INSTANCE_ID and GREENAPI_API_TOKEN as module globals. picklebot/main.py imports ADMIN_GROUP_ID at module level but those two only inside other functions, so the reference raised NameError, process_command() 500'd and the webhook logged "Picklebot returned 500" without replying. NOTHING WAS CHARGED: the crash is in the permission check, which runs before handle_shyam_court(), so no credit was posted and no cancellation attempted - the North court was still booked afterwards, as Gene confirmed. WHY TESTS MISSED IT: the test set main.GREENAPI_INSTANCE_ID, which CREATED a module attribute production does not have, so it manufactured the exact thing that was missing. Patching a name onto the module under test instead of where the code really reads it turns a NameError into a pass. The test now patches shared.config and asserts the function references no undefined module global. WHY THE SMOKE TEST MISSED IT: it proves the container starts and serves; this raised on a lazily-reached code path inside one handler, which no startup check can reach.

083026 · venmo-sync-trigger

When 08/29/26 21:15:37 → 21:24:40 PT

How it was timed measured from real timestamps

Cause external

Detected by Gene forwarded the GCP alert email ~10h later. Every automated layer missed it — see notes.

Google Sheets API returned HTTP 503 'The service is currently unavailable' at shared/venmo_sync.py:189 during a cold start (instance began 21:15:34, failed 21:15:37 PDT). Vendor-side and genuinely external: no deploy, no code of ours misbehaved. Restored is the alert's own auto-recovery at 21:24:40; the condition is 'ERROR count > 0', so it cleared by itself once no further errors logged. Nobody resolved it. IMPACT WAS NIL, BY LUCK: the last Venmo payment was 08/29 00:33 PDT and was acknowledged at 00:33; nothing arrived at 21:15, so the sync had nothing to record. Had a payment been pending it would have been lost silently, because venmo-trigger swallows exceptions on purpose (0fda64c, to stop a Pub/Sub retry storm) while its comment promised 'the daily scheduled sync will catch anything missed' — a sync that did not exist. THE REAL FAILURE WAS DETECTION. Three layers should have surfaced this and none did: (1) error-reporting.yml judges freshness against a fixed 70-minute window commented 'slightly wider than the hourly cadence' — the cadence is not hourly, GitHub ran that cron 7 times in 28 hours with gaps to 6.5h, and the 05:29Z sweep saw this error at ~70 minutes and classified it stale, going green having filed nothing; (2) diagnose-logs.yml queried three of the four deployed functions, omitting venmo-sync-trigger, so investigating it would have returned an empty report reading as 'no errors'; (3) no agent session has any route to a GCP alert email. FIXED IN THIS SESSION: freshness window now measured from the previous run that actually happened (7f99641); diagnostic queries all four functions and reports the window it used (980bb2d, 2e0b4a0); execute(num_retries=3) on all 11 Sheets calls in venmo_sync and zelle_sync so a transient 5xx is absorbed in-process; payment-sweep.yml re-runs both syncs daily before the reminder job and on demand, making the promised safety net real.

082726 · court-booking

When 08/27/26 23:58:00 → 08/30/26 18:31:47 PT

How it was timed measured from real timestamps

Cause deploy

Detected by Shyam reported a missing cancellation confirmation while testing /pb shyam. Not by any monitor - a green workflow and a swallowed branch look identical from outside

Introduced by Finish the timezone consolidation properly

Fixed by fix: load .env before any import that reaches shared config

a32da25 ('Finish the timezone consolidation properly') added `from webhook.shared.config import PST` to email_service.py. court-booking.py had imported email_service three lines BEFORE load_dotenv() since January, which was harmless until email_service began reaching config. config.py resolves constants at module level, so ADMIN_GROUP_ID froze to '' before .env was read, and Python does not re-execute a module on re-import - the later `from webhook.shared.config import ADMIN_GROUP_ID` returned the cached ''. `chat_id = reply_chat_id or ADMIN_GROUP_ID` was then falsy and `if chat_id:` skipped the send WITHOUT LOGGING ANYTHING. SAME COMMIT SERIES AS THE 8/27 pytz OUTAGE: consolidating PST into shared config added a shared-config import to two places that were not ready for one. That is the pattern, not the individual bug. STARTED is the first court-booking run after a32da25 (the nightly booking at 11:58 PM PT on 08/27; a32da25 landed 12:51 PM PT the same day), not the commit time. The GitHub API reports that run as 06:58Z, which is 11:58 PM PT -- it was first recorded here as '06:58 PT', a 7-hour error across a day boundary - the script runs in CI, so the bug took effect on the next run. RESTORED is verified at every hop, not assumed. PUBLISH: the read-only probe at 6:31 PM PT on 08/30 logged 'Reservations sent to WhatsApp via Pub/Sub' where the identical probe 10 minutes earlier logged nothing. CONSUME: whatsapp-message-sender logged 'Processing message ... ' and 'Message sent to' for that window. DELIVER: Snow White queried GREEN-API directly and confirmed the message is in Admin Dinkers. That last hop matters because a green publish into a crash-looping consumer is exactly the 2026-08-27 pytz incident -- a publish is not a delivery, and until someone looked at the group this was still an inference. BLAST RADIUS measured, not estimated: 25 hours of whatsapp-message-sender logs show 5 messages, all from picklebot, none from any court-* source - including across the 11:58 PM PT nightly run on 08/29. WHY NOTHING CAUGHT IT: the deploy gate checks packages, not config; the smoke test proves containers start, and this is a GitHub Actions script with no container; and the failing branch wrote no log line at all, so even a log-based alert had nothing to fire on.

082726 · whatsapp-message-sender

When 08/27/26 10:27:49 → 10:38:48 PT

How it was timed measured from real timestamps

Cause deploy

Detected by GCP Error Reporting email, relayed by Gene

Introduced by Date the reminder email subjects, and collapse the duplicates behind them

Fixed by Fix: whatsapp-message-sender missing pytz, which broke message delivery

Consolidating the PST constant put `import pytz` into shared/config.py. All four Cloud Functions import that module, but whatsapp-sender's requirements had no pytz — its own main.py never referenced it. ModuleNotFoundError at import, so the Pub/Sub consumer for every WhatsApp message never started. Verifying the import locally, where pytz exists, proved nothing about the deploy targets. A CI check that each function's requirements cover the imports of webhook/shared/ would have caught it before deploy; that guard does not exist yet.

082526 · GREEN-API instance

When 08/25/26 22:41:10 → 22:51:24 PT

How it was timed measured from real timestamps

Cause operational

Detected by Self-induced and watched live — the logout was ours

Originally logged as an undated 6-minute EXTERNAL GREEN-API drop, 'noticed by chance'. All three of those were wrong. GREEN-API's authorization log (INVESTIGATION-2026-08-25-group-events.md) shows the session was continuously authorized from 06/04 12:12:57 PM to 08/25 10:41:10 PM, so no vendor-side drop happened at all: Gene logged the Firefox linked device out at 22:41:10 while reverting the mystery group-admin actions, and that device WAS the GREEN-API session. Re-linked at 22:51:24, so 10m14s of real unavailability with exact timestamps. A second, deliberate 5m23s logout on 08/26 10:30:40–10:36:03 AM proved the attribution; that one was a controlled test, not an outage, and is not logged as one. Kept self_inflicted=false because change failure rate measures deploys and no deploy was involved — but the cause is 'operational', not 'external', so the dashboard no longer blames a vendor for our own logout. handle_state_change() in webhook/main.py now alerts by EMAIL (not WhatsApp, which is the thing that would be broken) on stateInstanceChanged, so a repeat is timestamped rather than reconstructed months later.

082426 · whatsapp-message-sender

When 08/24/26 19:55:00 → 20:10:06 PT

How it was timed measured from real timestamps

Cause dependency

Detected by Slack alert relayed by Gene

Introduced by Stop gating payment thank-you emails on the SMS opt-in

Fixed by Pin google-api-core below 2.35.0 to restore Firestore

google-api-core 2.35.0 URL-encoded the Firestore database id (projects/P/databases/%28default%29), so every call returned 400 Invalid database id. The package appeared in no requirements file — it arrived as a transitive dep resolved at build time, which made every deploy a bet on whatever shipped that day. Fixed by pinning google-api-core<2.35.0; the follow-up (0d62f52, f0ee6c7) moved all four functions to pip-compile lockfiles so transitive deps are pinned. Counted self-inflicted: the dependency was upstream, but shipping unpinned transitive deps was our choice.

082126 · cancel-game workflow (smad-whatsapp.py cancel-game)

When 08/21/26 22:09:22 → 09/08/26 23:41:00 PT

How it was timed measured from real timestamps

Cause deploy

Detected by Gene, reading '⏭️ Players notified: nobody voted yes' on a game three people had voted for. No monitor: the run was green, the 403s were logged on a GitHub runner that Cloud Logging never sees, and the swallowed False rendered as a skip

Introduced by feat: /pb cancel game next + notify everyone who voted for a canceled game

Fixed by the commit adding this entry: cancel-game.yml credentials, three-state cancel summary, non-zero exit

fc45723 added the notify step to cancel-game but not the GREEN-API credentials to cancel-game.yml; _send_dm() sends straight to GREEN-API, so every send was an unauthenticated 403. _send_dm() returned False without raising, _notify_canceled_game_players() returned only the players it reached, and the caller's one explanation for an empty list was 'nobody voted yes'. Email rides a successful DM, so nobody was emailed either. The 9/4 fix (0a267b6) added GMAIL_USERNAME to this workflow and did not notice the WhatsApp side had never worked. Restored by hand: the two other 9/9 voters were DMed and emailed from the desktop at 23:41 PT. Fix: the credentials, a third result state ('failed', ❌) that names the unreached voters, and a non-zero exit so the workflow goes red.

Velocity

Commits per Week

A January–February build-out sprint, a spring taper into maintenance, then steady feature evolution — the shape of an app reaching stability while still shipping weekly.

0
55
110
165
220
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
222
Sep
Weekly counts as a table
Week of Jan 1225
Week of Jan 1956
Week of Jan 26111
Week of Feb 261
Week of Feb 973
Week of Feb 1669
Week of Feb 2332
Week of Mar 230
Week of Mar 919
Week of Mar 1619
Week of Mar 2310
Week of Mar 304
Week of Apr 62
Week of Apr 137
Week of Apr 209
Week of Apr 270
Week of May 40
Week of May 111
Week of May 185
Week of May 251
Week of Jun 115
Week of Jun 89
Week of Jun 153
Week of Jun 220
Week of Jun 295
Week of Jul 612
Week of Jul 1317
Week of Jul 206
Week of Jul 275
Week of Aug 30
Week of Aug 103
Week of Aug 1764
Week of Aug 24222
Week of Aug 31107
Week of Sep 7105
Week of Sep 1446
Week of Sep 2173
Week of Sep 28158

Commits per Day

Every calendar day since the first commit. 145 of 263 days have at least one commit; the busiest single day is 83.

0
20
40
60
80
JanFebMarAprMayJunJulAugSepOct

Commits by Hour

The same commits collapsed onto a single axis — the shape of a day. Peak at 10pm, quietest at 6am.

12am3am6am9am12pm3pm6pm9pm
0≥ 1≥ 33≥ 62≥ 80

Commits by Time of Day

Every commit placed by local hour and weekday. Busiest hour is 10pm, and 35% of all commits land between 9pm and 5am — the retirement-project signature.

12am
1am
2am
3am
4am
5am
6am
7am
8am
9am
10am
11am
12pm
1pm
2pm
3pm
4pm
5pm
6pm
7pm
8pm
9pm
10pm
11pm
MonTueWedThuFriSatSun
0≥ 1≥ 4≥ 9≥ 14

Lines of Code over Time

Net lines after every commit. The codebase has grown steadily — but that hides the churn underneath: 131,044 lines of Python were written and 28,448 deleted along the way, so 22% of the Python ever written has since been refactored away.

0k 10k 20k 30k 40k 50k 60k 70k 80k 90k 100k 110k 120k 130k 140k 150k JanFebMarAprMayJunJulAugSep
All tracked filesPython only

Pipeline

Deploy Time

Every day with a deploy since the first one, Pacific days. The solid fill is the day's mean; the bar continues to p90. Days with no bar had no deploys and sit out the average rather than counting as zero. A push whose deploy was skipped (it changed nothing the function runs, since 2026-10-01) is not a deploy and is left out. No rolling average: each day stands alone, so a change shows the next day. Latest: Oct 3, mean 99s over 4 deploys. From ci-metrics.json, refreshed daily.

0s
60s
120s
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
day's meanday's p90

Test Suite Time

The suites alone: the Tests workflow's 'Run every suite under coverage' step on GitHub's runner, every day since the workflow started (2026-09-12), Pacific days. Same reading as Deploy Time. The dashed line, on the right-hand axis, is how many checks the suites ran that day (the day's last run in test-results.json), so a slower day can be read against a bigger suite. Latest: Oct 3, mean 34s over 4 runs. From ci-metrics.json, refreshed daily.

0s
15s
30s
45s
0
3,736
7,471
Sep
Oct
day's meanday's p90checks (right axis, latest 7,471)

Tests Workflow Time

The whole Tests workflow end to end: the runner's setup, checkout, the package install, the suites above and the upload of their results. The gap between this chart and the one above is the overhead. Latest: Oct 3, mean 50s over 4 runs. From ci-metrics.json, refreshed daily.

0s
120s
240s
Sep
Oct
day's meanday's p90

Test Coverage

Statement coverage from coverage.py over every tracked Python file except the suites themselves, so a module no suite imports counts as zero rather than dropping out of the denominator. Measured by tests.yml on each push that touches Python and written to ops/test-results.json; the suites are tests/test-*.py, run by scripts/run-tests.py. Last run: 2026-10-03 21:38 PT, commit 04dda59.

webhook/shared99.3%9,338 of 9,400
picklebot99.7%3,062 of 3,070
vote webhook99.3%564 of 568
whatsapp-sender100%155 of 155
venmo-trigger96.8%92 of 95
scripts98.2%5,490 of 5,591
CLI (root)99.2%5,865 of 5,910
0%
25%
50%
75%
100%
22 days, Sep 12 → Oct 3, one point a day (its last run); 0–100%
all statements 98.5% webhook/shared 99.3% picklebot 99.7% vote webhook 99.3% whatsapp-sender 100.0% venmo-trigger 96.8% scripts 98.2% CLI (root) 99.2%

Codebase

140,870 lines across 424 tracked files — counted from git ls-files each time this page is built, so it never drifts from the repo. Tests are the tests/ suites, split out from Python so the application and the code that checks it are counted apart.

Python53,882104 files
Tests48,719174 files
Config & data15,32746 files
Documentation13,61731 files
CI/CD workflows6,18338 files
Terraform1,43514 files
Shell & batch1,2849 files
Web2323 files
Other1915 files

Reliability

Time to Restore

Every outage in ops/incidents.json since the first, at the day it started: the dot's height is how long it took to restore, on a log scale (they range from minutes to weeks), filled for self-inflicted, hollow for a vendor's. The line is the trailing 30-day mean time to restore, the tile's figure, day by day. 1 outage with no measured restore time not plotted. Latest: Sep 27, 4m.

1m
10m
1h
10h
1d
1w
Aug 21 → Oct 4; 12 timed outages
self-inflictedvendor30-day mean

Production & cost

/pb Reply Time

Every /pb command players typed, from the bot's own command log (dry runs left out): the time in the bot from the command arriving to the reply leaving, the fill the day's mean and the bar its p90. A first command after a quiet spell pays a cold start; a post-game report pays for its Claude-written story. The dashed line, on the right-hand axis, is commands that failed that day: a crash, or a reply that refused the command (an unknown player, a bad score), counted since 2026-10-02. Latest: Oct 3, mean 4.2s over 8 commands. From ci-metrics.json, refreshed daily.

0s
5s
10s
15s
20s
0
2
3
Sep
Oct
day's meanday's p90failed (right axis, latest 1)

WhatsApp Messages

Every message the sender delivered (its "Message sent to" log line), the red foot of a bar the ones it gave up on (its ERROR lines). Two outages were messages that silently stopped; a day that should have reminders and shows none is the tell. From ci-metrics.json, refreshed daily.

0
30
60
90
120
150
Sep
Oct
sentfailed7-day avg

Pages

ERROR lines from the four functions: each one emails an alert, so this is how often the system paged. The dashed line, on the right-hand axis, is alert issues opened that day (a workflow that failed). From ci-metrics.json, refreshed daily.

0
1
2
3
4
0
1
Sep
Oct
ERROR lines7-day avgalert issues opened (right axis, latest 0)

GitHub Actions Minutes

Billed minutes per day for this repo's workflow runs: each job rounded up to the minute, as GitHub bills it. The dashed line, on the right-hand axis, is the month's running total, against 2,000 included. Other repos on the account draw from the same allowance; the tile shows the account total when the billing token can read it. From ci-metrics.json, refreshed daily.

0
60
120
180
240
300
0
152
303
Oct
billed minutes7-day avgmonth to date (right axis, latest 303)

Release Notes

What changed for players, a day at a time: each day covers the 24 hours ending 8:00 AM PT, written by Claude from that day's commits and sent to the SMAD Pickleball group at 8 AM. 136 days so far.

Sat 10/3/2026 10 commits

🎮 Game Plan works mid-game
/pb game plan now works after a game has started. With no game named, it plans today's game until four hours after its start, then the next upcoming one. Posted mid-game, it shows the Game Day block and the next suggested matches, numbered to continue from the matches already played. This fixes the "no upcoming game" reply when a player joined after the start.

📈 Weight and SMAD DUPR chart
/pb weight record and /pb stats my now send a chart of your weight and SMAD DUPR over time. Weight in lbs is on the left and your SMAD DUPR after every game is on the right. The DUPR from before your first fit is dashed. You need at least two weigh-ins, and the reply tells you the chart is on its way.

📊 Is the model any good? New dashboard charts
The SMAD DUPR dashboard now has three charts on how well the ratings are doing. They show how balanced the matches are, how often the favorite wins compared with a coin flip, and how close the real margin lands to the predicted spread. Each chart marks 9/22, when the Game Plan started picking matches.

🕸️ Cleaner match graph
The match graph now fills the whole frame, and no dot covers another player's name. Names also have a halo in the page's color, so lines running under them no longer cut through the text.

🏆 Top 5 list names
The most-partners list is now titled "Most promiscuous". The other lists include Dynamic Duo, En Fuego!, and Mr. Clutch, and every title is in Title Case.

🔢 Easier-to-read match lines
The spread and win chance now appear with a slash, like +3/64%. Recorded matches, suggested matches, and post-game report lines all use it. Rebuilt dashboard reports pick it up on the next export. Stories already kept are not rewritten.

🇺🇸 American English everywhere
Everything you read now uses American spellings, such as favorites, favored, color, and canceled. This covers the post-game story and spread check, the dashboard, and the court shortfall and canceled job messages.

🛠️ Behind the scenes
Commands the bot refuses, like an unknown player or a bad score, now show as failed in the command log instead of as successes. We also added tests for the Game Plan and the match graph, and five more model trends are on the backlog for the dashboard and post-game report.

Earlier release notes · 135 days

Fri 10/2/2026 69 commits

📊 SMAD DUPR 2.0 ratings
Ratings now start from each player's sheet DUPR, and recent matches count more than old ones. Win chances allow for a 6-point miss, so they are less overconfident: in a backtest, favourites won about 69% of the time, against 55% for the old model. Match and suggestion lines show the win chance after the spread, like +3 69%. The dashboard is titled SMAD DUPR 2.0 and has a summary of the model, a brighter match graph with dots sized by matches played, a colour key, and top-5 lists (winningest partnerships, biggest rivalries, win streaks, giant killers, clutch players, hot right now, and more). Players under 10 matches are left out of the lists. The 1.0 page is kept for comparison.

➕ Add a game mid-week
/pb add game 10/1/26 7:30pm south @Rich Vic adds a game outside the weekly poll. It gets Last Call, the Game Plan, and the lights job just like a poll game, and registers the players you name. Names separated by spaces now read as whole names ("Gene Thanh John Wang 2" is three players), and @-mentions no longer swallow the next name. The reply ends with the command to join the game: /pb register 10/1/26 7:30pm.

⚡ Much faster commands
/pb match now answers in about 0.7 to 1.2 seconds in the bot, down from 4 to 8. /pb game plan, /pb post game, /pb games and /pb balances also make far fewer sheet reads, and /pb games went from 1.46 s to 0.32 s. Match replies end on a ⏱️ line showing the time. Keepalive pings should also stop the slow first match after a quiet spell (it took 18.7 s once).

🎾 Match and Game Plan fixes
Suggestion numbers are now stable all night: a number is never reused for a different match, and undo restores the list exactly. Match lines end on the ratings' spread (+3 favoured, -2 an upset), and the Game Plan shows "+1, 66% to win". Impossible scores like /pb match 61 15-3 are refused. /pb undo -1 is refused, and undoing the only match still lists what to play next. Courts are saved once per game, so replies stay quick.

📅 Dates without a time
/pb cancel game 10/3/26 used to say "Invalid date or time". Now /pb cancel game, /pb register, /pb left and /pb unregister take a bare date and use the first game that day. The reply says which time it assumed.

📝 Post-game story
Catchphrases now appear in full, woven into the story's own sentences rather than as quotes or tacked on after a dash. There are new Chick Hearn lines and a "Hodl to your dreams. To the moon!" for rising stocks and underdog wins. Post-game reports end on the winners' spread, and the analysis gains a 🎯 Spread check on how the predicted spreads did against the night's scores.

💸 Payments and court fixes
Paying twice in one Venmo sync now thanks you with the right balance each time. Zelle emails ending in a period ("$8.00.") are booked, and Venmo and Zelle times are no longer 7 hours off. /pb record payment John 20 is refused when two Johns exist. /pb court cancel without a court no longer cancels both courts. Names like Mary-Nell are no longer mistaken for dry-run flags. /pb stats my now shows the SMAD DUPR as of each row's date.

🛠️ Behind the scenes
Test coverage rose to 99.2%, which turned up many small bugs, all fixed. Deploys are faster and skip unchanged functions. We moved to Python 3.14, and the Release Dashboard got new charts. Release notes are now written daily by Claude Sonnet 5.5.

Thu 10/1/2026 8 commits

🎾 The Game Plan never suggests the same matchup twice
A four-player plan was listing round 1's match again as match 3, because the balancing step threw out the one unplayed pairing for being an 81% court. Now an unplayed pairing always beats a repeat, and balance only chooses among the unplayed ones. A matchup already listed, or still on court, is left out, so four players see their three pairings and the list stops. Once nothing new is left, a repeat can follow.

⚡ /pb match answers faster, and the numbers stay in order
A reply that took 11.2 s now skips the Game Day weather call (5.3 s of it) that a match reply throws away — that header is built only for the Game Plan report. The planner reuses the game the match was recorded against and the roster already read, instead of re-reading the poll and roster, and admin lookups are cached for 30 minutes instead of 5 (another 1.35 s). Match numbers now continue from the highest recorded or listed, so a typed match no longer jumps from 18 to 28.

📝 The post-game story reads straight through
Every line about a match now names its players and gives its score, so no line leans on the one before it — "That 12-10 needed extra time, and the booth needed oxygen" won't happen again. The stock-down 📉 line must follow the stock-up 📈 line as the template has them, "extra time" is now "overtime", and stories that invent time spans like "for years" or "all season" are rejected. Any story that breaks these rules is dropped in favour of the template.

🔁 /pb post game force rewrites the night's analysis
For a night whose matches were corrected after the story was written, admins can now re-run the night and have the analysis rewritten over the kept story. The report is saved and the dashboard redrawn so it picks up the new story, and the reply tells you whether the new story stood. If the rewrite fails its checks, the kept story stays — never the template.

📊 /pb survey results counts only current players
20 of 67 survey responses came from archived players, so the counts, best times and heatmap now use the 47 who still play. Anyone the roster can't match is still counted.

🛠️ Behind the scenes
The error sweep now measures its window from the newest previous run rather than whichever GitHub lists first (a 15-day-old run had stretched the window to 366.7 h and re-filed two stale 9/25 errors), only asks for runs created in the last 26 hours, and retries when it gets an empty or stale answer.

Wed 9/30/2026 37 commits

🏓 Game Plan reads cleaner and the numbers hold still
Suggested matches are now just a number and the two sides — no 🏓, no "balanced:" / "learn:" / "playing:" tags — with the match still on court listed first, and the sums line (6.33v5.62) sits at the margin so WhatsApp stops wrapping it. A suggestion keeps its number until it's recorded, so on a night like 9/29 court 2 can record match 2 first and match 1 is still match 1; an undone match comes back under its own number. The Game Plan lists whole rounds — 6 suggestions on two courts, 4 on one — and /pb game plan 2 (or 1) forces the court count. Round 3 is now titled "ratings learn most from".

📝 Match lines fit a phone
Every /pb match, undo and ✅ reply plus the /pb post game list now use one short form: "1 751 Thanh/Vic 11-6 Gene/Rich" — number, recorded time, first names joined by "/", score where "beat" used to be. Post-game match lines lead with the time they were recorded (07:51pm). /pb match 14 5 now tells you which match 14 is and how to write its score, and the "* under 10 matches / Move" footnote is gone from the report (the dashboard legend still explains it).

🙋 Guests are rated, not counted
The 9/29 clinic coach subbed for a late Roger and was recorded as Roger in matches 2 and 4; both rows now read Guest (3.80), and Roger's night is 3-3. A guest is held at his known rating so his partners and opponents are rated fairly, but he's left out of tonight's records, the standings, the match graph and the recap — the match lines still name him.

🕢 A 7:30 game is a 7:30 game
Games logged at 7:30 kept being labelled "7pm"; they now keep the minutes (match ids like 20260929-730pm-3) and the 17 rows from 9/29 were relabelled. Kept post-game stories were silently saved under a blank key and never shown again — they're found now, and the 11 existing ones are labelled. Everywhere a player reads it, "game night" is just "game", since games can be at noon or Saturday morning.

👋 New members get their row and welcome DM again
A broken insert meant three members joining between 9/22 and 9/29 got no row and no welcome DM; that's fixed and the sync now checks the sheet against the group after every run. New members are named from Google Contacts first, so "cunningham dan" is welcomed as Dan Cunningham. A member who leaves now gets a note that their stats are archived and how to come back.

🎙️ More booth calls, and Gene can add his own
The post-game story can now call "Boom shakalaka!", "To beat the champ, you've got to knock 'em out!" and "The mustard is off the hot dog!". All 33 catchphrases moved to a Catch Phrases tab — a new line Gene adds is live within ten minutes.

📊 SMAD DUPR dashboard hides archived players
Archived players are off the Ratings table, the History chart and the suggested-match advice, on the page and in /pb smad dupr graph. They stay in the match graph, drawn faded, since their matches still rate everyone they played.

🛠️ Behind the scenes Watchdog alerts now go out by issue, email and WhatsApp independently so one outage can't silence the rest, member sync retries a connection blip before failing, and the Release Dashboard gained lead-time and detection tiles plus a shared post-mortem page.

Mon 9/28/2026 8 commits

🏓 Logging matches before the game starts
Once the Game Plan is posted, /pb match and /pb match undo <n> belong to that upcoming game for the hour before it starts — no more scores landing on last week's session. So /pb match undo 3 before the start removes the match you logged off suggestion 3. If no plan has been posted and no game has started, the reply now tells you that posting a Game Plan opens the game.

📵 A 4-minute gap in messages on 9/27
A scheduled security change to our WhatsApp connection went wrong and Picklebot sent nothing from 10:40 to 10:43 PT. Sends were restored and we wrote up what happened so it doesn't repeat.

🛠️ Behind the scenes
The token rotation script now handles the real-world delay before a new token goes live (retrying for up to 3 minutes), keeps the token safely saved as it goes, and can resume a failed run; an incident record and post-mortem were filed with a shareable page linked from the Release Dashboard. Our secret-scanning sweep now reports the actual file and commit for each alert and says whether that file still exists in the current code.

Sun 9/27/2026 22 commits

🎾 SMAD DUPR starts everyone from the middle
Your rating no longer begins at your declared DUPR — every player now starts from 3.25, the group's mean, and only match results move you from there. Ratings pull in toward the middle: on today's log the top came in from 4.72 to about 4.36 and the bottom moved up. Your sheet DUPR is still shown beside your rating and still sets the scale, so a 1.0 gap means what it means in DUPR. The model also re-measures itself at every refit instead of being pinned, and the dashboard's older nights are re-fitted the same way so the trend line has no step at the switch.

📝 The post-game story is now written for the night
The analysis in your post-game report is written from that night's facts — the matches, every rating, move and record — instead of fixed template lines. Every number in it is checked against the facts, and anything that doesn't match falls back to the old template. It quotes catchphrases on their own, with no announcer name and no quotation marks, and once a night's story is written it's kept for good: refits, remodels and dashboard refreshes never rewrite it.

📈 Dashboard: every game night's report, and a history chart you can hover
The dashboard now rebuilds a post-game report for every game night from the Win Loss Log with the current model, so old reports match how ratings work today. "Over time" is now SMAD DUPR History and starts at 8/25, the first night with enough matches, with one point per game night. Hover or tap a line to see the player's name and current SMAD DUPR, with the rest faded — handy when twenty lines overlap. The explainer text is much shorter: the formula, the 3.25 start, and what ± means.

🔁 /pb post game is safe to re-run
Move is now measured against the previous game night from the match log alone, so running /pb post game twice posts the same report instead of a different one, and it won't save a duplicate report row. The reply's footnote now reads "Move = since the previous game night".

🧪 /pb post game remodel
New command for changing the model without generating a new game report: it saves the refit and redraws the dashboard, but leaves the dashboard's latest post-game report as the last game night's.

🚫 Cancelling a game cleans up properly
When a game is cancelled, the Game Plan post, the eve Last Call and (for night games) the lights reminder are all deleted together. Previously a cancelled game's Game Plan still fired — the cancelled Sat 9/26 9am game posted at 8:00 AM PT.

🔔 Less noise in Admin Dinkers
The per-payment "Payment Received" posts and the survey, payment, vote, DUPR and game reminder summaries no longer go to the admin group. Your thank-you DM, email and Payment Log row are unchanged. The one alert left is "Game Reminder SKIPPED", for a reminder that couldn't go out.

🛠️ Behind the scenes — Tokens and keys are now scrubbed from every log, email and issue, the message sender retries connection timeouts and pages once for a genuinely lost message, token-rotation scripts were added, and GitGuardian alerts now file issues automatically. 65 test suites, 2241 checks, 0 failed.

Sat 9/26/2026 11 commits

🏆 /pb post game is now the whole night in one report
Run /pb post game and it refits the SMAD DUPR ratings and replies with "Post-Game Report for 9/25/26": the numbered matches, the night's SDPR / Move / W-L table, and the post-game analysis. /pb compute smad dupr still works as another way in. Only an admin's run saves the results and redraws the dashboard — anyone else gets the same report, just not saved. On the dashboard it appears under "Latest Post Game Report", and the masthead now reads the refit date, like "SMAD Pickleball · 09/25/2026 · refit 5", instead of "Win Loss Log".

🎾 Suggested matches follow the night you actually played
The Game Plan now builds from the matches really recorded, so a late start or an off-plan match no longer throws the numbers off. Matchups already played tonight aren't offered again unless every alternative repeats one — replayed over the 9/25 night, the new list had zero repeats against four in the old one. The next three suggestions print under the Match Recorded, undo and register replies, numbered on from the last recorded match. /pb match <n> <score> now also understands "11 to 7" and "11 7", and refuses a number already recorded by naming the next one.

↩️ /pb match undo 2 removes a specific match
You can now undo any recorded match of tonight's game by its number — /pb match undo 2 or /pb match undo #2 — no matter who recorded it, and the number matches the one in the Match Recorded reply. Plain /pb match undo still removes your own last match. The reply, headed "Undo Match 2", shows the night's last three matches and the next suggested matches, and a suggestion freed up by the removal is listed again under its number. Out-of-range numbers and empty games are refused with nothing deleted.

📊 Top 20 board: ten or more matches
The Top 20 board on Game Day Group Stats and the daily Hall of Shame and Fame now only lists players with at least ten logged matches, and the header says so: "🏆 Top 20: SMAD DUPR > W-L > Hrs (10+ matches)". Exactly ten qualifies; nine doesn't, even at 9-0 with the best rating. If nobody qualifies, the board is left out rather than shown empty.

💡 Lights reminder times itself around the game
The reminder email to the desk is now scheduled one hour after the game starts (8pm game → 9pm) instead of a fixed 8pm, and it asks for the lights to stay on for 3 hours after the start — an 8pm game runs to 11:00pm. Tonight's 8pm game keeps its original 2.5 hours, so its email still says 10:30pm.

🎙️ Post-game analysis calls it like LA's booth
The analysis now drops famous commentary catchphrases on every line: "It's time for Dodger baseball!" and "Pull up a chair!", "This game's in the refrigerator!" and "Slam dunk!", "I don't believe what I just saw!", "Showtime!", plus "Booyah!" and "Show me the money!" on stock up. Stock down stays upbeat — "Tomorrow's another ballgame.", "Every slump ends. Think Blue!", "No harm, no foul." — never a jab. A new Thriller line calls out the night's longest extra-time game, where the winning score went past 11.

🛠️ Behind the scenes
The board, the undo numbering and the reply block are each written once now instead of in duplicate copies, and the test suite finished at 61 suites and 2115 checks with 0 failures.

Fri 9/25/2026 5 commits

🎾 Game Plan now shows five suggested matches
/pb game plan prints the first five suggestions instead of the first two rounds (four matches on our two courts), so you can see a bit further ahead. The numbering is unchanged — matches 1 to 5 in the report, with the match replies picking up from 6 — so /pb match <n> <score> and the "suggested matches left" block still point at the same games. The same list shows up in the reminder that goes out 1h before.

📋 Match Recorded keeps the last three matches
After you record a score, the reply now lists just the last three matches of the night, including the one you just entered, each under its own number. The count line still counts the whole night, and /pb post game and the undo reply still show the full list.

🛠️ Behind the scenes
The developer's release dashboard trends now cover the last 30 days instead of 90, with incidents counted inside that same window and the test-time tile labelled accordingly. A test that assumed a fixed date now pins its clock so it passes on any day; 60 suites, 2057 checks, 0 failed.

Thu 9/24/2026 4 commits

🎯 Record a match by its number
You can now log a result with just the suggestion number and the score: /pb match 14 9-11. The score is read in the order the two sides are listed, so 9-11 means the right-hand side won. Numbers are fixed when the Game Plan is made and never shift, so recording out of order just leaves gaps — no confusion about which match is which.

🔢 Suggested matches are numbered, continuing from what's been played
The Game Plan now numbers the courts of rounds 1 and 2 as 1. to 4., and suggested matches pick up from there: with 13 matches recorded, the next suggestion is 14. One running list all night, so the number you type is always the number you see.

🙋 /pb unregister for no-shows and early leavers
If someone doesn't turn up, /pb unregister @player takes them off the night and off the night's charge (and tells you if the cell held game-day hours). If someone plays and then heads home early, /pb unregister @player left drops them from the Game Plan but keeps their charge. Either way the plan rebuilds on the spot, and the next two rounds appear right under the reply. Every /pb match reply after that follows the new roster.

✅ Cleaner Match Recorded replies
Names now read the same short way they do in the players list — "Gene C", "John W2" — everywhere in the reply, with no rating tacked on after each name. Wins read as "beat" instead of "def.", and the suggested matches that follow are numbered.

🛠️ Behind the scenes
60 test suites and 2049 checks pass, with the README and command help updated for the new /pb unregister and numbered /pb match.

Wed 9/23/2026 18 commits

🎾 The Game Plan arrives — An hour before each game, Picklebot now posts "Picklebot Suggested Matches for Balance and Algo Training": the night's date, weather, courts, who's in with their SMAD DUPR, and suggested teams and matches built from the ratings and who has played whom. It replaces the second Last Call, and you can ask for it anytime with /pb game plan. It ends with Fun Picklebot Graph Stats — things like "Gene C and Ryan H have faced each other 14 times, the most of anyone here" — plus a link to the SMAD DUPR dashboard.

📋 The plan follows you down the night — The posted report now shows only the first 2 rounds, and every /pb match confirmation lists the next 2 unplayed rounds ("28 of 32 left; next 2 rounds"), so you don't scroll back up to find the plan. A court clears once that same pairing is logged, either side winning, in any name order. The plan also keeps going as long as the night does instead of running out after round 5, it's frozen at game time so later rounds no longer replay round 1's courts, and a dry run to Admin Dinkers can no longer swallow the real post to the group.

🗣️ /pb match understands more of how you type — Of 20 attempts on 9/22, 7 were refused; all seven now work. Two first names with no "and" ("ryan dom beat richard mark 8") are read as a team, "Daum" finds Dominic, and dictation slips in the score position are caught (won 1, to/too 2, for/fore 4, ate 8). When it still can't tell who you mean, the reply now lists tonight's first names so you can pick.

📊 A refit reply about tonight, not the whole ladder — /pb compute smad dupr now answers with "SMAD DUPR Refit for 9/22/26", one summary line ("13 matches played between 8 players."), and a table of tonight's players with SDPR, Move and W-L. Under it is a post-game analysis drawn from the night: stock up, stock down, the rookie card, the upset. Each reply is kept and shown on the SMAD DUPR dashboard under "Latest recalc report", with older ones behind "See more", and the dashboard redraws itself after a refit.

✍️ /pb register a late arrival — If the pinned poll is hard to find, or someone turns up last minute, any member can add a player to today's roster exactly as a poll vote would, so their matches can be recorded. It takes an @-mention, a typed name or nothing at all for yourself, finds today's game (or the next one, telling you how to name it), and reports rather than overwrites an existing mark.

🛠️ Behind the scenes — The SMAD DUPR column now sits right beside DUPR on the main sheet, a new Court Finance dashboard turns the weekly consolidated report into a page, and a rerun lever lets fixes ship without the desktop being on — on 09/20 a fix sat from 10:16 AM to 5:43 PM waiting on it.

Tue 9/22/2026 6 commits

🏓 /pb players shows a self-rated DUPR when we haven't fitted one yet
If you haven't got a fitted SMAD DUPR, the game-night roster now shows the self-rated DUPR from the sheet instead of an empty cell, so newcomers aren't a mystery. A fitted rating always wins when both exist. The ranked lists — /pb players, the Top N board and the shame report — stick to fitted ratings only, so players who haven't played in months don't float above the people showing up every week; with no fitted rating the cell stays blank and the player ranks last.

🛠️ Behind the scenes
Test results, CI metrics and the SMAD DUPR export now live in a reports bucket instead of the repo, with the old copies kept as a fallback until both dashboards were confirmed reading from the bucket. The release digest and dashboard also stopped counting bot-written records as releases (119 such commits now excluded instead of 59), and the alert sweep now closes an alert once every workflow has a green run after its newest failure. 55 suites, 1787 checks, 0 failed.

Mon 9/21/2026 11 commits

🗳️ The Sunday games poll is back
The 8am Sunday runner failed to start, so for ten hours there was no games poll, no Last Call, no Games This Week and no vote or payment reminders. The poll was restored by hand at 5:54 PM PT and everything downstream ran green. Nothing you voted on was lost — the week just started late.

🎾 Shyam's court handover now works closer to game time
The handover used to need your vote 48+ hours before the game; it now needs 24. The late Sunday poll put Shyam's vote 47.1 hours before Tuesday's 7 PM game, so the transfer was skipped without anyone being told. Now if a vote lands inside the window, the bot DMs you that the court transfer was not started, says why, and gives you the override: /pb shyam MM/DD/YY.

📊 The SMAD DUPR dashboard has a match graph
A new section sits between Over time and How it is calculated, with the graph drawing, a legend and a tile row showing players, matches and pairs met out of possible. If there's no graph to show the section simply doesn't appear, and names with characters like < now display correctly instead of breaking the page. The export carries only names, ratings and pair edges — never a phone number or an email.

📋 Every outage you feel now gets a written post-mortem
Any outage that affects players or money gets a public write-up: what broke, end-user impact, who did what and the full timeline. The 2026-09-20 poll outage has one, and the Release Dashboard links it right on the outage row, naming the commits by subject instead of a bare code.

🛠️ Behind the scenes
The exact mistake that stopped the runner was caught once before on 2026-09-04, so there's now an automatic check that a workflow grants everything it calls, plus 15 new tests covering both shapes of the failure. Alert auto-closing moved onto the watchdog's hourly sweep after the old gate silently stopped working, and the suite stands at 53 suites, 1740 checks, 0 failed.

Sun 9/20/2026 3 commits

🎾 /pb games signs off once
The weekly games list was coming through with two Picklebot signatures and two jokes stacked at the bottom. Now /pb games shows a single footer, the way the other game listings already did.

🛠️ Behind the scenes
Retired a leftover monitoring workflow that duplicated a check already running every ten minutes, and made the alert-closing job skip itself unless an alert is actually open — both were quietly eating build minutes. Test suite still fully green.

Fri 9/18/2026 7 commits

📊 Your Stats now shows hours this month with a rank
Your stats DM leads with this month's hours and where you stand, like "12 hrs this month (#1 of 20)", ranked among players who've played this month. The year milestone line stays. Your rating line now reads SDUPR (the SMAD DUPR); if the fit hasn't rated you yet, you'll see the sheet DUPR, clearly labelled.

🏓 SMAD DUPR: your W-L record, and steadier ratings
/pb compute smad dupr now shows your wins and losses instead of a bare match count, and the table still fits a phone. The k setting is now sticky — it's carried from the last refit and only re-measured every 50 matches or when you ask with /pb compute smad dupr rescale — so a night you sat out no longer nudges your number. The reply tells you whether k was carried or re-measured and why. A live dry run with k carried at 3.25 since 98 matches moved every rating +0.00.

🕸️ See the match graph behind the ratings
New /pb smad dupr graph shows what the log actually supports: which players are connected, which matches hold the picture together, pairs we can't yet compare, and a suggestion for the next match that would tell us the most. Anyone can ask — it only reads.

📈 Dashboard explains the sticky k
The SMAD DUPR dashboard (now version 9) says where k was set and when it re-measures, with a note on why a night you sat out used to move you and no longer does — Victorio's +0.08 on 9/17 was all k. The methodology section spells out that k was measured at 98 matches and re-measures at 148.

📋 Last Call is shorter
The Last Call message now sticks to attendance and balances and drops the Top 20 board. Game Day, /pb game next and the shame report still carry the board.

🙂 No joke on reminders
The game day reminder and Last Call no longer carry a joke — neither is a reply to something you typed. The signature and help hint still close every reminder, and the admin copy reads the same way.

🛠️ Behind the scenes
A failed connection check now warns first and only escalates after 15 minutes, so brief network timeouts stop paging. 50 suites, 1634 checks, 0 failed.

Thu 9/17/2026 3 commits

🛠️ Behind the scenes The hourly error sweep now runs on a schedule we control, firing at :23 past every hour instead of the old setup whose gaps stretched from 2h to 6.7h (about six runs a day) — so problems get noticed sooner, and a follow-up fix stopped the sweep from filing duplicate reports and made it tidy up stale ones on every run. Shared release-notes links now always show the latest version.

Wed 9/16/2026 12 commits

📊 SMAD DUPR dashboard, redrawn every time
/pb compute smad dupr now rebuilds the dashboard page after a real refit, and the reply's last line tells you whether the redraw was requested. The page shows the Move column (how much your rating changed since your last refit, "new" the first time), a time series of every player's SMAD DUPR across refits, and a closing "Caveats, and what would make it better" section. The reply now ends on the SMAD DUPR Dashboard link with no signature footer. The first export covers 20 players, 1 refit and 88 matches.

🏓 /pb match understands "Jon" and full scores
"Gene and Shyam beat Jon and Vic five" was refused because "jon" matched Jonathan, who wasn't playing — now a name match among the players on the court wins over a club-wide name who isn't in the session, and if two people who played both fit, you get an error naming both. /pb match also accepts a full score on the end: "11 - 8", "11 to 8", "8-11" or "eleven to eight". The winner's score is still set by the league rules, so a typed 11-10 records as 12-10, and "John Wang 2 8" still reads the 2 as part of his name.

📋 Match confirmation lists every match again
The numbered list of matches logged so far is back in the /pb match reply, after a game got recorded twice 18 minutes apart and the duplicate was invisible. You get the count line, a blank, then the full list; the W-L table stays with /pb post game.

📈 Post-game tables now show SMAD DUPR
In /pb post game, the W-L Record column is headed SDPR and shows your roster SMAD DUPR to two decimals, blank until you have one. The numbered match lines show SMAD DUPR too ("Gabe 3.11 & Vijay 2.89 def. ..."), in the match confirmation and undo as well. The signature now sits directly under the W-L table instead of a line below it.

💡 Lights reminder asks for 9:30pm
After the 8:00 PM email asking for lights until 10pm went out and the lights still went off at 8:45 PM, the reminder now reads "Reminder to turn all court lights on until 9:30pm - Thanks Gene". Same recipient, same 8pm send on night games.

🎾 No more empty cancellation threat
Inside the 12-hour window the court can't be cancelled and we pay for it either way, so Game Day and Last Call no longer say it will be. The line now reads that we need more players to fill the court and it's within 12 hours. One-court and two-court shortfalls both changed; past games, full rosters and resting pairs read the same as before.

✨ SMAD DUPR table is easier to read
There's a blank line above the table and a rule under the header, measured by what's actually visible so it lines up.

🛠️ Behind the scenes
New and rewritten test suites cover the shortfall wording, name resolution, full scores, the dashboard and the table rule — 49 suites, 1524 checks, 0 failed. Documentation and the investigator's wake routine were also repaired after it stopped firing.

Tue 9/15/2026 10 commits

🏆 The daily report is now the Hall of Shame and Fame
The daily message leads with the signature and Daily Hall of Shame and Fame for MM/DD/YY, and ends on the SMAD DUPR Dashboard link one line under the board — no more redundant signature footer or trailing blank lines. Money only shows up when someone actually owes; a creditors-only table no longer nags the group. The Top 20 board closes the message, the Not Voted list is unchanged, and /pb shame now also answers to "hall of fame" and "hall of shame" aliases.

📊 Player tables: SDPR, new ranking, shorter names
The rating column is the fitted SMAD DUPR shown as SDPR to two decimals (blank until it's fitted), and /pb players, the Game Day board and the Hall of Shame and Fame all rank the same way: SMAD DUPR, then W-L, then Hrs. You can still sort with dupr, dpr or rating — old commands keep working. First names cap at 6 letters with proper nicknames (Vic, Jon, Rich, Stan, Spen, Dom, Will), roster digits stay glued to the initial (John W2 beside John W), and Pct prints without its dot, so tables fit 32 columns again on the live roster.

💸 Game Day and Last Call only mention money when somebody owes
The Bal/Cred block in the Game Day Reminder and the Last Call summary now appears only when a player carries a balance — the same rule the daily report got earlier that day. If only creditors are listed, the block and its congrats line stay out and the board still renders.

🥒 /pickleball works as a command prefix
Typing /pickleball match undo used to get an unrecognised-slash reply. All three prefixes — /pb, /picklebot and /pickleball — now work in any case, and the unrecognised reply names all three.

📬 Picklebot Upgrades digest left the SMAD Pickleball group
The daily upgrades digest was too noisy for the main group, so it now goes to Admin Dinkers and Gene's DM only. /pb commits digest still answers whoever asks for it.

🛠️ Behind the scenes
Venmo sync now reads Sheets through the shared cached accessor instead of building its own connection — the duplicate shape that OOM-killed Picklebot on 09/08. Plus a generated command registry, a no-duplicate-code ratchet test, and quieter alerting so routine restarts stop paging Gene.

Mon 9/14/2026 9 commits

🗳️ Your vote always lands, and under your real name

Two votes on Sunday morning hit a connection error before reaching the poll sheet. No votes were lost — every one went through on a fresh connection — and the webhook has been fixed so it can't get stuck on a stale connection again.

📇 Poll entries now use your full name

When someone votes who isn't in the roster yet, Picklebot now looks up the phone book contact name first and only falls back to your WhatsApp display name as a last resort. That's why one Sunday vote logged as "Dru" instead of Dru Huang — that row was corrected by hand, and new ones will come through right the first time.

🛠️ Behind the scenes

Failure alerts now name the actual error in their title and close themselves on the next clean run, so stale alerts don't pile up. The alert investigator pipeline is documented end to end, and the resident instructions file was slimmed from 1,271 lines to 169 with nothing deleted — just moved.

Sun 9/13/2026 17 commits

🤐 No jokes on top of admin replies
Picklebot was still tacking a joke onto a couple of admin answers, including /pb booking list and /pb shyam, because those two commands sat outside the no-joke rule. They're inside it now, and a check scans every admin command so a new one can't slip out again.

💰 Court finances, properly kept
The club's running costs are now split into what's genuinely variable month to month — Anthropic, GitHub, GREEN-API, Twilio, plus an estimate for GCP — instead of being lumped in as fixed, and Anthropic is charged 100% to pickleball. Member credits are now booked against the month of the game they reverse, so Shyam's 9/1 credit written on 8/30 counts in September, and dues accrue for the open month: August no longer reads as a big loss next to a flattering September. The Caltech Associates donation sits deliberately outside the P&L, so it doesn't tangle with the tax deduction.

📊 A readable Court Finance sheet and email
There's now a "2026 Court Finance" tab — one per year, formatted like an actual finance sheet: bold grey header, frozen Month column, dollars with negatives in red parentheses, and a bold year-to-date line under a double rule. The notes explain each column family in plain English, including Operating = Gross − Overhead and what a negative month means, and the same notes go out with the weekly consolidated email. That email's totals row also lines up with its columns again after a formatting bug lost its alignment.

🛠️ Behind the scenes
The 38 regression suites moved to tests/ and now run automatically on every change: 38 suites, 1,306 checks, 0 failed, 23.1% statement coverage, with tests and coverage tiles added to the Release Dashboard, which now defaults to dark.

Sat 9/12/2026 10 commits

🏓 SMAD DUPR: your own rating, fitted to our actual matches
Every player now has a SMAD DUPR in its own column next to DUPR, calculated from your win/loss record plus how strong your partners and opponents were. The first run wrote 20 ratings from 76 matches. Anyone with under 10 matches is marked provisional.

🔄 Refit the ladder when game night is done
Type /pb compute smad dupr (admin) once all the night's matches are entered and the ratings recalculate on the spot — no waiting for a daily job. The reply is a phone-width ladder showing each player's rating, how much they moved since the last refit, and a star for provisional players.

📈 Every refit is saved, so progress is visible
A new "SMAD DUPR Log" tab stores one row per player per refit — rating, previous, change, matches and who ran it — so your rating now has a history instead of just a current number. A refit that changed nothing isn't logged, and if the log write fails the ratings still land on the sheet and the reply tells you why.

✅ Match Recorded is now three tight lines
Recording a match replies with the header, the result, and the session count directly underneath — no blank lines, no "N matches logged" sentence, no signature. Batches still list every result first, then the session line.

🔌 /pb match no longer loses matches after a quiet stretch
When Picklebot sat idle between matches, a stale connection to the sheet made it read an empty roster and claim it couldn't find your names. Now a connection idle more than 60 seconds is refreshed before use, and if the roster genuinely can't be read you get "Nothing recorded: the roster read from the sheet failed", the actual error text, and a nudge to send the same line again — never a silent miss. The two matches lost that night were written back in at the times they were first sent.

🗣️ /baby and /bb now work
Courtside dictation keeps turning /pb into "/Baby" and "/BB", and both were being refused. They're now accepted aliases, so "/Baby Gabe and Vijay beat..." records like normal.

👥 Vacationing players are back on the Top N board
Only archived players are filtered out now, so the Top N board and /pb players list exactly the same people in the same order again, and the "#N of M" rank in your DM counts the full group. The survey availability grid is unchanged — it still marks vacationers rather than hiding them.

🛠️ Behind the scenes
Picklebot's logs are now structured, so an error actually triggers the alert chain instead of vanishing — that night's failure had raised nothing. Plus new tests across the ratings, the match reply shape, the idle-connection fix and the aliases.

Fri 9/11/2026 1 commit

🚫 /pb cancel game won't cancel a game that already happened
If you point /pb cancel game at a date in the past, it now stops you right away instead of running the cancellation. Before, a past game would quietly go through the whole process and spit out six "not found" lines, with nothing useful reaching the Court Log or the players.

🛠️ Behind the scenes
A new test guards this, failing the build if a past game ever gets dispatched again.

Thu 9/10/2026 34 commits

🩹 /pb match stopped running the bot out of memory
Recording a match was building the connection to the scoresheet ten times over and could kill Picklebot mid-request at 992 MiB. It now builds it once and reuses it, so the same work peaks at 68 MiB and /pb match stays up.

🧾 Cleaner match confirmations
When you record a score, the reply is now just the line you wrote plus the running "N matches so far" count — no more W-L table. The full W-L Record for Today belongs to /pb post game, and undoing a match still shows the remaining list.

📊 Tidier W-L and standings tables
Every record in a table now pads to the same width, so a night of 3-1 and 0-1 lines up just like a Top N with a 12-4 in it. Column headers shift so the dash in the header sits right over the dash in the records, under any sort and any name length.

💸 Payments find the right player
Venmo and Zelle now match payers through the same roster matcher the survey uses, so a profile with the names swapped, a nickname, or a unique last name lands on the right person instead of being missed on one side and found on the other. Venmo also accepts a whole number of session fees — James Ji's $20 for two and a $50 monthly pre-pay now count — and a one-word Zelle name no longer gets handed to whoever happens to share that first name. Payment runs can also reach Google properly now, so thank-you DMs, the Admin Dinkers notice and the Zelle sheet read all work (and the daily joke comes from the real jokes sheet again, not the fallback).

⚡ /pb payment sync on demand
Admins can run the Venmo + Zelle sweep right away with /pb payment sync (also venmo sync, zelle sync, payment sweep), and the counts come back in the chat whatever the outcome.

🙋 /pb members and /pb players no longer collide
/pb members sync used to be read as a players list sorted by "sync" and refused with a sort error. Member services and stats are now separate: players sync and sync players belong to syncing, and bare /pb members gets the normal unknown-command reply.

🗓️ Join Date, Referrer and Join Source on the Player sheet
Every member row now carries when they joined, who referred them and how they found us, backfilled from welcome messages, first votes and the group's own join events — 142 cells filled, 59 join dates corrected from the chat archive and 3 referrers recovered. Where someone's first game predates their join date, the earlier date wins, so the three founding members read 7/10/2025.

🛠️ Behind the scenes
The group's first year of chat was archived from a phone export (5,346 records) into a private bucket, and court finance gained a full reconciliation and P&L with weekly and monthly reports to Gene — including excluding golf, happy hours and non-SMAD courts from the court numbers. Court booking also stopped republishing the dashboard on nights with nothing to book.

Wed 9/9/2026 25 commits

🎾 /pb match is harder to fool and quicker to read The bot now refuses the same match logged twice in a row — two scorekeepers logged one 11-1 43 seconds apart — and naming the same person on both sides of a line ("James and James") is rejected too. If a first name is ambiguous and nobody by that name voted, you'll hear "David Lin and David Sohn are not playing tonight — did someone forget to vote?" The confirmation now shows the line you recorded, the match count and the session W-L table instead of relisting every match, so the twentieth match of the night isn't a screen-long reply.

📊 Player tables: Pct, Top 20, and smarter names Every player table gains a Pct column right after W-L (1.000 for unbeaten, blank with no matches), the Top N board is now Top 20, and /pb players sorts by W-L, then hours, then DUPR by default like the leaderboard does. Names are only shortened to "First L." when they truly don't fit — on the live roster 13 of 45 names were shortened and every 11-character name stayed whole. W-L columns are zero-padded to a fixed width so the columns line up.

💬 Commands that used to vanish now answer A /pb command sent as a reply to another message is now read — one /pb payment transfer reply was dropped entirely. /phoebe and /pv, what iOS dictation makes of "/pb", are accepted, and any other slash command gets a reply instead of silence. /pb post game now matches any spelling with report or summary (plus game summary and match summary) and is listed in /pb help.

🏖️ Admins can set vacation for someone else /pb set vacation @player <date> and /pb set vacation <name> <date> both work now, which silences that player's vote and survey. Admin DMs also work correctly from the bot's phone, so the command no longer gets refused in a member's DM.

🗓️ Cancelling a game, and actually telling people /pb cancel game 9/9/26 now works with just a date when there's only one game on it; two games ask for the time and name both. Separately, the cancellation DMs to voters had been failing silently since 08/21 — that's fixed, and the summary now names anyone who couldn't be reached instead of saying "nobody voted yes."

🏟️ /pb courts list and your DUPR on every match /pb courts list answers instantly with the courts we hold from today on, one line per slot, noting when a court is on someone else's account. Match rows now carry each player's DUPR, with 46 older rows filled in from history, and the post-game report shows the rating of the night.

🛠️ Behind the scenes Court billing with the Athenaeum is now reconciled month by month against our own Court Log, weigh-ins and all logs are stamped in Pacific time, admin-only replies drop the joke, and admins can pull the upgrades digest with /pb commits digest.

Tue 9/8/2026 9 commits

🙋 Tag any player, any command
Typing @Toby Hsieh used to work in some places and fail in others — /pb archive player @Toby Hsieh came back with 'Player "@19175282626" not found'. There is now one way names are read across the bot, so /pb archive player, remind vote, remind payment, set DUPR and record payment all understand the same @mention, @me and plain names.

🎾 Cancelling a court tells you the truth
When a handover cancellation went through but the bot couldn't confirm it, it reported "failed" — so you'd think you still had a court when you didn't. The bot now reloads the reservations page after cancelling and sends a separate "couldn't verify" message, which is different from an actual failure.

📝 The survey knows you by the name you use
If you took the survey as "Alex Laussu" but the roster says Alexandre, the bot now matches you by last name and adopts the first name you typed. It also stops flagging a DUPR as changed when "3" and "3.0" are the same number.

📣 Court reports say who asked
Cancellation notices and results used to show a raw chat id. They now show the player's name instead.

📖 "Court Booking" in the help menu
The /pb help section called "Court Handover" is now "Court Booking" — only /pb shyam was a handover, the other three commands are the booking list. The README matches.

🔕 No more Last Call Summary
The Last Call Summary sent to Admin Dinkers is gone, both after DMs and in the "everyone has voted" case.

🛠️ Behind the scenes
Lifted the google-api-core version cap now that 2.36.0 handles the Firestore database id correctly, added a standing rule that every session reads times in Pacific Time from the message itself, and documented the shared player resolver in the README.

Fri 9/4/2026 11 commits

📬 The daily "What Shipped" digest
Picklebot now posts a plain-language list of every change made the day before, titled like "What Shipped on Wed Sept 2 2026", sent at 8am by Cloud Scheduler. The first version cut off at 15 items and said "and 13 more" — now the whole day's list goes out. It briefly went to the SMAD Pickleball group; that's been dropped, so the digest stays in Admin Dinkers and Gene's DM and doesn't fill up the club chat.

📶 Fewer silent outages, and no more false alarms
Picklebot's WhatsApp connection is now checked every 10 minutes from the keepalive, instead of waiting for a notification that may never arrive — so if the bot goes offline, it gets noticed and fixed sooner. The old alarm treated every state except "authorized" as broken, including the normal starting and sleepMode states that clear on their own within 5 minutes. Those no longer page anyone, while a genuinely unlinked instance still does, and an unknown state now says plainly that it doesn't know.

🛠️ Behind the scenes
Deploy and metrics jobs no longer report a failure as a success, the "what changed" hook stopped attributing our own work to someone else, and scheduled jobs are moving off GitHub cron (which is UTC-only and shifts an hour twice a year) onto Cloud Scheduler for about $0.10/month by folding them into jobs that already run often enough. Also wrote up an evaluation of the official WhatsApp Business API: it's blocked for now because official groups cap at 8 participants with no way to add members, so the group chat you use would not work there.

Thu 9/3/2026 11 commits

🏓 Court booking knows when Shyam can't book
Shyam can only book a free court on his own account 48 or more hours ahead, so the bot no longer hands a booking off to him automatically when that window has already closed. It checks this before the cancel cutoff, since 48 hours is the binding limit against 12. Typing /pb shyam <date> by hand still works exactly as before, for the times you know something the bot doesn't.

🛠️ Behind the scenes
A pile of plumbing on the development side: the daily change digest now sends once a day instead of once per push (with a link and commit subjects capped at 15), the health watcher stopped alarming while everything was healthy, push access and permission prompts were untangled after an outage, and the developer's Commits per Week chart stopped drawing one bar fatter than the rest. New tests cover the court rules with 16 cases.

Wed 9/2/2026 23 commits

💸 New: /pb payment transfer, when one member pays for another
When Roger covers Alexandre at the court, one command now records it: a debit for the payer and a credit for the player, sharing one Transaction ID. Names work the same way as in /pb match, the reply is in plain members' language, and both people get a DM — the payer because they're out of pocket, the other because their balance moved. A payer's debit no longer moves their Last Paid date, since paying for someone else isn't a payment of their own dues.

📈 DUPR ratings now keep a history
A new DUPR Log tab records every rating change: when, who, old, new and status — including ratings that were set by command, ratings ratcheted up by the survey sync, and survey values that were declined. Ratings always show one decimal, so 4.0 no longer prints as "4". Players without a rating read "no score".

🏆 /pb players sorts the way you ask, and so does the Top 15
/pb players [dupr] [w-l] [hrs] takes up to 3 criteria in priority order, and the stat columns reorder to match what you sorted on, so the first column is always the one being ranked. An unknown or repeated criterion is refused with the reason instead of being quietly ignored. The Top 15 board is now ranked by W-L, then hours, then DUPR — so unrated players are no longer pushed down, and anyone at 0-0 sits below every real record.

📵 Stats rows stop turning into phone numbers
WhatsApp was linkifying runs of digits in the stats tables and making rows look like phone numbers. An invisible separator between the stat columns breaks the run, with no change to column widths or spacing.

🗂️ Archived players off the roster listing
/pb players now leaves archived members out, matching the Top N board, which has always excluded them. Players away on vacation still appear — they're members who are away. An all-archived list says so rather than showing an empty table. Archived players still get their payment reminders.

📴 WhatsApp outage on 09/01, and a watcher so it can't go unnoticed
Every /pb command and every outgoing message stopped at 18:01:10 PT when WhatsApp suspended the account; service came back at 00:05:20, an outage of 364 minutes. Nothing announced it — Gene found it by typing a command. A new check now asks the vendor every 15 minutes and raises an alert on anything other than a healthy state, including the suspension expiry time, so the wait is known instead of guessed.

📰 Release notes paused on WhatsApp, dashboard unchanged
Release notes are the leading suspect for the message volume behind the suspension: one WhatsApp message per push, with 30 runs on 08-31 and 57 on 09-01, of which 34 were notes-only commits. Sending to the group is paused behind a setting while that's sorted. The dashboard still gets every release note.

🛠️ Behind the scenes
Court-booking and CI failures now actually reach someone with the relevant log tail attached instead of "check the logs", release notes go through the normal sender so messages are counted, and payment and sorting changes are covered by new mutation-tested suites.

Tue 9/1/2026 36 commits

🎾 Courts are counted, not guessed
When Shyam hands his North booking back to his own club account, the bot used to only see one court — so a 6-player game got told "We have 6 players for 1 court, excellent!" and stopped recruiting while two spots sat open. Courts are now actually counted (including "North & South"), and every "we need N more players" line comes from that same count, so the header and the shortfall can't disagree. The 8:00 PM Last Call on 08/31 that read "Court: South" for a two-court game is the exact case this fixes.

📒 A ledger of every court we hold
There's a new Court Log that records each court-hour, written the moment we book it (with the hours we actually booked) and marked cancelled the moment /pb court cancel or /pb cancel game gives one back. /pb shyam now writes its handover row before anything else, so a hiccup mid-command can never hand the same court back twice. Game reports read courts from this ledger before trusting the poll text, so a court added or dropped after the poll went up is reflected.

🏷️ You can see whose court it is
A handed-over court is now labelled, so "Courts: North + South" doesn't hide that one of them sits on Shyam's booking — one we can't cancel or move ourselves.

🔄 Court info is actually live
The court fetch was quietly dying every scheduled run because chromium was never installed, so reminders went out showing courts as of poll creation and looked perfectly healthy. That's fixed, failures now say so loudly, and court figures carry how old they are instead of a three-day-old answer passing as fresh.

🚫 No more requests the club will refuse
The Athenaeum's booking horizon and cancellation cutoff are now known to the bot: /pb shyam won't be offered past the cutoff and /pb court cancel refuses inside the window and tells you what to do instead, rather than firing off something that gets rejected. /pb cancel game still goes through, since it also DMs players.

✅ Booking reports tell the truth
Both courts for Tue 09/08 7pm were booked and confirmed, and the run still reported a loss — the north and south jobs each try both courts, so one always "loses" the other. Confirmed bookings are no longer reported as lost, and a real loss still shows.

🗓️ /pb booking list
Show, add to and remove from the weekly booking schedule. Times are normalised (7pm, 7:00 PM and 19:00 all work), duplicates and no-op removals are refused, and the list always reads Monday-first with earlier times first. Also: the Game Day report no longer has a doubled blank line after the balance table, which tidies /pb show games too.

🛠️ Behind the scenes
Documentation corrections, safety guards on pushes and deploys, and a lot of work on the developer session tooling and its test suite.

Mon 8/31/2026 78 commits

🎾 Court handover with Shyam, now hands-free
When Shyam votes in the weekly poll, Picklebot now runs /pb shyam MM/DD/YY for him on every game he's voted for, and the confirmation lands in his DM. The credit is worked out from the hourly rate times the 2-hour session length instead of a fixed amount, so it stays right if the rate changes. Three separate guards make sure a vote, a re-vote or a repeat weeks later can never double up a payment.

🔔 Court bookings and cancellations are being announced again
For 4 days, court notifications were silently dropped and nobody was told — that's fixed and verified. Cancellations and bookings now go to both the person who asked and Admin Dinkers, with each send independent so one failure can't swallow the other. Cancellation replies also go back to whoever ran the command, instead of nowhere. The joke in the game cancellation reply is gone.

🙋 /pb shyam from a DM
Admins can now run /pb shyam by DM, not just Shyam himself, and a crash that made the command fail from a DM is fixed — nothing was ever charged by that bug. The command is now listed in the admin help and documented in the READMEs.

👋 A welcome message that tells new members the truth
The welcome text now comes from the WhatsApp Group Info description that all 45 members actually read, so there's one master copy instead of three that had drifted apart. The game times are gone — two of the three were wrong, and the weekly poll already carries that week's real times. The poll day is now named in one place, so it can't go stale again.

📈 DUPR ratings only go up when they should
Rating sync now moves in one direction only, so a stale read can't walk your SMAD rating backwards. Score pushes are now treated as a publish rather than a save, so a batch of results actually goes out.

🛠️ Behind the scenes
Every deploy is now smoke-tested instead of trusted, failed deploys actually fail, and production logs can be pulled from CI for diagnosis. Plus a long run of timezone fixes (everything is stated in Pacific now), developer safety hooks, and test suites that catch the bugs the old ones structurally couldn't.

Sun 8/30/2026 55 commits

🔔 No more vote nagging once the week is done
The daily vote reminder now checks whether there are any games left this week. Once the last game has begun, the reminder stops and non-voters are left off the shame list — no reason to chase a vote for a game you can no longer play.

📋 The Daily Nags Shame Report
The report now reads "Daily Nags Shame Report" with the date, so it's clear what it is when it lands.

😐 /pb match gets straight to the point
Asking for a match no longer tacks a joke onto the answer. Everything else, including /pb match undo and the post-game report, still comes with a joke.

💸 Payments find their way in on their own
There's a new /pb sync-zelle command, plus a payment sweep that now runs daily before the reminder job, so Zelle and Venmo payments get picked up without anyone asking. The bot also retries when Google Sheets hiccups mid-request instead of giving up. One such hiccup happened on 08/30 — nothing was lost, and the sweep and retries are there so the next one is handled quietly.

🛠️ Behind the scenes
The developer dashboard got a lot of attention: it no longer slides sideways on a phone, the commit log collapsed from 321 phone screens to 26 with a month index, and deploy timing is now measured (p90 of 108s across 324 runs) and charted instead of hardcoded. Also cleaned stray files out of the project root and tightened how commits are categorised and logged.

Sat 8/29/2026 11 commits

🎾 Scores are easier to record
You can now dictate a match and Picklebot will understand it — spelled-out scores like "eleven nine" work, and scores on plain typed lines are read the same way as lines with @-mentions. "best", "b" and "w" now count as separators alongside "beat" and "def". And when a line can't be read, you get the real reason instead of a misleading one.

📋 "Record for this session" now shows DUPR
The session table gained a DPR column pulled from the roster, so you can see ratings next to the results. It still fits at 24 columns on a phone, and if the roster can't be read you just lose the column, not the table.

↩️ /pb match undo tells you where you stand
Undo now has a proper header and lists the matches still recorded for the session, so the scorekeeper can confirm the right one was removed. An empty session says "no matches recorded for" rather than showing nothing, and a dry run is clearly marked "(DRY RUN)".

⭐ Admins can set someone else's rating
New /pb set dupr @player <rating> for admins, while setting your own rating works exactly as before. Ratings must be between 2.0 and 6.0 in 0.5 steps, and set dupr @someone with no number now tells you the rating is missing. It's listed in the admin help only.

🗳️ Clearer poll nudge
The vote reminder header now reads "Not Voted in This Week's Poll".

📱 Pocketed phones are starting group video calls
On 8/28 a pocketed phone started a WhatsApp group video call during a game, with three members joining — the same pattern as the 8/25 incident. If WhatsApp is open when you pocket your phone, close it or lock the screen first.

🛠️ Behind the scenes
Timestamps you see now say "PT" instead of a hardcoded PST label that was wrong half the year, release notes no longer tack on a joke, and set-dupr is available from the command line with the same rating rules.

Fri 8/28/2026 19 commits

💵 Balances that line up
The dollar figures in the balance block are now aligned, so you can scan who owes what down a single column instead of hunting across ragged lines.

😳 A tidier shame report
Money and voting are now separate sections of the shame report. Missing a payment and missing the poll are different sins, and now they read that way.

📬 Reminder emails you can actually file
Reminder email subjects now carry the date, and duplicates are collapsed behind them. Your inbox gets one clearly dated thread instead of a pile of identical-looking nudges.

📶 Messages going out again
A missing piece in the message sender had been breaking delivery, so some Picklebot messages never reached the group. That's fixed, and deploys are now blocked if a missing dependency like that ever sneaks in again.

🛠️ Behind the scenes
Every corner of the bot now reads the clock from one shared Pacific timezone setting instead of 13 files doing their own thing, and GCP errors are swept hourly so problems get caught even when nobody's watching. The developer dashboard also picked up outage tracking with mean-time-to-restore and change-failure-rate tiles, a 5-per-row layout, and a build stamp.

Thu 8/27/2026 10 commits

🔔 The bot now tells us the moment it goes offline
If Picklebot's WhatsApp connection drops, an email alert now fires right away instead of the group finding out the hard way. On 8/26 the bot was offline for six minutes and we only noticed by chance — that was the last time. The alert goes by email on purpose: when the WhatsApp session is the thing that's broken, a WhatsApp message would never arrive.

🔒 Tighter security on the bot's WhatsApp account
Two-step verification is now switched on for the number Picklebot runs on, with a recovery email registered, so nobody can re-register it. Everything kept working through the change — the bot's session stayed connected and a test message went out fine.

🛠️ Behind the scenes
We closed out the investigation into the 8/25 group changes, ruling out one possibility at a time until only one explanation remained, and logged a support request with our WhatsApp provider for their session logs. We also wrote up how to audit and restore the bot's linked devices, so the next hiccup takes minutes instead of days.

Wed 8/26/2026 23 commits

🎾 Court bookings are now verified, not assumed
Picklebot used to report a booking as done the moment it submitted the request. Now it reloads the reservations grid afterward and checks the court is actually there, and the confirmation email says SUCCESS only for a confirmed booking — otherwise SUBMITTED. Dry runs are flagged as such, and the verification step is time-bounded so it can never swallow the report.

📖 New /pb post game report
You can now pull up the post-game story on demand with /pb post game. The same renderer powers the match confirmation, so the two always tell the same tale.

✅ /pb match now shows the session's matches so far
When you record a score, the confirmation lists the running match list for that session, so you can see at a glance what's been logged and catch a mistyped score right away.

🗓️ Game day info in one place
/pb game next now mirrors the Game Day report, so you get the same details whichever you ask for. The list of who hasn't voted yet moved into the Game Day report, and /pb game remind is new for nudging them.

📋 Tidier rosters and standings
The game roster now uses the same player table as everywhere else, so columns line up consistently. The trailing period after the rank is gone, which keeps /pb players from wrapping on narrow phone screens.

🛠️ Behind the scenes
Deploy and Terraform failures now open GitHub issues with the actual exception line, error reporting can be listed and resolved from CI, and the diagnose tool gained free-text and multi-term log search with configurable sweeps (25h, 48h, 7-day). Also added a roster-width check that measures the Top N leaderboard and no longer passes when it reads zero players, plus some file cleanup and docs.

Tue 8/25/2026 31 commits

🗳️ Poll and reminder nudges
If the poll goes out today, you won't also get a vote reminder for it — no more double nudges. Shame-list ties are now broken by last name, so the order is stable instead of random. Weekly poll creation now runs as part of the one daily job, so it fires on a single reliable schedule.

🤖 /pb is back to answering properly
The AI model behind /pb intent reading had been retired, and a bad library version was breaking its Firestore lookups — both are fixed, so asking /pb things works again. Every /pb command now gets written to an audit trail after your reply is sent, so logging never slows your answer down.

💸 Payment thank-yous for everyone
Thank-you emails for Venmo and Zelle payments were only going out to players who had opted into SMS. That gate is gone, so you get your confirmation either way.

📋 Cleaner Last Call report
Tightened the spacing to a single blank line before Group Stats, so the Last Call report reads cleanly in WhatsApp.

🔔 Game day reminders no longer vanish quietly
If a game day reminder gets skipped — nobody marked y/2 for the date, or a marked name doesn't match the roster — it now gets reported instead of silently doing nothing, so a missing reminder gets noticed and fixed.

🛠️ Behind the scenes
Plenty of plumbing: logging that was being silently thrown away in all four functions now works, keepalive checks fail loudly instead of pretending the bot is warm, WhatsApp send failures record the full response, and usage and log diagnostics plus a scheduler audit were added. We also locked dependencies across all the Cloud Functions and CLI, cleaned out dead Twilio secrets, inventoried the rest, and fixed Terraform timeouts and plan failures hiding themselves.

Mon 8/24/2026 4 commits

📱 The Sunday poll post now reads clean on your phone

The upcoming-games report that goes out with the weekly poll is titled "Week of MM/DD/YYYY Upcoming Games" so you can see at a glance which week it covers. The per-game rosters and the "we need N more players" warning are gone from that post — right after the poll goes up the only voter is whoever created it, so those lines said nothing. "This wk" now counts the poll's week instead of the week that already finished, so the attendance number matches the games listed above it.

📊 Leaderboard and stats titles fit one line

The player leaderboard no longer wraps awkwardly on mobile, and the group stats headers were shortened to "Top 15: Hours Played Since 6/25" and "Player Bal/Cred and Nags" so each sits on a single line. The vote shame title lost its trailing colon too.

📌 A nudge to pin the poll

When the weekly poll posts, Gene now gets a DM with the poll question and its options as a reminder to pin it to the top of the group. Vote reminders, last call and the shame report all tell you the poll is pinned, so a missed pin made three messages wrong. WhatsApp offers no way for the bot to pin on its own, so the reminder arrives at the one moment it can be acted on — and if the DM fails, the poll still goes out.

🗂️ Archiving a player actually removes them from the group

Archive removals had been silently failing: the bot was asking WhatsApp to remove the player without the credentials to do it. Those are now in place, and the bot confirms removal by checking the live participant list rather than trusting a "success" reply, re-checking once after 2 seconds to allow for WhatsApp lag.

🛠️ Behind the scenes

The weekly attendance count had been copy-pasted into three files and is now one shared piece of code, and missing credentials log a clear error instead of an unhelpful failure.

Sun 8/23/2026 8 commits

🏓 Match scores are here — /pb match now takes an optional score, and you only type the losing score: the winner's number comes from league rules (to 11, win by 2 from 10-10, hard cap 15). A losing score above 14 gets rejected, since 15 is the cap. You can also use @me for yourself (WhatsApp won't let you @-mention yourself), and typed names work too, including multi-word ones like @Jerry Shen, so mentions, @me and typed names all mix on one line.

📅 Matches are logged under the game's own date — The log now stores the date and time the game was actually played, not the moment the score was typed. Match IDs are anchored to the game as well, so 20260821-8pm-1 is the first match of the 8/21 8pm game. Scores like 11-9 and times like 8pm no longer get mangled into dates and clock values.

📊 Win-loss on the player list and leaderboard — /pb players and the Group Stats leaderboard now share one layout and both show a W-L column next to hours and DUPR, with a blank record showing as 0. The balance column is gone from both, which narrows /pb players from 43 to 37 characters so it stops wrapping on phones — balances are still in /pb balances and the group stats balance block. Sorting stays on hours played, as there isn't enough match data yet to rank on win-loss.

😳 Daily shame report — New /pb shame shows who owes money and who hasn't voted in this week's poll, and it also posts to the group from the 8am daily run, right after the Venmo sync and payment reminders so balances and nag counts are same-day fresh. Nobody gets shamed on the day the poll is created, so you always have a chance to vote first — you can still pull it up on demand any day. The balance table now has Name, Bal/Cred and Pay Nags headers, and non-voters are listed by how many vote nags they've collected.

🎉 A nicer note when everyone's square — If there are credits but nobody owes, the report says so under the Player Balance/Credit header instead of reading like a shame list, and the existing all-square message still covers the case where no one has any balance at all. The balance and vote lists now sit together as one block, the header carries the report date as Daily Shame Report for MM/DD/YY in PST, and the "want to show up" typo is fixed.

🛠️ Behind the scenes — Shared renderers for the player table and balance block so the same numbers show up the same way everywhere, plus README and docs updates.

Sat 8/22/2026 37 commits

🏆 Match results are now tracked
You can log doubles matches straight in chat by @-mentioning the four players, one match per line, so a whole session goes in with a single message. Only doubles count — exactly two players a side — and everyone in a match must have voted to play that session, which keeps the log in step with payments. Your own record shows in /pb stats my and everyone else's in /pb standings; /pb record is gone, and record/records now take you to the standings.

❌ Canceling a game tells the people who were playing
/pb cancel game next cancels the earliest upcoming game from the current poll, and if there's nothing upcoming it says so plainly instead of choking. Every cancel now DMs and emails each player who voted yes, with the game time and a note that the courts were released, sent before the vote column disappears so nobody gets missed.

🔔 Reminders now tell you how many you've had
Payment reminder DMs count your nudges and suggest pre-paying $50/mo, and vote reminder DMs say how many vote reminders you've received. Your Stats block shows both Vote Reminders and Payment Reminders after your DUPR, with the pre-pay hint when you owe. The vacation example now reads /pb set vacation MM/DD/YY with your return date.

🥇 Leaderboard back to top 15
Group stats list 15 players instead of 20, with the header updated to match.

😂 Better pickleball jokes
Social links shared in the group chat are now collected daily into the jokes sheet, with duplicates of the same reel merged and @-mentions turned into first names so phone numbers are never stored. A deeper scan back through June added 14 more jokes, bringing the sheet to 150, and non-jokes like photo albums and group invites are filtered out.

⚖️ Health log reads cleanly
Entries now show as 08/21/2026: 195.4 lbs | 226h | 3.5, and a credentials fix means the log no longer comes back empty when it actually has entries.

🛠️ Behind the scenes
The Release Dashboard got codebase and velocity stats, commits-per-day and time-of-day charts, and emails and phone numbers are stripped from it automatically. Deploys got faster — about 73 seconds, down from 90.

Fri 8/21/2026 15 commits

🏆 Top 20 leaderboard, now with money and attendance — /pb games grew from a top-10 list into a full report: Top 20 — Hours Played Since June 2025 with DUPR ratings, then weekly attendance, then a balances and credits section with neatly aligned columns and reminder nag counts. The report now also gets posted to the SMAD group after a poll is created and with the daily reminders, so everyone sees the same numbers.

📊 Your stats in DMs, the group's stats in the group — DMs now show your own personal stats block, including your playing streak read from your full game history, while group messages show the group picture. The pointless "Played this week!" line for a streak of 1 is gone, and every message ends with a standard signature, a hint on how to get help, and a joke.

🎾 Court numbers on your games — /pb games, your next game and every game display now show which court you're on. Court info is fetched live from Athenaeum for all courts and refreshed on demand, so you're not looking at a stale booking from an old poll.

🌤️ Humidity in the forecast — Game reports and /pb weather now include humidity %, on its own line so the forecast stays easy to read.

💸 Venmo payments match more reliably — For standard payments, we now match on a unique last name as a fallback, so fewer payments get stranded as unmatched. Thank-you messages also show the correct PST/PDT label instead of a hardcoded one.

🧹 Tidier Last Call — The "Already voted" list was dropped from the Last Call group summary, keeping it to what you actually need to see before the deadline.

🛠️ Behind the scenes — Fixed the daily welcome-back DM for returning players (missing credentials, plus it now only sends after a vacation is actually cleared), replaced hardcoded "2h before" references with the real game-time constant, and worked through some Venmo timestamp and email auth logging, docs and code cleanup.

Mon 8/17/2026 1 commit

🎾 New: /pb shyam
One command now cancels the North court and credits $10 back — no more sorting it out by hand in the chat.

Sun 8/16/2026 2 commits

🏖️ Vacation bookings land on the right day
Fixed a bug where booking a court while a player was on vacation could end up with the wrong date. Your booking now holds the day you actually picked.

🛠️ Behind the scenes
Added a backup scheduled trigger so the member sync keeps running even if the usual runner isn't available.

Fri 7/31/2026 1 commit

🎾 Court info straight from the source

Game-day and last-call messages now pull the court listing live from the Athenaeum, so what you see is what's actually booked.

Thu 7/30/2026 4 commits

📋 Weekly poll is simpler
The "Want to play but not these times" option is gone from the poll — it wasn't being used. One less thing to scroll past when you vote.

🏖️ Vacation mode handles your balance
When you confirm vacation, if you still owe money, Picklebot now shows your balance and a payment link right there so you can settle up before you go. The exemption message no longer mentions payment separately, since it's now handled in the confirmation.

🔔 Reminders in a smarter order
Player reminders now lead with payment reminders, so what you owe is the first thing you see instead of buried further down.

Sat 7/25/2026 2 commits

🎾 "Need more players" now says how many

When we're short for the week, the nudge is personalized to how many courts we have booked, so you know exactly how many more players we need instead of a generic call for bodies.

⏰ Last call moved to 1 hour before game time

The pre-game last call used to go out 2 hours ahead; it now lands 1 hour before we play. That keeps the final heads-up closer to the actual decision moment.

Wed 7/22/2026 1 commit

🌴 Vacation mode for the group

While Gene is away, Picklebot will sit quiet: no weekly poll, no reminders, and no court booking. Nothing to do on your end — normal service picks back up after the break.

Tue 7/21/2026 4 commits

🔔 Last call only pings people who haven't voted
If you already responded to this week's poll, last call will leave you alone — a blank game cell for a poll responder now counts as an implicit "no". Only players who haven't voted since the poll went up (or have never voted) get the nudge. Vote reminders and last call now read your voting history the same way, so the two can't disagree.

🆕 A correct free first week for new players
New members now start with their join date on record instead of next Sunday, so your first week is free no matter which day the poll was created.

🎾 Courts shown where you need them
Last call and the game-day report now show which courts are booked. Poll options and the sheet's column headers carry the court name too, so you can see where you're signing up to play — now without the clunky angle brackets around the court name.

Sun 7/19/2026 1 commit

🛠️ Behind the scenes — Switched the Claude Code plan from Max ($100/mo) to Pro ($20/mo) to keep running costs down. Nothing changes for you in the group.

Fri 7/17/2026 2 commits

🏝️ Vacation can now run as long as you need

Setting a vacation no longer caps your return date at one month out. Tell Picklebot any future date and it's used exactly as you gave it — the old "max length" warning is gone from the help and status text too.

🛠️ Behind the scenes

New players added to the roster now start with their Last Voted date set to the coming Sunday.

Thu 7/16/2026 2 commits

👤 Your name, spelled out properly
When WhatsApp only hands us your initials, Picklebot now checks Google Contacts to find your full name instead. So you should see real names on the poll, the Game Plan and the score sheets rather than mystery letters.

🛠️ Behind the scenes
The member sync job now carries the SMAD hourly court rate in its own settings, so cost-related numbers stay consistent.

Wed 7/15/2026 10 commits

🔔 Vote reminders now go to the right people
A wiring problem was sending reminders based on the wrong poll date, so some of you got nudged when you'd already voted. Poll creation dates are now read correctly, including from the Poll Log Archive for older polls, and "last voted" is compared by date only so a vote earlier the same day counts. Odd entries in the Poll Log no longer throw the reminder off either.

🛠️ Behind the scenes
We added error alerts for the things that quietly break a player's day — a failed WhatsApp send, a Venmo or Zelle payment not recorded, a missing payment thank-you, or a failed poll date lookup — so we hear about them instead of you. The webhook that receives your messages also got more memory, from 256MB to 512MB.

Tue 7/14/2026 1 commit

🎾 Court names are back to normal
We briefly had the North and South courts mixed up — that's been put back the right way, so North is court 3 and South is court 4 again. Bookings and reminders will now point you to the court you actually expect.

Mon 7/13/2026 6 commits

🛠️ Behind the scenes — Picklebot's underlying libraries (Playwright, python-dotenv, pytz, the google-* packages, venmo-api, matplotlib and the webhook requirements except cloudevents) are now unpinned so they always pull the latest versions. Nothing changes in how you use the bot.

Sun 7/12/2026 3 commits

🎾 Court bookings land on the right court
North and South were swapped behind the scenes, so a booking could show up on the wrong court. The IDs are now correct — North is court 4, South is court 3 — so what you see in the booking is the court you'll play on.

🛠️ Behind the scenes
Picklebot's WhatsApp messaging now runs through one shared internal piece instead of an outside library, and some stray debug files were cleaned out of the project.

Thu 7/9/2026 1 commit

💸 Zelle payments from business accounts now match up

Picklebot reads the Zelle column names a little more flexibly, so payments sent from business accounts get matched to your name instead of sitting unrecognized. If you've paid from a business account before, your dues should now land where they belong.

Wed 7/8/2026 2 commits

🌍 International numbers now work
If your WhatsApp number isn't a US one, Picklebot now handles it properly — no more mix-ups when it DMs you.

📵 No more text messages
SMS is gone. From now on you'll hear from Picklebot by WhatsApp DM and email only, so everything lands in one place instead of being split across your texts.

Fri 7/3/2026 5 commits

🎾 Court booking got smarter
Picklebot can now book the West and East Tennis Courts, so there are more slots in play each week. The backup booking job now skips single-court entries, which stops us from accidentally double-booking the same court. And when a slot is already taken or the date doesn't match, the bot closes the pop-up and carries on instead of giving up on the rest of the bookings.

🛠️ Behind the scenes
Updated python-dotenv (security fix) and the browser automation tool Playwright that drives the court bookings.

Sat 6/20/2026 3 commits

💳 Venmo payments keep getting picked up
The mailbox connection that spots your Venmo payments now refreshes its access on both sides at once, so the payment checker on Cloud Run stops going quiet after a token renewal. Fewer moments where you paid and the bot hadn't noticed yet.

🎾 No pointless backup booking runs
The court booking backup job now skips itself entirely when there's no |Both entry in the booking list, so it only wakes up when there's actually a second court to chase.

🛠️ Behind the scenes
Documented how the Gmail token stays in sync across both secret stores and gave CI the write access it needs to do it.

Sat 6/13/2026 1 commit

🎾 Court names Picklebot understands
You can now say "south court" or "north court" and Picklebot will know exactly which court you mean, instead of not recognizing the name.

Wed 6/10/2026 7 commits

⏰ Game day reminders reach everyone
If you voted on game day itself, the reminder could skip you. Now everyone who's in gets the reminder.

🙋 Clearer opt-out in vote reminders
The vote reminder now shows you the exact command to use — /pb set vacation — so you can mute the nudges without guessing the wording.

🔁 Fewer missed polls and bookings
The daily runner, the Sunday poll and court booking now retry automatically if something hiccups, so the weekly poll and your court are less likely to be held up.

🛠️ Behind the scenes
A new watchdog keeps an eye on all 5 automated workflows and alerts Gene by DM and email if one fails, and the monitoring and sync-members schedule are now documented.

Tue 6/9/2026 1 commit

- On Sundays, last-call was reading only the current week, so it never saw the Monday games that had just been created, and nobody got a reminder.
📣 Sunday last call fixes
The last-call reminder was only looking at the current week, so on Sundays it missed the Monday games that had just been created and nobody got a nudge. It now checks both weeks, so you'll get your reminder to sign up for the upcoming games.

Fri 6/5/2026 3 commits

👥 The roster keeps itself up to date
Picklebot now checks the WhatsApp group every morning at 8am PT and updates the player list automatically. If someone leaves the group, they're archived; if they come back, they're restored. Names are also looked up through Contacts, so new members show up properly instead of as a bare phone number.

📋 Survey answers come along for the ride
The daily member sync now also pulls in survey responses, so your preferences stay current without anyone having to re-enter them.

Wed 6/3/2026 10 commits

💰 Last Paid dates stick around again
Your Last Paid date now gets stamped straight onto the main sheet when you pay, so it no longer disappears when old payments are archived. New players get a proper date from the start, and there's a backfill that scans both the Payment Log and the Archive to repair any dates that went missing.

🫀 Early warning before the Dead Man Switch
If Gene hasn't checked in by day 9 or 10, Picklebot now sends an alive check before the Dead Man Switch fires. Both the alive check and the Dead Man Switch notification go to the Admin Dinkers group as well as Gene by DM and email, so nothing slips through quietly.

📋 Archived players no longer clutter reminders
Vote and survey reminder previews now list archived players in their own "Archived" section instead of mixing them in with players on vacation. Archived players are also left out of survey slot rankings entirely, so the Game Plan only considers people actually playing.

🔐 Sheet access handled automatically
Picklebot now grants viewer access to the sheet when a new member is synced in with an email, and revokes it when a player is archived. A one-time cleanup removed access for players who were already archived.

📇 Better contact sync
/pb members sync now pulls names and emails more reliably from Google Contacts, so emails land in the sheet on sync instead of staying blank — which is also what makes the automatic sheet access work.

🛠️ Behind the scenes
The README now covers the Drive access lifecycle, the player archive system and the Dead Man Switch early warning, plus details like the MM/DD format for /pb set vacation and the Contacts fallback during member sync.

Tue 6/2/2026 2 commits

💵 Court fee is now $5/hr
The court rate used for session costs has been updated to $5/hr, and the welcome message now says $10/session. Sessions before the rate change date still use the old rate, so past balances stay correct.

🌴 Leaving the group and coming back is smoother
Players who leave are now parked with a vacation date of 12/31/2099 instead of being moved to a separate archive sheet. If you rejoin, your row is found by phone number and your vacation date is cleared, so your history comes back with you. Archived players still get payment reminders if they owe money.

Sun 5/31/2026 1 commit

🗳️ The weekly poll now lands Sunday morning — Picklebot creates the poll at 8am on Sunday instead of Saturday at 1pm, so you'll see it when the week ahead is actually on your mind.

Wed 5/20/2026 5 commits

👋 New players get a proper welcome
When WhatsApp doesn't share your name, Picklebot now checks Google Contacts to find it. If it still can't, your welcome DM just says "Hi there!" instead of the old "Hi Unknown (1926)!".

📧 Email notifications reach everyone
Heads-up emails now go to any player with an email address on file, instead of only those set up for SMS reminders. Archive notices do the same, so if you're being archived you'll actually hear about it.

✍️ Clearer wording when you're archived
The reasons now say "last month" instead of "last 3 months", and "not voted in the weekly games poll" instead of just "not voted", so it's obvious what happened and how to come back.

🛠️ Behind the scenes
Added a global off switch for emails, enabled the People API, and updated the Gmail setup docs for the new contacts permission.

Sun 5/17/2026 1 commit

🏖️ Vacation dates are easier to type
You can now set your time away with just the month and day — no year needed. Type /pb set vacation with a date like MM/DD and Picklebot will understand it.

Sat 4/25/2026 2 commits

🗳️ New poll option for "none of these times work"
The weekly availability poll now ends with an extra choice: "Want to play but not these times. DM Gene your openings." Pick it if you want in but none of the listed slots work, then message Gene your openings. Choosing it marks you as unavailable for all the listed times, so the poll count stays accurate.

Thu 4/23/2026 6 commits

📋 Game report player list is easier to read
The player list in the game report is back to a clean column-aligned layout, with name, DUPR and hours lining up down the page. We tried a few formats to get it rendering right in WhatsApp and settled on the one that's easiest to scan, and the spacing around the list is tidied up so there's no stray gap after it.

🛠️ Behind the scenes
Added a separate email blast script that reads the 2026 Senior Parents Email List and groups 220 students into 207 unique families so each household gets one personalized note.

Tue 4/21/2026 1 commit

📦 Roster cleanup now moves faster, and vacations are safe

Players who haven't played in 1 month (down from 3 months) are now flagged for archiving, so the roster stays current. If you're on vacation, your return date counts as your latest activity, so you won't be archived while you're away. Setting a vacation updates your Last Voted date to your return date, and the archive preview message now says "1 month" to match.

Sat 4/18/2026 2 commits

💸 Payment thank-yous now show when you paid

When your Venmo or Zelle payment is picked up, the thank-you message now includes the exact time of the transaction, like "on Apr 16, 2026 at 5:53 PM PST", so you can match it to your own records at a glance.

🕒 Payment Log timestamps in PST

Every payment entry is now stamped in Pacific time, and the old "Date" column is now "Transaction Date". Venmo payments keep their full transaction time instead of being trimmed to just the day, so the log reflects exactly when money moved.

🔁 No more duplicate payment entries

Payment sync now checks both the Payment Log and the Archive before recording anything, and skips transactions older than the last aggregation. That means a payment you already made won't show up twice after dues are totaled.

Fri 4/17/2026 5 commits

💸 Refunds and credits now work in /pb record payment
You can record a negative amount, so /pb record payment Derek Chang -4 venmo zeroing balance finally goes through instead of failing to parse. Handy for refunds, credits, or zeroing out a balance.

🧾 New /pb aggregate payments to tidy up the Payment Log
This rolls each invoiced player's payments into a single row and moves the individual rows to a new Payment Log Archive sheet. Your Paid and Balance numbers stay exactly the same, and running it again just updates the existing rows rather than double-counting. A dry run shows the aggregate amount per player before anything changes.

📦 Archiving inactive players got simpler
Archiving now goes by 3 criteria instead of 4: no games in 3 months, no votes in 1 month, and not on vacation. Filling out the survey no longer keeps an inactive player on the active roster. The preview, email, and DM notifications all list the new criteria.

🗳️ No more empty games summary right after the poll
The next-week games summary is no longer posted the moment the poll goes up, since it had zero votes and nothing useful to show. The poll itself still posts as usual, and last-call reminders are unchanged.

🛠️ Behind the scenes
Documented /pb aggregate payments in the README and tidied up the Payment Log columns so everything lines up.

Thu 4/9/2026 1 commit

🧹 Removing someone from the group now tells the truth

When a player is removed from the WhatsApp group, Picklebot now checks whether it actually worked instead of assuming it did. Failed removals used to be reported as successes, so someone could stay in the group while the bot said otherwise. Failures are now logged with the full response, which makes the occasional hiccup much faster to track down.

Wed 4/8/2026 1 commit

📋 Survey sync now shows who's missing

When the survey syncs, the summary now lists respondents whose email or name didn't match anyone in the main sheet, plus the players who haven't taken the survey yet (skipping anyone on vacation). The Survey Reminders summary also lists the reminded players as bullets, the same way Vote Reminders does, and adds an On Vacation section sorted by return date. Less guesswork about who still needs to fill it out.

Fri 4/3/2026 1 commit

💸 Payment thank-yous show the right balance

When your Venmo or Zelle payment is recorded, the thank-you message now shows your balance after the payment is subtracted, so the number you see matches what you actually owe.

🛠️ Behind the scenes

Deploys now copy the whole shared code folder, so updates can't go out half-stale.

Wed 4/1/2026 2 commits

💰 Credits now show up correctly
If you had a credit on your account, the bot was sometimes reading it as zero when Venmo or Zelle payments came in. Balances are now read the same way everywhere, so your credit is counted properly and payment notes show the right amount.

✉️ Clearer vote reminder DM
The reminder DM has been reworded and reordered: the note about opting out now comes right after the group link, and the copy mentions the Saturday 1pm poll and that you can set your own time off with /pb set vacation.

Tue 3/31/2026 1 commit

💵 Credits now read like credits

When you're ahead on payments, Picklebot says "$3.00 credit" instead of the confusing "$-3.00". Balances now look the same everywhere you see them — the game day DM, /pb balance mine, and payment reminders.

Mon 3/30/2026 1 commit

📣 Last call now finds next week's games

When you send a last call for a specific game date and time, Picklebot now checks this week's poll first and then next week's. Games sitting on next week's poll can finally be targeted instead of coming back empty. If the date still doesn't match anything, the error message lists the games available across both weeks so you can pick the right one.

Wed 3/25/2026 7 commits

🏖️ Set your own vacation — no more asking an admin
Use /pb vacation to see your current status, /pb set vacation <MM/DD/YY> to go on vacation (up to 31 days — longer dates get capped with a warning), and /pb clear vacation when you're back. Dates work as MM/DD/YY or MM/DD/YYYY, and the commands work in any chat, not just DMs. Admins get a heads-up automatically whenever you set or clear vacation, so the poll and Game Plan stay accurate.

🎾 Check and update your own DUPR
/pb dupr shows your current SMAD DUPR rating, and /pb set dupr <rating> updates it (2.0 to 6.0, in 0.5 steps). Works in DMs, the SMAD group, and Admin Dinkers. Changes are sent to the admins so ratings stay trustworthy.

📦 Archiving inactive players (admins)
/pb archive players finds players who have gone quiet on all four counts — no games in 3 months, no votes in 1 month, no survey, and not on vacation — moves them to an archive sheet, removes them from the WhatsApp group, and sends them a DM and email. /pb archive player <name> archives someone directly. If an archived player comes back, the members sync now restores their original row instead of starting them from scratch, and the archive sheet keeps its game columns in step when a poll is created or a game is cancelled. The notification DM now includes a working group link, and if one player's archive fails the rest of the batch still goes through.

😄 Joke is for everyone
/pb joke now sits in the self-service part of the help text instead of under admin Actions, because anyone can use it.

🛠️ Behind the scenes
Deploy config now ships the pieces the archive command needs to remove players from the group, and the README env var table was brought up to date.

Tue 3/24/2026 2 commits

💵 Payments accept more methods and dollar signs
Recording a payment now works with any method you name — court-credit and credit included, not just Venmo, Zelle, cash or check. You can also type the amount with a dollar sign (like $8) and it will be read correctly instead of being rejected.

Sun 3/22/2026 4 commits

📧 Game day reminders reach everyone again
Your game day DM now goes out with email notifications working properly — some players were missing them before. The message also looks cleaner, with the extra blank line between the player list and your balance removed.

🗳️ The weekly poll only shows up when it should
The poll is now created only by the Saturday 1pm job, so it can no longer appear by accident during the daily 8am run. Vote reminders are also more reliable: the poll's creation date is saved the moment the poll goes out, so reminders work even before anyone has voted.

⚖️ Weight logging now shows your full stats
When you record your weight, you get the confirmation plus your complete stats report and history right away, instead of just a one-line reply.

🛠️ Behind the scenes
Reminders now pull player info from a single source and one fewer spreadsheet read, and a shared Firestore setup was added across the bot's services.

Sat 3/21/2026 1 commit

🗓️ The weekly poll now drops Saturday at 1pm

Instead of waiting for Sunday morning, the poll for the week's game goes out Saturday at 1pm. You get an extra day to check your plans and lock in your spot, and we get a clearer headcount earlier for the court booking.

Fri 3/20/2026 1 commit

📋 Poll reminders now come every day

If you haven't answered the weekly poll yet, Picklebot will nudge you daily instead of only on even-numbered days. Fewer missed replies means the lineup locks in sooner.

Thu 3/19/2026 8 commits

💸 Payments record themselves again
Venmo and Zelle payments were failing to sync, so some payments weren't being recorded — that's fixed. Zelle now only looks at the last 24 hours of email, so old payments no longer get re-recorded as new ones. Zelle thank-you messages send properly again too.

📧 Emails are back on
Email had been silently switched off in production, so notifications that should have reached your inbox never did. Email is now on by default, with a single switch to turn it off if ever needed.

💰 Clearer payment reminders
Payment reminder DMs now show the date of your last game, a Venmo link, and consistent 💰💸 emojis whether they reach you on WhatsApp, SMS or email. The thank-you message after a payment got its missing 💰 back.

✍️ Picklebot signs off the same way everywhere
Venmo and Zelle messages were signing as "SMAD Pickleball" without the emojis while everything else used the proper signature. Now every message Picklebot sends uses one identical signature.

📝 Tidier messages
Vote reminders and game reports lost their stray blank lines — before "Games This Week", before "We need X more", and between the signature and the joke. Game reports now always show the detailed DUPR and hours format instead of sometimes falling back to a plain list of names.

📋 Shorter survey reminder
The survey reminder is now a simple one-time explanation of the DUPR, SMS and email opt-ins. The outdated session schedule and bar hours text is gone.

🛠️ Behind the scenes
Shared formatters and config were consolidated so reminders, signatures and deploys all pull from one place, and the docs were updated to match.

Wed 3/18/2026 4 commits

💸 Zelle payments now record themselves
Pay with Zelle and Picklebot picks it up straight from the payment email — no more asking someone to mark you paid. It matches your name even if you go by a nickname (Gabriel → Gabe), and every payment is tagged with a transaction ID and a recorded-at time so nothing gets counted twice. Venmo keeps working exactly as before.

📧 Reminders now arrive by email too
Vote reminders, balance reminders and last call can now reach you by email as well as WhatsApp and SMS, all sent together so you never get one without the other. Email subjects carry an emoji so you can spot them at a glance (Vote Reminder 🗳️, Balance Reminder 💰, Last Call 🥃).

🙏 A thank-you when you pay
Record a payment and the player gets a thank-you DM on WhatsApp, SMS and email, sometimes with a joke. Messages also got a light polish: no more "Thanks," opener and the signature is now bold.

💡 Lights for night games
For games starting at 6pm or later, Picklebot emails the courts desk at 8pm to ask for the lights, so you're not playing in the dark. If the game gets cancelled, that reminder is cleaned up automatically along with the other scheduled jobs.

🛠️ Behind the scenes
Added the Gmail credentials to our secret setup and refreshed the docs, made the Venmo sync optional when no token is set, and fixed a scheduling bug that could pass the wrong setting when creating reminder jobs.

Tue 3/17/2026 1 commit

🗓️ Availability heatmap always shows the latest answers
The heatmap now updates when someone edits their existing poll answer, not just when a new person replies. Previously an edit could leave the old picture in place, since only the number of responses was being checked. Each heatmap link is also freshly stamped so WhatsApp can't show you an old image.

Mon 3/16/2026 4 commits

🗓️ New games show up right away
/pb games used to say "No games scheduled" until someone voted — now every game appears as soon as the poll goes up. Last call also works properly right after poll creation, and the night-before last-call nudge now goes out at 8pm instead of 7pm.

❌ Cancel a game by day name
You can now type /pb game cancel mon (or fri, etc.) instead of the full date, and Picklebot finds this week's game for you. If there's no game that day — or more than one — it'll tell you instead of guessing. Sunday cancels now look ahead to the upcoming week's poll, so a Mon 3/16 game is found correctly.

Fri 3/13/2026 4 commits

💸 No more double payment pings
Venmo syncs and their WhatsApp messages could fire twice when a message got delivered more than once. That's now locked down, so you get one payment confirmation per payment — not two.

📋 Game info looks the same everywhere
Game Day Reminder, Last Call, Vote Reminder and Next Game now use one shared layout, with the same date style — DoW MM/DD/YYYY @ time — in all of them. The full player list always shows, so no more cut-off at 8 players. Weather for the Vote Reminder is fetched in one go, so it arrives faster.

🗓️ /pb games shows the whole week
It used to list only games still ahead of you. Now you see every game this week, with past ones marked with a checkmark and no weather.

🛠️ Behind the scenes
README and architecture notes were updated for the payment dedup work and the court argument removal from game reminders.

Tue 3/10/2026 11 commits

🔔 Vote reminders every day
Vote reminders now go out daily instead of only on game days, so you get a nudge whether or not it's a game night. The reminder DM is shorter and names the poll you haven't voted in, with a link straight to the group.

📣 Last Call knows which poll
The Last Call DM now names the poll and includes the group link, so you can see what you're being called for and jump in with one tap.

😄 Jokes can carry links
The jokes sheet now supports a URL column, so a joke can come with a link attached. The meme command has been retired.

📲 Texts go out through a registered messaging service
SMS now sends over an A2P 10DLC messaging service instead of a single number, which keeps texts from the bot landing reliably.

🛠️ Behind the scenes
We removed the Sentry error-tracking setup, consolidated duplicate GitHub token secrets, and tidied the README with updated secret counts and a codebase size table.

Mon 3/9/2026 5 commits

😄 A joke with every message

Picklebot now signs off at the end of its messages instead of the top, and tacks a pickleball joke onto every WhatsApp send — including these release notes. Same info, slightly more groan.

⏰ 7pm Last Call

There's now a Last Call reminder in the evening at 7pm, so you get one more nudge before the weekly poll closes. Scheduling also correctly rolls over to next week when it should, so reminders land for the right game.

🛠️ Behind the scenes

Fixed credentials handling for the jokes sheet so it works from our command line and automated runs, and smoothed out infrastructure setup by reusing the existing database instead of failing on it.

Sun 3/8/2026 1 commit

😂 A joke with every reply
Picklebot now tacks a random joke onto the end of its responses, including court bookings — the only exceptions are /pb joke and /pb meme, which are already in the joke business. Small thing, but it should make checking the poll a little more fun.

💸 Cleaner Venmo handles
If you include an @ at the front of your Venmo username, Picklebot now strips it before saving, so your handle is stored the way payments expect it.

Sat 3/7/2026 2 commits

🎾 Court booking is back to grabbing courts cleanly
The booking run no longer does its warm-up pass, which was sometimes leaving a pop-up overlay on screen and blocking the click that locks in your court. Bookings should now go through without getting stuck.

🛠️ Behind the scenes
The warm-up change is written up in our profiling notes so we can keep an eye on booking speed.

Fri 3/6/2026 17 commits

🗑️ Cancel a booked game
You can now call off a court with /pb game cancel — no more asking someone to do it by hand. It's listed in the help text, and the Game section now shows a paddle icon so it's easier to spot.

⚖️ Track your weight with Picklebot
New /pb weight record lets you log your weight, and it works in every group, not just one. Each entry comes back with the date so you can see exactly when you logged it, and your weight is now stored on the main sheet alongside your DUPR.

📊 See your own stats
/pb stats my shows your current stats plus your full Health Log history, newest first, with weight, hours and DUPR on each entry. Health commands now sit in the read-only section of the help list so it's clear they only show you information.

🌐 A home page for SMAD
Picklebot now has a public landing page, complete with a header photo.

🛠️ Behind the scenes
Fixed a missing module in the deploy so the health commands work reliably, corrected the Health Log column mapping, tidied the court-booking script's behavior when a cancel fails, and updated the READMEs and systems diagram.

Thu 3/5/2026 3 commits

💌 Payment confirmations keep flowing
Picklebot now renews its Gmail connection every other day instead of every 6 days. That means less chance of it going quiet and missing a Venmo or Zelle confirmation, so your payment gets marked as received on time.

🛠️ Behind the scenes
The repo is now private with a security audit document added, and some internal notes were tidied up so they render properly.

Wed 3/4/2026 1 commit

🗳️ Vote reminder DMs look like the rest of the bot

When Picklebot nudges you to vote, the games in that DM now show up in the same format you see everywhere else. One consistent look for upcoming games, no more second version drifting out of sync.

Tue 3/3/2026 1 commit

🎾 Court names in your booking list look right again
When you pull up the booking list, each court now shows its proper name instead of an unresolved alias. No more guessing which court you're actually booked on.

Mon 3/2/2026 5 commits

🏝️ Vacation-aware booking suggestions
Booking suggestions and best times now know who's away, so the slots Picklebot recommends line up with who can actually play.

🔁 Repeat a command as often as you like
Sending the same /pb command twice in a row no longer gets silently swallowed — every message gets an answer now.

💪 Picklebot stops crashing mid-conversation
Picklebot was running out of memory (544 MiB against a 512MB limit) and dropping out; it now has 1024MB of room. Last call also no longer comes up empty — if this week is done, it rolls forward to next week.

🗳️ /pb survey sync
A new command that matches survey responses in two passes, so poll and survey answers line up with the right players.

🛠️ Behind the scenes
Shared game, poll and formatting code now lives in one place for both the bot and the command line, so the same answer comes back wherever you ask.

Sun 3/1/2026 8 commits

🗳️ Your votes stick now
Voting on game day no longer gets quietly overwritten — a yes on the day of play is recorded as 2 hours, and an old "cannot play" rule that stripped valid future game-date votes is gone. Votes for games already in the past are now blocked, so the poll only reflects games you can actually play.

💸 No payment nudge right before you play
If you have a game today, Picklebot holds off on payment reminders until after game time. One less Venmo/Zelle ping while you're grabbing your paddle.

📅 Court bookings fetched automatically
Picklebot now pulls reservations itself instead of leaning on a manual booking list, and it merges back-to-back hours into one block. The court info you see is current and reads as one clean session instead of separate hours.

📵 No more double messages
The sender now remembers what it has already sent, so you shouldn't get the same message twice.

🔄 Sync players either way around
Both /pb players sync and /pb sync players now work, so you don't have to remember the word order.

🛠️ Behind the scenes
Vote handling was consolidated into one shared helper, 120 lines of dead code were deleted, action result messages were unified, and the internal docs were updated.

Fri 2/27/2026 3 commits

🤖 The bot won't act on something you didn't ask for
Picklebot no longer lets a misread message trigger a real action like booking a court or cancelling a job — those have to come from a command you actually typed. Phrases like "court reservations" now reliably pull up your reservation list, and cancelling a job works whether you say it as job cancel or the other way around. Anything the bot doesn't recognize about courts or jobs gets handled gracefully instead of guessed at.

📬 No more double messages
The part of Picklebot that sends WhatsApp messages now runs as a single sender, so you shouldn't see the same message land in the group twice.

🛠️ Behind the scenes
Added a global SMS on/off switch, currently off.

Wed 2/25/2026 5 commits

🔔 No more phantom vote nudges
If you had already voted in the weekly poll, a bad poll date entry could still ping you to vote. That's fixed — the bot now reads the poll date one single way, so vote reminders only go to players who actually haven't voted yet.

📋 See who's been reminded with /pb players reminders
New view showing each player's balance, pay reminders and vote reminders, sorted so you can see at a glance where things stand. Handy for sorting out who still owes and who still needs to vote.

📣 Last Call reaches everyone
Last Call now also goes to players who haven't answered the survey, so nobody misses their shot at a spot. The summary is always posted to the SMAD group too, so the final count is right there in the chat.

⏱️ Tidier game day list
The game day player list got a small cleanup: one space between DUPR and Hr, and "Hrs" is now just "Hr". Easier to scan on a phone.

🛠️ Behind the scenes
Fixed the 2026 Hours formula drifting when new poll date columns are added, so hour totals stay correct, and SMS dry-run logs now show player names instead of phone numbers.

Tue 2/24/2026 11 commits

📣 Last Call now arrives on its own, 2 hours before each game
Picklebot schedules the Last Call DMs automatically 2 hours before every game, so nobody has to kick it off by hand. The summary now includes the weather, the player list with DUPR and hours, and whether we still need people. Each DM ends with its own random joke, slightly warmer copy ("Next game is coming up soon!"), and the availability note now sits right before the vote prompt.

🎾 Court booking retries, and later slots
If the date entry hiccups during booking, the bot now retries and reloads the page instead of giving up — that matters most at midnight when courts open. Suggested booking times now run as late as 9pm instead of stopping at 7pm.

🧭 Commands are easier to find
The /pb command list has been reorganized noun-first and grouped by category, with emojis on the Read-only and Actions section headers so you can spot what you want faster. New: /pb players dupr sorts the roster by DUPR rating highest first, then by hours.

💸 Quieter payment watching
Picklebot now watches only the Venmo label in Gmail instead of the whole inbox, so payment detection stays focused on actual Venmo notifications.

🛠️ Behind the scenes
Suppressed noisy SSL retry logs, added an infrastructure profiling audit and midnight booking performance notes, and updated the docs for last-call scheduling and /pb players [dupr].

Mon 2/23/2026 11 commits

📋 Cleaner player list
The DUPR and Hrs labels in the game player list lost their colons, so the lineup reads a little tidier at a glance.

🔔 Reminders that tell you more
The survey reminder now mentions your DUPR rating and the SMS opt-in, so you know what you're signing up for when you reply. Vote reminders now share one format and fall back to next week when there's no game this week, so you always get a sensible nudge. Vacation skip is still in place, so you won't be pinged while you're away.

🛠️ Behind the scenes
We unified duplicated code (one date parser, shared helpers, column mapping instead of hardcoded indices) and switched the whole project from print() to proper logging, which makes issues easier to spot and fix.

Sun 2/22/2026 17 commits

📱 Text messages, if you want them
Picklebot can now forward reminders to you as a text message through Twilio, for players who opt in. There's a new "SMS Reminder" column on the sheet (right after Mobile), and your DUPR and SMS preference stay in sync with the survey.

🥒 The weekly poll now matches the real court bookings
Poll options are built from the actual Athenaeum reservations instead of a fixed list, so you'll never vote for a slot we don't have. Options now carry emoji too: 🥒 for games and 😭 for can't play.

🔔 Reminders go to the right people only
/pb reminders payment no longer sends vote reminders by mistake, and it skips players who have a game today that hasn't happened yet. You now get a numbered list of who was reminded and how much they owe. Admin Dinkers also stopped getting the game day reminder in production.

🎾 More from /pb games
New /pb games last week shows the games just played. Game player lists now show each player's DUPR score with zero-padded hours, and when there are no games left this week Picklebot shows next week's instead. Court booking suggestions now appear right in the games view.

📋 Cleaner player and availability lists
DUPR ratings now display as X.X and line up in a neat column. The availability grid and Best Times use two-letter days (Mo/Tu/We/Th/Fr/Sa/Su), Best Times only lists slots with 4+ available players, and the Last Played column is gone from /pb list-players.

🕚 Court booking fires at 11:58 PM PST
The booking scheduler moved from 12:58 AM to 11:58 PM PST, so bookings go in just before the window opens.

🛠️ Behind the scenes
Fixed double-counting of early 2026 game hours (1/3 and 1/6), merged the CLI and Picklebot games handlers into one shared path, and updated the cost table with GCP Secret Manager, GREEN-API and Claude Code.

Sat 2/21/2026 14 commits

🥃 New: Last Call
If you said you're available in the availability survey but haven't voted in the poll for an upcoming game, you'll now get a nudge DM. The DM includes the game date, the weather, and who else is in with their hours, so you can decide fast. Only people who left the poll blank get pinged — if you voted no, you're left alone.

📊 DUPR ratings now in /pb players
The players list shows your DUPR rating right between your name and your hours, with 9 ratings pulled in from the availability survey. To keep the list readable, the Last Voted, VR and PR columns are gone. There's also a new reminder for players who still need to share a DUPR rating.

👋 Smoother welcome for new members
New members added through member sync now get a welcome DM with the group info. Members are also inserted in the right alphabetical spot on the sheet, and the hours formula now points at the actual first game column. James Ji's row was repaired and moved into place.

✅ Member sync tells you when it's done
When member sync is triggered from the group, you now get a message back in the same chat when it finishes, with a summary. The "triggered" message says a completion message is on its way, so there's no guessing.

🧑‍🤝‍🧑 Full names in survey availability
The best times list now shows full names instead of partial ones, matched to the main sheet by email so everyone appears the same way they do everywhere else.

🔔 Clearer reminders
The poll reminder is now called the "games poll" reminder instead of the "availability poll" reminder, so it's obvious which poll needs your vote. Survey reminders work the same way as vote and payment reminders now, including the dry-run preview, and the admin summary separates vacation skips from no-phone skips.

🎾 Court booking runs at 12:58 AM PST
The nightly court booking attempt moved from 11:55 PM to 12:58 AM PST, so it fires in the right booking window.

🛠️ Behind the scenes
Added time limits to all automated jobs (5 minutes for infrastructure jobs) so nothing hangs silently, plus some code cleanup around reminders and column lookups.

Fri 2/20/2026 12 commits

📊 Availability heatmap image
Survey results now come with a picture. Picklebot sends a heatmap of everyone's availability right after the results text, so you can see at a glance when the group is free. If nothing has changed since the last one, the saved image is sent instantly instead of being redrawn.

🔄 Easier-to-read grid
The availability grid flipped: days run across the top and times down the side. It reads the way a calendar does.

🙋 Names in Best Times
The Best Times list now shows who is actually available at each slot, not just a count.

✏️ Edit your answers
The availability results header now includes a link straight to the survey, so you can change your times without hunting for it.

⏰ Fresher numbers before reminders
The heatmap is refreshed automatically each day before the survey reminders go out, so what you see matches the latest responses.

🛠️ Behind the scenes
Heatmap generation moved to its own workflow with proper storage and auth set up, fixing the case where the image never arrived, plus the survey link now comes from config instead of being hardcoded.

Thu 2/19/2026 8 commits

📋 Availability survey, right in the chat
Type /pb survey to see who has filled out the availability survey and who still hasn't, and /pb survey results to see everyone's availability. The survey link now lives with the bot, so you can grab it any time.

⏰ Survey reminders that know when to stop
Picklebot now sends survey reminders automatically as part of the daily reminders, on even days of the month so your phone doesn't buzz every day. Players on vacation are skipped, and the message comes with a freezing emoji plus a note about Rathskeller Bar in the same paragraph.

🛎️ Admins can nudge on demand
Group admins can now run /pb survey reminder to send the survey reminder right away, without waiting for the even-day schedule. A dry run shows the current survey status first, so you can see who's missing before anything goes out. A bug that also fired game, vote and payment reminders alongside the survey has been fixed.

🔑 Admin commands work in the group
SMAD group admins can now run action commands directly in the SMAD group chat instead of having to message the bot separately.

Wed 2/18/2026 7 commits

🗓️ See next week's games
There's now a command to look up the games coming up next week, so you can plan ahead without scrolling back through the chat.

🎾 Booking and cancelling courts works properly again
Asking Picklebot to book a court or cancel a reservation now reads your details correctly, so the right court and time go through the first time. A crash that could hit certain messages before Picklebot understood them is also fixed.

🛠️ Behind the scenes
Duplicate game, phone and payment code was consolidated into shared modules, and deploys were split into separate per-function workflows that run one at a time with retries to avoid GCP throttling errors.

Mon 2/16/2026 8 commits

🗓️ See next week's games
You can now ask Picklebot what's coming up — try next week games in the chat and it'll list next week's matches. The bot understands a wider range of phrasings for it now, so you don't have to get the wording exactly right.

📣 Next week's games posted automatically
After the Sunday poll goes out, Picklebot now posts next week's games straight to the group. One less thing to ask for.

🗳️ Smoother voting
Removed some leftover cleanup work that ran every time someone voted on the poll and was throwing errors behind the scenes. Voting should just work.

🛠️ Behind the scenes
Added Sentry error tracking across the bot so problems get spotted and fixed faster, tidied up how configuration and secrets are managed, and consolidated duplicate game logic into shared modules.

Sun 2/15/2026 3 commits

🗳️ Weekly poll shows the right week and the right time
The poll title now points at the next upcoming Monday, so "week of" matches the week you're actually signing up for. Times with minutes also show properly — a 10:30am start reads as 10:30am instead of being rounded down to 10am.

📊 Game totals count correctly
Marking a game as played now updates your Totals with a straight sum, so your played count stays accurate.

🛠️ Behind the scenes
All the automatic jobs — poll posting, reminders, and the nightly court booking — moved into managed infrastructure and were consolidated from 7 jobs down to 3, still running at $0.00/month.

Sat 2/14/2026 1 commit

💰 Payment thank-yous got a little shinier

When Picklebot confirms your payment, the thank-you message now comes with a money bag emoji. Small touch, but it makes your paid-up status easier to spot in the chat.

Fri 2/13/2026 31 commits

💰 Check your own balance, anywhere
/pb my balance is here, so you can see what you owe without asking. You can also now DM Picklebot directly with /pb commands instead of posting in the group.

🏓 Game posts now show weather and player hours
/pb next game and the games list now include the weather forecast for upcoming games, plus a tidy lineup showing each player's hours. Past games show a checkmark instead of a forecast.

🎾 Court reservations from the chat
You can now list, book and cancel reservations through Picklebot (admin only). Reservation lists include the day of the week, booking confirmations spell out date, time, duration and court, and results are sent back to whichever chat asked. Court names accept shorthand like south, north or both, and if a booking fails you now get the real reason instead of a generic message.

⚡ Quicker answers to everyday commands
Commands the keyword parser already understands skip the extra AI step, so they come back faster.

📋 A cleaner help menu
/pb help has reorganised sections, emojis at the start of each line, and a tip on using natural language. /pb next is now /pb next game.

🛠️ Behind the scenes
Shared modules for GREEN-API, configuration, reminders and game formatting removed a pile of duplicated code; totals formulas now update automatically when new players are added. Also fixed a Venmo sync retry storm, trimmed cloud storage and costs, and set up automatic release notes.

Thu 2/12/2026 2 commits

💬 Fewer duplicate reminder DMs
If you already got your game day reminder today, Picklebot now skips sending you a separate payment reminder DM. One nudge a day is plenty.

🛠️ Behind the scenes
Cleaned a stale schedule out of the daily reminder workflow so reminders fire on the right timetable.

Wed 2/11/2026 23 commits

🌤️ Weather in your game day reminder
Game day reminders now include the forecast for Pasadena at game time — emoji, temperature, chance of rain and wind. You can also check it yourself any time with /pb weather, which now looks ahead 7 days instead of 3.

🏓 Game Day Reminder got friendlier
The reminder is now titled "Game Day Reminder!" and shows each player's 2026 hours played in a neat aligned list. Group messages and DMs use the same layout, so everyone sees the same info.

⏰ One daily reminder run at 8am PST
Game day reminders and vote reminders now go out together from a single daily run, so timing is consistent. Payment and vote reminders can also be sent independently instead of always together.

🗳️ Vote reminder DMs show what's coming up
Vote reminder DMs now list the upcoming scheduled games with who's already in, plus a "need X more!" nudge so you know exactly how close we are to a full game.

📋 Clearer List Players
The player list is now titled "List Players" with tidier columns: Hrs 2026, Hrs 2025, Last Played and Last Voted. Dates show as MM/DD/YY with no day-of-week or time clutter, and the header spacing lines up properly.

💰 Credits now show up correctly
Balances written in accounting format were being misread, so credits didn't appear. Credits now display in game reminder DMs, the payment admin summary and in Picklebot, and payment reminder DMs got money emojis.

✨ Emojis on commands
Every /pb help entry and every Picklebot command response header now has a matching emoji, making it easier to spot what you're looking at.

🛠️ Behind the scenes
Duplicate code across the bot was consolidated into shared modules (-1,060 net lines), weather, player data and display formatting now live in one place, and deploy notifications render as a clean monospace block on WhatsApp.

Tue 2/10/2026 5 commits

✅ No more confirmation links
Picklebot actions now just happen — the extra "click here to confirm" step has been removed everywhere. Fewer taps, less clutter in the chat.

🔔 Reminders go to the right people
Vote and payment reminders are previewed in detail before they're sent, and the vote check now compares your last vote against the date the poll was created. If that date can't be worked out, the reminder stops instead of pinging the wrong players.

💬 Replies always come through
A bug that silently swallowed Picklebot's reply messages during test runs is fixed, so you get an answer when you expect one.

🛠️ Behind the scenes
New admin commands to list and cancel court reservations directly, making booking cleanup quicker.

Mon 2/9/2026 2 commits

💸 Venmo payments match even if we don't have your username

If your Venmo username isn't in our spreadsheet yet, Picklebot now matches your payment by your name instead, so it gets credited instead of sitting unmatched. When that works, your Venmo username is saved automatically, so every payment after that matches instantly.

🗳️ One poll, one place

The weekly poll now only posts to the SMAD group — the duplicate post to Admin Dinkers is gone.

🛠️ Behind the scenes

Deploy notifications now include full commit details, and a payments sync call was fixed to match the current function signature.

Fri 2/6/2026 14 commits

💸 Pay in one tap
Payment reminders now include a direct Venmo link, so you can settle up without hunting for the right handle. Admins also have a new /pb record payment <player> <amount> <method> [notes] command, and the Payment Log gained a Mobile column so payments match to the right player automatically.

⚡ Faster court booking
Picklebot now warms up before the booking window opens: it logs in early and does a practice run on both courts while it's waiting. That shaves about 0.7s off login and makes the second court book about 0.5s faster, with all the extra work happening during idle time instead of the moment that counts. The date field was also made more reliable so a stale page can't trip up a booking.

🔢 Cleaner lists
/pb games and /pb next now show players as a numbered list, so it's easier to count who's in at a glance.

👥 Keeping the roster in sync
New /pb sync members command lines the WhatsApp group up with the spreadsheet, so new joiners don't get missed.

🛠️ Behind the scenes
Added token authentication for the WhatsApp webhook and documented it, fixed a deploy that was missing shared modules, and cleaned up the admin deploy notifications to show just the commit subjects.

Thu 2/5/2026 30 commits

🏟️ Court booking is faster and books the right date
Booking now jumps straight to the court slot instead of hunting through the page, cutting slot search from about 1.1s to 0.04s, with another ~0.35s saved per booking by calling the court directly ('3' = North PB, '4' = South PB). Before anything is submitted, the bot double-checks that the date and time on screen match what we asked for, so a parallel job can no longer grab the wrong day. A matching bug where "2:00 PM" could match "12:00 PM" is fixed, so you get the slot we actually wanted.

💬 Messages get through more reliably
WhatsApp notifications — booking confirmations, Venmo thank-yous, error alerts — now go out through a new message pipeline with a backup queue, so a hiccup in one part of the system doesn't swallow your message.

🗳️ Vote counts you can trust
The weekly count now reads the main 2026 Pickleball sheet as the single source of truth, counts both a "y" and hours played for past games, and filters the week properly from Monday 00:00 to Sunday 23:59. There's also a new next-game command to pull up the upcoming game.

💸 Payment reminders and email
Added a workflow to nudge a single player about an outstanding payment. Email sending moved to a Gmail login that no longer depends on an app password, so reminders and receipts keep flowing.

🧯 Dead Man Switch
If Gene goes unresponsive, the bot now halts operations instead of quietly continuing. A bug that made it read only part of the sheet is fixed, so it looks at the full picture before deciding.

🔒 Your info stays private
We stopped logging full webhook payloads and sensitive error details that could contain personal info, locked down who can call the service, and switched game screenshots from public links to private links that expire after 7 days.

🛠️ Behind the scenes
Column lookups now read sheet headers instead of fixed positions, booking code dropped ~240 lines of unused fallback logic, a --dry-run flag makes test bookings safer, and several deployment and import failures were fixed.

Wed 2/4/2026 7 commits

💸 Payment reminders on demand — You can now send a payment nudge to everyone with /pb reminders payment, alongside /pb reminders vote for the weekly poll. The help text now lists both so you don't have to guess the wording.

🏓 Game day reminders look and read better — The reminder now goes out titled "Game Day!" with a paddle emoji, and each message gets its own random joke instead of repeating the same one. Reminders also only count as sent once they actually go through, so nobody gets skipped.

⏰ Pending actions expire at the right time — Fixed a timezone bug that made pending actions time out at the wrong moment, so confirmations stay open as long as they should.

✅ Played games get marked properly — Marking a game as played now records it correctly, so match history and scores line up.

🔤 No more garbled characters — Pages served by the bot now use UTF-8, so names, emojis and accents show up as intended instead of as strange symbols.

Tue 2/3/2026 8 commits

⏰ Game reminders, 2 hours before you play
Picklebot now messages the group two hours before each game with the player list and a joke, and DMs each player their balance plus a payment reminder. Your attendance also gets marked in the sheet automatically when the reminder goes out. These reminders are scheduled for every game as soon as the poll is created, and they clean themselves up after they run.

😂 A joke with every nudge
Vote reminders, payment reminders and payment thank-you notes now come with a random joke pulled from the Pickleball Jokes sheet. If that sheet can't be reached, there are 3 backup jokes so you still get a laugh.

🗳️ show-votes knows who actually voted
The non-voter list now reads the Last Voted column instead of the poll log, since WhatsApp's webhook sometimes misses votes. Players on vacation are also left off the "haven't voted" list, so the nudges only go to people who still need to answer.

🎾 More reliable court booking
Booking now waits a touch longer for the court page to finish loading before hunting for slots, which stops those "nothing found" failures. A grid refresh after the date is entered fixes evening slots (like 7pm) that previously never appeared, and if a slot button slips away mid-click Picklebot retries a different way instead of giving up. When something does go wrong, it now records the available courts and slots so the issue can be chased down.

🛠️ Behind the scenes
Gmail token refreshes now update their stored credential automatically without being able to break a run, and all the booking timing tests plus two already-shipped speed optimizations are now documented.

Mon 2/2/2026 15 commits

🏓 Court booking is faster and lands on the right slot
Booking now finishes both courts in 14.16 seconds total — 2.66s for North and 1.80s for South, which skips re-typing the date. We also fixed a bug where the booker could click the wrong time slot, and it now recognizes both "07:00 PM" and "7:00 PM" so 7pm courts get grabbed correctly.

💸 Payment reminders are simpler and less frequent
Zelle is no longer offered in payment reminders — Venmo only. Vote and payment reminders now go out every other day instead of daily, so the group chat stays quieter.

🛠️ Behind the scenes
Spreadsheet settings moved into secure storage, the court booking jobs were renamed to north/south/sweeper, and a new profiling mode measures each booking step so we can keep trimming seconds. Temporary debug screenshots and logging were added during testing and removed before production.

Sun 2/1/2026 18 commits

⚡ Court booking just got a lot faster
Booking now fires at 12:01:00 instead of 00:01:01, and we trimmed the slow parts of the process: the first court skips a redundant page reload (about 1.7s saved) and the calendar wait dropped from 2s to 1s (about another 1s). We also stopped scanning all 139 cells and go straight to the right time row. More speed means a better shot at the courts we actually want.

⏱️ You can see how fast it was
The WhatsApp booking confirmation now shows the elapsed seconds for each court, and the email adds a "Booking Time" field. If a booking felt slow, you can see it instead of guessing.

🛡️ A second backup attempt
Added a new backup booking job at 12:00:30 AM PST, on top of the existing backup that handles the South court. If the main run stumbles, there's another chance to land the reservation.

🛠️ Behind the scenes
A lot of this window was tuning timings by trial and error — Playwright slow_mo between 100ms and 10ms, calendar waits between 0.6s and 1s, interactive delays between 0.5s and 0.75s — with several reverts when a setting was too aggressive for the schedule to render. Login and navigation were also sped up, and the date field went back to typing because the calendar control needed it.

Fri 1/30/2026 15 commits

🔔 Nudge one person, not everyone
You can now send a reminder to a single player with /pb remind payment <name> or /pb remind vote <name>. It understands @mentions, plain names, and phone numbers, so tagging someone in the group just works. Payment reminders now end with a joke after the signature — so there's a little sugar with the ask.

😂 Jokes and memes, hand-picked
Jokes now come from a curated list of 50 instead of being written by AI, so no more groan-worthy robot humor. Memes come from a sheet of 49 pickleball memes collected from Reddit, with a random one each time, sent as a proper image.

👥 /pb players shows the whole roster
New /pb players command lists every player with their stats: 2026 hours, 2025 hours and balance, sorted by 2026 hours. Hours and last-played dates from the old 2025 sheet are now included, so the bot and the CLI finally agree on your totals. Last Voted dates are formatted like every other date (M/DD/YY).

🎾 Backup court booking can go South first
The backup booking job can now reverse the court order and grab South before North, giving us another way to land a court when the usual order doesn't work out.

🛠️ Behind the scenes
Group IDs, phone IDs and spreadsheet IDs moved out of the code and into secrets, and there's now a private test chat for trying commands without spamming the group.

Thu 1/29/2026 29 commits

🤖 Meet SMAD Picklebot — ask the bot things right in WhatsApp
Type /pb help to see what it can do. /pb status, /pb balance and /pb deadbeats answer questions on the spot, and the bot understands plain English, not just exact commands. It works in both the SMAD group and Admin Dinkers; action commands like booking and polls stay admin-only, so the SMAD group sees a shorter help menu.

📅 See the week's games with /pb games and /pb next
/pb games lists every game scheduled this week straight from the poll votes, and /pb next shows the next one with a countdown and who's playing. Options like "Cannot play this week" are filtered out, and if you changed your vote only your latest one counts, so nobody shows up twice.

🎾 Book courts through the bot
Admins can ask Picklebot to book a court, and anything more than 7 days out is automatically scheduled to run at 12:01 AM, 7 days before the date — exactly when reservations open. /pb jobs lists what's queued and /pb jobs cancel <job_id> drops one. Booking, polls and reminders now ask for confirmation first via a one-click link that expires after 24 hours, so nothing fires by accident.

😄 Jokes and memes
/pb joke serves up a fresh pickleball joke and /pb meme posts a captioned pickleball meme. Meme images now come from a reliable source and are checked before sending, so you get a picture instead of a broken link.

🏟️ More reliable court booking
Booking a second court no longer trips over a stale page — the booker reloads before each attempt. The summary now includes booking timings so we can see where things slow down.

🚨 We find out instantly when a booking fails
A new monitor watches the booking runs and sends a WhatsApp alert with the court, date and time whenever a slot isn't secured — even if the run otherwise looked fine. It says why: courts not released yet (with the exact time remaining), booked by someone else, or already reserved by you. Alerts include a screenshot link so the problem is obvious at a glance.

💵 Cleaner balance reports
The "Totals" row from the spreadsheet is no longer counted as a player, so /pb balance and /pb deadbeats stop double-counting.

🛠️ Behind the scenes
Added an automated error monitor for our scheduled jobs, gave Picklebot more memory, and updated the project documentation and architecture diagrams.

Wed 1/28/2026 6 commits

🏖️ Vacation dates that just work
Vacation dates now understand the MM/DD/YY format, so you can type dates the way you normally would and they'll be read correctly. Players out on vacation also show up in their own section of the vote reminder summary, so nobody gets chased for a vote they can't give.

⏰ Daily reminders at 8:00 AM
Daily reminders now go out at 8:00 AM PST.

🗳️ A clean poll every week
When a new availability poll goes up, the previous week's poll log is moved into an archive sheet and the log starts fresh. That means this week's responses are never mixed in with last week's, and the old entries are still kept if we need to look back.

🛠️ Behind the scenes
Some housekeeping on internal files that players won't notice.

Tue 1/27/2026 28 commits

⏰ Reminders and the poll now run on time
The weekly poll and the daily vote/payment reminders moved to Google Cloud Scheduler instead of GitHub's timer, which wasn't firing reliably. The poll goes out Sunday at 10:00 AM PST and reminders go out daily at 10:00 AM PST.

🎾 Court booking starts warming up early
Booking now kicks off at 11:55 PM PST so everything is loaded and ready, then waits and books right at 12:01 AM. This stops us from missing the booking window because the machine was still starting up.

🏝️ Vacation mode takes a return date
Instead of just marking vacation on or off, you now give a return date (MM/DD/YYYY) and you'll be skipped from vote reminders until that date passes. Payment reminders still come through while you're away, and vacation status now shows in the player list.

🗳️ Votes can be recorded by hand
New update-vote command lets a vote be entered for a player, with --list-options to see the available dates and --dry-run to preview first. It marks your chosen dates, clears your previous answer, and updates your Last Voted date. Also fixed a bug where a second vote on the same poll could get attached to the wrong poll date.

💸 Clearer payment follow-ups
Payment reminder summaries now include each player's Last Played date, so it's obvious who owes for what. When a payment comes in, a notification is sent to the Admin Dinkers group.

🙂 Emojis show up properly
Poll output no longer mangles emoji characters on Windows.

🛠️ Behind the scenes
GCP resources moved to Terraform, workflows were split and renamed (court booking, poll creation, vote/payment reminders), the booking script was renamed to court-booking.py, and the docs were reorganized around the whole project rather than just booking.

Mon 1/26/2026 11 commits

🗓️ The weekly poll now gets the dates right

The poll question now names the correct week for the dates it offers, and poll dates are calculated in PST so options line up with real Los Angeles days. Vote confirmations and other timing in the bot also run on PST now, so nothing drifts an hour off.

📣 Admin Dinkers group gets its own updates

The weekly poll is now also posted to the Admin Dinkers group (votes there are not tracked, so vote in SMAD as usual). After payment and vote reminders go out, and after court bookings run, a summary lands in the admin group too — so the organizers can see at a glance what the bot did.

✅ Only SMAD votes count

Votes are now matched to the SMAD group, so taps in any other group are ignored and the weekly poll tally stays clean.

✍️ Bot now signs off as SMAD Picklebot

Messages from the bot carry the SMAD Picklebot signature across all of its replies, so it's clear who's talking.

🛠️ Behind the scenes

Nudged the daily booking and weekly poll schedules so they fire reliably, moved the admin group setting to a plain config variable, added the timezone library to the webhook, and fixed a Windows console crash in the poll dry run.

Sun 1/25/2026 1 commit

🎾 Court booking hangs on a little longer

Booking sometimes gave up before the court system came back with an answer, so a reservation could fail for no good reason. Picklebot now waits up to 15 minutes instead of 10 before calling it quits, so more bookings go through on the first try.

Sat 1/24/2026 10 commits

💸 Venmo payments confirm themselves again
Automatic Venmo sync is back on, so your payment gets picked up without anyone chasing it. Picklebot now sends you a WhatsApp thank you the moment it records your payment, and the balance in that message is the freshly recalculated one, not a stale number. Duplicate notifications can no longer double-count a single payment.

🗳️ Vote reminders tell you how to stop them
The reminder DM about the weekly poll now includes instructions for opting out, so you can turn it off yourself if you'd rather not get the nudge.

🎾 Court booking no longer misses the midnight drop
Booking was failing when new courts opened at midnight because the page still showed the old availability. Picklebot now reloads for a fresh look, so it grabs the court when it appears.

🛠️ Behind the scenes
The payment sync got a dry-run mode for safe testing, a shared module behind the CLI, and a deploy workflow entry, plus an updated architecture diagram.

Fri 1/23/2026 18 commits

💸 Venmo payments say thanks back
When your Venmo payment lands, Picklebot now sends you a WhatsApp thank you with the amount and a "Your balance is now: $X.XX" line, so you know where you stand without asking. Payments are picked up automatically from the Venmo email, synced straight into the sheet.

🗳️ Weekly poll and reminders run on their own
The weekly poll is now created automatically on Sunday mornings, and daily reminders for payments and voting go out each morning. Reminder wording now points you to "this week's poll pinned to the top of the group" instead of referring to a pin that never worked.

📅 Poll question shows the week you're voting for
The poll question now includes the Monday date of the current week, so there's no guessing which week you're signing up for. Votes can still be changed for 7 days, after which a poll is treated as expired.

🧹 Tidier poll history
Poll log entries older than 7 days are cleaned up every Sunday, and weekly totals now tally correctly as new dates are added.

🛠️ Behind the scenes
The poll log moved from Firestore to Google Sheets and all the old Firestore plumbing was removed, plus a systems architecture diagram was added to the README and the automation workflows got fixes for dry-run mode and poll-date overrides.

Thu 1/22/2026 1 commit

💰 Balances and "last paid"
We reworked how your balance and your last payment date are worked out in the sheet, so what you see in /pb balance lines up with what you've actually sent over Venmo or Zelle.

Wed 1/21/2026 8 commits

🎾 Court booking right at the stroke of midnight
Booking now opens 7 days ahead of the actual target booking date, not whenever the bot happens to run — so no more missed courts around the midnight border. The booking attempt now fires at 12:01:01 AM PST, and the bot reliably picks the right 60- or 120-minute slot from the dropdown.

🗳️ A cleaner weekly poll
/pb poll now pulls its game date options straight from the booking list for the upcoming week, so the dates you vote on are the ones we're actually trying to book. The nudge to vote has been reworded to be a little friendlier and clearer.

🛠️ Behind the scenes
All Google Sheets work moved into one central place, which makes scores, payments and ratings easier to keep consistent going forward.

Tue 1/20/2026 3 commits

🎾 Court booking now fires at 12:01 AM
Picklebot now aims for 12:01 AM PST when courts open, instead of 12:00:15 AM PST. Same goal: grab our court the moment it's available.

📋 Pickle Registry in SMAD Sheets
Player info now lives in a Pickle Registry in SMAD Sheets, so the bot has one tidy place to look you up.

💬 WhatsApp connection
Picklebot is wired into WhatsApp, so everything happens right in the group chat.

Mon 1/19/2026 6 commits

🎾 Court booking lands on the right day, every time
The booking robot now always targets the exact day of the week at least 7 days after it runs, so a run that fires near midnight no longer books a day off. We also fixed a leftover case where the 7-day window was measured from the moment the job started instead of the day we're actually booking for.

⏰ Booking runs shifted later
The two scheduled booking jobs now kick off a bit later, and the bot waits for the booking page to finish loading before it starts clicking. Fewer missed slots from a page that wasn't ready yet.

🛠️ Behind the scenes
Cleaned up logging so booking runs are easier to trace, plus a small text-encoding fix.

Sun 1/18/2026 3 commits

🎾 Court booking at midnight is now rock-solid
Fixed a midnight wraparound bug that could make the booking script wait 23 hours instead of seconds, so courts get grabbed the moment the window opens. The booking date is now calculated from the actual booking time (12:00:15 AM) rather than when the job kicks off (11:54 PM), so we always reserve the right day. The safety wait is capped at 10 minutes, matching the grace period.

🛠️ Behind the scenes
Reorganized the bot along 12-factor app principles to keep configuration and deploys clean.

Sat 1/17/2026 13 commits

🗓️ Courts now book themselves, every week
Picklebot now holds a weekly booking list using plain day names like Monday or Saturday, and grabs your court 7 days ahead automatically. No more setting an alarm for midnight — it runs on its own at 11:50 PM PST, with a backup attempt at 12:01 AM so a slow start never costs us a court. Booking times are now configurable, and everything runs on proper PST/PDT time so dates never drift when the clocks change.

🏟️ Both courts at once
If we need the space, Picklebot can now book North and South together in one go instead of one at a time. Handy for the weeks when the group runs big.

⏱️ A 10-minute grace period
Midnight booking can get congested, so Picklebot now keeps trying for 10 minutes (up from 5) instead of giving up. Fewer nights where we end up courtless for no good reason.

📧 Email summary after each booking run
You now get a booking summary report by email with a clearer subject line, so you can see at a glance what was booked and when. Booking date and time are now shown as one combined date-time, and passing a single date-time by hand overrides the weekly list for one-off bookings.

🛠️ Behind the scenes
Scheduling moved to automated daily runs with a corrected UTC schedule, manual runs for debugging were improved, and timestamps plus the ignore list were tidied up.

Fri 1/16/2026 1 commit

🗓️ Court booking on autopilot
The daily midnight booking run and BOOKING_LIST are now working, so courts get grabbed for us automatically without anyone staying up for it.

Thu 1/15/2026 3 commits

🎾 Automatic court booking
Picklebot can now book our court on Ath by itself — the first version is live. Navigation through the booking pages was tightened up, so it moves through the flow faster and with fewer places to get stuck. Less chasing courts by hand before each week's game.

🛠️ Behind the scenes
The booking bot now runs on a scheduled GitHub Actions job instead of waiting on someone to kick it off.

Commit Log

Every commit, newest first. 644 of 1384 touched deployed code. Click a subject to read its message. 76 commits carrying nothing but session-to-session notes are excluded here and from every count on this page: that channel moved to its own repository on 2026-09-02 PT because each push here announced itself as a release. 173 commits that are bots writing their own records back (CI timing, payment sweeps, court-booking anomalies) or a session triggering a workflow (a diagnose, a log pull, a rerun) are excluded the same way: a commit, but not an upgrade.

By type:feat820fix131perf5infra/CI159tooling105deps9docs116refactor39

October 2026 112 commits

feat102docs4infra/CI4tooling1fix1
toolingb22df13Claude Opus 5.5 (1M context)
  • Each post-mortem's shareable link and its incident record now go to smadpicklebot.com/postmortems#pm-<id>
  • The Release Dashboard's outage rows link there too
feat04dda59Claude Opus 5.5 (1M context)
  • The Release Dashboard on the site is republished by the daily 8 AM release notes run, right after the digest goes out
docs2c17aa6Claude Opus 5.5 (1M context)
  • The alarm watches every Cloud Run service, so smadpicklebot.com's errors page Gene like the bot's
feate60d353Claude Opus 5.5 (1M context)
  • New pages: smadpicklebot.com/finance, /releases and /postmortems, each linked from the home page with its own link preview
  • Court Finance republishes every Monday with the weekly report; the post-mortems republish whenever one is written or changed
  • The old dashboard links keep working for now
feat18c98c0Claude Opus 5.5 (1M context)
  • LinkedIn and Facebook ignored the preview tags because the pages had no <head> element; every page now has one
  • Longer preview titles and descriptions, to LinkedIn's minimums
feat346ba59Claude Opus 5.5 (1M context)
  • The Picklebot logo now shows in the browser tab
featf6c8fc9Claude Opus 5.5 (1M context)
  • Sharing a smadpicklebot.com link now shows a preview card with the page's title, a short description and the Picklebot logo
  • The browser tab and a phone's home-screen shortcut show the Picklebot logo
featf39eb0aClaude Opus 5.5 (1M context)
  • Post-game reports, refit replies, the Game Plan footer and Sunday's Top 5 Lists all link to https://smadpicklebot.com/dupr
  • The old dashboard link keeps working for now
feat04846c6Claude Opus 5.5
  • 8b2dc0b asked the lever for Tests to clear #90; the dispatched run and the next push's both failed test-rerun-lever.py, because the request was still in the file
  • To re-run Tests, push a Python change; this push is that change
  • tests/test-rerun-lever.py pins the refusal
infra/CIb7fc38eClaude Opus 5.5 (1M context)
  • Terraform's failure issues carry the same marker as every other alert, so the hourly sweep closes them and the alert metrics count them
  • The rerun lever no longer fires twice when a session pushes the same change to its own branch
featb4ed4afClaude Opus 5.5 (1M context)
  • The domain and www map to the site service, with the DNS records Cloud Run asks for and an automatic HTTPS certificate
  • /health replaces /healthz, which Cloud Run reserves
infra/CIa6f1e67Claude Opus 5.5 (1M context)
  • The first deploy crashed inside gcloud before building; the bot functions' own settings are now passed explicitly
feat7b0868dClaude Opus 5.5 (1M context)
  • The site's two pages were left out of the last commit by a rule that ignores generated HTML; they are source
feat35fd84bClaude Opus 5.5 (1M context)
  • A small website service now serves the group's pages, starting with a home page and the SMAD DUPR dashboard
  • The dashboard is published there on every refit, straight from GitHub Actions
  • The current dashboard links keep working until everything has moved over
infra/CI8d65940Claude Opus 5.5 (1M context)
  • The deploy account needs it to read and manage the smadpicklebot.com DNS zone
infra/CI59c5cb4Claude Opus 5.5 (1M context)
  • Gene registered smadpicklebot.com in Cloud Domains, the future home of the dashboards and the app
  • Its Cloud DNS zone (with DNSSEC) and the Cloud Domains and Cloud DNS APIs are adopted into Terraform as they were made
  • The registration itself (renewal, privacy) stays in the console
feat65edc81Claude Opus 5.5 (1M context)
  • Under All Time, En Fuego! is the best overall record, for players with at least 10 matches
  • Every list now appears in every day range on the dashboard
feat7661452Claude Opus 5.5 (1M context)
  • Every Sunday at 8 AM the group gets the SMAD DUPR 2.0 Top 5 Lists for the last 7 days, with a link to the other ranges on the dashboard
  • Games This Week now goes out on Monday mornings instead of right after the Saturday poll
feat9745c28Claude Opus 5.5 (1M context)
  • New order: Dynamic Duo, En Fuego!, Streak is On!, Domination, Giant Killers, Mr. Clutch, Most Improved, I am Ironman!, Most Promiscuous, Biggest Beefs
feat848ece5Claude Opus 5.5 (1M context)
  • When nobody meets a list's minimum (say 4 matches as partners), the minimum lowers one step at a time until someone does, and the list says which minimum it used
  • A list nobody qualifies for at all still shows, reading "Nobody yet."
feat4a66454Claude Opus 5.5 (1M context)
  • New order: Dynamic Duo, Biggest Beefs, Most Improved, En Fuego!, Streak is On!, Domination, Giant Killers, Mr. Clutch, I am Ironman!, Most Promiscuous
  • The same order on the dashboard and in WhatsApp
feate6fb95fClaude Opus 5.5 (1M context)
  • Domination 👊 ranks players by how much they win by on average, counting scored wins only
  • It sits right after Mr. Clutch, so the blowouts are next to the close games
  • It needs the same minimum number of wins as En Fuego!, smaller for the 7 and 14 day views
feat351a1a3Claude Opus 5.5 (1M context)
  • The dashboard shows one set of Top 5 Lists with buttons for 7 days, 14 days, 30 days and all time; 30 days shows first
  • The minimums shrink for shorter windows, so the 7 and 14 day lists still fill up
  • New list I am Ironman! 🦾: most matches played, right after En Fuego!
feat4367536Claude Opus 5.5 (1M context)
  • The Biggest Beefs list on WhatsApp now leads with 🥩
feat48937baClaude Opus 5.5 (1M context)
  • Biggest Rivalries is now Biggest Beefs, and its lines read "32 games, Thanh 19-13" to fit a phone
  • The win streak list is now Streak is On! (⚡)
  • Hot Right Now is now En Fuego! (🔥)
feat0ac93c4Claude Opus 5.5 (1M context)
  • Each line keeps full names when it fits a phone screen
  • Otherwise it switches to first name and last initial (Gene C), and to the first name alone (Gene) if it still wraps
  • The dashboard keeps full names
feat8a485e3Claude Opus 5.5 (1M context)
  • Last 30 Days counts only the games in the 30 days up to the latest one
  • Its Most Improved compares each rating with where it stood 30 days ago (6+ matches since)
  • Hot Right Now moves to the Last 30 Days set; All Time keeps the other seven lists
  • Provisional players stay out of both, judged on every match they've played
feat7fa354fClaude Opus 5.5 (1M context)
  • Title reads "🏓Post-Game Report for 10/2/26🏓" over "13 matches recorded · 3hr 40min session"
  • After the matches, a 📊 block compares the predicted spreads with the actual margins: averages, the average miss, its standard deviation, how many landed within 3 points, and last game's numbers
  • The story now ends with the 🎯 Favorites line, then a new 🎲 Spread line on how the predictions did
  • Seven new Vegas catchphrases for the Favorites and Spread lines
  • Every spread shows one decimal (+2.4/66%), and match times show a colon (7:51)
feat64c0accClaude Opus 5.5 (1M context)
  • Once a match is recorded, a late vote posts "A new player has entered the game: <name>!" (or "<name> has left the game.") with the recorded matches and the rebuilt suggested matches
  • /pb register, unregister, left, late and here show the same two lists once a match is recorded
  • The rebuilt suggestions re-read the game first, so the player who just joined is in them
feat624bbd2Claude Opus 5.5 (1M context)
  • From when the scheduled Game Plan goes out until four hours after the start, a vote that puts a player in or takes them out of the game posts a fresh Game Plan to the SMAD group
  • The window follows the Game Plan job's own timing, so moving the job moves it
  • /pb register, unregister, left, late and here rebuild the suggestions in their reply over the same window, not just "today"
feat17531a2Claude Opus 5.5 (1M context)
  • Removes the rule that dropped an on-court suggestion for the night when a different match was recorded: on two courts it dropped #1 when #2 finished first
  • A recorded match takes the on-court flag only off suggestions sharing a player with it
  • On one court, any recorded match frees the court
feat88280d1Claude Opus 5.5 (1M context)
  • A match is recorded as the next number in order, whether it was a suggestion or typed by name
  • Each reply numbers its suggestions from the next match: 6 recorded, the list reads 7, 8, 9, 10
  • Replaying 10/2, its 12 matches number 1 to 12 instead of running up to 32
  • The rerun lever is back to idle after the dashboard export it asked for ran
  • The weight-trend test closes its chart file and leaves its temp folder before removing it (it failed on Windows)
docs604e464Claude Opus 5.5
  • Calibration, covering the spread, partner chemistry, how sure the ratings are, upset rate per game (Gene, 2026-10-03 PT: "Backlog the other trends you suggested")
  • Kept in the README, not GitHub issues; an item leaves the list in the commit that ships it
featb2333acClaude Opus 5.5
  • How the model is doing: three charts over every graded game, each graded on the ratings from before it as its post-game report is (Gene, 2026-10-03 PT)
  • How balanced are the matches: the average expected spread of the matches played against the average real winning margin
  • Picking the winner: how often the favorite won, that game and every game so far, against a coin flip
  • Calling the spread: how far the real margin landed from the spread, that game and overall
  • Each chart marks where the Game Plan started picking the matches (9/22)
featbaff9ebClaude Opus 5.5
  • Two curves on one graph: weight in lbs on the left, SMAD DUPR after every game on the right, and the DUPR recorded before your first fit dashed (Gene, 2026-10-03 PT)
  • The survey heatmap's pattern: picklebot dispatches weight-trend.yml, whose runner draws the chart (webhook/shared/weight_trend.py), uploads it under a random name and sends it to the chat the command came from
  • Only with two or more weigh-ins; the reply says the chart follows, and a failed dispatch leaves the stats reply as before
  • One workflow dispatcher in picklebot (_dispatch_workflow); the dashboard redraw uses it too
  • CLI: smad-whatsapp.py weight-trend --player <name> [--no-upload] [--reply-chat-id <chat>]
feat53b2ffeClaude Opus 5.5 (1M context)
  • A suggestion on court when a different match is recorded is passed over and stays off the list for the night; an undo takes the pass back
  • On 10/2 "Gene/Vic v Kevin/Shyam" sat on top for eleven matches and was never played
  • /pb late John keeps him off the Game Plan until /pb here John, or until his first recorded match; still registered and charged
  • A Game Plan posted mid-game starts a fresh list numbered on from the last recorded match; with nothing recorded it starts at 1
  • Numbers that left the list are no longer held back
feat2152480Claude Opus 5.5
  • The spring layout is stretched to fill the frame, then every node is pushed clear of the others by its dot plus its name (Gene, 2026-10-03 PT: name labels were covered with the larger nodes, and the edges of the frame sat empty)
  • Names carry a halo in the page's own color, so a line running under one no longer cuts it
  • tests/test-smad-dupr-graph-and-dashboard-paths.py: twenty bunched players end with no overlap, inside and across the frame, the same picture every time
feat5e1cb68Claude Opus 5.5 (1M context)
  • A reply that turned a command down (an unknown player, a bad score, a failed cancel) was logged as a success
  • It is now logged as failed, with "refused:" and the reply's reason, so a night's failed commands show in the log
  • The Release Dashboard's /pb Reply Time chart counts these in its failed line from today
  • A test pinned to 10/2's date now uses today's
feat447bf5fClaude Opus 5.5
  • With no game named, today's game counts until four hours after its start, as for /pb register, then the next upcoming one (Gene, 2026-10-02 PT: Victorio joined after the start and the plan said no upcoming game)
  • Posted mid-game, the plan shows the Game Day block and the next suggested matches from what was played, numbered as the match replies number them, never rounds from 1 again
  • tests/test-game-plan-gaps.py covers the game in progress, a game already over, and the mid-game report
feat795be43Claude Opus 5.5 (1M context)
SMAD DUPR Top 5 Lists: Dynamic Duo, En Fuego!, Mr. Clutch, and every title in Title Case
feat98f5f72Claude Opus 5.5 (1M context)
  • The post-game spread check says "favorites" and "favored by"
  • The post-game story and the daily release notes are told to write American English
  • The SMAD DUPR dashboard says "color" and "favorite"; the court shortfall line, a canceled job and an unrecognized court read the American way
feat3690d2dClaude Opus 5.5 (1M context)
  • Recorded matches, suggested matches and the post-game report lines all use the new form
  • The SMAD DUPR dashboard's rebuilt reports pick it up on the next export; the kept stories are not rewritten
feat193670bClaude Opus 5.5 (1M context)
SMAD DUPR dashboard: the most-partners top 5 is titled "Most promiscuous"
feat254b2e3Claude Opus 5.5 (1M context)
  • /pb cancel game 10/3/26 answered "Invalid date or time": the time found for the date never reached the cancel; it does now
  • With no time given, two games on a date take the earlier, and a date with no game on the sheet takes the first SMAD court booked in the Court Log
  • The reply says which time it assumed
  • /pb register, /pb left and /pb unregister take a bare date the same way, the first game that day
featcae3712Claude Opus 5.5 (1M context)
  • The post-game story and the daily release notes are written by Claude Sonnet 5.5 instead of Opus 5
  • A call refused for lack of API credits is recognised, not treated as a bad draft
  • The post-game story then logs an ERROR, which emails Gene, and falls back to the template
  • The 8 AM digest then fails into an alert issue titled "Anthropic API out of credits", after sending the commit list
featb07fed1Claude Opus 5.5 (1M context)
Release Dashboard: only the latest day's release notes open, earlier days behind a disclosure
feat6b97c2bClaude Opus 5.5 (1M context)
  • Lines between players use a solid bright colour instead of a faint translucent one
  • Width runs from a hairline for one meeting to 9 px for the most-met pair, with opacity rising alongside
featb2583ecClaude Opus 5.5 (1M context)
  • Claude writes each day's release notes from that day's commits, grouped by feature and written for players
  • A day is the 24 hours ending 8:00 AM PT, labelled with the year (Fri 10/2/2026); the commit digest uses the same window
  • Admin Dinkers and Gene get the notes on top of the commit list; the SMAD Pickleball group gets the notes alone
  • A number the commits do not contain sends the draft back once, then the notes are refused and the run reports it
  • The Release Dashboard shows a Release Notes diary above the Commit Log
  • A one-time backfill writes the notes for every day since the first commit, and sends nothing
feat02cbab1Claude Opus 5.5 (1M context)
  • A player under 10 matches (provisional on the ratings table) is in no top-5 list, nor any pair with them
  • The least-certain comparisons and teach-it-the-most lists are off the page; /pb smad dupr graph still gives them
feat32c03bcClaude Opus 5.5 (1M context)
  • Winningest partnerships, biggest rivalries with who leads, most improved since their first rating
  • Longest win streaks, giant killers (wins as the underdog, with tries), clutch in 2-point games
  • Hot right now (last 30 days) and social butterflies (most different partners)
  • Archived players and guests are left out; the least-certain and teach-it-the-most lists stay
featd16be59Claude Opus 5.5 (1M context)
  • Each player starts at their sheet DUPR, recent matches count more at the learned window, win chances allow for a 6-point miss
  • Links the frozen SMAD DUPR 1.0 page for comparison
feat70011e4Claude Opus 5.5 (1M context)
  • Deploy Time, Test Suite Time, Tests Workflow Time and /pb Reply Time show each day alone
  • A deploy or test speed-up now shows the next day instead of being smoothed over a week
featc81aba9Claude Opus 5.5 (1M context)
  • The dashboard is titled SMAD DUPR 2.0; the 1.0 page is kept frozen for comparison
  • Match graph dots grow linearly with matches played, 5 px for the fewest to 30 px for the most
  • A key under the graph shows the colour as a spectrum from the lowest to the highest SMAD DUPR, with sample dots for the fewest, middle and most matches
  • The refit's CLI table names the recency window the fit learned
feat31f92d9Claude Opus 5.5 (1M context)
  • Each player's prior is their own sheet DUPR again (the roster mean without one)
  • Matches decay by age, never dropped: a match one half-life older than the latest game counts half
  • The half-life is learned at every refit: each past game predicted from the games before it, the window with the best win-odds log loss wins, ties to the steadier one (14 days on today's log)
  • Win odds use a 6-point spread of real margins, not the fit's 3.5: the old odds were overconfident
  • Backtest: favourites won about 69% of the time, against 55% for the old model
  • Match and suggestion lines show the win chance after the spread: +3 69%
  • Every recorded match keeps its pre-match Spread and Win Prob on the Win Loss Log, written with the row, never recomputed
  • The SMAD DUPR dashboard explains the new model and shows the learned window
  • The dashboard workflow can rewrite every game's post-game story, on Gene's word only
feat7df9b0cClaude Opus 5.5 (1M context)
  • Coverage: picklebot 100%, scripts 100%, webhook/shared 99.9%, CLI 99.6%, vote webhook and sender 100%; 165 suites, 7264 checks
  • Faster suite: heavy imports load lazily and the runner precompiles once
  • update-vote CLI: a vote for the 7pm game no longer marks a 5pm game on the same date 'y' (a billed game)
  • Last Call: a DM that fails to send is reported as failed, not "skipped (no phone)"
  • The production metrics collector no longer crashes on a fresh record when Firestore cannot be read
  • run-tests.py measures its own coverage instead of omitting itself
feat41bd6c0Claude Opus 5.5 (1M context)
  • Every post-game analysis grades the pre-night ratings' spread on each match whose four players were rated before the night
  • Favourites' record, mean miss in points, Brier against a coin flip's 0.25, the best call and the widest miss
  • A match with a first-timer is set aside and counted, never graded on its own night's fit
  • The template gets a 🎯 Spread check line; Claude's story gets the per-match spreads and one 🎯 line
  • Kept stories are not rewritten
feat03635a9Claude Opus 5.5 (1M context)
  • Gene: outages matter more than any other graph on the page; Time to Restore stays under Reliability
feat7e3b352Claude Opus 5.5 (1M context)
  • Tiles and charts grouped the same way, in the same order: Velocity, Pipeline, Reliability, Production & cost (Gene)
  • /pb Reply Time: every typed command's time in the bot per day, mean and p90, failures on the right axis; the tile is the week's own p90 (13.1 s today)
  • WhatsApp Messages: sends per day with the failed ones in red; a day with no sends shows as zero, the silent stop two outages were
  • Pages: ERROR lines per day (each one emails an alert), with alert issues opened beside them
  • GitHub Actions Minutes: billed minutes per day and the month's running total; the tile reads the account-wide total and allowance through the new billing token, else this repo against 2,000
  • scripts/collect-prod-metrics.py gathers them daily in ci-metrics.yml (Firestore, Cloud Logging and GitHub over REST); a source that cannot be read keeps its last figures and is named, never a zero
  • The billing and GCP tokens are on the redaction list
feat2b97a67Claude Opus 5.5 (1M context)
  • Every workflow's setup-python and the four functions' --runtime read vars.PYTHON_VERSION (3.14); it was 3.11 in 26 places
  • All five requirements files resolve to 3.14 wheels; every suite passes on 3.14 and 3.13
  • python-versions.yml (Mondays): opens one issue when a newer CPython is released, and one when Cloud Functions offers a newer runtime than ours (Gene: "keep an eye out")
  • Terraform's function_runtime and CLAUDE.md say 3.14 and name the variable
feat89fb5bbClaude Opus 5.5 (1M context)
  • Court booking: a full court name ("North Pickleball Court") books that court, never both; an unknown court word is skipped; a cancel is verified by date, not text; "07:00 PM" books; a slot it cannot verify is never submitted; a cancel with no credentials fails loudly; the email counts real attempts; quotes in the password no longer break login
  • payments-management.py records through the bot's shared record_payment: an ambiguous first name is refused, timestamps follow the Payment Log convention, the thank-you goes to the player recorded and through the publisher; list --days reads new-format rows
  • A Mobile cell with text but no digits is refused with the reason, not int('')'s ValueError; an empty one still records blank
  • Finance: a NOT SMAD night is no longer claimed from the club; a month keeps its share of a later statement's credit whatever months are run; a January credit for "12/30" books to December; a missing overhead.json no longer crashes the export
  • smad-whatsapp.py: refresh-courts exits 1 when the fetch fails; no joke on the cancel-game DMs or the sync summary; merged poll hours keep every court; "sunny" no longer cancels Sunday; the game reminder marks the game by date and time
  • smad-sheets.py: on a two-game day a game is marked played by its time, never the first column
  • Vote webhook: a voter id with no digits matches nobody; the forward log no longer carries phones or names; image captions follow the joke rule text does
  • Release Dashboard: an odd incident record can no longer crash the build; skipped deploys leave every deploy figure; read-all no longer passes the permissions check; a multi-day outage shows its restore date
  • Match graph: a newcomer group joined to the pool by one match is reported as a weak link (Gene)
  • The setup-gmail-watch, archived-access, email-escaping and log-flattening fixes; a test assertion reworded after GitGuardian read a stub and a file name as a credential pair (incident 37808530, false positive)
featb939bf7Claude Opus 5.5 (1M context)
  • CI runs 3.11, which refuses more than 20 nested blocks in one statement; the desktop's 3.13 accepted it, so the suite broke only on CI
  • Every Python file in the repo compiles under 3.11 (checked with uv)
feat1d97493Claude Opus 5.5 (1M context)
  • 27 new suites, 1,640 more checks: the vote webhook and sender, the Release Dashboard builder and CI collector, the finance and one-off scripts, smad-whatsapp.py, court-booking.py (against a fake browser) and the other root CLI scripts
  • Every suite runs offline with .env stubbed out, so nothing reads real credentials, reaches the club site, a sheet or WhatsApp, and CI behaves the same as the desktop
  • The local full run is 24.6 s (was 22.9 s) for 146 suites and 6,731 checks
  • The bugs these suites found are listed for a decision; none is pinned as passing
feat267b2d7Claude Opus 5.5 (1M context)
  • Its number was on the tests tile and the Test Suite Time chart carries the trend; it left one tile orphaned in a six-wide grid (Gene, 2026-10-02 PT)
feat110dfb8Claude Opus 5.5 (1M context)
  • Court Log: a booking with no length leaves Hours blank (was "0"); only a missing tab reads as an empty log, so a wrong spreadsheet raises; duplicates in one batch append one row
  • /pb usage counts each command on its Pacific date, not UTC
  • Health Log: a missing tab is created before its headers are written, a weigh-in refuses rather than appending when it cannot be, and the date is a label cell with its apostrophe
  • Sharing the sheet with an invalid address reports failure, not "already granted"
  • DUPR Log: the reply says the same status as the row; changes show to the hundredth (3.456 to 3.47 no longer reads 3.5 to 3.5); a failed write is a WARNING in the library and an ERROR from each caller that changed a rating
  • Archive candidates read an ISO Last Voted date; a Feb 29 poll option parses in a leap year; a second survey answer from one player is not reported unmatched; one malformed survey header no longer empties the read
  • "This week" says Tue 7:30pm, not Tue 7pm; a court-list row with an unreadable time has no double space; two formatters no longer reorder the caller's list
  • A transfer whose two readings name the same payer and payee is accepted, not refused as ambiguous
  • The booking preview says PT, not PST
feat3dea48bClaude Opus 5.5 (1M context)
  • /pb court cancel with no court (or an unknown word) is refused with the usage, never cancels both courts
  • /pb set dupr --dry-run no longer writes a real DUPR Log row
  • The dry-run flags match whole words only: Mary-Nell and Jean-Noel stay names, and their payment or match is no longer a silent dry run
  • /pb smad dupr graph replies again (it returned text where the webhook expects a reply, and answered with a 500)
  • /pb match graph, /pb match plan and /pb match standings run those commands instead of recording a match against the words
  • /pb balances and /pb balances gene look up everyone and gene, not "s" and "s gene"
  • /pb game cancel next cancels the next game instead of answering with the usage
  • Survey booking suggestions leave out archived players' answers instead of counting them as available
  • A refused /pb undo -1 is signed once
feat0c7c132Claude Opus 5.5 (1M context)
  • A commit that only edits a lever file (diagnose, payment sweep, error sweep, rerun, court probe, log pull) or commits a pulled log back shipped nothing: 37 such commits over the history, 14 of 8/30's 97 (four read "Retry the log pull")
  • The daily digest uses the same rule, so it drops them too
  • The found-by-monitoring tile counts every outage since logging began
feat03ecccaClaude Opus 5.5 (1M context)
  • 29 new suites, 1,850 more checks: player data, formatters, Court Log, Firestore, match log, SMAD DUPR log, email, weather, health, GREEN-API, the scheduler, and picklebot's parser, dispatcher, webhook entry and command handlers
  • Every suite runs offline in under a second; nothing reaches Sheets, Firestore, WhatsApp, GitHub or the Athenaeum
  • requests now loads on first use (shared/lazy_import.py): importing the shared package went from 0.43 s to 0.15 s, which pays for the new suites; a CI-shaped run is 41.1 s before and 42.2 s after
  • Suspected bugs the new suites found are listed for a decision; none is pinned as passing
featf26892bClaude Opus 5.5 (1M context)
test-no-joke stubs the main-sheet prefetch: CI has no credentials file to build a service with
feat124786eClaude Opus 5.5 (1M context)
  • The rating history is the SMAD DUPR dashboard's own (every game back to 8/25/26), read from the reports bucket once per request; the SMAD DUPR Log is the fallback
  • A row from before any SMAD DUPR fit shows the DUPR it recorded, marked DUPR, so the two scales never read as one
  • The stats block shows the current SMAD DUPR above the DUPR
featc211cc9Claude Opus 5.5 (1M context)
  • /pb games: the main sheet and the Court Log in one Sheets call, the live courts and each game's weather in one Firestore call: 8 outside calls to 2, 1.46 s to 0.32 s warm in a read-only run against production
  • /pb balances <name>: the name lookup and the balances share one read
  • /pb games, /pb players and /pb balances end on the timing line and log a [TIMING] line
  • Perf report: the read commands row
feat28e3070Claude Opus 5.5 (1M context)
  • Catch Phrases tab edited (live within 10 minutes); SEED mirrors it as the fallback
docsace0bacClaude Opus 5.5 (1M context)
  • The /pb match, game plan and post game report as a doc with a chart of the seven rounds
feat31fef16Claude Opus 5.5 (1M context)
  • Gene, 2026-10-02 PT: "this is color commentary, and I want the full color if it's a legit and popular catch phrase from a legendary announcer"
  • The story's style guide says so; the suite's 65-character cap on a phrase is gone
feat76f40c0Claude Opus 5.5 (1M context)
  • Catch Phrases tab edited (live within 10 minutes); SEED mirrors it as the fallback
featba9a978Claude Opus 5.5 (1M context)
  • /pb aggregate payments: archive first, then the aggregate updates, the deletions and the new aggregate rows in ONE spreadsheets.batchUpdate: a failure between them can no longer count a payment twice
  • New aggregate rows are inserted after the remaining ones, so a payment a sync appends mid-run is pushed down, never overwritten
  • /pb record payment John 20 with two Johns on the sheet is refused, naming both; the full name records
  • Zelle: a subject ending in a period ("sent you $8.00.") is booked, not skipped; "$1.2.3" is still refused
  • Zelle: the last 24 hours are read page by page (cap 200), not the newest 10
feat2a58091Claude Opus 5.5 (1M context)
  • CI has no credentials file, so building a Sheets service for the prefetch reached for the metadata server: test-match-roster-read, test-register and test-smad-dupr-command broke on the last push
  • prefetch_match_tabs returns before building a service when no request is open
feat4ec33baClaude Opus 5.5 (1M context)
  • /pb post game: the main sheet, Win Loss Log and SMAD DUPR Log in one batchGet; the refit's save takes its header and rows from it, and the log tab is checked once per process (nine Sheets calls down to three)
  • /pb game plan: the same tabs plus the Court Log in one batchGet, and the live courts, departures, suggestions and weather in one Firestore round trip
  • An undone suggestion comes back under its own number when a re-plan listed the same pairing again (10/1: undoing 3 showed it as 5)
  • /pb undo -1 and /pb match undo 0 are refused with the two valid forms, never read as match 1
  • test-add-game no longer fails after 8:30 PM PT on 10/1: its handler clock is pinned
  • Perf report: the post game and game plan baselines and what changed
docs3589a46Claude Opus 5.5 (1M context)
  • docs/perf/2026-10-01-pb-match-optimization.md: the seven measured phases, calls per reply before and after, and what made the difference
  • How to repeat it on another command, and the suggested next targets (/pb post game, /pb game plan, register/left, the read commands)
  • Linked from the README
feat4ffd27fClaude Opus 5.5 (1M context)
  • One batchGet fetches the main sheet, the Win Loss Log and the SMAD DUPR Log; the roster, the poll, the log and the ratings readers all answer from it
  • The poll read the whole main sheet a second time; it shares the batched copy now
  • After an append the row Sheets stored (includeValuesInResponse) joins the request's copy, and an undo removes its row, instead of re-reading the log
  • The Win Loss Log's tab id is read once per process; every undo fetched the spreadsheet metadata for it
  • Each write forgets its tab's copy, so nothing reads past its own write
feat13ed178Claude Opus 5.5 (1M context)
  • "6.34v5.86 → +1, 66% to win" instead of "Gene's side by ~1 (66%)": the left side's signed spread, like every match line, and its chance to win
  • Undoing the only match left showed "No matches recorded" with no suggestions; the next suggested matches now follow
feat74ff0d7Claude Opus 5.5 (1M context)
  • The style guide's own example hung the phrase off a dash ("...17 times — somebody get an ice bath ready"), and it forbade the phrase as a sentence of its own; both gone
  • check_tacked() catches a listed phrase straight after a dash, a colon or an ellipsis
  • Such a draft is redrafted once with the problem named; a second failure keeps the template
feat1e2dcc2Claude Opus 5.5 (1M context)
  • token_format: access_token made the service account mint a token for itself, a permission it lacks (403); every Tests run since 14e801e went red
  • scripts/upload-report.py signs the PUT with the key file the auth step writes; gcloud stays the fallback
  • The results were never lost: the gcloud fallback uploaded them each time
feat6eb5257Claude Opus 5.5 (1M context)
  • A dashed line on its own right-hand axis shows how many checks the suites ran each day, from test-results.json
  • The suite's time on GitHub tracks the check count, so a slower day reads against a bigger suite
feat3982c01Claude Opus 5.5 (1M context)
  • ensure_match_log_sheet checked the tab before every append: the full spreadsheet metadata and the header row, two Sheets calls; now once per process
  • current_session prefetches the game's saved suggestions and departures once the game is known, so the number check and the next suggestions read no Firestore of their own
  • test-claude-hooks.py, which fetches GitHub, gets a 60 s limit; every other suite keeps 30 s
featd60fb1dClaude Opus 5.5 (1M context)
  • warm_clients makes one single-cell Sheets read per credential and one small Firestore read on each GET
  • Opening a client makes no connection: the first /pb match after a quiet spell took 18.7 s re-establishing them (2026-10-01 5:24 PM PT)
  • README: the Tests workflow's cached environment and one-PUT upload
feat14e801eClaude Opus 5.5 (1M context)
  • The Tests workflow caches the installed virtualenv, not pip's downloads: pip runs only when a requirements file or Python changes
  • The results upload is one curl PUT on an access token instead of a gcloud start-up, with gcloud kept as the fallback
  • check-function-deps.py walks each function's imports once per process: test-deploy-affected took 13 s under CI's coverage
featf98d8c8Claude Opus 5.5 (1M context)
  • Test Suite Time is now the suites alone: each Tests run's suite step, read from its jobs and backfilled to 2026-09-12
  • Tests Workflow Time is the whole workflow; the gap between the two charts is setup, install and upload
  • A test suites p90 tile joins the tests workflow p90 tile, both on the last 7 days
featfa8027cClaude Opus 5.5
  • SUMMARY_A accepted only "N failure(s)" with nothing after, but the harness prints a colon before listing failures, so every red suite was also counted "summary MISSING" (issue #85, the investigator's second finding)
  • tests/test-run-tests.py parses the harness's real red output
feat8df73f1Claude Opus 5.5 (1M context)
  • Time to Restore plots every outage at the day it started, its restore time on a log axis, ours filled and a vendor's hollow
  • The line under it is the trailing 30-day mean time to restore, the MTTR tile's figure, day by day
  • The deploys tile is a plain count of deploys in the last 7 days, no weekly average and no 30-day fallback
feateeba757Claude Opus 5.5 (1M context)
  • scripts/run-tests.py runs every suite offline: any outside connection is refused and the suite fails, naming the call
  • Five suites were reading production sheets, one writing a test game's suggestions to production Firestore; all stubbed
  • That write, retried under load, is what stalled test-planned-session past the 30 s limit
  • zelle_sync's 24-hour Gmail cutoff is taken from an aware UTC clock (CI failed on a 7-hour host offset)
feat53af03bClaude Opus 5.5 (1M context)
  • New suites take payments.py, venmo_sync.py and zelle_sync.py from 34%, 26% and 23% to 99-100% coverage
  • A player paying twice in one Venmo sync is thanked with the right balance each time (it repeated the first)
  • A mobile cell with no digits no longer aborts a Venmo sync mid-batch, losing the payments after it
  • Venmo and Zelle timestamps are read as UTC on every host (the desktop booked them 7 hours late)
  • Two cash payments recorded in the same second are both archived when payments are aggregated
  • Deploy time, test p90, lead time and deploys/week tiles read the last 7 days, 30 when the week had under 10 runs
feat76a1bd0Claude Opus 5.5 (1M context)
  • The Test Coverage panel charts coverage day by day: the whole repo and each area as its own line, on a 0-100% axis
  • tests.yml fetches the last test-results.json before writing; until now every run started its history over
  • Each run records coverage by area; 2026-09-12 to 09-30 backfilled by re-running each day's commit (23.1% to 38.2%)
  • venmo-trigger, the payment entry point, goes from 0% to 94%: the lock, the config checks, both syncs, no retry storm
  • test-add-game and test-register stub the Game Day block, which had them calling live services
feat03cc08aClaude Opus 5.5 (1M context)
  • Deploy Time and Test Suite Time are daily charts (Pacific days): mean, p90, and a 7-day rolling mean that breaks across gaps
  • collect-ci-metrics.py builds deploy_daily and tests_daily from GitHub run metadata, so every day since 2026-02-17 is filled in
  • A deploy run whose deploy step was skipped is not counted as a deploy
feat8899814Claude Opus 5.5 (1M context)
  • With no Game Plan posted, the first match reply works out the courts and saves them with the game's suggestions; later replies read no court source
  • A lookup that finds nothing saves nothing; a posted plan still works the courts out afresh
  • scripts/run-tests.py reports a suite still running at 30 s as broken (--timeout to change it); the full run takes about 22 s
feat3efcc0eClaude Opus 5.5 (1M context)
  • The posted Game Plan works out the courts once and saves them with the game's suggestions; match replies plan on that and read no court source
  • The Court Log is read once per request, and the Payment Log handover search runs only for dates before the Court Log (8/31/26)
  • /pb match 54 with a saved list is refused from it instead of re-planning the night (3.3 s)
  • After an undo restores the earlier list, the numbers the undone reply showed stay reserved (65 no longer reused)
  • The test runner times out a hung suite after 180 s instead of waiting forever
fix610f76cClaude Opus 5.5 (1M context)
  • A number shown tonight never comes back for another match: dropped suggestions hold theirs, new ones skip them
  • /pb match 61 15-3 is refused (no game ends that way) instead of recorded as 11-3; a low winning score is still corrected
  • A failed reply says "Match Not Recorded"; a refused numbered undo says "Undo Match 66"
  • A Sheets quota error (429) on the Win Loss Log is retried twice instead of reading as an empty log
  • A match reply reads its Firestore documents in two batched round trips instead of four or five
  • A roster read takes 2025 hours from a new 2025 Hours column instead of opening the 2025 sheet; no 2026 game means last played 12/31/25
feat0d33a05Claude Opus 5.5 (1M context)
  • Each deploy workflow first asks the import graph whether the push changed a file its main.py reaches, and skips the deploy if not (800 deploys over the last 200 shared/ commits; 523 were needed)
  • It deploys when unsure: a dispatch, a new branch, an unreadable diff, a non-.py shared file, its workflow or the script
  • The dependency walker now follows `from . import x`, which it missed
  • The hooks suite runs its seven parts side by side: 35 s to 20 s, the same 93 checks; the full gate is 23 s, from 100 s this morning
  • Tests caches pip downloads; the smoke test reads state and URL in one describe
featf0a8658Claude Opus 5.5 (1M context)
  • Undo's reply lost its match list and suggestions once the handler passed its roster: the roster test had been folded into the session test
  • A regression check now pins the block with a roster passed
  • scripts/run-tests.py runs the suites concurrently (--jobs, default one per CPU): the same checks, about 100 s down to 34 s
feat8ad455eClaude Opus 5.5 (1M context)
  • The parallel weather fetch predated the daily weather cache; with the 8 AM forecast cached, the header's weather is a Firestore read
  • One less thread and two helpers gone
feat66063efClaude Opus 5.5 (1M context)
  • /pb match, undo, game plan and post game read the roster, Win Loss Log and SMAD DUPR Log once per reply (shared request_scope), dropped on that reply's own writes
  • The roster was read two to four times per reply: get_poll_games() re-read it on every call
  • /pb game plan and /pb post game end on the ⏱️ line and log a [TIMING] line per step, for tonight's baselines
  • The Game Plan's weather is fetched alongside the planning instead of after it
  • A game hour's first forecast of the day (normally the 8 AM Game Day job's) is cached in Firestore and reused all day
  • /pb register and /pb add game end on the game as the Game Day reminder shows it, without stats, balances, nags or not-voted
  • No joke on anything a person's /pb command sends, replies or DMs; the 8 AM reminders and the payment-sync thank-yous keep theirs
feate2ae52aClaude Opus 5.5 (1M context)
  • /pb post game's match lines end on the winners' spread from the pre-night ratings, the ones its upset is judged by
  • The SMAD DUPR dashboard rebuilds its reports through the same formatter, so they carry it on the next export
feat7677b3dClaude Opus 5.5 (1M context)
  • Every recorded match line and the ✅ line end on the winners' spread at game time
  • Every suggested match line ends on its left side's spread
  • Signed and rounded to a point, no ~; from the ratings and k the reply's plan already reads, so no extra read
feat88c6e71Claude Opus 5.5 (1M context)
  • Undo right after a record restores the saved list as it was, on-court flags and numbers
  • An undone typed match nobody suggested no longer comes back as a suggestion
  • A matchup dropped from the list and planned again later takes its old number back (48 no longer returns as 61)
  • Only what the reply shows is planned and saved, so a typed match numbers right after the visible list
  • The undo reply reuses the roster the handler already read (a second read cost about a second)
featb38e38cClaude Opus 5.5 (1M context)
  • New suggestion numbers follow the last recorded or still-listed one (18 recorded no longer jumps to 44)
  • A typed match nobody suggested takes its court's suggestion off court but keeps its number (37 no longer becomes 45)
  • /pb match and /pb match undo both list four suggestions in all, on-court first
  • Recording by number reads the saved suggestions instead of planning the night twice
  • Undo puts a suggestion back exactly as it was, on court included
  • The Win Loss Log is read once per reply (match_log.log_read_cache), dropped on every write
  • Each reply logs a [TIMING] line per step (roster, session, record, reply)
  • Picklebot's GET (the 10-minute keepalive, the deploy smoke test) opens the Sheets and Firestore clients
feateaf04b4Claude Opus 5.5 (1M context)
  • Undo joins /pb match in NO_JOKE_INTENTS, found while profiling the two commands
featd1fb514Claude Opus 5.5 (1M context)
  • The model is told to say a catchphrase as part of the sentence ("...10 of 17 times — somebody get an ice bath ready."), not as a quoted aside or a sentence of its own
  • Double quotes were already stripped from every story since 9/26; the 9/25 story Gene saw was written before that rule and is kept as written
feat3929b11Claude Opus 5.5 (1M context)
  • "⏱️ 9.3s: 1.4s to the bot, 7.9s in the bot", for profiling the two commands during games
  • The webhook passes when WhatsApp stamped the message; picklebot adds its own start
  • The send back to WhatsApp (2-3 s) happens after the reply is written, so it is not in the line
feat9d918dfClaude Opus 5.5 (1M context)
  • "This is a mid-week added game and will not show up in the games poll. If you want to be added to this game: /pb register 10/1/26 7:30pm", the game's own date and time filled in
feate47daadClaude Opus 5.5 (1M context)
  • A mention arrives as phone digits, which matched Gene with the next three words attached, so John Wang 2 vanished
  • A mention is now one name, and an @ always starts the next one
feat4f177a1Claude Opus 5.5 (1M context)
  • The list was split word by word whenever it did not name one player, so "John Wang 2" became the ambiguous "john" and "wang"
  • Each run of words now takes the longest that names exactly one player: John Wang 2 before John Wang before John
  • Commas still work; a bare ambiguous name is still refused with the candidates
feat3e6b9c7Claude Opus 5.5 (1M context)
  • /pb add game 10/1/26 7:30pm south @Rich Vic: the game's column on the main sheet in date order, with its Totals count
  • The same jobs a poll game gets: Last Call the evening before, the Game Plan an hour before, the lights an hour after a night game
  • Players named are registered as a vote would register them; one name that does not resolve stops the whole command
  • The job creators moved from schedule-last-call.py into webhook/shared/game_jobs.py, which both use
  • CLI twin: smad-whatsapp.py add-game
featbbd3b56Claude Opus 5.5
  • format_smad_dupr skips the plain dashboard line whenever the note already carries the link (Gene, 2026-10-01 PT: the 9/29 force reply printed it twice)
  • tests/test-smad-dupr-command.py pins one link for force- and remodel-shaped notes

September 2026 350 commits

feat235tooling48docs38infra/CI24fix4refactor1
feat598ecbbClaude Opus 5.5 (1M context)
  • A regenerated 9/29 story told the drop last under a blue heart; the story is now rejected unless the 📉 line follows the 📈 line, as the template has them
  • "Extra time" is now "overtime" in the facts, the story and the template (a game past 11 is won by two)
  • A story that says "for years" or "all season" is rejected: the facts give no time spans (the regeneration said Alex and John had partnered "for years"; it was twice, on 9/1)
feat3689636Claude Opus 5.5 (1M context)
  • The 10:23 AM sweep got a page of nothing but 9/5-9/7 runs; only the re-ask found the real previous sweep
  • A stale page cannot answer now; an empty answer is asked for once more, then falls to the 24 h fallback
feat2444e76Claude Opus 5.5 (1M context)
  • The 9/29 story had "That 12-10 needed extra time, and the booth needed oxygen", which only made sense beside the line before it
  • The model is told each line about a match names its players and gives its score, and never points back to another line
  • A story with a line that gives a score but no player of the game is rejected, and the template stands in
feat3da9da7Claude Opus 5.5 (1M context)
  • The 7:23 AM sweep took a 15-day-old run listed first as "previous", so the window was 366.7 h and two stale 9/25 errors were filed as new
  • .github/scripts/prev_sweep.py picks the newest start and prints the candidates into the step log
  • An answer over 3 hours old is asked for once more, keeping the newer
feat31b1cacClaude Opus 5.5 (1M context)
  • The 7:17 AM reply took 11.2 s in picklebot, 5.3 s of it the Game Day header's weather call that a match reply throws away; the header is now built only for the Game Plan report
  • The planner reuses the game the match was recorded against and the handler's roster read, instead of reading the poll and the roster again
  • The webhook caches the group's admins for 30 minutes (was 5); the refresh cost 1.35 s of the same reply
  • Suggestion and typed-match numbers go on from the highest recorded or listed, so a typed match no longer jumps from 18 to 28
feat403cd00Claude Opus 5.5 (1M context)
  • 20 of 67 survey responses belong to archived players; the results now count the 47 who still play
  • A respondent the roster cannot match is kept
  • The CLI's survey-results does the same
feat4e6cbfbClaude Opus 5.5 (1M context)
  • The 8:25 AM four-player plan listed round 1's match again as 3: the "learn" round threw out the one unplayed pairing for being an 81% court
  • An unplayed pairing now always wins over a repeat; balance only chooses among the unplayed ones
  • A matchup already listed, or still on court, is left out of the list, so four players list their three pairings and stop
  • A repeat of a recorded match can still follow once nothing new is left
feat5e795a2Claude Opus 5.5
  • For a night whose matches were fixed after its story was written (Gene, 2026-09-30 PT: 9/29's match 14, corrected by hand on the Win Loss Log)
  • Admin only; the saved run passes rewrite to llm_analysis, saves the report and redraws the dashboard, whose rebuilt report finds the new story
  • The reply says whether the new story stood; one that fails its checks leaves the kept story, never the template
  • Help, README, CLAUDE.md and the smad_llm_recap docstrings name force as the rewrite path
feat00b7b2eClaude Opus 5.5
  • was "what the ratings would learn most from" (Gene, 2026-09-30 PT); the module docstring follows
  • tests/test-game-plan.py pins the new title
feat3c0a923Claude Opus 5.5
  • format_game_plan's second line per match starts at the margin, so WhatsApp no longer wraps it (Gene, 2026-09-30 PT)
  • tests/test-game-plan.py pins the unindented 6.33v5.62 form on all six lines
feat0283117Claude Opus 5.5
  • format_game_plan prints "1 Thanh/Vic v Gene/Rich", no 🏓 (Gene, 2026-09-30 PT)
  • tests/test-game-plan.py finds court lines by their leading number and pins the emoji's absence
feat0681bd1Claude Opus 5.5
  • Gene, 2026-09-30 PT: "I want all labels removed from suggested matches"; 92be034 had dropped only "balanced:" / "learn:"
  • tests/test-game-plan.py: every line is its number and the two sides, the on-court match first; both checks fail on the old code
feat92be034Claude Opus 5.5
  • Gene, 2026-09-30 PT: "Remove balanced: learn: etc label from suggested matches"; the Game Plan's round headers still say why a round was picked
  • tests/test-match-undo-number.py told recorded lines from suggestions by the label's ':'; it now uses the suggestion's " v "
feat02528b9Claude Opus 5.5 (1M context)
  • Tests went red on 117d6c0: the new script made Sheets calls outside webhook/shared, which the no-duplicates ratchet forbids
  • create_tab() holds them now, with the same refusal to touch a tab that already has phrases
feat117d6c0Claude Opus 5.5 (1M context)
  • New sheet tab Catch Phrases: Category, Catch Phrase, Source; the 33 existing phrases are its first rows
  • The template analysis and the model writing the kept story both draw from it; a new row is live within ten minutes, no deploy
  • The model is handed the list without sources and told to use only these
  • An unknown Category is skipped with a warning; an unreadable tab falls back to the original list
  • The template says "going 5-1" and "partnered 2 times", not "night"
feata024eb5Claude Opus 5.5 (1M context)
  • Stock up can call "Boom shakalaka!"
  • An upset can call "To beat the champ, you've got to knock 'em out!" or "The mustard is off the hot dog!"
feat4bf4080Claude Opus 5.5 (1M context)
  • The History chart legend reads "every game fitted with the current model"
  • A report from before the model had 10 scored matches says "by this game"
feat0957e26Claude Opus 5.5 (1M context)
  • The report no longer explains * and Move under the table: people know what both mean now
  • The dashboard's legend keeps its explanation
feat8b8317cClaude Opus 5.5 (1M context)
  • /pb unregister only un-votes; /pb left only takes a leaving player off the plan
  • "unregister Dom left" is refused and points at /pb left, so it can never un-vote a player who played
featd35641dClaude Opus 5.5 (1M context)
  • /pb left takes any number of players (names, "Dom Rich", or @-mentions): off the Game Plan, still charged, next suggestions rebuilt once
  • /pb unregister is only for un-voting a player from a game, a no-show off the night and its charge
  • "unregister Dom left", the old form, is read as /pb left
  • CLI twin: smad-whatsapp.py left "Dominic Cheung" Rich
feat719840fClaude Opus 5.5 (1M context)
  • A suggestion keeps its number until it is recorded, and a match is recorded under that number: on 9/29 court 2 recorded 2 first and "/pb match 1" was refused
  • Match replies show what is still on court ("1 playing"), then the next 4 suggestions and the last 4 recorded matches
  • The Game Plan lists whole rounds: 6 suggestions on two courts, 4 on one
  • /pb game plan 2 (or 1) forces the court count over the court cache, and the match replies keep it
  • The scheduled Game Plan refreshes the club court bookings before posting
  • An undone match comes back under its own number; no other match changes number
feat60d455fClaude Opus 5.5 (1M context)
  • Archived players are off the Ratings table and the SMAD DUPR History chart
  • They are left out of "Least certain comparisons" and "Matches that would teach it the most", on the page and in /pb smad dupr graph
  • They stay in the match graph, drawn faded, because their matches still rate everyone they played and they can come back
feat26ded7dClaude Opus 5.5 (1M context)
  • SMAD DUPR, its History chart, the rebuilt reports and Move are all recomputed from names and scores, so the cells were never used
  • A frozen rating goes stale at the next remodel; matches recorded by suggestion number had been writing blanks anyway
  • record_match writes ten columns; rows that still carry the old cells read the same
  • The one-off backfill and side-reorder scripts for those cells are removed
feat2028b8dClaude Opus 5.5 (1M context)
  • One formatter, match_line(), for /pb post game's list, the /pb match and undo replies and the ✅ line
  • Number, recorded time with no colon, am/pm or leading zero, partners joined by "/", the score where "beat" was
  • First names only; two players sharing a first name in one game fall back to "John W" / "John W2"
  • Suggested matches: "/" between partners, no period after the number
  • "/pb match 14 5" now says which match 14 is and how to write its score, not the @-mention usage
feat6669330Claude Opus 5.5 (1M context)
  • The 9/29 post-game footnote still read "Move = since the previous game night" after the rename (Gene, 2026-09-30 PT: games can be at noon or on Saturday morning)
  • Same wording in the dashboard legend, the facts handed to Claude, the README and the tests that pin them; docs/REGISTRY-notes.md is frozen prose and left as it was
feate3368e5Claude Opus 5.5 (1M context)
  • session_label dropped the minutes, so the 9/29 7:30 game was logged "7pm"; it keeps them now, and a Match ID drops the colon (20260929-730pm-3). The 17 rows of 9/29 are relabelled on the sheet; no other game in the log was at :30
  • same_game_time(): an old hour-only label still matches its hour's game, in session_rows, the Game Plan's frozen inputs and the recap; next_match_id numbers on across -7pm- and -730pm-
  • Kept Claude stories were never found: the recap read a 'label' key the game dict never had, so every story was saved under a blank key and none was shown or kept since. game_label() names the game; the 11 existing rows are labelled from the games their text describes (10 games; 9/25 was written twice)
  • Gene, 2026-09-30 PT: games can be at noon or on Saturday morning. night_recap/night_fits/previous_night/night_start are game_recap/game_fits/previous_game/game_start, and the Analysis tab's Night column is Game
  • tests/test-game-time.py pins the labels, the matching and the numbering
feat55090acClaude Opus 5.5 (1M context)
  • Gene, 2026-09-30 PT: "first show the recorded timestamp first in HH:MMpm (in Pacific time)": 07:51pm 1. Alex L & Guest beat John W2 & Ryan H 11-3
  • From the Win Loss Log's Recorded At (already Pacific); recorded_clock() renders it; only the post-game report turns it on, match replies are unchanged
  • short_name() renders a `Guest (3.80)` as Guest, not "Guest ("
feat492e440Claude Opus 5.5 (1M context)
  • Gene, 2026-09-30 PT: Roger arrived 30-35 minutes in; the 5-7:30 PM clinic's coach (DUPR 3.8, not a member) played matches 2 and 4, which were recorded as Roger. Both rows now read `Guest (3.80)`; Roger's night is 3-3
  • shared/guests.py: a `Guest ... (d.dd)` name carries its rating; the SMAD DUPR fit holds a guest there (prior sd 0.01) so his partners and opponents are rated against a known 3.8, and never reports him
  • Tonight's records, the standings, the match graph and the post-game recap leave guests out; the match lines still name him
  • tests/test-guests.py pins the name rule, the pinned fit and every exclusion
feat524fe72Claude Opus 5.5 (1M context)
  • "2m" read the same for 90 seconds and for 149; the median is now shown as seconds (132s), the p90 stays human-scaled (9.9h) (Gene, 2026-09-29 PT)
featc1d576cClaude Opus 5.5 (1M context)
  • tests/test-watchdog-whatsapp.py imported yaml, installed on the desktop but not in CI; its workflow checks now parse the steps as text, like test-workflow-permissions.py
  • A guard in the same suite fails if any test imports yaml again
featc9326d7Claude Opus 5.5 (1M context)
  • Issue #80 (2026-09-29 PT): a GREEN-API connect timeout failed Sync Group Members at 1:55 PM, and the watchdog's WhatsApp step, then first, hit the same host and skipped the issue and the email; Gene heard only from GitHub's generic mail
  • workflow-watchdog.yml: issue, then email, then WhatsApp, each continue-on-error; a last step fails the run naming any channel whose outcome was not success; timeout 3 -> 5 minutes; email errors redacted
  • .github/scripts/watchdog_whatsapp.py: group and DM sent independently; retried only when nothing can have been delivered (connect/send URLError, 5xx, 429), 3 attempts 5s then 10s apart, never on a read timeout; errors through redact()
  • sync-members.yml's Notify on failure calls report-failure (issues:write, the step log teed for the title) instead of echoing
  • README's watchdog section rewritten (four channels in order, checkout, retry rule); CLAUDE.md, Investigator.md and LESSONS.md: a cloud session reads job logs through the GitHub connector's get_job_logs (measured 2026-09-29 PT); the direct blob download is still refused
  • tests/test-watchdog-whatsapp.py pins the retry rule, the independence, the redaction and both workflows
featee52e2aClaude Opus 5.5
  • The 9/29 1:55 PM PT backup run failed on one 10s connect timeout to getGroupData ("Failed to get group data"); since a9873cf a sync error fails the run, so GitHub emailed Gene over a blip
  • greenapi.call_api(connect_retries=N): a CONNECT timeout only (the request never reached GREEN-API), 5s then 10s apart, each at WARNING; a read timeout is never retried; off by default
  • Only the CLI member sync asks for it (2 retries); the vote webhook's admin check and picklebot's participant read keep 0 against the 60s function timeout
featf7fdb1aClaude Opus 5.5 (1M context)
  • collect-ci-metrics.py lead_times(): each commit, from its author time to its push's last deploy finishing, last 30 days, in ci-metrics.json as lead_time_30d; measured today: 297 commits, median about 2 minutes, p90 9.9 hours
  • ops/incidents.json gains `detected` (observed time, null when not recorded) and `auto_detected` for all 13 outages: only 3 were found by a monitor (the 8/24 Slack alert, the 8/27 Error Reporting email, the 9/20 watchdog issue)
  • New tiles: lead time (median, p90) and found by monitoring (1 of 7 in the last 30 days, 47m mean to detect over the 4 timed); 12 tiles, 6 per row; time to restore reads 31.1h rather than 1867m
  • tests/test-ci-metrics.py (new) pins lead time; tests/test-run-tests.py pins detection and the duration format
feat19a00b3Claude Opus 5.5 (1M context)
  • A new artifact starts private and only Gene can share it; the 9/20 page is already shared by link, so it now holds every post-mortem: an index, and each at https://claude.ai/artifact/NipTX88FpbLrdED8Ga1bNp#pm-<id>
  • build-postmortem-page.py --book renders every docs/postmortems/*.md into it, newest first; the anchored one shows through CSS :target, no script; the bandaid picture is embedded once
  • The three incidents' postmortem links (the Release Dashboard's outage rows) and each md's Shareable page line point into the book; CLAUDE.md says to publish there
  • The 9/20 post-mortem gets its key times and lede in the markdown, so it renders like the others
feat7206a63Claude Opus 5.5 (1M context)
  • The Production Outages table showed the new-member outage as 092226, its first refused member; it was found and written up on 9/29 (Gene, 2026-09-29 PT)
  • The date comes from the incident id (YYYY-MM-DD, the day found), falling back to `started`; the table is ordered by it; duration and the restore window still come from started/restored
  • ops/incidents.json's schema says what the id's date means
feat802937cClaude Opus 5.5 (1M context)
  • scripts/build-postmortem-page.py renders docs/postmortems/<id>.md into the 9/20 page: title, Picklebot-with-a-bandaid picture right after it, lede, key-times row, impact callout, timed timeline, Done/Open pills; paragraphs run the full width of the tables (the 9/20 page capped them at seventy characters)
  • tests/test-postmortem-page.py refuses any title not in the "Post Mortem: M/D/YY" form and checks every post-mortem in the repo
  • New-member sync post-mortem: 9/20 title and key times, the fix push (11:57:47 AM), action item 4 linked to 0a68198, Snow White's part on 9/29, item 12 (the leaver notice; Jerry Farrell notified 12:36 PM PT), and the row deletion time corrected to "between 12:14 and 12:37 PM, not recorded" (Snow White's note had guessed 12:55)
  • GREEN-API rotation post-mortem: key times and a lede; both pages and the 9/20 one (full-width text only) republished
tooling836976bClaude Opus 5.5
  • restored from Snow White's manual sync on the fixed code (the last of the three welcome DMs, GREEN-API outgoing log); outage 7 days
  • Action items 6 (18 rows deleted, 66f2c31), 7 (only Andy Tien voted, can't play; nothing owed) and 8 done; Snow White's follow-ups added as 9-11 (8f7dfae, 66f2c31, 8b92ced)
  • ND is Andy Tien, "cunningham dan" Dan Cunningham; the leftover rows had copied the formulas of the row above
feat8b92cedClaude Opus 5.5 (1M context)
  • sync_integrity_problems(): every group member has exactly one active row, every active row is a group member, no number is on two rows, no row lacks a first and last name; compared by phone, so the error names who is off
  • check_sync_integrity() re-reads the sheet on both exits of a real run (nothing to do, or after inserts and archives); each problem is a run error, so the run exits 1 and the watchdog files it (Gene, 2026-09-29 PT, after 19 green days of failed inserts)
  • First live run: 48 group members, each on one active row; no nameless rows
feat66f2c31Claude Opus 5.5 (1M context)
  • sync-members archived a leaver silently (Jerry Farrell, 9/29 PT); now they get Gene's notice by DM + email, only after their row is archived: "You left the SMAD Pickleball group, sorry to see that..." naming ADMIN_FIRST_NAME (config, default Gene)
  • index_player_phones(): a nameless row is never a player, and a number on two named rows fails the run so the watchdog files it; 12 nameless rows had carried Nardo Manaloto's number, and the last row with a number used to win
  • delete-blank-player-rows deletes rows with no first or last name: the 18 left by refused inserts carried the row above's formulas, so "no value and no formula" found none; the 18 were deleted today with Gene's approval
  • tests/test-sync-members.py pins the notice wording and order, the phone index and the new delete rule
feat8f7dfaeClaude Opus 5.5 (1M context)
  • Contacts was consulted only when WhatsApp gave no name or initials; "cunningham dan" became first name "cunningham" ("Hi cunningham!") and "John S" kept the initial, while Contacts had Dan Cunningham and John Stowell (2026-09-29 PT)
  • resolve_new_member_name(): the contact's name and email when Gene has the number, the WhatsApp display name only when he has not
  • tests/test-sync-members.py pins last-name-first, a last initial, initials, no contact and no name
toolingbf41be7Claude Opus 5.5
  • The refused inserts left nameless rows carrying 9 formula cells each, so delete-blank-player-rows (no value and no formula) found 0 and deleted nothing; Snow White counted 18 before Totals, not the 20 the logs' attempts imply, not yet explained
  • Action item 6 waits on Gene to confirm deleting the 18 by an empty-name test
feat08cf29bClaude Opus 5.5
  • player_data.delete_blank_player_rows(): rows with no value and no formula between the header and Totals, deleted bottom-up in one call; --expect N refuses any other count, so an unexpected blank row is looked at first
  • smad-whatsapp.py delete-blank-player-rows [--expect N] [--execute]: dry run by default; re-ranges the Totals formulas after a delete
  • Post-mortem 2026-09-29-new-member-sync: item 5 done (4324176), item 6 approved by Gene ("delete 20 empty rows"), to be run from the desktop
infra/CI4324176Claude Opus 5.5 (1M context)
  • Mr Sandman's fix for the 19-day new-member outage makes a failed insert exit 1 so the watchdog sees it; without this, that exit would also skip the DUPR/SMS survey sync and the jokes refresh
  • Both steps run when the member step ran, pass or fail (steps.members.outcome), and not when setup failed or the run was cancelled
feat0a68198Claude Opus 5.5
  • ops/incidents.json 2026-09-29-new-member-sync: started at the first refused member (9/22 12:20:40 PT, from the run log), trigger 9428645, fix a9873cf, detected by Gene; restored is filled from the first fixed run
  • docs/postmortems/2026-09-29-new-member-sync.md and its shareable page (https://claude.ai/artifact/8rL8fD6eM3DX9soCxmEaAr), linked from the incident
  • The insert deletes the row it added when the write is refused: 20 refused attempts left 20 empty rows in the Player sheet
feata9873cfClaude Opus 5.5
  • Since 9428645 (9/9) sync-members built each new member's row through every static column but wrote it to a range ending at W/L; Sheets refused every insert ("tried writing to column [X]"), so no row and no welcome DM, and the run still exited green
  • ColumnMapper.static_row() / static_range(): the one layout of a whole Player-sheet row, used by the insert (Gene: "Why doesn't any row insert follow the same column meta data??")
  • sync-members exits 1 when a member could not be added, so the workflow watchdog files it; a dry run never fails
  • tests/test-sync-members.py: the first suite for the insert, with a fake that enforces Sheets' row-width rule
featc514f24Claude Opus 5.5
  • A posted plan (/pb game plan, or the runner when it sends for real; never a dry run or a match reply's suggestions) marks its game in Firestore (game_plan/planned)
  • match_log.choose_session(): the planned game is current before its start, on its own day, while no other game has started since the plan; otherwise the most recent game that has started, as before
  • /pb match undo <n> resolves the game once for the undo and its reply, so undo 3 before the start removes the match that took suggestion 3
  • The no-session reply says a posted Game Plan opens the game
featd0ed40cClaude Opus 5.5
  • presence describes the occurrence inside its own commit: it read "present" on every row of #74-#79, including .env, temp_logs.json and a saved page deleted months ago
  • The column is now "File in HEAD", yes or no from the checked-out tree the sweep runs in (Alert Investigator's finding, 2026-09-27 PT)
tooling870ef09Claude Opus 5.5 (1M context)
  • Title, summary, page and incident impact said "about 2-4 minutes"; the record runs 10:40:04 to 10:43:41 PT, which the Release Dashboard shows as 4 minutes
tooling999b445Claude Opus 5.5 (1M context)
  • https://claude.ai/artifact/3sy2tHXTY4f5PymsKJq9ot, carried by the Release Dashboard's outage row through the incident's postmortem field
tooling899f332Claude Opus 5.5 (1M context)
  • The console's new token is refused (401) for a few minutes after GREEN-API shows it; the script took that as a bad copy and stopped, and the old token died minutes later
  • The check now waits out a 401 or a 429 every 15 seconds for up to 3 minutes, with the token already saved in .env
  • The console-paste flow is the default; updateApiToken moves behind -Api, its one try refused
  • ops/incidents.json: 2026-09-27-greenapi-token-rotation, first logged failure 10:40:04 PT, first confirmed send 10:43:41 PT
  • docs/postmortems/2026-09-27-greenapi-token-rotation.md: timeline, who did what, why, action items
toolingdf583baClaude Opus 5.5 (1M context)
  • 2026-09-27 PT: the console regenerate took effect minutes later, so the new token was refused (401) as not yet active, then the old one died; copying the fix-up command from the session replaced the token on the clipboard
  • -Paste waits at a prompt while the token is copied, saves it to .env, then checks, updates both secrets and restarts the functions; it restored sends at the first try
tooling4a1e8eaClaude Opus 5.5 (1M context)
  • The console route handed over the token already in use, then some other key (401); neither was a new instance token (2026-09-27 PT)
  • The new token goes into .env the moment GREEN-API returns it, before any step can fail; -Resume finishes a failed run from .env
  • A 429 on the check is GREEN-API's rate limit, waited out and retried instead of ending the run
feat1e9d315Claude Opus 5.5 (1M context)
  • The workspace watches every repo the GitHub app sees: 13 open incidents, 6 of them SMADPickleBot's (measured 2026-09-27 PT); the incidents endpoint ignores source_id, so the sweep lists through /sources/<id>/incidents/secrets
  • Neither list embeds occurrences, so each new incident's file and commit are read from /occurrences/secrets; the dry run had titled all 13 "in an unknown file"
docs4682069Claude Opus 5.5 (1M context)
  • Investigator.md's appendix prompt, first line: alerts also come from the watchdog on GitGuardian's behalf; the routine trig_019cYNV8RgZ5k9gvmPhvnPCR carries the same text since 2026-09-26 14:55 PT
feat6a33831Claude Opus 5.5 (1M context)
  • tests/test-redact.py: FAKE_TOKEN was built from the leaked GREEN-API token and kept its real first 25 characters (the part GREEN-API keeps across a regenerate); GitGuardian flagged it. Now 'f00d' * 12
  • .github/scripts/gitguardian_alerts.py: the watchdog's hourly sweep lists open GitGuardian incidents (incidents:read) and files "GitGuardian incident <id>: <detector> in <file>" once per incident, marker-deduped
  • The issue carries the dashboard link, file, commit and line link; never the flagged string, the committer or the hash, and the body goes through redact()
  • An open issue closes when its incident is resolved or ignored in GitGuardian; with no GITGUARDIAN_API_KEY secret the step prints a notice and passes
  • Investigator.md: GitGuardian is the fourth producer; the routine prompt says how to judge a flagged string (real, fixture derived from a real secret, or false positive) without ever quoting it
  • tests/test-gitguardian-alerts.py pins filing, the no-secret body, dedupe, closing and the watchdog step
featce6a097Claude Opus 5.5
  • Kept stories are found by the night alone; the facts hash is now only a record of what a story was written from. Before, a shift in a past night's ratings (a sheet DUPR moving k, a model change) missed the key and wrote a new story
  • A rewrite happens only when Gene asks: a session runs llm_analysis(rewrite=True), and the new row wins
  • The no-quotation-marks rule moves into the style guide; with the night as the key, a guide change rewrites nothing
  • The rule is in CLAUDE.md for every session
feat5dcee97Claude Opus 5.5
  • The model is asked for it in STYLE_ADDENDA, rules sent with the style guide but kept out of the saved story's key, so the 11 kept stories are not rewritten (Gene: "no need to retro-generate")
  • Double quotes in the model's text are dropped before it is checked and kept; apostrophes stay
  • The key for the fixture night is pinned, so a change that would rewrite every kept story fails the suite
feat0fd7009Claude Opus 5.5
  • Each player's line, dots and label are one group; the script names the line nearest the pointer (in the name column, the nearest label), lifts it and fades the rest, with a tooltip of the name and current rating
  • Nearest-line picking, not a hit area per line: twenty lines' hit areas overlap, and the top one won over the line actually pointed at
  • Keyboard focus shows it too; <title> is the no-script fallback
  • The legend drops the 9/26 re-fit note (every night is fitted with the current model since 5ebdca1)
feat08bab1dClaude Opus 5.5
  • The style guide named Vin Scully and Chick Hearn, so the model wrote "Chick says ..."; it now asks for the phrase on its own, never the announcer, player or character behind it
  • check_attribution() rejects a text that names a broadcaster (unless a player of that name is in the facts) or says "as X would say", "X says '...'", "in the words of"; the template stands in, as for a stray number
  • The saved analysis's key covers the style guide, so the attributed 9/25 text is not reused: the next /pb post game writes a new one
feat6a84126Claude Opus 5.5 (1M context)
  • smad-dupr-dashboard.yml's export step has ANTHROPIC_API_KEY (Mr Sandman's 10:05 PT note on d6cc8d4): the export writes the Claude post-game story, once per night and kept on the SMAD DUPR Analysis tab, for nights that have none; without the key they show the template. The GitHub secret is a copy of the Secret Manager key picklebot already uses, copied without printing
  • scripts/rotate-greenapi-token.ps1: reads the regenerated GREEN-API token from the clipboard, checks it against GREEN-API before writing anything, then updates Secret Manager, the GitHub secret, the local .env, and restarts smad-picklebot, whatsapp-message-sender and smad-whatsapp-webhook on it (GREEN-API ends the old token at once, so it is one run)
  • ops/rerun.txt back to comments only: e2be6ef left workflow=smad-dupr-dashboard.yml in it after its run, and tests/test-rerun-lever.py pins that the checked-in lever dispatches nothing (this push fires the lever with nothing to dispatch)
  • 65 suites, 2241 checks, 0 failed; check-workflow-reporting.py passes
feat5ebdca1Claude Opus 5.5
  • The chart read the SMAD DUPR Log, whose first refit was 9/11; the reports were rebuilt from the Win Loss Log back to 8/21. night_fits() fits each night once and feeds both, so the line starts at 8/25, the first night with enough matches (8/21 had one).
  • Move on the dashboard is the chart's: the latest night against the one before, as in that night's report. The table's rating stays the Log's (what was published).
  • "Over time" is now "SMAD DUPR History".
  • seedless_history() and SEEDLESS_SINCE are gone; nothing reads the Log's old refits for the chart any more.
tooling3914f62Claude Opus 5.5 (1M context)
  • a hidden Read-Host prompt in the VS Code terminal takes one character of a paste; the script now reads the clipboard and confirms with the token's first 11 and last 4 characters
  • under ErrorActionPreference Stop the gcloud.ps1 wrapper dies on its own Test-Path of the bundled python (Access is denied); gcloud.cmd skips the wrapper
  • a failed run no longer clears the clipboard, so it can run again (GitHub shows a new token once)
  • used on 2026-09-26 PT: Secret Manager GITHUB_TOKEN version 6 and GH_PAT_TOKEN set; the new token read variables and workflows and dispatched ci-metrics.yml
toolingeb09820Claude Opus 5.5 (1M context)
  • prompts for the token as a secure string, writes Secret Manager GITHUB_TOKEN through a temp file (no trailing newline) and GH_PAT_TOKEN with gh, deletes the file; lists the permissions the token needs
feat3a8e42eClaude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT (Mr Sandman's 19:45 PT task): a GREEN-API connect timeout wrote the API token, which lives in the URL path, into Cloud Logging, Error Reporting, two alert emails and diagnose issue #71
  • webhook/shared/redact.py (stdlib) masks the GREEN-API URL token, GitHub / Anthropic / Bearer token shapes, and the literal value of every secret the process holds (SECRET_ENV_NAMES, from the environment or the workflow's .env; JSON credentials by their secret fields); cached, never raises
  • applied at every exit: the functions' JSON log formatter (message and traceback, so Flask's own uncaught-exception line too), the CLI's logging (install_redaction), both email senders (email_notify, email_service), report-failure's issue title and body (withheld if the scrub cannot run), and diagnose-logs.yml before it posts
  • whatsapp-sender: _post() retries a CONNECT timeout up to three times inside the 60s budget (it never reached GREEN-API, so it cannot double-send) and never a read timeout; a failed attempt is WARNING, a lost message is ONE ERROR with no re-raise (the deploy has no Pub/Sub retry, so the re-raise only paged a second time); the three senders no longer log before raising
  • tests/test-redact.py pushes a fake token through each exit and pins the retry and the single page; CLAUDE.md, docs/LESSONS.md and greenapi's docstring follow; REGISTRY.md regenerated
  • the leaked GREEN-API token still has to be rotated, now that the scrub is live
  • 64 suites, 2215 checks, 0 failed; check-function-deps.py and check-workflow-reporting.py pass
featd6cc8d4Claude Opus 5.5
  • Gene, 2026-09-26 PT, after an example written from 9/25's facts: "ok lets do this llm analysis"
  • shared/smad_llm_recap.py: recap_facts() turns night_recap()'s output, the night's matches and each player's rating, move and record into a facts block in display names; Claude (ANTHROPIC_RECAP_MODEL, default claude-opus-5, low effort, refusal fallbacks on) writes 5 to 8 WhatsApp lines from it under a style guide: only the facts, compute nothing, kind to anyone who dropped, one LA booth call at most per line
  • check_numbers(): every number in the text must appear in the facts; one that does not, a text over 1,500 characters, a refusal, an HTTP error or a timeout returns '' and the template lines stand. Failures log at WARNING: the template is not an outage
  • Written once per night and set of facts, kept on the new SMAD DUPR Analysis tab (night, facts hash, text, model, when); a re-run, a non-admin preview and every dashboard rebuild reuse it, and new facts write a new row. Only a saved /pb post game (or the CLI with the key) generates; the dashboard export generates only when its run has ANTHROPIC_API_KEY
  • format_post_game(analysis='template'|'cached'|'generate') picks it; /pb post game reads the tab once per reply; the CLI's report now goes through format_post_game too instead of its own copy
  • Plain HTTPS like the intent parser, requests imported inside the call: the SDK would add a pinned dependency tree to picklebot's lock, the thing that took WhatsApp down on 2026-08-25
  • smad_dupr_log: _ensure_text_tab() is the one tab creator for the Reports and Analysis tabs
  • "It's a bold strategy, Cotton, let's see if it pays off for 'em." joins the upset catchphrases and the style guide (Gene: "as a rotating catch phrase"); the phrase-length cap is 65
  • tests/test-smad-llm-recap.py: the facts, the number check, written once and reused, the request shape, every failure falling back unsaved, WARNING not ERROR, the report switching between story and template; a mutation that skips the number check fails
  • 64 suites, 2213 checks, 0 failed; check-function-deps.py passes
infra/CIfe5d4f2Claude Opus 5.5 (1M context)
  • cancel-game.yml's comment still said it cancels the last-call job; since 645a68f it deletes the game's -eve Last Call, Game Plan and lights jobs from player_data.game_job_ids() (Mr Sandman's 08:32 PT note)
  • deploy-venmo-sync.yml stops setting ADMIN_DINKERS_WHATSAPP_GROUP_ID: 8c8d03d removed the Admin Dinkers payment posts, venmo-trigger/main.py no longer reads it, and the only reader on its shared path (match_log's chat label for court-cancel reports) is not on the payment path; --set-env-vars replaces the list, so the next deploy drops it from the function (Mr Sandman's 09:17 PT note)
  • check-workflow-reporting.py passes; 63 suites, 2192 checks, 0 failed
featb1eaad5Claude Opus 5.5
  • Gene, 2026-09-26 PT: "If I run /pb post game right now ... will it recalculate and regenerate last night's game, or just show that generated report - is the call idempotent?" It refit fine, but Move came from the sheet's SMAD DUPR cell, which the run before had overwritten: a second run posted +0.00 for everyone and no stock up/down, unlike the dashboard's report of the same night. "Sure" to the fix
  • smad_rating.previous_night(): the log re-fitted without its latest night; compute_smad_dupr's `previous` is that, so Move depends on the log alone. The sheet cell is no longer read for it (load_fit_inputs drops 'previous')
  • smad_rating.fit_model() and night_start(): the one recipe for fitting a set of matches and the one parser of a night's start; the refit, previous_night, rebuild_reports and seedless_history all use them, so the chat reply and the dashboard cannot rate the same night differently
  • /pb post game saves no second Reports-tab row when the latest saved report is the same night's and the same text; a night whose report changed is saved again, and an unreadable tab never costs the save
  • The reply's footnote reads "Move = since the previous game night"; README says a re-run is safe
  • ops/rerun.txt back at rest (34eb821's dashboard dispatch was one-off)
  • tests: previous_night (positive, row order, repeatable, under MIN_MATCHES, undated), fit_model is the recipe, night_start, the chat baseline equals the dashboard's, the re-run posts the same reply and saves no second row; a mutation that keeps the latest night in the baseline fails
  • 63 suites, 2187 checks, 0 failed; check-function-deps.py passes
feat96dc218Claude Opus 5.5
  • Gene, 2026-09-26 PT, after /pb post game remodel: "maybe the right thing to do is to retro regenerate all the post game reports and replace them in dashboard game reports history? Because we just need the matches historical log to regenerate all old reports right? And I believe the first game or 2 didn't event have report"
  • rebuild_reports() in export-smad-dupr-data.py: one report per (date, time) night, fitted seedless on the scored matches up to it with k measured afresh, Move against the night before, the analysis handed only the log up to that night; a night before 10 scored matches shows its matches and why there are no ratings. On today's log: 11 nights from 8/21 (1 match, no ratings) to 9/25, the first ratings on 8/25
  • format_post_game() in command_formatters is now the one assembly of a night's report (matches, table, analysis); /pb post game and the rebuild both call it. The no-ratings title is dated too
  • The SMAD DUPR Reports tab is untouched and still written by /pb post game: the record of what was sent. The dashboard no longer reads it; the caption says the reports are rebuilt
  • Catchphrases are picked per night and player, so a rebuild is the same text on every redraw
  • tests: order, stamps, titles, the no-ratings night, determinism, no later night leaking into an earlier report, the fit and Move per night, the wiring; a mutation passing the whole log to the analysis fails
  • 63 suites, 2175 checks, 0 failed; check-function-deps.py passes
featdfac88cClaude Opus 5.5
  • Gene, 2026-09-26 PT: "Remove the Vote Reminders, DUPR Reminders and Game Reminder summaries from admin as well", after the survey and payment ones went in 8c8d03d
  • smad-whatsapp.py: the three send_admin_summary calls and the details built only for them are gone; each run's counts stay in its log. The one admin post left is "Game Reminder SKIPPED", the alert for a reminder that could not go out: a failure, not a summary
  • tests: no reminder summary is sent, the skipped alert is the only send_admin_summary call left, and the game reminder sends nothing to the admin group
  • 63 suites, 2164 checks, 0 failed
feat8c8d03dClaude Opus 5.5
  • Gene, 2026-09-26 PT: "I no longer need payment received, payment reminders summary and survey reminders summary sent to admin group - they served as debug reports when the commands were not fully stable, now they are they are just noise"
  • Venmo and Zelle syncs: the per-payment "Payment Received" group post is gone; the payer's thank-you DM and email and the Payment Log row are unchanged. The admin-group parameter that only fed it is removed from send_whatsapp_thank_you_pubsub, sync_venmo_to_sheet, sync_zelle_emails, _send_thank_yous and their callers (venmo-trigger, payments-management.py)
  • smad-whatsapp.py: the Survey Reminders and Payment Reminders admin summaries are gone; the run log keeps its counts. Vote, DUPR and Game Reminder summaries stay
  • tests/test-admin-noise.py: the thank-you DM still goes, no group post, no parameter, no summary for the two reminders and the others still sent; test-no-joke's control uses Vote Reminders
  • 63 suites, 2162 checks, 0 failed; check-function-deps.py passes
feat79611b4Claude Opus 5.5
  • Gene, 2026-09-26 PT: "Change this to /pb post game remodel - that's a better name to do a model change without generating a new game report"
  • Same behaviour: the refit is saved and the dashboard redrawn, and the report is kept off the SMAD DUPR Reports tab so the dashboard's latest post-game report stays the last game night's; the param is `remodel`, the help and README say what it is for
  • tests: `remodel` parses to the refit without a report row, the old name does not
  • 62 suites, 2155 checks, 0 failed; check-function-deps.py passes
feat1ec1024Claude Opus 5.5
  • Gene, 2026-09-26 PT: "Is all the static explainer text in the dashboard updated? Can you simplify it a bit too? Too verbose, and everyone now knows how calculation is done so you don't have to treat the reader as a rookie"
  • Stale text fixed: the lede no longer says each player's declared DUPR is the starting point, Move reads "since the previous game night", the trend is one point per game night with pre-9/26 nights re-fitted, the footer names /pb post game
  • Cut: the Elo / Bradley-Terry primer, the signals list, "Why the prior matters" and the circuit and bridge paragraphs; the method is the formula and four lines (start at 3.25, k measured every refit, one linear system, what ± means); caveats are one line each, data-driven figures kept
  • tests: no primer or stale model text left, and the formula, the 3.25 start, the live k and the Move legend are there
  • 62 suites, 2154 checks, 0 failed
feat1ff60bfClaude Opus 5.5
  • Gene, 2026-09-26 PT: "let's remove the k pinning altogether, looks like a clean start along with removing seeding". choose_k, K_RESCALE_STEP and the `rescale` suffix are gone; compute_smad_dupr measures k with estimate_k on the whole log every time. Seedless, 3.25 -> 3.00 on 123 matches moves nobody more than 0.05
  • Dashboard: one trend point per game night, and Move is against the previous night; a refit that added no matches (a re-run, or this morning's model switch) updates the night's point instead of adding one (Gene: "keep move as delta from last two games, instead of delta from a model change")
  • The trend's re-fit of pre-switch refits measures k afresh at each, the same model end to end
  • /pb post game no report: saves the column and the log and redraws the dashboard, but keeps the report off the SMAD DUPR Reports tab, so the dashboard's latest post-game report stays 9/25's (Gene: "don't change the last post game report/analysis")
  • current_k() stays for the Game Plan's margin scale; the dashboard, README, help and intent text drop the sticky-k and rescale wording
  • tests: no pinning left, the refit measures its own k over a logged one, no report saves no report row but still refits and redraws, Move and the one-point-per-night trend; a mutation undoing the same-night merge fails
  • 62 suites, 2152 checks, 0 failed; check-function-deps.py passes
feat6cb7461Claude Opus 5.5
  • Gene, 2026-09-26 PT, after a side-by-side refit of the same 123 matches: "I like the unseeded SMAD dupr better, scores are pulled more towards the middle and this seems more realistic to outside dupr (I don't think we have anyone > 4.5) ... make seedless SMAD dupr real"
  • Every player's prior is PRIOR_MEAN (3.25, the group's mean sheet DUPR, a constant) where it was their own sheet DUPR; only the results separate players. On today's log the top comes in from 4.72 to about 4.36 and the bottom up from 2.01 to 2.16; the order barely moves
  • The sheet DUPR still sets k's scale (choose_k/estimate_k regress margins on sheet-DUPR gaps), so a 1.0 gap means what it means in DUPR, and it is still reported beside the rating and kept in the Log's Sheet DUPR column
  • Dashboard trend: refits logged before SEEDLESS_SINCE are re-fitted seedless at export time (seedless_history: the matches up to that refit's day, its own k), so the line has no step at the switch; the SMAD DUPR Log keeps what was published (Gene: "Recompute it")
  • Dashboard, README and graph copy no longer call the sheet DUPR the prior or the anchor; the reply drops the "No sheet DUPR (roster mean used)" footnote, which no longer moves anyone's rating
  • tests: identical results with sheets 4.5 and 2.5 give identical ratings, k measured against the sheet, the Log row's Sheet DUPR is the sheet, the trend re-fit and that the Log rows are untouched, the page's mirror of PRIOR_MEAN; mutations restoring the seed fail
  • 62 suites, 2157 checks, 0 failed; check-function-deps.py passes
feat645a68fClaude Opus 5.5
  • Issue #73: the cancelled Sat 9/26 9am game's Game Plan job still fired at 8:00 AM PT and failed the run. cancel-game rebuilt a `last-call-<date>-<hour>` id that no job has carried since the 1h-before slot became `game-plan-` (2026-09-22 PT), and the eve Last Call (`...-eve`) never matched it
  • One definition of a game's job ids, `player_data.game_job_ids()`: schedule-last-call.py creates from it and `cancel_game_jobs()` deletes from it, the eve Last Call, the Game Plan and (night games) the lights reminder
  • A delete that fails outright is now a failed step and a red run; a job already gone stays a skip
  • NIGHT_GAME_START_HOUR lives in config.py, not three literal 18s
  • tests/test-cancel-game-jobs.py pins the creator's ids against the canceller's
feat7817f13Claude Opus 5.5 (1M context)
  • Gene, 2026-09-26 PT: "merge /pb post game with /pb calculate smad dupr, because the two are the same... just keep /pb post game, have it also calculate smad dupr, and will render the Recorded Matches, the WL table of players for tonight, and the post game analysis"; "then the SMAD DUPR Dashboard will show Latest Post Game Report MM/DD/YYYY"
  • _post_game_reply() is the one handler: it refits, then replies titled "Post-Game Report for 9/25/26" with the numbered matches (players-list names, beat), the night's SDPR / Move / W-L table and the analysis; /pb compute smad dupr is its admin-only old name with the same reply
  • saved only for an admin (the admin chat, or an Admin Dinkers member anywhere, so Gene can run it in the SMAD group): the column, the log, the SMAD DUPR Reports tab and the dashboard redraw; anyone else gets the same report computed and not saved, with one footnote
  • post_game_report joins ADMIN_GATED_INTENTS (no joke) and NO_SIGNATURE_INTENTS (it ends on the dashboard line)
  • the dashboard's report section is headed "Latest Post Game Report MM/DD/YYYY"
  • tests: the post-game layout and the not-saved footnote (test-smad-dupr-command), who saves and the reply shape through process_command (test-no-joke), the dashboard heading, the signature set; README, CLAUDE.md and help texts follow; a not-saved render of 9/25 read correctly
  • 61 suites, 2121 checks, 0 failed; check-function-deps.py passes
featec6a38cClaude Opus 5.5 (1M context)
  • Gene, 2026-09-26 PT: "remove 'win loss log' at the top and replace it with MM/DD/YYYY of the refresh"
  • the eyebrow reads 'SMAD Pickleball · 09/25/2026 · refit 5', the date from the newest refit's computed_at (PT)
  • tests/test-smad-dupr-dashboard.py pins it
  • 61 suites, 2115 checks, 0 failed
feat3fb06d6Claude Opus 5.5 (1M context)
  • Gene, 2026-09-26 PT: "Stock up - Booyah and Show me the money! Stock down - something more positive than regression to the mean. ... Since this is LA, let's have more Vin, Chick, Dodgers and Lakers catch phrases"
  • stock up: Booyah! / Show me the money!; stock down: Tomorrow's another ballgame., Every slump ends. Think Blue!, No harm, no foul., and more, never a jab
  • LA's booth across the lines: It's time for Dodger baseball! and Pull up a chair! (Vin Scully), This game's in the refrigerator! and Slam dunk! (Chick Hearn), I don't believe what I just saw! (Jack Buck's Gibson call), Showtime!, Purple and gold, Think Blue
  • every phrase is a few words; tests/test-smad-recap.py pins the stock-up pair, the upbeat stock down, the LA calls and the length
  • 61 suites, 2114 checks, 0 failed
feat7b89fcaClaude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT, after the first night recorded by suggestion number: an off-plan start (Thanh late) left the fixed 7 PM plan's numbers wrong, catching up made them labels only, and the list re-offered matchups already played; "suggestion number should always start the one after the number of last played match", "make sure you don't suggest dupes", "we also need to show next 3 suggestions - 2 isn't enough"
  • plan_rounds(tonight=...) starts from the night's real matches: tonight's partnerships count against repeats, a matchup already played tonight is suggested only when every alternative repeats one, and the sit-out and the matchup are chosen together so whoever has played the most sits (choosing sitters first forced repeats on a six-player, one-court night)
  • a night played exactly as suggested gets exactly the suggestions it would have had, pinned in tests/test-game-plan.py; replayed over the 9/25 night the new list has zero repeats against four in the old one
  • suggestions() numbers on from the recorded count; format_suggestions() prints the next three under the Match Recorded, undo and register replies ("Next suggested matches"); the fixed-plan remaining_matches / night_rounds and the growing "N of M left" count are gone
  • /pb match <n> <score> resolves against the current suggestions and also reads "11 to 7" and "11 7" (the 9:14 PM refusal); a number already recorded is refused naming the next one
  • the ratings and log stay frozen at game time; README, the Game Plan footer and REGISTRY.md follow
  • 61 suites, 2110 checks, 0 failed; check-function-deps.py passes
feat89a33c4Claude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT: "feel free to be even more edgy and use famous color commentary catch phrases, like Booyah, This game is in the fridge, it aint over until it's over"
  • smad_recap.CALLS holds a pool per line (stock up: Booyah!, Boom goes the dynamite!; rout: This game is in the fridge!, Get the brooms out!; upset: Do you believe in miracles? YES!, Ball don't lie.; thriller: It ain't over till it's over!, and more); one is picked per line per night by a stable hash, so a night always reads the same and the next night differently; every figure still comes from night_recap()
  • a new Thriller line names the longest extra-time game of the night (a winning score past 11)
  • tests/test-smad-recap.py pins the pools, the per-line pick, stability, variety across nights and the thriller
feat0692515Claude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT: "for /pb undo report - show the new suggested match list ... the undone match should show up on this list?"
  • match_reply_block() is the one builder of the block under both replies: the night's last three matches in players-list names with 'beat', then the Game Plan's next suggested matches; render_match_recorded() and render_match_undone() both use it
  • after an undo the plan is re-read against the matches that remain, so a removed match that had used up a suggestion frees it, and it is listed again under its fixed number
  • the header of a numbered undo reads 'Undo Match 2'; plain /pb match undo keeps 'Undo last Match Recorded'; an emptied game still says so
  • tests/test-match-undo-number.py pins the list window, the freed suggestion, both headers and the empty case; README follows; live read-only preview against the 9/22 session reads correctly
  • 61 suites, 2103 checks, 0 failed; check-function-deps.py passes
feat2691c14Claude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT (relayed from the ChuangFinance session): "create a new optional arg /pb match undo 2 - will undo recorded match #2"
  • match_log.session_rows() is now the one definition of a game's matches and their numbers; get_session_matches() lists from it and undo_match_number() deletes its Nth row, so the number in the Match Recorded reply is the number the undo takes
  • the bot parses "match undo 2" / "match undo #2"; bare "match undo" still removes the caller's own last match; out of range, an empty game and no session are refused with nothing deleted; the reply shows the session after the removal
  • CLI: smad-whatsapp.py match-undo [N]; help texts, the intent doc and README follow; REGISTRY.md regenerated
  • tests/test-match-undo-number.py pins the shared numbering against a fake sheet with another game on the same date and a blank row, the exact row deleted, the refusals, the dry run and the parse; read-only dry run against the live session named the right match
  • 61 suites, 2098 checks, 0 failed; check-function-deps.py passes
featd178b80Claude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT: "keep tonight's email as is, but for future emails have them keep the lights on for 3 hours"
  • LIGHTS_HOURS = 3.0 (an 8pm game: until 11:00pm); send-lights-reminder.py takes --hours, and lights-reminder-runner.yml a lights_hours input passed only when set
  • tonight's job (lights-reminder-20260925-8pm, 9:00 PM PT) is updated after this push to dispatch with game_datetime and lights_hours 2.5, so its email still says 10:30pm; every job the scheduler creates from now on passes no hours and gets 3
  • tests/test-lights-reminder.py pins 3 hours, the 2.5 override and the workflow wiring; README follows; dry runs: tonight 10:30pm, a future 8pm game 11:00pm
  • 60 suites, 2083 checks, 0 failed; check-workflow-reporting.py passes
feat375ed09Claude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT: "move it to 9pm, and also make this dynamic - email reminder needs to be scheduled 1 hour AFTER the game start"
  • schedule-last-call.py: LIGHTS_REMINDER_AFTER_HOURS = 1 replaces LIGHTS_REMINDER_HOUR = 20, so an 8pm game's reminder goes at 9pm, a 7pm game's at 8pm, a 6pm game's at 7pm; the fixed 8pm was the start itself for an 8pm game
  • tonight's job (lights-reminder-20260925-8pm) was rescheduled by hand to 9:00 PM PT before this push; the next scheduler rebuild creates every lights job at start + 1h
  • tests/test-lights-reminder.py pins the trigger for three start times, the game input and the morning-game skip; README, the workflow header and the script docstring follow
  • 60 suites, 2081 checks, 0 failed; check-workflow-reporting.py passes
featb239ddeClaude Opus 5.5 (1M context)
  • Gene, 2026-09-25 PT: "tonight's game is at 8pm, so the 9pm lights reminder email will be wrong - make it dynamic so it says to turn off 2.5 hours after game starts"
  • send-lights-reminder.py computes the end time from the game's start (8pm -> 10:30pm, 7pm -> 9:30pm, 6pm -> 8:30pm); the game comes from a new game_datetime input, or tonight's latest 6pm-or-later game on the poll when a job passes none, which is how tonight's already-scheduled job gets 10:30pm; no game found sends with the old 9:30pm and a warning; --dry-run prints the subject
  • lights-reminder-runner.yml takes game_datetime and now has Sheets access for the poll lookup; schedule-last-call.py passes the game to new lights jobs
  • tests/test-lights-reminder.py pins the arithmetic, midnight rollover, the poll fallback and the wiring; README follows; dry run against tonight's poll: "until 10:30pm"
  • 60 suites, 2078 checks, 0 failed; check-workflow-reporting.py passes
feat7db797cClaude Opus 5.5
  • Gene, 2026-09-25 PT: "For top X players list, filter out those who don't have at least 10 matches". The Top 20 board on Game Day (Group Stats) and on the daily Hall of Shame and Fame now lists only players with ten or more logged matches (wins + losses). Ten is PROVISIONAL_MATCHES, the number the SMAD DUPR fit already calls provisional below, so the board and the fit agree on who is measured well enough to rank
  • The header says it: "🏆 Top 20: SMAD DUPR > W-L > Hrs (10+ matches)". format_player_table()'s docstring used to refuse a match cutoff because a board that silently dropped everyone under ten would be ranking by attendance "without saying so"; saying so is the fix, and the docstring now says the table applies none (/pb players is still the whole roster) while the boards do
  • One helper, format_top_board(), replaces two hand-built copies of the board in format_group_stats_block() and format_shame_report(), so the rule is written once; matches_played() counts an unfilled COUNTIF as no matches rather than an error. Two now-dead imports removed
  • tests/test-formatters.py: positive case first (exactly ten is on the board), nine is not even at 9-0 with the best rating, no record is not, the header states the cutoff, no qualifying player means no board rather than an empty header, and both boards apply the same rule. Fixtures for the vacation, archive and header cases were given records of ten or more, since those cases are about something else. Mutation-checked: removing the cutoff fails four cases, off by one (> for >=) fails the positive case and the fixture cases, dropping the header text fails two, dropping the archive filter fails one
  • 60 suites, 2065 checks, 0 failed; check-function-deps.py green; REGISTRY.md regenerated; README's two descriptions of the board updated
feat1e64193Claude Opus 5.5
  • tests/test-register.py failed on origin/main from this morning: its positive case registers for a "future" game hard-coded as Fri 9/25/26, and get_vote_column_value() reads the real clock, so on 9/25 itself the code correctly wrote game-day hours (2) instead of 'y'. A fixture that expired, not a regression; every push touching Python would have turned Tests red and filed an alert
  • The suite now pins datetime.now() to Wed 9/23/26 noon PT, between its 9/22 "past" and 9/25 "future" games, so the fixtures mean what they say on any date. The cases that already pass an explicit `now` are unaffected
  • Verified: the failure reproduced on a clean origin/main before the change; 60 suites, 2056 checks, 0 failed after it
feat209a798Claude Opus 5.5
  • Gene, 2026-09-25 PT: "For pb game plan - show 5 suggested matches". The report printed the first two rounds since 2026-09-22, which is four matches on the club's two courts. It now prints the first REPORT_MATCHES = 5 suggestions counted in plan order across rounds, so on two courts that is rounds 1 and 2 and the first court of round 3; round 3's header prints with the court that fits and its second court follows under the match replies with the later rounds
  • The numbers are the plan's own and unchanged (1 to 5 in the report, the match replies continuing from 6), so `/pb match <n> <score>` and the "suggested matches left" block under each Match Recorded reply read the same plan; only the report's cut moved. The "more rounds follow" line appears only when the plan has more than the five it printed
  • One formatter serves `/pb game plan`, the CLI's `game-plan` and the 1h-before runner, so all three change together. README (the command and the numbered-match lines), the picklebot intent catalogue and the CLI help say five matches
  • tests/test-game-plan.py: the positive case pins five (not the constant, which an earlier draft compared against itself), three rounds with the third partial, the five being the plan's first five courts in order, numbers 1 to 5, and no follow line for a plan of four or of exactly five. Mutation-checked: back to four fails four cases, whole rounds only (six printed) fails three
featd966c76Claude Opus 5.5 (1M context)
  • the 30-day window commit read tests_30d but its label edit did not land; the tile read '69 runs' with no window
  • tests/test-run-tests.py pins the label
  • 60 suites, 2057 checks, 0 failed
featfce57f5Claude Opus 5.5 (1M context)
  • Gene, 2026-09-24 PT: "change from last 90 to last 30, we now have enough data to present last month trends"
  • WINDOW_DAYS = 30 in scripts/build-shipping-log.py drives deploys/week, change failure rate and MTTR
  • dora() now counts only outages that started inside the window; until now every outage ever was set against the window's deploys, which overstated the change failure rate and would have tripled it at 30 days; MTTR says "(last 30d)", and a month with no outage reads "no outages" and 0.0% rather than dropping the tiles
  • scripts/collect-ci-metrics.py adds tests_30d; the test-suite p90 tile uses it and says "last 30d" once the daily metrics run writes it, all-time until then
  • tests/test-run-tests.py pins the window, the incident windowing, naive and zoned start times, and the empty month
  • 60 suites, 2056 checks, 0 failed; check-workflow-reporting.py passes
feat564d900Claude Opus 5.5 (1M context)
  • Gene, 2026-09-24 PT: "for match recorded results, just list the last 3 recorded matches including the just recorded match"
  • format_post_game_report(last=) keeps the final N matches under their own numbers while the count line still counts the night; the /pb match reply passes MATCH_REPLY_RECENT (3); /pb post game and the undo reply keep the full list
  • tests/test-match-dupr.py pins the three-line window, the short-night case and the unlimited post-game report; README follows; REGISTRY.md regenerated
  • 60 suites, 2052 checks, 0 failed; check-function-deps.py passes
featc517269Claude Opus 5.5 (1M context)
  • Gene, 2026-09-23 PT: record suggested matches by number instead of four names ("For who won, just do record the full score /pb match 14 9-11"; "suggested matches number is set when game plan is made - if a match record is made out of order ... will see a numerical gap")
  • remaining_matches() gives each court its number in the plan (1-4 are the Game Plan report's rounds 1 and 2), and format_remaining_matches() prints that number, so a suggestion never renumbers; the recorded-count offset from the last commit is gone
  • resolve_suggested_match() / resolve_suggested_for_session(): the plan supplies the four names, the score is read left side first (9-11: the right-hand side won; the winner's score is derived by league rules, 12-14 records 14-12); refused with the reason for a number already recorded or not in the plan, a tie, a game short of 11, a missing score
  • handle_record_match() takes "14 9-11" lines (1-3 digits, never an @-mention's phone digits) alongside named lines in one batch; record_match() still applies the vote guard and the duplicate rule; help texts, the intent doc and the empty-command usage lead with it; CLI twin match-suggested N SCORE
  • tests: tests/test-game-plan.py pins fixed numbers and gaps and every resolve case; tests/test-register.py pins the handler path, a mixed batch and the phone-digit guard; tests/test-match-dupr.py follows; README documents it; REGISTRY.md regenerated. Checked read-only against the live 9/22 session: 1 refused as recorded, 21 and 22 resolved, 99 out of range
  • 60 suites, 2049 checks, 0 failed; check-function-deps.py passes
feat7dc9abcClaude Opus 5.5 (1M context)
  • Gene, 2026-09-23 PT: "change the suggested matches numbering to continue on from the recorded matches numbering, so it would be 14. 15. etc in this case. Also enumerate the suggested matches in a Game Plan, starting from 1."
  • format_remaining_matches(start=) numbers from the night's recorded count + 1; the match reply and the register/unregister replies pass it
  • format_game_plan() numbers the courts of rounds 1 and 2 as 1. to 4., the sequence the match replies continue
  • tests/test-game-plan.py and tests/test-match-dupr.py pin both; README follows; REGISTRY.md regenerated
  • 60 suites, 2038 checks, 0 failed; check-function-deps.py passes
feat1153f62Claude Opus 5.5 (1M context)
  • Gene, 2026-09-23 PT: "change the names to the abbrev first name used in players list, remove the SDUPR score after each name in the recorded matches list, and substitute 'def.' with 'beat'. Finally when you list the next suggested matches, put ordinate before each"
  • render_match_recorded() builds one short_names() map over every name in the reply and hands it to the ✅ line (format_match_recorded(names=)) and the session list (format_post_game_report(names=)), so "Gene C", "John W2" read the same everywhere; /pb post game passes no map and keeps first name + SDPR and "def."
  • format_remaining_matches() numbers each suggestion 1., 2., ...
  • tests/test-match-dupr.py pins the compact list, the rebuilt ✅ line and the unchanged post-game report; tests/test-game-plan.py pins the numbering; README follows; REGISTRY.md regenerated
  • 60 suites, 2035 checks, 0 failed; check-function-deps.py passes
feat1f295f1Claude Opus 5.5 (1M context)
  • Gene, 2026-09-23 PT: "need a /pb unregister - someone doesn't show up to game, or leaves early, then I need to rebuild the game plan"
  • the two cases differ on money, so they are two forms: plain unregister writes 'n' (the "can't play" vote) through register_for_game(playing=False), off the roster and the night's charge; when the cell held game-day hours the reply says so and how to undo it; "unregister <name> left" leaves the sheet and the charge alone and records the departure in Firestore (game_departures/<game>, mark_left_early / get_left_early)
  • plan_tonight() plans without the departed (skipped under four on court), and a new plan_tail_for_game() puts the rebuilt next two rounds under the register and unregister replies on game day; every later /pb match reply follows the new roster
  • intent, handler, both help texts, the intent doc and keyword guard; the CLI twin is smad-whatsapp.py unregister <name> [game] [--left]
  • tests/test-register.py pins the parse, the 'n' write and the already-off case, the no-show and left paths of the handler, the departures record, and a plan with two departures on no court; README documents it; REGISTRY.md follows
  • 60 suites, 2031 checks, 0 failed; check-function-deps.py passes
featdc45fb7Claude Fable 5.1
  • Gene, 2026-09-22 PT: "sometimes the pinned poll is not findable in whatsapp client, or admin wants to register a player on his behalf, so that new last minute player's matches can be recorded"; "use the same name match/search function, so /pb register @name works"
  • player_data.register_for_game() writes the vote value (get_vote_column_value: 'y' before the day, the hours figure on game day, never a past game) into the game's column on the player's row; an existing mark is reported, not rewritten; a player not on the sheet is refused with the pointer to /pb sync members
  • player_data.find_registration_game(): today's game, the one nearest to now among those not yet over (four hours' grace), or the game named by a trailing date and time; with no game today the reply names the next one and how to say it
  • picklebot: the register intent (target through resolve_player(), so an @-mention, @me, a typed name or nothing for the sender), the handler, both help texts, the intent doc and keyword guard; open to every member; the CLI twin is smad-whatsapp.py register <name> [game]
  • the Game Plan intent doc and CLI help say two rounds now, not five
  • tests/test-register.py pins the parse, the sheet write against a fake, the game choice and the handler; README documents the command; REGISTRY.md follows
  • 60 suites, 2009 checks, 0 failed; check-function-deps.py passes
feat438d305Claude Fable 5.1
  • Gene, 2026-09-22 PT: "we played the first 10-11 matches all in accordance to the plan!" against a remainder that credited 4 of 13; the plan under the reply was being rebuilt from the live state (tonight's matches in the graph, the post-game refit in the ratings), so it was not the plan posted at 6 PM
  • frozen_inputs(): the night's own matches leave the graph, each player's rating and k come from their last SMAD DUPR Log row before the game (blank when none, so the sheet DUPR stands in as it did then), and the sheet stands when no refit precedes the game; plan_tonight() plans from that. Re-run on 9/22: 10 of 13 on plan, rounds 1-2 identical to the runner's 6 PM post
  • only the first round is free of the tonight-partner penalty: with the five kinds cycling, round 6 (balanced) replayed round 1's courts exactly on the first long night
  • tests/test-game-plan.py pins the freeze (tonight out, an earlier game that day in, ratings and k from the pre-game refit, a first-time player blank, no-refit fallback, an unlabelled row) and that round 6 differs from round 1; README says why; REGISTRY.md follows
  • 59 suites, 1977 checks, 0 failed; check-function-deps.py passes
featbcbebd8Claude Fable 5.1
  • Gene, 2026-09-22 PT: "just show the 1st 2 rounds of suggested matches, then for each pb match results report, show next 2 rounds"; 2026-09-23 PT: "tonight we played well past round 5 and ran out of suggestions, so don't cap the total suggestions to 5 rounds"
  • format_game_plan() prints the first REPORT_ROUNDS (2) rounds and says more follow under each /pb match reply; the plan behind it is the full night, so the partner rules hold across every round
  • format_remaining_matches() prints the next LEFT_ROUNDS (2) rounds that still have an unplayed court, titled with the full count of unplayed courts ("28 of 32 left; next 2 rounds")
  • night_rounds(played, courts): plan as many rounds as matches logged plus the two shown and a spare, never under five, capped at eight on three courts; plan_tonight(played=) uses it from the match reply, and since plan_rounds() is sequential and deterministic a longer plan is the posted one continued (16 rounds for tonight's 13 matches in 3.4 s live)
  • tests/test-game-plan.py pins the two-round report, the moving window, the extension and night_rounds(); tests/test-match-dupr.py pins the wiring; README and REGISTRY.md follow
  • 59 suites, 1970 checks, 0 failed; check-function-deps.py passes
feat557763bClaude Fable 5.1
  • Gene, 2026-09-22 PT: "append remaining Suggested Matches from Game Plan, so I don't have to scroll up to find Game Plan post to assign what the next pairing match is"
  • game_plan.remaining_matches() clears a planned court once a logged match has the same two teams, whichever side won and in any name order, one match per planned court; a pairing the group made up itself consumes nothing; format_remaining_matches() prints "Suggested matches left (N of M)" with one line per court in plan order, round and kind, compact names, and one line when the plan is all played
  • render_match_recorded() appends it under the session list for the session's game, best-effort like the list itself, and prints nothing extra on a night with no plan; the bot and the CLI share the renderer
  • tests/test-game-plan.py pins the remainder and its block; tests/test-match-dupr.py pins the wiring with a stubbed plan; README says what the reply carries; REGISTRY.md follows
  • 59 suites, 1965 checks, 0 failed; check-function-deps.py passes
feat450affaClaude Fable 5.1
  • Gene, 2026-09-22 PT: "move SMAD DUPR column to before DUPR column"; done on the live sheet with one moveDimension (Z to C), which carried every row formula, the Totals row and the balance colouring with it; the game columns still start at AA
  • nothing reads that sheet by position: every reader maps by header (ColumnMapper), verified live after the move with the roster reader, a dry-run refit and the dashboard export
  • compute_smad_dupr() inserts a missing SMAD DUPR column right before DUPR instead of after Join Source, so the code's picture of the sheet matches the sheet; SHEET_COLUMNS lists it beside DUPR with the why
  • tests/test-sheet-columns.py pins the new position; tests/test-smad-dupr-command.py pins the insertion point; README, the CLI docstring and REGISTRY.md follow
  • 59 suites, 1956 checks, 0 failed; check-function-deps.py passes
infra/CI917fe9aClaude Fable 5.1
  • smad-dupr-dashboard.yml ends with "Fire the redraw routine": POST to the routine's /fire endpoint with the bearer token in the repository secret SMAD_DUPR_REDRAW_TOKEN and the id in the variable SMAD_DUPR_REDRAW_ROUTINE_ID (set); unset secret is a warning and a green run, a refused fire fails the run into an alert issue with the HTTP status
  • the routine's prompt names the <routine-fire-payload> block, so the workflow's text reaches it; a session can still fire it by hand
  • why not a workflow_run webhook: the routines API takes no filter on workflow_run (every shape probed 2026-09-22 PT; only push, issues, issue_comment, check_suite, release and pull_request filter, on a fixed vocabulary), an unfiltered hook would wake a cloud session for every run of every workflow, and anthropics/claude-code#94260 reports event hooks never firing for repositories with capitals in the name
  • README, CLAUDE.md and docs/LESSONS.md say so; the "open item" wording is gone
  • 59 suites, 1955 checks, 0 failed; check-workflow-reporting.py passes
feat98b0067Claude Fable 5.1
  • the night's reply is titled "SMAD DUPR Refit for 9/22/26" (the session's date, or the date in the poll option), its summary is "13 matches played between 8 players." and nothing else (k and the log-wide counts stay on the dashboard), and the provisional key sits directly under the table, before the post-game analysis (Gene, 2026-09-22 PT); the whole-ladder reply is unchanged
  • get_smad_dupr_reports() keeps one report per refit stamp, the last row written, so a report re-saved in a newer format replaces the earlier one without deleting anything from the tab; tonight's report is re-saved in this format
  • the dashboard's method section is headed "How SMAD DUPR is calculated"
  • tests/test-smad-dupr-command.py pins the title, the summary line, the key's position and the option-date fallback; tests/test-smad-dupr-dashboard.py follows the heading
  • 59 suites, 1955 checks, 0 failed
featb53e135Claude Fable 5.1
  • the /pb compute smad dupr reply is cut to the current session (Gene, 2026-09-22 PT): tonight's players, headed SDPR / Move / W-L, with tonight's record; the whole ladder only when there is no session to cut to. The "N row(s) added to the SMAD DUPR Log" and "log untouched" lines are gone (useless to players; a log failure still reports itself). After a redraw request one line says both things: "SMAD DUPR Dashboard refresh requested and will update in a few minutes: See the SMAD DUPR Dashboard <link>"
  • webhook/shared/smad_recap.py (stdlib): the post-game analysis under the table, every figure from the night's matches, the refit and the log before the night -- stock up / stock down with records, the rookie card, the upset the pre-night ratings liked least, the rout, the partner-only pairs the night finally put across the net, first-time partnerships, the iron man (one or two players only)
  • the reply as sent is one row of the new SMAD DUPR Reports tab (smad_dupr_log.record_report / get_smad_dupr_reports; SMAD_DUPR_REPORT_COLUMNS; SMAD_DUPR_REPORTS_SHEET_NAME), saved before the redraw is dispatched so the export sees it; the CLI --apply saves the same report. Tonight's 9/22 refit report is backfilled
  • export-smad-dupr-data.py carries the newest twenty reports; build-smad-dupr-dashboard.py shows "Latest recalc report" after the ratings table with the earlier ones behind "See more" (WhatsApp markdown rendered, escaped)
  • the redraw: a routine "Redraw SMAD DUPR dashboard after a refit" (trig_01EyqPsFZdsLCxuyHQFZoeXz) renders from the bucket and republishes the artifact alone; the routines API refused every workflow_run filter shape tried, so its GitHub trigger is attached in the routines UI (README, CLAUDE.md); until then a session fires it after a refit
  • tests: test-smad-recap.py (new), test-smad-dupr-command.py (the night's table, the headers, the lines gone, the redraw line, the saved report through process_command with a faked session), test-smad-dupr-dashboard.py (the reports section). REGISTRY.md regenerated; README documents the reply, the tab and the routine
  • 59 suites, 1952 checks, 0 failed; check-function-deps.py clean
feat4372876Claude Fable 5.1
  • 20 attempts on 2026-09-22 PT, 13 matches, 7 refused (Gene: analyze, backfill, make them work): nothing to backfill, every refusal was retried and stuck; three had no "and" between two first names ("richard mark" read as one name and fuzzed to Richard), three were "Daum" (iOS for Dom, 0.57 against the 0.72 fuzzy cutoff), one was "too" for 2
  • _resolve_side(): a two-word side is tried as one name WITHOUT dictation, then as two session players, then as one with dictation, so "ryan dom beat richard mark 8" is Ryan + Dom over Richard + Mark
  • resolve_by_name(): a consonant-skeleton pass after the fuzzy one, session only, prefix of the first name ("daum" -> d-m -> Dominic), one clear hit or a refusal naming both; and the "No player found" reply lists tonight's first names, so the retry is one attempt
  • SCORE_HOMOPHONES in the score position: won 1, to/too 2, for/fore 4, ate 8; the tail rule still refuses anything that spells a roster name, so "victorio" stays a player
  • tests/test-match-resolution.py pins each of tonight's lines and the refusals that must stay refusals (a skeleton shared by two who played, a player who sat out, no hint without a session); README documents the three forms; REGISTRY.md regenerated
  • 58 suites, 1911 checks, 0 failed; check-function-deps.py clean
fix9049679Claude Fable 5.1
  • the sender dedups on correlation_id alone for 24 hours; the 4:51 PM dry-run dispatch used game-plan-20260922-1900 for Admin Dinkers and the 6:00 PM send to the SMAD group used the same id, so the sender logged "DEDUP: Skipping duplicate message" and the group got nothing on the feature's first night (2026-09-22 PT; Gene posted /pb game plan by hand)
  • the id is now game-plan-<stamp>-group / -admin; tests/test-game-plan.py pins it; docs/LESSONS.md: "A Deterministic Correlation Id Names the Recipient Too"
featae80251Claude Fable 5.1
  • "John W2 and Ryan H have faced each other 11 times, excluding Gene C." now follows "Gene C and Ryan H have faced each other 14 times, the most of anyone here." (Gene, 2026-09-22 PT); it was a "Most faced, excluding" line further down
  • the section is headed "Fun Picklebot Graph Stats" instead of "Worth knowing", and the footer carries the SMAD DUPR dashboard link (the one constant in command_formatters)
  • tests/test-game-plan.py pins the sentence shape and position, the heading and the link; README says the same
feat12231f2Claude Fable 5.1
  • webhook/shared/game_plan.py (stdlib): night_players() plans each player at the SMAD DUPR, else the sheet DUPR; night_covariance() gives a newcomer a row at the prior; predict() turns a team-sum gap into a margin (k x gap) and a win chance with a sigma that widens for less-measured players; plan_rounds() brute-forces every assignment of players to courts and partners and picks five rounds: balanced, new partners, what the ratings would learn most from (the graph's A-optimal gain, among near-balanced options), new partners, learn again. No court in any round is predicted beyond three points; a partnership is never repeated tonight while any candidate avoids it; with more players than seats the sit-outs rotate, veterans first
  • the report (Gene, 2026-09-22 PT): the Game Day block itself (build_game_report: date, weather, courts, players with SDPRs, "we need N more"), the title "Picklebot Suggested Matches for Balance and Algo Training", the rounds with both team sums and the favourite's chance, and "Worth knowing": partner-only pairs and the round that separates them, the most frequent opponents, the most partnered pairs, both again excluding the hub (Gene), the never-partnered veteran pairs, and anyone's first recorded match
  • /pb game plan [datetime] (a read: no joke, no second signature) and `smad-whatsapp.py game-plan [game] [--send]`, both resolving the game the way Last Call does; --send goes through whatsapp_publisher with a deterministic correlation id
  • game-plan-runner.yml, dispatched by Cloud Scheduler an hour before each game; schedule-last-call.py creates it as the `game-plan-*` job in the slot that was the second Last Call, deletes those with the others on every rebuild, and keeps the 8pm-eve Last Call
  • tests/test-game-plan.py: the nine-player positive case, the rating fallbacks, the covariance row, predict(), the partner and court rules, the notes, small nights, the report, and the wiring of scheduler, workflow, CLI and picklebot; tests/test-match-reply-shape.py pins the new signature set. README documents the command, the jobs and the CLI; REGISTRY.md regenerated
  • 58 suites, 1896 checks, 0 failed; check-function-deps.py clean
docs1f800ffClaude Fable 5.1
  • a standing rule written into CLAUDE.md or docs/LESSONS.md is done as far as the action goes; "rule" was a status nobody asked for
  • the shareable page (version 13) says the same; item 8 keeps "declined"
docs25ef0a9Claude Fable 5.1
  • rows 7 and 9 link the rerun-lever commit by subject; row 8 carries Gene's reason for keeping the reusable-workflow call
  • the shareable post-mortem page already showed the decisions (Mr Sandman); this is the repo copy, per rule 4
feat6ba31b2Claude Fable 5.1
  • post-mortem items 7 and 9 (Gene, 2026-09-22 PT). On Sunday 09/20 the fix was on main at 5:43 PM and could have been at 10:16 AM; the difference was a dispatch only the desktop could make. One billed minute per use
  • rerun.yml (on: push, paths: ops/rerun.txt; permissions contents: read, actions: write for `gh workflow run`, issues: write for report-failure) runs .github/scripts/rerun_lever.py, which parses `workflow=<file>` on the first line and `key=value` inputs after it, passes each input as --raw-field, and refuses a name that is not a file in .github/workflows/, any path or `..`, a second workflow= line and a self-dispatch; a refused file fails the job and files an issue. A file with no workflow= line dispatches nothing, which is how ops/rerun.txt is checked in, so this push's own run of the lever proves it on the runner
  • tests/test-rerun-lever.py pins the spec's example, the guardrails, the resting state and the workflow's wiring; tests/test-alert-title.py names rerun.yml as the one report-failure caller allowed actions: write
  • gmail-watch-renewal.yml deleted: workflow_dispatch only despite "Every 6 Days", last run 2026-03-04, failed; the daily runner's even-day step is the live renewal (it writes the GitHub Secret, not Secret Manager, and README now says so). README's four rows, GMAIL_WATCH_SETUP.md step 5 and the Terraform comment updated
  • the lever tables in CLAUDE.md and docs/LESSONS.md gain the ops/rerun.txt row
  • 57 suites, 1851 checks, 0 failed; check-workflow-reporting.py clean on 32 workflows
feat8546126Claude Fable 5.1
  • scripts/export-court-finance-data.py runs the same reconcile(), consolidated() and overhead_for() calls as the Monday email and writes court-finance.json: the consolidated rows with their estimated marks, the reconciliation totals and flagged nights (date, courts, hours, dollars, flags), overhead items by month, claims by date and status, the rate eras. Never a player name, a payment row, an email quote or a claim's addressee; the pure build_payload() reads only those fields
  • scripts/build-court-finance-dashboard.py (standard library only) renders six year-to-date tiles, one chart of invoiced, net court cost, overhead and the operating result per month, the consolidated table split into the court bill and the P&L, every flagged night in the reader's words with its signed difference, the claims, overhead by month with estimates starred, and the glossary; the lede's sentence follows the sign of gross and operating
  • court-finance.yml exports after the consolidated step, renders once as a check, and uploads the export to gs://smad-pickleball-reports; reports_store.py lists court-finance.json; ops/court-finance.json is ignored
  • published as https://claude.ai/artifact/GsymgcNWEuGXpim1oSmT8h (Gene, 2026-09-22 PT); README, CLAUDE.md and ops/athenaeum/README.md say how it is drawn and what never goes in the export
  • tests/test-court-finance-dashboard.py: the payload from fixture rows that carry names, none of which reach it; tiles, chart, tables, flags, claims, overhead, glossary, escaping. 56 suites, 1828 checks, 0 failed
tooling530785aClaude Fable 5.1
  • tile order is now lines, commits, fix commits, deploys/week, deploy time, test time, tests, statement coverage, change failure rate, mean time to restore (Gene, 2026-09-22 PT); the two DORA tiles read as the outcome of the test tiles beside them
  • the "peak commits week" and "peak commits all-nighter" tiles and their trailing-window computation are removed; the 90-day window label they shared stays for the deploys/week and change-failure tiles
  • ten tiles again, two rows of five; the grid comment that tracks the count records the drift to twelve and back
  • 55 suites, 1793 checks, 0 failed
feat7066d10Claude Fable 5.1
  • eleven outages had made the section long (Gene, 2026-09-22 PT); rows past INCIDENTS_SHOWN (3) carry class `more` and are hidden once the page's js class is on <html>, so a reader with no JavaScript still gets every row, as with the commit bodies
  • the button below the table reads "Show all 11 outages" with "8 older outages folded" beside it; unfolded it reads "Show the latest 3 only", and folding again closes any detail panel a folded row had open so no panel sits on the page with its row hidden
  • the panel is lifted out of build() into outages_panel(incs) so tests/test-run-tests.py pins the fold on a fixture (five outages: three plain rows and two `more` rows; three: no button; none: no panel) without rendering the whole page
  • docs/LESSONS.md, Recording Production Outages, says so
  • 55 suites, 1793 checks, 0 failed
feat3216355Claude Fable 5.1
  • ops/test-results.json, ops/ci-metrics.json and ops/smad-dupr.json are untracked (already ignored since 900aacb); the bucket smad-pickleball-reports is seeded with all three and both dashboards were confirmed reading from it
  • tests/test-reports-store.py asserts the fallback file's own content comes back when the bucket is unreachable (Alert Investigator, #70); tests/test-run-tests.py adds the ci_metrics() missing/present cases
  • tests/test-claude-hooks.py: the sessions-doorbell precheck restores the whole tracked tree before its fast-forward; the guard cases leave .gitignore modified in the fixture clone, and the first commit to touch .gitignore (900aacb) made git refuse the merge, failing four cases as "2 commits behind"
  • close_alerts_on_green.py: an alert closes once every marked workflow has a green run started after that workflow's newest failing run (failure, startup_failure, timed_out); the issue's updated_at is no longer the clock. #70's fix ran green two seconds before the investigator's finding landed on the issue, and the first cut would have held it open until the next push (Mr Sandman, 2026-09-21 PT). tests/test-alert-title.py pins the comment, repeat-failure, cancelled and out-of-window cases; Investigator.md says the same
  • 55 suites, 1787 checks, 0 failed
fixeb8f59bClaude Fable 5.1
  • 900aacb went out with two red checks in tests/test-run-tests.py: the suite points bsl.TEST_RESULTS_FILE at a fixture, and the new loader ignored the constant. load_report() gains a fallback path; both loaders pass their constants; the suite points the bucket at a dead port so the fallback is what it measures, whatever the bucket holds
  • The push itself was my error: the suite's failure line was read through a pipe whose exit status was tail's, so the commit was not gated. 55 suites green here except the hooks suite, which fails identically with these edits stashed (a stale fixture clone) and is being looked at separately
feat900aacbClaude Fable 5.1
  • Gene, 2026-09-21 PT: only code belongs in the repo, not data. tests.yml, ci-metrics.yml and smad-dupr-dashboard.yml upload their JSON to the new public-read reports bucket (smad-pickleball-reports, Terraform: bucket, allUsers objectViewer, github-deploy objectAdmin) instead of committing it; each keeps contents: read. ci-metrics.yml fetches the current record first, since the collector carries history forward
  • scripts/reports_store.py is the one reader: an explicit path, then REPORTS_DIR, then the bucket over plain HTTPS, then the checked-in ops/ copy, so the publishing routine (no pip, no Google credentials) and a local run both render. build-shipping-log.py and build-smad-dupr-dashboard.py read through it; the DUPR workflow's render check pins the file it just exported
  • The bucket holds aggregates and ratings only; payment, court and Athenaeum records keep their own path
  • This push creates the bucket (Terraform applies on push) while the three files stay tracked as the fallback; a second push seeds and removes them once the bucket is proven reachable. Redraw of the SMAD DUPR dashboard after a refit now waits for the next push or the routine's cron until the routine gets its own trigger
  • tests/test-reports-store.py, new: 11 checks, positive case first, no network; 55 suites, 1783 checks, 0 failed; CLAUDE.md, README and .gitignore updated
feat4edd76fClaude Fable 5.1
  • Gene, 2026-09-21 PT, on seeing the ranked list after this morning's change: do not backfill in the ranked Players list, it bubbles up players who have not played in months and have no match recorded above those who play now. Backfill the blank SDPR on the Game Day and Last Call player list, for relative comparison and partner matching on the night
  • format_player_table() gains backfill_sdpr=False; format_player_list_with_hours(), the one roster renderer, passes True; /pb players, the Top N board and the shame report keep fitted ratings only, blank and last for the unfitted
  • tests/test-formatters.py: the roster shows the self-rating and keeps its order, the ranked list leaves the cell blank and ranks the player last, a fitted rating is never overridden; 54 suites, 1772 checks, 0 failed; README line rewritten
feat241e2c1Claude Fable 5.1
  • release_scope.py, the one definition both the WhatsApp digest and the Release Dashboard read, now counts ops/test-results.json, ops/smad-dupr.json and anything under ops/athenaeum/data/ as records a bot wrote back, not upgrades (Gene, 2026-09-21 PT: five "Test results for" lines in one digest, "they are noise"). Directory records match by prefix; the digest's git log pathspec excludes them the same way
  • The files stay committed: the dashboard pages are drawn from them by a routine with no Actions token, so a file in the repo is how the numbers travel. A commit that also touched code is still a release
  • On the real history the dashboard now excludes 119 bot-record commits instead of 59
  • tests/test-release-scope.py, new: 13 checks, positive case first, including the newest real "Test results for" commit against the real functions and the real git log; 54 suites, 1769 checks, 0 failed; README updated
feat1f57302Claude Fable 5.1
  • The SDPR column and the sdpr ranking fall back to the sheet's self-rated DUPR when there is no fitted rating (Gene, 2026-09-21 PT); a fitted rating still wins when both exist, and a player with neither keeps a blank cell and ranks last. The self-rating is the prior the fit starts from, so it places them where the fit would
  • tests/test-formatters.py +4 checks, positive case first; 53 suites, 1756 checks, 0 failed; README line updated
featbca734aClaude Fable 5.1
  • New section between Over time and How it is calculated (Gene, 2026-09-20 PT: put the match graph into the dashboard): the drawing from scripts/match_graph_svg.py loaded by path, a legend, a tile row (players, matches, pairs met of possible with density, pools, busiest, blind pairs), least-certain comparisons and the matches that would teach the fit the most with their caveat, three method paragraphs from smad_graph.py's docstring and the line on cut matches versus bridges. Every figure from ops/smad-dupr.json[graph]
  • Degrades, never crashes: no graph block renders no section; a graph block without diagnostics renders the drawing and legend only. The six colour variables the renderer reads are defined in both themes
  • match_graph_svg.py escapes player names in labels, tooltips and the title attribute (Ann <B> reached the page raw)
  • CLAUDE.md and README: the export carries names, ratings and the aggregated pair edges, never a phone or an email; ops/incidents.json: the stray word in the 09/20 impact line
  • tests/test-smad-dupr-dashboard.py +14 checks, positive case first (section present with a graph; one circle per player; tiles from the data; absent without a graph block; drawing only without diagnostics; both themes define the variables); 53 suites, 1752 checks, 0 failed
feata2a9696Claude Fable 5.1
  • MEMBER_FREE_BOOKING_MIN_HOURS 48 -> 24 (Gene, 2026-09-20 PT). The 48 came from one relayed report never checked against the club; the first late Sunday poll put Shyam's vote 47.1 hours before Tuesday's game and the handover was skipped silently
  • A vote inside the window no longer skips in silence: _notify_shyam_skip() DMs the voter's chat that the court transfer was NOT initiated, names the reason, and gives the override (/pb shyam MM/DD/YY), through whatsapp_publisher with a correlation id keyed to the game date so a re-vote sends it once; a failed notice is a WARNING, never a raise into the vote path
  • The vote webhook publishes one DM itself now, so google-cloud-pubsub joins webhook/requirements.in and the compiled lock (pip-compile; the Windows-only colorama line dropped by hand)
  • tests/test-shyam-skip-notice.py, new: 30h vote runs the handover (positive), 20h vote sends one notice with the reason and the exact command, the 2026-09-20 vote now runs, started/non-game/non-Shyam do nothing, a publisher failure never raises. tests/test-court-rules.py pins the 24-hour boundary and that both the 09/20 and the 09/02 votes are allowed
  • 53 suites, 1740 checks, 0 failed; check-function-deps green; README updated
docsd56a9b1Claude Fable 5.1
  • He voted at 7:54 PM Sunday, 47.1 hours before Tuesday 7 PM; the handover requires 48 because a member can only book his own free court 48+ hours ahead, so the vote webhook skipped it by rule (logged). With the poll out at 8 AM he would have voted 11 hours earlier. The automation is intact; added to the end-user impact. Page republished as version 7
tooling6429976Claude Fable 5.1
  • ops/incidents.json gains a postmortem field (URL); the schema says it is required when players or money were affected, and add_an_entry says what the post-mortem holds. The 2026-09-20 entry links https://claude.ai/artifact/NipTX88FpbLrdED8Ga1bNp
  • scripts/build-shipping-log.py renders a post-mortem link on the outage row and first in its detail panel, and names Introduced-by and Fixed-by commits by subject line with a GitHub link instead of a bare sha (Gene, 2026-09-20 PT: a hex number means nothing to me); commit_subject() falls back to the linked sha for a commit this checkout lacks
  • CLAUDE.md Recording outages and docs/LESSONS.md carry the rule and the shape: summary with end-user and financial impact, who did what, observed timeline, causing commit by subject and link, why it shipped, action items each with its fix commit
  • The post-mortem's summary and its one-paragraph explanation are merged under the title, with end-user impact (no player could vote on the week's games for ten hours) and financial impact (no direct loss; potential loss from Sunday voters who may not vote later in the week) as their own lines; page republished as version 6
  • 52 suites, 1716 checks, 0 failed; the dashboard rendered locally with the link and the linked commit subjects
docsc9fffe4Claude Fable 5.1
  • Title as Gene wrote it, on the page (version 5) and the repo copy
docs04cc70eClaude Fable 5.1
  • Gene was driving his son to UCSD and packing all day, saw the alert around 5 PM from the road in the Mr Sandman session, allowed Mr Sandman only the one-line workflow edit (no live logs, no keys, not the author), turned the desktop on around 6 PM and handed the rest to Snow White; Snow White's desktop had been off since Saturday night
  • Timeline rows for the gap, the 5 PM handover and the 6 PM handover; the detection-and-repair paragraph says the alert reached the right places and nobody who could act
  • Page republished as version 3 at https://claude.ai/artifact/NipTX88FpbLrdED8Ga1bNp
docs3ddf98aClaude Fable 5.1
  • Snow White caused the regression and later repaired the gate, dispatched the restore and wrote the post-mortem; the Alert Investigator triaged correctly and wrote one ambiguous line; Mr Sandman fixed the caller within the hour and also misread that line as a 20-day-old Gmail watch, repeated it in two notes and the incident record without checking the workflow file or the 9/18 run, and wrote "no monitor caught it" when the watchdog had; the Workflow Watchdog detected it at 88 minutes; Cloud Scheduler dispatched on time; Gene reassigned the edit and chose the dispatch
  • Page republished as version 2 at https://claude.ai/artifact/NipTX88FpbLrdED8Ga1bNp
tooling3f86d0cClaude Fable 5.1
  • docs/postmortems/2026-09-20-sunday-poll-never-sent.md names every commit by its subject line with a GitHub link, references issue #69 and the shareable page https://claude.ai/artifact/NipTX88FpbLrdED8Ga1bNp, and lists ten action items each with the commit that fixed it or the decision it waits on
  • Investigator.md Appendix A gains WRITE FOR A READER WHO HAS NOT SEEN THE CODE, applied to the live routine trig_019cYNV8RgZ5k9gvmPhvnPCR at 6:40 PM PT: every figure carries its meaning in the same sentence; commits, runs and issues by title and link, never a bare hash. "Gmail watch renewal (day 20, even)" is the example it must never write again
  • ops/incidents.json: the 09/04 entry names the commits that introduced and repaired the caller (Send the whole day's list, to three chats, at 8am from Cloud Scheduler; Let the 8am runner start again: grant the digest job the scopes it calls for), not the run heads
toolingd90ca40Claude Fable 5.1
  • docs/postmortems/2026-09-20-sunday-poll-never-sent.md: summary, observed timeline, what 720b9df changed and which of its three parts broke the caller, why it shipped (the verification proved the top-level path, the search matched a string not a relationship, the tests encoded the same predicate), the Gmail watch question, detection versus repair, nine action items with status and owner
  • The Gmail watch was never at risk: the investigator wrote "day 20, even" (the runner renews on even calendar days); the phrase became "day 20 of a 7-day watch" across two relays and the incident entry. Renewed 9/18, expires 9/25
  • ops/incidents.json corrected: impact no longer claims a watch deadline; detected_by names the watchdog sweep that filed #69 at 9:28 AM PT (88 minutes) and the 8-hour wait for a session allowed to act, instead of "no monitor caught it"
  • docs/LESSONS.md: A Relayed Number Carries Its Source
  • 52 suites, 1716 checks, 0 failed
featf748c1fClaude Fable 5.1
  • The Actions token cannot write a repository variable (HTTP 403 "Resource not accessible by integration", read off the sweep's own log), so the gate 720b9df put on close-on-green never opened and auto-closing was silently off for a day: #69 sat open behind green CI. The refusal was a WARNING under a step that reported success
  • close-on-green.yml is deleted. close_alerts_on_green.py --sweep runs from the Workflow Watchdog's hourly schedule, which already bills a minute: it reads the open issues and the last 100 completed runs and closes every marked issue once EVERY workflow it is marked for has a success run started after the issue's last update, naming the run. alert_title.markers() parses the markers. No per-workflow trigger, no list to keep, no state to write; an alert closes within the hour of the next green run. Dry-run against the live repo: it plans #69
  • actions: write comes off all fifteen callers and off release-notes.yml's caller in daily-reminder-runner.yml (c32a3e6 granted it to match); the watchdog goes back to actions: read. check-workflow-reporting.py check 3 keeps every caller consistent with its callee
  • ops/incidents.json: 2026-09-20 restored 5:55 PM PT from the hand-dispatched run 35549088614 (poll created 5:54 PM PT, every step green); the 2026-09-04 entry that never existed is added from the runs API (started 8:00:04 AM, restored 9:58:28 AM PT)
  • tests/test-alert-title.py: markers() and plan_closes() pinned, positive case first (a later green run closes; before-the-issue, failed, or partial runs do not); no caller grants actions: write; close-on-green.yml is gone. 52 suites, 1716 checks, 0 failed; check-workflow-reporting green; Investigator.md and CLAUDE.md updated
featc32a3e6Claude Opus 5
  • Issue #69. Daily Reminder Runner run 35518182853 went startup_failure at 08:00:04 AM PT today, zero jobs, no step log. No Sunday games poll for the week, no Last Call scheduling, no Games This Week, no vote/payment/survey reminders, no Venmo sync, no court cache refresh, and no Gmail watch renewal on day 20
  • Cause: `720b9df` (2026-09-19 22:03 PT) added `actions: write` to fifteen workflow files for the OPEN_ALERTS gate, including release-notes.yml, but not to daily-reminder-runner.yml's `release-digest` job — the repo's ONLY `uses: ./.github/workflows/` edge. A called workflow is bounded by its caller's job, and GitHub refuses to START such a run rather than degrading it
  • THE SAME FILE FAILED THE SAME WAY ON 2026-09-04 (issues:write that time), and the comment documenting that outage sits six lines above the block that was wrong again. Nothing could catch it: tests/test-alert-title.py pins that a report-failure workflow grants actions:write, which release-notes.yml passed — the caller was the half nobody looked at, twice
  • `scripts/check-workflow-reporting.py` gains check 3: every `uses: ./.github/workflows/X.yml` job must grant each scope X's jobs ask for, compared against the MAXIMUM any job in the callee wants. It reproduces #69 from the files alone and now fails Lint Workflows on every push, so the next scope added to a called workflow cannot reach main without its caller
  • Parsed BY HAND, not with PyYAML, and a case pins that: yaml is in neither the standard library nor requirements.txt, and that file's own comment records an earlier version importing it, passing locally and failing on the runner — the exact class of bug the checker exists to stop
  • `tests/test-workflow-permissions.py`: 15 checks, positive case first, covering both outage shapes (a scope short, and no permissions block at all), level ordering (read does not satisfy write), a second callee job's higher scope, write-all, non-callers, and the real .github/workflows being clean — the case that was false at 8 AM. Mutation-checked: reverting the YAML fix fails it, and two ways of weakening the checker fail three more
  • `ops/incidents.json`: appended, `restored` left null on purpose — the fix is pushed, but restoration is the first successful run, not the push, and this file takes observed timestamps rather than estimates. Noted there that the 2026-09-04 outage has no entry at all, so the MTTR and change-failure tiles do not count it
  • Workflows are the desktop session's to write (CLAUDE.md); Gene authorised this one directly, since Snow White's box is off on Sunday mornings and every daily run — Monday's included — fails until the caller is fixed
  • 52 suites, 1708 checks, 0 failed; check-workflow-reporting.py green across 33 workflows
feat47ae0f4Claude Fable 5.1
  • render_show_games() signs and jokes itself because the CLI posts the same text to the group after poll creation; through picklebot's reply gate it got a second signature and a second joke (Gene, 2026-09-19 PT: "Why does Games this week have 2 picklebot signatures")
  • show_games joins NO_SIGNATURE_INTENTS and NO_JOKE_INTENTS, so the renderer's footer is the only one; the sibling next-game, next-week and last-week renderers do not self-sign and keep the gate's footer
  • tests/test-no-joke.py +4 checks, positive case first (exactly one signature through process_command); tests/test-match-reply-shape.py's pinned set updated; 51 suites, 1697 checks, 0 failed; README line updated
docs985a7dcClaude Fable 5.1
  • A push here fires four deploys, Tests, Lint Workflows and Release Notes, about ten billed minutes whatever it carries; commit as you go, push once when the task is done
feat720b9dfClaude Fable 5.1
  • September 1-19 billed ~1,920 Actions minutes against ~225 for the same days of August (90% alert on a 2,000-minute plan); GitHub rounds every job up to a minute, so run COUNT is the cost. Per-push CI was ~46%, monitoring ~38%
  • greenapi-watch.yml deleted: README recorded it as folded into the keepalive poll on 2026-09-03, but the file outlived the decision and billed 126 minutes in September checking what _poll_instance_state already checks every ten minutes
  • close-on-green.yml's job is gated on vars.OPEN_ALERTS == 'true': a skipped job is free, and this six-second job ran 193 times in nineteen days to close almost nothing. report-failure sets the flag when it files, close_alerts_on_green.py clears it once no marked issue remains, and the watchdog's hourly sweep re-derives it from the open issues (--sync) so a stale flag lasts an hour at most
  • Every report-failure caller grants actions: write so it can open the gate (a permissions block is a denylist by omission); the watchdog's actions: read becomes write for the sync
  • OPEN_ALERTS created as false from the desktop (no alert issue is open); the variable write from a workflow token is proven on the first alert, and the sweep covers a refusal
  • Lint Workflows already had a path filter on .github/workflows; its 50 runs were 50 pushes that touched a workflow
  • tests/test-alert-title.py +12 checks: any_open_alerts(), the gate in the workflow, the reporter, the sweep, and that every caller grants actions: write; 51 suites, 1693 checks, 0 failed; REGISTRY.md regenerated; Investigator.md and README updated
feat3fa5453Claude Opus 5
  • Gene, 2026-09-18 PT: "can the sdupr be represented as a graph ... graph theory graph", then "export the edge list and build the graph diagnostic and draw a graph diagram". It already was one: `XtX` in the fit's normal matrix is exactly the signed Laplacian of the match graph — degree on the diagonal, (times partnered − times opposed) off it — and tests/test-smad-graph.py asserts that entry by entry against the real fitter rather than leaving it a docstring claim
  • `webhook/shared/smad_graph.py` (stdlib, so it ships to all four functions): components, articulation points, cut matches, effective resistance, blind pairs, and `suggest_next()` — the A-optimal next match by Sherman-Morrison on the variance, ~18k candidate pairings scored in 0.14s without a refit per candidate
  • Var(r_p − r_q) IS the effective resistance between them, verified equal to 1e-9 against a direct network solve; the ± column already in the ratings table is each player's resistance to ground. Two players who only ever partner are a null direction — the graph measures their SUM and nothing about their difference, their variance pinned at the ceiling 2·tau². `suggest_next()` proposes splitting such a pair without being told they are the problem, which is the case that convinced me the objective was right
  • `bridges()` was written, then deleted: a match is a clique on its four players, so every edge it creates sits in a triangle and no edge is ever a bridge (0 of 300 random logs, and the suite keeps a seeded 150-log sweep of that). `cut_matches()` — the hyperedge version, matches whose removal splits the pool — replaced it
  • One definition, not two: `smad_rating.normal_matrix()` and `load_fit_inputs()` are extracted so the fit and the graph build the same system from the same matches. A second loader would have made the graph a picture of a log nobody was rated on
  • `/pb smad dupr graph` (a READ, so not in ACTION_INTENTS — anyone may ask what the log supports) and `scripts/smad-dupr-graph.py`, the sibling of compute-smad-dupr.py
  • The export carries a `graph` block: AGGREGATED pair edges with opposed/partnered counts, plus the readings, because the publishing routine has no Google credentials and cannot read the log itself. Names only, like the rest of the payload
  • `scripts/match_graph_svg.py` draws it — a deterministic Fruchterman-Reingold embedding seeded from the player names, so the same edge list draws byte-identical SVG and a republish that changed nothing cannot appear to move a node. Node size is matches played, fill is rating, edge width is times met, partner-only pairs dashed, articulation points ringed
  • 51 suites, 1677 checks, 0 failed. Mutation-checked: a partner/opponent sign flip fails the Laplacian identity, a loosened blind threshold fails the blind case, a cut-match off-by-one fails two, a reseeded layout fails determinism, an unclamped layout fails the bounds case (which needed a scattered pool to bite — a well-mixed one converges on its own), and removing the prior's ground makes the system singular and the suite exit 1
  • NOT wired into the dashboard yet: preview published for Gene at https://claude.ai/artifact/HMpaNF8EfVZnJa2WCDbQQb with real names, match counts and ratings but a PLACEHOLDER topology, since the edge list first ships with the next /pb compute smad dupr
feat81666adClaude Fable 5.1
  • The hours line is this month's hours with a rank among non-archived players who have played this month ("12 hrs this month (#1 of 20)"); the year total barely moved between DMs by September (Gene, 2026-09-18 PT). The year milestone line stays
  • get_player_game_history() now sets hours_this_month, month_rank and month_players via add_month_hours(), a pure function ranked with ties sharing the better rank; archived players are excluded from the field
  • parse_vacation_return() is the one parser for the Vacation Return Date cell, used by the roster reader and the history walker
  • The rating line is SDUPR (the SMAD DUPR); a player the fit has not rated shows the sheet DUPR labelled as such
  • tests/test-formatters.py +13 checks, positive case first; 50 suites, 1634 checks, 0 failed; check-function-deps green; REGISTRY.md and README updated
feat06be7a6Claude Fable 5.1
  • export-smad-dupr-data.py adds fit.k_set_at, k_rescale_at and k_rescale_step, the same walk as smad_dupr_log.current_k() over the history already in hand
  • build-smad-dupr-dashboard.py: a caveat that says why a night you sat out used to move you and no longer does (Victorio +0.08 on 9/17, all of it k), and the methodology's k paragraph says k is sticky, measured at 98 and re-measured at 148 or on /pb compute smad dupr rescale
  • ops/smad-dupr.json re-exported from the live log (refit 3, k 3.25 set at 98); the artifact is republished as version 9
  • tests/test-smad-dupr-dashboard.py +5 checks, positive case first; 50 suites, 1623 checks, 0 failed
feat8a05893Claude Fable 5.1
  • fit_ratings() counts wins and losses per player; /pb compute smad dupr and the CLI report print W-L (total record in the log) instead of a bare match count; the name budget gives up the width so the table still fits a phone (25 wide on the live roster) (Gene, 2026-09-17 PT)
  • k is sticky: choose_k() carries the k of the last logged refit and re-measures only once the log has grown K_RESCALE_STEP (50) matches past where that k was set, or on request (/pb compute smad dupr rescale, scripts/compute-smad-dupr.py --rescale). Re-measuring every run re-read every past match at a new scale and moved players who had not played: tonight's ten matches took k 3.68 -> 3.25 and Victorio's +0.08 was all k
  • smad_dupr_log.current_k() reads the newest refit's k back and walks the run of refits that carried it, so set_at needs no new column
  • The reply's summary says whether k was carried or re-measured and why; the result carries k_note
  • Live dry run: k carried at 3.25 since 98 matches, every rating +0.00 against the sheet
  • tests: +14 rating, +13 command, +4 log checks, positive case first; 50 suites, 1618 checks, 0 failed; check-function-deps green; REGISTRY.md and README regenerated
featb24c601Claude Opus 5
  • Gene, 2026-09-18 PT. Neither is a reply to a typed command, so the 2026-09-09 admin-reply gate in build_result() never touched them: both add the joke in the SENDER. Five sites, all of them now joke-free — the game day group message and each player's DM (`send_game_reminder`), the Last Call group summary and each DM (`cmd_last_call`), and the same Last Call DM from picklebot's own copy, so `/pb last call` and the scheduled runner send identical text
  • The footer stays: `format_dm_signature()` is called with no joke rather than an empty one, so a reminder still closes with the signature and the help hint
  • `send_admin_summary()` takes `joke: bool = True`, and the two Game Reminder callers ("Game Reminder", "Game Reminder SKIPPED") pass False — the admin copy of a joke-free reminder reading differently would be one message in two voices. Payment, Vote, Survey and DUPR summaries are untouched and still joke
  • `tests/test-no-joke.py` +22 checks that drive the real senders with fakes and read what was actually handed to WhatsApp: a sender that still calls get_random_joke() and drops the result is indistinguishable from one that never calls it, so asserting on the built message is the only test that holds. The control case comes first — a Payment Reminders summary through the same fake must still carry the sentinel, since every other case here asserts an absence
  • Mutation-checked all six removals one at a time: each restored joke fails the case that covers it (the admin-summary one fails two)
  • 50 suites, 1577 checks, 0 failed; `check-function-deps.py` green; `REGISTRY.md` regenerated; README's two lines describing these messages updated in the same commit
feat7ccdffbClaude Fable 5.1
  • format_group_stats_block() gains leaderboard=True; the two Last Call callers (picklebot handle_last_call, CLI cmd_last_call) pass False, so the summary keeps attendance and balances and drops the board (Gene, 2026-09-17 PT)
  • Game Day, /pb game next and the shame report keep the board; the switch is on the shared block so nothing is duplicated
  • tests/test-formatters.py +7 checks, positive case first: the block without the board still renders attendance and balances, the default still carries it, and both Last Call callers turn it off
  • README, REGISTRY.md regenerated; 50 suites, 1567 checks, 0 failed; check-function-deps green
featcb88534Claude Opus 5
  • The `data is None` branch of `_poll_instance_state()` logged ERROR on every failed read, and ERROR is the page (a GCP log-based policy emails Gene on every ERROR line from the four functions). Three isolated 10.000s ReadTimeouts on the 10-minute poll — 2026-09-15 and 2026-09-16 PT, each a single on-schedule poll the next one cleared — woke him for an instance that was authorized throughout, and woke him TWICE per event, because `get_state_instance()` logged its own ERROR for the same timeout (issue #63; assigned to this session by Gene via Snow White, both halves)
  • `_note_unreachable_poll()` gives the failed read the first-sighting clock `handle_state_change()` already has: WARNING on the first failure, WARNING while waiting, ERROR once GREEN-API has been unreachable for `_TRANSIENT_PERSIST_MINUTES`. A poll that gets through clears the clock, whatever state it found — this measures reachability, not health, so the clearing lives in the poll rather than in `handle_state_change()`, which also runs on the callback path and learns nothing about polls
  • The WARNING line stays, deliberately. A 5-day log read on 2026-09-16 PT found the first `getStateInstance` timeout ever at 2026-09-15T17:00Z after ~538 clean polls, so the timeout rate is new rather than newly visible and is worth counting. Debouncing the page must not cost the measurement
  • Two clocks, two documents: `_transient_doc(key)` puts the unreachable clock in a sibling of `instance_state/transient`. Sharing one row would let a timed-out poll reset a `starting` that had been standing 14 minutes, so a transient state could restart its countdown forever and never reach its alarm
  • `get_state_instance()` logs its two failure paths at WARNING, the one place in `greenapi.py` that does. It returns None so the CALLER decides what a failed read means, and only the caller can tell the first miss from the fifteenth minute. Its sole production caller is this poll
  • `tests/test-instance-state.py` +21 checks, positive case first (a poll still unreachable past 15 min logs ERROR). Mutation-checked: restoring the unconditional ERROR fails 8, restoring the library's ERROR fails 2, collapsing the two clocks into one document fails 1 — that last one passed until the suite asserted the document PATH, since the fakes keyed by argument and would have kept the clocks apart even if Firestore did not
  • 50 suites, 1555 checks, 0 failed; `scripts/check-function-deps.py` green (no new import in `webhook/shared/`); `REGISTRY.md` regenerated. Nothing was down, so `ops/incidents.json` gets no row
feat73955a9Claude Fable 5.1
  • e02e9a0 moved the sweep to Cloud Scheduler, whose dispatch arrives as workflow_dispatch; the Report step's gate still keyed on event_name == "schedule", so every hourly sweep took the "someone asked" branch and filed a fresh issue (#65, #66, #67, #68), and the auto-close nested inside the dead branch stopped closing stale issues (investigator, 2026-09-16 21:14 PT; Mr Sandman 21:30 PT)
  • workflow_dispatch gains a `source` input (manual|scheduler); the hourly-error-sweep job sends source=scheduler; the gate treats that or the legacy schedule event as the sweep
  • The close leg is hoisted out of the no-op branch and guarded on ALLQUIET=1 alone, so it runs whoever asked (Mr Sandman's shape: two unrelated questions, answered separately)
  • The step reads EVENT_NAME, DISPATCH_SOURCE, REPO and RUN_URL from env instead of github expressions, so tests/test-error-sweep-gate.py runs the real script under bash with a fake gh: sweep with nothing fresh files nothing and closes stale issues; not quiet closes nothing; something fresh files or comments; a manual dispatch still answers (50 suites, 1542 checks)
  • Local terraform plan: only hourly-error-sweep updates in place (its body); a first draft had put the input on the daily runner's body, caught by the plan before the push
infra/CIe02e9a0Claude Fable 5.1
  • New Cloud Scheduler job hourly-error-sweep (infra/terraform/scheduler.tf) dispatches error-reporting.yml at :23 PT every hour, the same shape as the daily runner and the nightly booking; the workflow's schedule: cron is removed
  • Why: measured over the last 12 scheduled runs the cron's gaps were 2h to 6.7h (mean 4.5h, about six runs a day). On 2026-09-15 PT a 5:00 PM poll failure paged Gene at 5:05, the investigator's 5:10 pass found no issue, and the sweep only wrote it at 5:26 (investigator note, 2026-09-15 17:51 PT). Gene approved the job (~$0.10/month) on 2026-09-16 PT
  • README's cron-vs-scheduler plan reverses its "leave error-reporting on GitHub cron" item with the measurement; the scheduler table and Investigator.md name the job
  • Local terraform plan: 1 to add, nothing destroyed (the three in-place "changes" it also showed are CRLF-vs-LF on the Windows checkout; CI plans from LF and last reported no changes)
docs5b6318aClaude Fable 5.1
  • claude.ai removed the per-version share pin by 2026-09-16 PT; Gene: pinning a past Release Dashboard version "was a solution looking for a problem"
  • README retires the pin-drift and Playwright re-pin entries into one note; LESSONS rule 7 stops asking whether the pin holds
  • The Substack link renders the current build; a frozen view is a static copy, as before
featfdf46e0Claude Opus 5
  • Gene, 2026-09-16 PT, from the group screenshot: the summary ran straight into the table and the header sat flush on the rows.
  • The rule is measured with visible(), not len(): WJ is zero-width, so counting the joiners draws it one character too long per stat column. Same shape as format_player_table(), so the two tables read alike.
  • Hyphen rather than an em dash, matching format_player_table's existing rule; an em dash is not reliably one monospace cell and would break the width the rest of the table is sized to.
  • tests/test-smad-dupr-command.py pins the rule's presence, that its width equals the VISIBLE header and NOT len(), that it matches the data rows, and the blank line. All three mutations fail it: dropping the rule loses 6 cases, dropping the blank line 1, len()-instead-of-visible() 2.
  • 49 suites, 1524 checks, 0 failed; check-function-deps clean.
feataefafe0Claude Fable 5.1
  • "Gabe 3.11 & Vijay 2.89 def. ..." on the numbered match list (post-game report, match confirmation, undo), from the roster's current SMAD DUPR, the same map the W-L Record table now uses (Gene, 2026-09-15 PT: "change DPR to SDPR in the match results")
  • The DUPR cells recorded on the match row are still written and still feed the fit; they are no longer displayed. A player with no SMAD DUPR yet is the bare first name
  • tests/test-match-dupr.py rewritten for the new source; README says the same
featccf0436Claude Fable 5.1
  • The last table still headed DPR and showed the sheet DUPR; every other table ranks on SMAD DUPR, and Gene (2026-09-15 PT): "change DPR column to SDPR for all tables"
  • The column reads the roster's SMAD DUPR at two decimals, blank until a player has one; the match lines above keep the sheet DUPR of the night, which is what the fit started from
  • tests/test-match-dupr.py pins the header, a cell, the blank and the untouched match line; README says the same
feat99603e0Claude Fable 5.1
  • The reply's last line is the dashboard link (the same line the Hall of Shame and Fame ends on); the redraw note sits just above it
  • compute_smad_dupr joins NO_SIGNATURE_INTENTS: the title carries the bot, the footer said nothing (Gene, 2026-09-15 PT: "link to the SMAD DUPR dashboard at the end, and remove the picklebot signature")
  • tests pin the tail order and the bare title; README says the same
feat26cc93eClaude Fable 5.1
  • /pb compute smad dupr now dispatches smad-dupr-dashboard.yml after a real refit (Gene, 2026-09-15 PT: "recalculate needs to redraw the dashboard"); the reply's last line says whether the redraw was requested
  • The workflow runs scripts/export-smad-dupr-data.py (SMAD DUPR Log + Win Loss Log + roster names and DUPRs, never a phone number) and commits ops/smad-dupr.json; that push fires the "Republish PickleBot dashboards" routine, whose new Part B renders scripts/build-smad-dupr-dashboard.py (stdlib only) and publishes the artifact when the commit touched the data or the renderer
  • The page: the WhatsApp reply's Move column (change since the player's last refit, "new" on a first), a time series of every player's SMAD DUPR across refits from the SMAD DUPR Log, and one closing "Caveats, and what would make it better" section replacing the "Read this before the table" opener, every number in it computed from the data (uncertainty range, the measured hub player, provisional count, k)
  • Matches and record in the table are the log as of now, one source for both columns
  • close-on-green.yml lists the new workflow; README and CLAUDE.md describe the pipeline; tests/test-smad-dupr-dashboard.py pins the payload and the page (49 suites, 1517 checks)
  • ops/smad-dupr.json is the first export (20 players, 1 refit, 88 matches, fitted 9/11/2026 PT); the page was published by hand from it as version 6
feat256aa5cClaude Fable 5.1
  • The footer joined every reply with a blank line; under a code-fenced table the closing fence is already a bottom edge, so it read as a gap (Gene, 2026-09-15 PT: "remove the extra newline between W-L Record and picklebot signature")
  • TIGHT_SIGNATURE_INTENTS = {'post_game_report'} joins with one newline; every other reply is unchanged
  • tests/test-no-joke.py drives /pb post game through process_command with a fenced body and pins both shapes
featb49369bClaude Fable 5.1
  • "Gabe and Milo beat Vitorio and Bill 11 - 8" was refused on 2026-09-15 PT as "No player found matching 'bill 11 -'": only the single trailing number was a score, and the rest was read as a name
  • Gene: two numbers at the end, the first a legit winning score (11 to 15), then the second is the losing score; the mirror "8-11" reads the same way
  • loser_from_full_score() decides whether a pair is a result; the winner's score is still derived by the league rules, so a typed 11-10 records as 12-10
  • "John Wang 2 8" and "John Wang 2" are unchanged: a pair that is not a result falls through to the single-number rule, which already knows the 2 is his name
  • tests/test-match-resolution.py: every separator, the verbatim line, both John Wang 2 traps, and 11-16 refused; README and REGISTRY updated
feat3fbf1f8Claude Fable 5.1
  • "Gene and Shyam beat Jon and Vic five" was refused on 2026-09-15 PT as "Jonathan Hsieh did not vote" while John Wang 2 was on the court: "jon" is a substring of "jonathan", so the club-wide partial match won before the session got a say
  • resolve_by_name(): a unique club-wide hit who is NOT in the session loses to the session-only dictation match when that has a clear winner; a tie between two who played is returned as the error naming them; a miss keeps the original hit and the vote guard refuses it as before (Gene: "Jon" should resolve to John if John is playing)
  • No club-wide fuzzy, no weakening of the vote guard; the dictation matcher is the same one, factored into a local helper
  • tests/test-match-resolution.py: seven cases around the one that failed, both Johns playing included
feat381a1dbClaude Fable 5.1
  • The numbered session list was dropped on 2026-09-09 PT for length; on 2026-09-15 PT a game was recorded twice 18 minutes apart and the duplicate was invisible in the replies. Gene: "turn back on listing all the recorded match thus far in each match recorded response, this will prevent future confusion"
  • render_match_recorded() passes show_matches=True, show_records=False: the count line, a blank, then the list; the W-L table stays with /pb post game
  • tests/test-match-dupr.py pins the new five-line shape; README says the same
docsbf74545Claude Fable 5.1
  • trig_01BYLpNE8rYjBWkiH5kUhpHf was created from the desktop through the HTTP API; the investigator's run-trigger tool refuses those ("Agents can only fire routines they created"), so every wake from 09/13 11:39 PM PT to 09/15 5:51 PM PT failed silently (investigator note, 2026-09-15 17:51 PT)
  • The investigator created trig_01Jk9aps3776d1y47mTf37RQ itself, bound to session_016A7iNcjiv8vD84tAzjoUiU, and proved delivery; the doc and both live prompts now name it
  • The investigator prompt gains the rule: if the fire is refused, create a replacement yourself and report the id; the wake routine's prompt is the generic handoff again, not the one-off issue #61 text
featbdd4daeClaude Fable 5.1
  • The 8:00 PM PT email on 2026-09-15 ("keep all court lights on until 10pm") was sent and confirmed in the run log, and the lights still went off at 8:45 PM; Gene: "this time just remind them to turn the lights on to 9:30pm"
  • Subject is now "Reminder to turn all court lights on until 9:30pm - Thanks Gene", the end time in one constant; recipient and the 8pm-on-night-games schedule are unchanged
  • tests/test-lights-reminder.py pins the subject and the recipient; README, workflow comment and schedule-last-call docstring say the same thing
feat1825b1dClaude Fable 5.1
  • Inside CANCEL_CUTOFF_HOURS the Athenaeum will not cancel and the court is ours at full charge, so the "or that court will be cancelled" threat was empty; the line now reads "We need 1 more player to fill the second court and it's within 12 hours of the game so court is not cancellable!" (Gene, 2026-09-15 PT)
  • The one-court and two-court shortfalls switch the same way; a past game keeps the old wording; full and resting-pair rosters are unchanged
  • court_rules.inside_cancel_window() is the one definition of "inside the window", beside cancel_cutoff_error(); build_game_report() takes a now= for tests
  • tests/test-court-shortfall-line.py: 18 checks on both sides of the window, the exact boundary, all three lines, past games and full rosters
  • REGISTRY.md regenerated
featb3e9ae9Claude Fable 5.1
  • format_group_stats_block() now gates the Bal/Cred block on a debtor (balance > 0), the same rule the daily Hall of Shame and Fame got earlier today; creditors alone no longer bring the block or the congrats line
  • Game Day Reminder and the Last Call group summary both render their stats through this block, so one gate covers both (Gene, 2026-09-15 PT: "apply to game day as well", "and apply to last call too")
  • tests/test-formatters.py pins both sides: a debtor brings the block with creditors beside them; creditors only shows no block and the board still renders
  • REGISTRY.md regenerated from the docstring
feat1c0b941Claude Fable 5.1
  • sync_venmo_to_sheet() maps a credentials path to credentials_file= and the SMAD_GOOGLE_CREDENTIALS_JSON string to prefer_smad_creds=True, the recipe the shared accessor already reads
  • The removed builder was the first duplicate the generated REGISTRY.md surfaced: a second googleapiclient.build() per sync, the shape that OOM-killed picklebot on 09/08
  • tests/test-venmo-sync-service.py pins the accessor call and the absence of a builder; the ratchet now asserts build('sheets') exists only in google_sheets_utils.py
  • Gene, 2026-09-14 PT
feat6618a11Claude Fable 5.1
  • scripts/build-registry.py renders every public function under webhook/shared (signature + first docstring paragraph), config's constants with their comments, and picklebot's handle_*/execute_* entry points; --check exits 1 when REGISTRY.md is stale
  • tests/test-registry.py pins the file current and every public function documented (positive case: the generator sees an undocumented function)
  • tests/test-no-duplicates.py: per-file baselines for inline Sheets calls, second pytz.timezone()s, sends that bypass whatsapp_publisher and home-grown resolvers, measured 2026-09-14 PT; a file may only ever lose them; one resolve_player(), one PST
  • Three scripts drop their own pytz zone for config.PST; two missing docstrings added
  • The hand-written prose registry is frozen verbatim as docs/REGISTRY-notes.md; CLAUDE.md and README point at the generated file
  • Gene, 2026-09-14 PT: "do registry refactoring"
featbcfe989Claude Fable 5.1
  • Gene typed "/Pickleball match undo" on 2026-09-14 PT and got the unrecognised-slash reply; the reply now names all three real prefixes
  • tests/test-command-prefix.py pins the new prefix (any case, strips to the command, /pickleballs is not one)
feat8fc6fd9Claude Fable 5.1
  • SMAD_DUPR_DASHBOARD_LINE names the dashboard beside its shared URL (WhatsApp cannot hide a link behind text)
  • shame_report joins NO_SIGNATURE_INTENTS so /pb shame matches the daily send
  • Gene, 2026-09-14 PT: the footer was redundant and left two blank lines under the Top 20
feate6b0642Claude Fable 5.1
  • format_shame_report: title reads Shame and Fame; balance block only when a player OWES (a creditors-only table is not a nag); the board is the LEADERBOARD_TOP_N (20) and comes last
  • short_names: FIRST_NAME_MAX 6 with FIRST_NAME_NICKS (Vic, Jon, Rich, Stan, Spen, Dom, Will) and a four-letters-minus-trailing-vowel fallback (Alex, Leon, Ben)
  • format_pct prints 750 / 1000, saving the column the dot cost
  • format_standings caps at LEADERBOARD_TOP_N too (was 15)
  • Suites updated across formatters, match, shame, SMAD DUPR and no-joke; help text, README and REGISTRY
  • Gene, 2026-09-14 PT, from the DM'd preview
feata505c39Claude Fable 5.1
  • The rating column is the fitted SMAD DUPR (two decimals, blank until fitted); criterion `sdpr`, with dupr/dpr/rating as aliases so old commands still work
  • LEADERBOARD_SORT = PLAYERS_DEFAULT_SORT = ['sdpr', 'wl', 'hrs'], so /pb players, the Game Day board and the Hall of Fame all rank the same way
  • short_names(): always "First L" with no period; a first name over 7 letters cut to 3 (Vic M, Jon S); the roster digit glued to the initial (John W2 beside John W); a collision split as Kev Cha / Kev Che; budget accepted and ignored
  • Tables fit 32 columns again on the live roster; the post-game and SMAD DUPR tables pick up the same names
  • Suites, help text, README and REGISTRY updated; Gene, 2026-09-14 PT
feat41d0ae4Claude Fable 5.1
  • Title is two lines: the signature, then `Daily Hall of Fame and Shame for MM/DD/YY` with two goats
  • Top HALL_OF_FAME_TOP_N (10) board leads, same renderer and sort chain as the Game Day board
  • The balance/credit block is omitted entirely when nobody has a non-zero balance; the "All square" filler is gone
  • The Not Voted list is unchanged; /pb shame gains "hall of fame" aliases; help text and README updated
  • tests/test-shame-report.py pins the shape (positive case first)
  • Gene, 2026-09-14 PT
feate99c11eClaude Fable 5.1
  • Gene, 2026-09-14 PT: too noisy. Targets are Admin Dinkers and Gene's DM; /pb commits digest still answers whoever asked
  • tests/test-release-digest.py asserts the group stays out even with SMAD_GROUP_ID set; SMAD_GROUP_ID dropped from the script and release-notes.yml
  • README digest section updated
featbb4449fClaude Fable 5.1
  • webhook/main.py: ERROR means alarm. A routine `starting` restart logged at ERROR paged Gene through the GCP log-based policy at 8:31 AM PT on 2026-09-14 while the line itself said "not alerting"; transient sightings are WARNING, a persisted or hard state stays ERROR
  • error-reporting.yml: the repo-level /actions/runs endpoint ignores ?workflow_id=, so "previous successful sweep" was the previous run of ANY workflow; the 11:40 AM sweep had a 0.3 h window and judged the 8:27 AM ERROR stale, so no issue, no investigator, no wake. Now reads /actions/workflows/error-reporting.yml/runs
  • tests/test-instance-state.py pins the levels (positive case: the persisted alarm is still ERROR); tests/test-error-sweep-window.py pins the endpoint
  • CLAUDE.md rule and Investigator.md history
docsa5ef140Claude Fable 5.1
  • Both routine prompts rewritten live and mirrored in the appendices; second source (claude-shared) recorded
  • The desktop-bound-routine claim downgraded to not measured: every such fire carried a payload
docs1c8935cClaude Fable 5.1
  • Resident instructions drop from ~44K tokens to ~4K per session; nothing deleted, everything moved verbatim
  • First line imports ../claude-shared/CLAUDE.md (new repo genechuang/claude-shared: always-on rules, LESSONS.md, gene-shared plugin with session-protocol, investigator-pipeline and reading-production skills)
  • REGISTRY.md holds the Shared Function Registry; the no-duplicate-code rule in CLAUDE.md points at it
  • docs/LESSONS.md holds the incident narratives and the critical rules' history
  • README pointers updated
  • Gene, 2026-09-13 PT: too verbose, duplicative, takes up session memory; extract the generic parts for ChuangFinance
feat1a78711Claude Fable 5.1
  • report-failure: title is `<stable prefix>: <last error line of the step log>` via .github/scripts/alert_title.py; a repeat failure comments and retitles; the body opens with a workflow marker
  • close-on-green.yml (workflow_run, success) closes every open issue carrying that workflow's marker; the watchdog writes one marker per workflow it pages about
  • error-reporting: title names the service and the error, comments on a same-titled open issue, and a quiet scheduled sweep closes the open Error Reporting issues
  • Investigator prompt: retitle the issue with the finding after commenting, keeping the prefix
  • tests/test-alert-title.py pins the summary rules, the marker, the closer's selection, the watchdog markers and that close-on-green lists every report-failure caller
  • Gene, 2026-09-13 PT: he reads titles in email and never opens GitHub; ten stale alerts were open for weeks
docscc9608dClaude Fable 5.1
  • Detection (report-failure, watchdog, error-reporting) -> issue conventions -> hourly cloud investigator -> Mr Sandman wake -> desktop; the measured channel map; operating notes; history
  • README's routines section replaced with a pointer; CLAUDE.md reference updated
  • Wake routine prompt corrected live: hand-off to the desktop goes through the notes file (the push channel is deleted), and a wake with no payload is a test, not an alert
  • Section 7 says what is generic vs project-specific so it can be lifted into its own repo or skill (ChuangFinance next)
docsa3a398aClaude Fable 5.1
Docs: the Snow White investigator routine and both desktop-bound wakes are deleted; the Sandman wake mis-delivered once in three
feat76ba287Claude Fable 5.1
  • resolve_voter_name(): roster by phone, then senderContactName (Gene's Gmail contact, full name), then senderName (the member's own display name) as the last resort
  • Gene, 2026-09-13 PT: the contact name is more accurate than a user-created display name; the old fallback went straight to the push name and logged Dru Huang as "Dru"
  • tests/test-webhook-voter-name.py drives handle_poll_update() with stubs and pins the order, blank-as-absent, and the audit JSON
docsa05fe59Claude Fable 5.1
Docs: a wake proves delivery by the session id in its receipt; from the cloud the notes file is the whole channel
docs518be85Claude Fable 5.1
  • README: the two wake routines, the measured `message`-vs-`text` result, why the Snow White wake is disabled and the push channel has the same defect
  • CLAUDE.md: the capability fact under Reading Production From a Session
feate50f133Claude Opus 5 (1M context)
  • 2026-09-13 PT: two votes (08:32, 10:07 AM) hit "EOF occurred in violation of protocol" on smad-whatsapp-webhook after 26- and 75-minute idle gaps -- the same signature picklebot showed on 09/11. Found by Gene from a Slack alert; both sessions were asleep
  • webhook/main.py kept a module-level _sheets_service and asked the shared accessor only while it was None, so the hand-out-time idle reset (39d1f86) never ran for a warm instance. It now delegates per call, as picklebot does; the shared cache still returns one object
  • tests/test-webhook-sheets-accessor.py: two calls are two hand-outs (fails 4 ways on the old code), one build across them, and a scan of all four main.py files for a module-level sheets cache
  • No votes lost (every vote reached the sheet on a fresh socket); Dru's 08:32 Pickle Poll Log row took the display-name fallback and was corrected to Dru Huang by hand. Not an incident entry
  • Relayed by Mr Sandman (session-notes, 10:22 AM PT) from the Alert Investigator's 10:14 AM note
tooling9ef1d82Claude Opus 5 (1M context)
  • Gene, 2026-09-12 PT. The dark palette moves to the bare :root so every viewer gets it; :root[data-theme="light"] carries the light palette for the artifact toggle; the OS preference is no longer consulted
docs108e8abClaude Opus 5 (1M context)
  • Routine trig_019cYNV8RgZ5k9gvmPhvnPCR updated 2026-09-12 PT: a comment from github-actions[bot] is the reporter adding a red run, not a response; issue #54 was skipped that way
feat3fc3e25Claude Opus 5 (1M context)
  • Gene, 2026-09-12 PT: "should they be under scripts/ or tests/?" -- tests/, the layout any Python reader expects, and it keeps the suites apart from the real tooling in scripts/
  • git mv of the 38 suites; the nine sibling-script loads go through ../scripts; two suites that leaned on scripts/ being sys.path[0] (whatsapp_export, and build-shipping-log's commit_trailers) put scripts/ on the path by name
  • scripts/run-tests.py discovers tests/, omits tests/* from coverage and drops tests/ from the denominator; tests.yml also runs on tests/ changes; the dashboard's Tests category is now the directory rule
  • Every scripts/test- reference in CLAUDE.md, the READMEs and code comments repointed; the CLAUDE.md test section says where they live and why
  • Whole suite from the new home: 38 suites, 1,306 checks, 0 failed, 23.1% coverage
toolingd44ccfaClaude Opus 5 (1M context)
  • Gene, 2026-09-12 PT: frozen column over full-width notes. A merge cannot span frozen and unfrozen columns, so the wide text moves one column in
tooling17d4afeClaude Opus 5 (1M context)
  • test-run-tests.py imports the dashboard builder and broke on CI while passing on the desktop's 3.13; every scripts/ and webhook/ file now parses under feature_version 3.11
feat2558d41Claude Opus 5 (1M context)
  • test-claude-hooks.py clones the repo and asserts the clone is current against the LIVE origin/main -- a race on the runner, where the workflow's own results commit and any other push land mid-run (3 of 93 cases read "6 commits behind"). It tests .claude/hooks, which never deploy; it runs locally before a hook is touched
  • LOCAL_ONLY names the suite and why; --ci (tests.yml) skips it, prints the reason, records it under `skipped` in ops/test-results.json; the dashboard tile shows the count
tooling1ec6933Claude Opus 5 (1M context)
  • The first CI run said test-claude-hooks failed 3 cases and showed none of them: the tail was the last 12 lines and the FAIL lines sat 40 above
infra/CI9fc0b1fClaude Opus 5 (1M context)
  • With both set, every https request carried two headers and GitHub returned 400: the results push failed and three hook cases fetching origin with it
infra/CIa33acfeClaude Opus 5 (1M context)
  • First run on the runner: 12 of 38 suites broke, none in the code -- ten picklebot suites import main.py, which needs functions_framework (only in the function's own requirements); test-release-digest reads git log and the checkout was shallow; test-claude-hooks refuses the detached HEAD actions/checkout leaves, and its clone fetches origin over https without the checkout token
  • fetch-depth 0; git checkout -B main at the sha with an upstream, the token in the global http extraheader; pip install every webhook/*/requirements.txt too
feat345c3acClaude Opus 5 (1M context)
  • Gene, 2026-09-12 PT: what kind of tests, how organised, can they be counted on the dashboard, test lines split from app lines, coverage calculated and tracked
  • scripts/run-tests.py runs every scripts/test-*.py as its own process, reads both reporter dialects, treats a suite with no summary line as broken, measures statement coverage (coverage.py, parallel mode) with every tracked non-test .py as the denominator, reports by area, and writes ops/test-results.json with a history entry per commit
  • .github/workflows/tests.yml (name "Tests", the name collect-ci-metrics has looked for) runs it on every push to main touching Python, fails on any failure, commits the results
  • Dashboard: tests and coverage tiles (never a 0 when unmeasured), a Test Coverage panel by area with a trend once there are two points, and Tests as its own Codebase category by path
  • First measurement: 38 suites, 1,299 checks, 64s, 23.1% statement coverage (shared 34.8%, picklebot 22.8%, two functions at 0%, CLIs 6.9%)
  • scripts/test-run-tests.py pins the parsers, the silent-suite rule, real subprocess verdicts, history-per-commit, null coverage, the category split; CLAUDE.md gains "The Test Suites" section; README's TODO becomes the shipped note
feat52ef2bcClaude Fable 5.1
  • Gene, 2026-09-12 PT: "emailed report aggregate line is not aligned to column"; "change sheet tab name to 2026 Court Finance"
  • The YTD row's inline style was built by slicing off the closing quote and appending " font-weight:bold" with no semicolon; Gmail dropped the malformed attribute and the totals lost padding and right-alignment. Every cell style is now a complete declaration list; YTD is bold, ruled and shaded; a starred month adds its footnote row
  • One tab per year, named from the table's first month ("2026 Court Finance", template env-overridable); a legacy "Court Finance" tab is renamed in place, never duplicated -- the live tab kept its sheetId
  • Tests: the YTD style parses and carries no unseparated declarations, the year name, the legacy rename, no rename when the year's tab exists, a 2027 table's name
tooling6e523a9Claude Fable 5.1
  • Gene, 2026-09-12 PT: credits belong to the month they reverse (standard accounting); August read as a big loss beside a September profit
  • The club's credits already followed the rule (claims.json); two lines did not. memo_game_month() books a member credit against the game date its memo names (8/17/26, 09/01/2026, 3/24), so Shyam's 9/1 credit written on 8/30 is September's; no date in the memo = stays where posted
  • accrue_dues(): the open month (and any later one) carries the last closed month's DUES and is starred; a closed month with no DUES line stays 0. Without it September's +$15 was a profit it had not earned
  • Both reports and the tab use them; the notes say so; tests pin the memo shapes, the accrual cases and collected() moving the 8/30 row
tooling832630cClaude Fable 5.1
  • Gene, 2026-09-12 PT: "remove Caltech Associates $4K donation, or Fixed, for now ... I also get tax deduction from this donation and then things get complicated"
  • overhead.json drops the item and says why; the model keeps kind 'fixed', and active_columns() shows the Fixed column only when some month carries one, in the text, the email and the tab (formats follow the column count)
  • Notes: Overhead = Dues + Variable (+ Fixed when there is one); the donation is named as deliberately outside the P&L; Operating's paragraph reads dues and tooling
  • Tests: the column drops at zero and returns with a fixed item; the tab and its formats follow; the donation sentence is present
tooling2018220Claude Fable 5.1
  • Gene, 2026-09-12 PT: "format the finance sheet so it's more professional looking ... I bolded the headers"; "on the footnote, please explain Operating, you skipped that"
  • sheet_format_requests(): title merged and bold, bold grey header on two frozen rows, hours bare, dollars $#,##0.00 with negatives in red parentheses, light rules between months, bold YTD under a double rule, sized columns, notes merged across the table and wrapped; idempotent (unmerge + clear formats first)
  • No frozen column: the API refuses a merge across frozen and unfrozen columns
  • consolidated_notes(): one paragraph per column family; Operating = Gross - Overhead, with what a negative month means; shared by the email and the tab
  • Tests pin the request batch (order, header, number formats per column kind, YTD rule, merges, freeze) and the Operating paragraph
tooling6f1d4e0Claude Fable 5.1
  • Gene, 2026-09-12 PT: "have anthropic 100% charged to pickleball"; "GH is now free, we had that experiment paying for GH Pages to get Twilio approval"; "will never use twilio again"
  • overhead.json drops the GitHub Pro $4 fallback; the March $9.34 stays in infra-costs.json as an actual, with the decisions recorded beside the share config
  • README running-cost table: GitHub free, Claude Code is Max from Sept, the total points at infra-costs.json instead of a typed ~$37
feat563e161Claude Opus 5
  • Gene, 2026-09-12 PT: "if it's cloud, it's not really fixed, it's variable, especially Anthropic Claude cost" -- Fixed is now the donation alone; Variable is the actual Anthropic/GitHub/GREEN-API/Twilio charges by month plus GCP estimated
  • scripts/pull-infra-costs.py exports ops/athenaeum/infra-costs.json from the ChuangFinance ledger on the desktop (accrual: Green-API $12 prepaid blocks spread forward, refunds reduce their month, cash view kept; per-merchant share config survives re-pulls); the runner reads the JSON and stars any month past ledger_through
  • court-pnl: load_overhead()/overhead_for()/overhead_note(); the income statement gains a variable column; overhead.json items carry kind fixed|variable
  • court-finance-report: Fixed / Variable (cloud/SaaS) / Overhead columns, notes shared by email and sheet, --sheet rewrites the 'Court Finance' tab (title, header, months, YTD, notes; RAW numbers)
  • court-finance.yml: every run also emails the year-to-date consolidated table and rewrites the tab
  • Tests: overhead_for on covered/uncovered months and shares, the accrual of prepaid blocks, the tab writer against a fake API, the new columns and star
feat6ee58a8Claude Opus 5
  • The joke gate read ACTION_INTENTS, which is not the set of admin commands -- it is the set blocked WHOLESALE outside Admin Dinkers, so a command only PARTLY admin-only cannot be in it. booking_list gates add/remove and leaves the read open; shyam_court admits Shyam's own DM.
  • Both were therefore outside the gate: `/pb booking list add Saturday 9am` came back with a punchline, editing the unattended booking schedule (Gene, 2026-09-12 PT -- he asked for joke-free admin replies on 2026-09-09 PT).
  • ADMIN_GATED_INTENTS is ACTION_INTENTS plus those two, and the gate reads it. Suppression is per-intent because build_result() is handed an intent name, so the open `/pb booking list` read is joke-free too -- the same command, not a second one.
  • scripts/test-no-joke.py now SCANS process_command() for every admin gate and fails when one names an intent the set does not hold, so the next gate cannot drift out the way these two did. Both mutations fail it: the old gate loses 5 cases, dropping booking_list from the set loses 6 including the scan.
feat9e74a3aClaude Opus 5 (1M context)
  • Gene, 2026-09-11 PT: "records each player's recalculated score as a log entry so we can have history and show progress"
  • New tab 'SMAD DUPR Log' (shared/smad_dupr_log.py): rating, previous, delta, sd, matches, provisional, sheet DUPR, measured k, matches in fit, source, who ran it -- one shared Computed At stamp per refit, so a refit reads back as a snapshot
  • Previous and Delta come from each player's last LOGGED row, not the overwritten cell, so the log is its own history; a first row has neither
  • A refit that moved nothing is not logged (same ratings AND same match count) -- the never-log-no-ops rule; the reply says the log was untouched
  • record_refit() never raises: the ratings are already on the sheet, so a log failure is reported in the reply with its text
  • get_smad_dupr_history() reads through the file -> SMAD-account -> ADC chain; bare ADC 403'd ACCESS_TOKEN_SCOPE_INSUFFICIENT on its first real read, the jokes.py failure again, and a test now pins the chain
  • compute_smad_dupr() logs the refit and carries the outcome; /pb compute smad dupr records who ran it
  • First real refit logged 20 rows; the immediate rerun logged none
feat51d9277Claude Fable 5.1
  • Gene, 2026-09-11 PT: a command to run once every match is entered; "a daily cron calculation doesn't make sense"
  • smad_rating.compute_smad_dupr(dry_run=): the whole job (roster + get_all_matches, fit, insert the column after Join Source on first use, RAW writes for everyone in the log, previous SMAD DUPR carried for the move column); refuses under 10 scored matches; empty roster reports the exception text
  • format_smad_dupr(): phone-width ladder with each player's move since the last refit, provisional star, k and match count; WJ between stat columns
  • picklebot: SMAD_DUPR_ALIASES, compute_smad_dupr intent in ACTION_INTENTS (admin, no joke), help lines, dry run honoured; scripts/compute-smad-dupr.py now calls the shared function
  • scripts/test-smad-dupr-command.py: aliases, admin gate, reply width and moves, dispatch through process_command, and the fit against a fake sheet (insert position, RAW numbers, dry run writes nothing, refusals)
feat74937b8Claude Fable 5.1
  • Gene, 2026-09-11 PT: calculate a DUPR from match record, partner and opponent strength; separate column named SMAD DUPR
  • shared/smad_rating.py: team = sum of two ratings, point margin is the observation, sheet DUPR is the prior (Bayesian ridge, exact solve, stdlib only); k measured from the log; posterior sd; provisional under 10 matches
  • match_log.get_all_matches() feeds it; sheet_columns registers smad_dupr (static, after Join Source) before the sheet grew it; get_full_player_data() reads it
  • scripts/compute-smad-dupr.py prints the table and with --apply writes the column; first run inserted column Z and wrote 20 ratings from 76 matches (k = 4.2)
  • scripts/test-smad-rating.py: synthetic league with known ratings (ordering and gaps recovered, level pinned by the prior, provisional boundary, k recovery, solver); test-sheet-columns pins the fourth static column
feat51f5138Claude Fable 5.1
  • Gene, 2026-09-11 PT: "compact the Match Recorded response even more - remove the Picklebot signature and remove the newline between Match Recorded and the match itself"
  • format_match_recorded() drops the blank after the header; NO_SIGNATURE_INTENTS = {record_match} in build_result() skips the signature footer for this reply only
  • test-match-dupr pins the three-line shape; new test-match-reply-shape drives process_command and pins no signature and no joke on /pb match, and the signature still on /pb help
feat33a5fbeClaude Fable 5.1
  • 2026-09-11 9:28 PM PT: picklebot's ERROR line reached Cloud Logging as plain stdout text with no severity; error-reporting.yml's hourly Error Reporting sweep saw nothing, no issue opened, the investigator routines had nothing to investigate
  • shared/cloud_logging.py: setup_logging() puts a JsonFormatter on the root logger (force=True kept) -- severity, message (+traceback on exc_info), logger; ERROR and above carry @type ReportedErrorEvent and serviceContext from K_SERVICE. Stdlib only
  • The four main.py call setup_logging() in place of logging.basicConfig(level=INFO, force=True)
  • scripts/test-cloud-logging.py: INFO comes out as JSON with severity (the positive case), ERROR typed, traceback on one physical line, WARNING untyped, unicode intact, K_SERVICE default, stale root handler replaced
  • CLAUDE.md log-search bullet and registry row; README shared-module row
feat489ccfaClaude Fable 5.1
  • Both happened courtside 2026-09-11 PT (7:53 PM "/Baby Gabe and Vijay beat...", 9:13 PM "/BB match Gerry and Gene beat...") and were refused; Gene's rule is an alias only for a mis-dictation that has happened
  • test-command-prefix pins both lines as commands and the alias tuple as exactly the four
featf039ee8Claude Fable 5.1
  • Gene, 2026-09-11 PT: "I'm technical, give me technical response instead of burying the error"
  • get_full_player_data() leaves the reason in player_data.last_roster_error ("ExcType: text", cleared on a good read; a service that could not be initialised says so too)
  • The reply is now "Nothing recorded: the roster read from the sheet failed." + the exception text + "Send the same line again"
  • Test: a dead service whose execute() raises the 9:28 PM SSL EOF leaves the text behind; the reply carries it; no text captured says so rather than printing blank
  • ops/incidents.json: restored 10:14 PM PT (39d1f86's picklebot smoke test)
feat39d1f86Claude Fable 5.1
  • 2026-09-11 9:28 PM PT: picklebot idle 26 min between matches, the cached Sheets connection was dead (EOF occurred in violation of protocol, 3 ms), the roster read returned [] and two /pb match lines were answered "No player found matching 'gene'" and lost
  • httplib2 0.32.0 re-raises a request-phase socket.error without closing the connection, so the dead socket was retried on the next command 9 s later
  • get_sheets_service(): a hand-out after 60 s idle closes and clears the service's pooled connections first (_drop_pooled_connections) -- one TLS handshake per burst, never a discovery rebuild (the #50 fix stays)
  • handle_record_match(): an empty roster is a third state -- "Couldn't read the roster ... nothing was recorded", not every name unknown
  • record_match(recorded_at=) for backfills; scripts/record-match-backdated.py wrote the two lost matches (20260911-7pm-9, -10) at the times they were first called
  • Tests: idle reset cases (burst untouched, gap closes and empties the pool with no rebuild, clock restarts, no-pool service untouched), the recorded_at override, and the empty-roster reply with its positive case
  • ops/incidents.json entry (restored filled after the smoke test); CLAUDE.md registry rows and a Liveness or Die row; README
feat9ec9473Claude Opus 5
  • Gene, 2026-09-11 PT: "Remove the 1 match logged line, it adds zero value. Then move the 3 matches logged line right up to the 1st line, no blank lines in between"
  • format_match_recorded() drops the "_N matches logged. W/L on the sheet updates automatically._" line and the blank that separated the result from the session line; the header keeps its blank line
  • a batch still puts the session line after every result line, not between them
  • scripts/test-match-dupr.py pins the adjacency for one result and for two, that no blank survives inside the body, and that the removed sentence is gone; three cases fail against the old shape
  • README described a per-player W-L table the confirmation stopped carrying on 2026-09-09 PT; it now describes the two lines that are actually sent
feat24351bcClaude Opus 5
  • Gene, 2026-09-11 PT: "I never said to filter out vacationing players for either list. I only said to filter out archived players for all lists"
  • format_group_stats_block() dropped vacationers as well as archived, so the board and /pb players disagreed about who the group is while sharing a renderer and a sort chain: David Lin ranked first on the roster at 07-02 .778 and was absent from the board, Jerry F. the same way, and every row below them sat one rank high
  • format_player_stats_block()'s hours rank excluded them from its denominator too, so a DM's "#N of M" counted a smaller group than List Players (M)
  • the survey availability grid is unchanged: it already filtered archived only and MARKS vacationers rather than hiding them
  • scripts/test-formatters.py pins the vacationer on the board, the archived player off it, the two views listing the same people in the same order, and the rank denominator; all three board cases fail against the old filter
feat28b7514Claude Fable 5.1
  • game_already_happened() short-circuits both the reply and execute_cancel_game(); a past date never reaches the workflow, the Court Log or the players
  • 9/1/26 9am cancelled on 9/10 had run the whole workflow to print six "not found" lines (Gene, 2026-09-10 9:25 AM PT)
  • test-cancel-game-date.py pins it, with requests.post armed to fail if a past game is dispatched; its M/D-without-year case is now date-independent
docs320a177Claude Fable 5.1
  • Gene, 2026-09-10 02:05 PT screenshot: the Release Dashboard's Shared-version list reads Version 82 down through 75, still climbing, nothing recycled
  • CLAUDE.md rule 7 and the README's republish-routine and pin-drift sections now describe the 20-slot window in the past tense, with the observation that ended it
  • whether the shared-version pin itself now holds is recorded as unverified: the recycling explained the 8/29-8/30 drift and is gone, which is not the same as a measurement
  • neither public release-notes feed reachable from the sandbox (code.claude.com changelog, platform.claude.com release notes) mentions artifact versioning; support.claude.com is blocked from here
toolingf39c817Claude Fable 5.1
  • Gene, 2026-09-10 02:07 PT: "I'm not going to act on 9/1 $17 overcharge, let it slide"
  • claims.json gains the date with status 'waived' and his words; the draft filter is by date, so a waived date is as closed as a credited one, and the _note says so
  • ops/athenaeum/README.md: 9/1 is no longer "the only open claim"
  • scripts/test-court-finance.py pins that a waived date stays out of open_overs() and that the live claims.json carries 9/1 as waived
docs067f4b9Claude Fable 5.1
README TODO: remote wake for the desktop session, now off nightly at 1 AM PT with a 5-minute sleep timer
tooling5899cf9Claude Fable 5.1
  • row_kind() member_credit for positive credit/courtcredit rows; net revenue = invoiced - member credits; gross and operating run from net revenue
  • 2026 to date: $234 of credits, gross +$1,449 (was +$1,683); consolidated report gains a Member credits column
  • Pinned in test-court-pnl.py
tooling1beef7eClaude Fable 5.1
  • golf and food_drink lists of substrings or re: regexes; a new course is one line there
  • SMOG (San Marino Occasional Golfers) history read: no round Gene fronted since its 6/22 floor; the three Almansor invoices re-dated 5/29/2025 -> 2026
  • Pinned in test-court-pnl.py; 2026 totals unchanged by the new terms
tooling824dfb3Claude Fable 5.1
  • Five 2026 payments (40) move from court cash to HH/other cash; court cash in is $4,579 vs $4,030 invoiced
  • Pinned in test-court-pnl.py
tooling607492cClaude Fable 5.1
  • row_kind(): Gene's negative rows under athhappyhour/athlunch/sushi roku/invoice (or a debit whose memo says so) are "other invoiced"; a Venmo/Zelle whose memo says sushi, whisky, lunch, drink or golf is "other cash"; everything else stays court; both ledger tabs read
  • The fee-multiple test was measured and rejected: 64 hits, mostly the January 2025-balance settlement, all court money
  • Consolidated report gains HH/other invoiced and HH/other cash columns; 2026 to date $558 / $438, court cash in $4,719 vs $4,030 invoiced
  • 3/1 is David Lin's private booking (his selfcourtbook debit), now excluded
  • Pinned by seventeen real rows in test-court-pnl.py
tooling7049847Claude Fable 5.1
  • adjustments.json kind not_smad: the 7/10 Valentine fundraiser tournament ($153) and Gabe's 3/29 court ($10); dropped from reserved, billed, over, under and cost, listed as "outside SMAD"
  • Discovery rule: a SMAD session always has a poll game; court-finance-report.py writes data/games.json from both tabs and reconcile flags NO POLL GAME, listing candidates for Gene to confirm
  • 2026 YTD: reserved 221.5 h / $2,845, billed 202 h / $2,600.11, net cost $2,347.11; the open claim is unchanged (9/1, $17)
  • Pinned in test-reconcile-athenaeum.py and test-court-pnl.py
infra/CI29efb24Claude Fable 5.1
  • The record step reads the script's own 'No bookings scheduled for this day' line from the log tail and sets NOOP; the commit gate skips a green no-op night as it skips a green notified one
  • Every such commit republished the dashboard (Gene, 2026-09-10 PT)
toolingcd658e2Claude Fable 5.1
  • reserved/billed/credits/net/over/under/agreed beside invoiced/cash/gross/dues/fixed/operating; credits allocated to the month they reverse
  • HTML table for the email, monospace text for the run log; pinned in test-court-finance.py
feat7cfe2daClaude Fable 5.1
  • get_statement_main() folds the months it read into the existing online_statements.json (a re-read month replaces its copy, everything else kept); merge_statement_months() is pure and pinned in test-court-finance.py
  • The first production run (bd90570) had written only Jul-Sep; the thirteen months are back with the run's fresh read on top
feat4a4501bClaude Fable 5.1
  • court-booking.py --get-statement reads the club's Online Statement (current + closed months) into JSON; the hidden TreeView postback is fired from an injected script tag
  • scripts/pull-court-confirmations.py merges confirmation-email durations through the Gmail API; scripts/court-finance-report.py reconciles the window and sends two emails: the uncensored internal report (overs and unders) and a claims draft in Gene's voice, overs only, minus dates already in claims.json or settled in adjustments.json
  • court-finance.yml: Mondays 9 AM PT and the 5th (after the club's statement), dispatchable with send=false; commits the data it pulled
  • P&L books a credit against the month it reverses (claims.json credit_allocation): August's court cost is $170.11, not $17 -- Gene caught it
  • Pinned by scripts/test-court-finance.py and the extended test-court-pnl.py
tooling2797f46Claude Fable 5.1
  • ops/athenaeum/adjustments.json: 1/27, 2/10, 3/17 metal-halide outages billed as the playable hour by agreement; 3/23 and 6/20 corrections credited; 12/4/2025 fee waived; each with the email that agreed it
  • reconcile-athenaeum.py reads it (--adjustments, default beside data/) and reports an "agreed" column; 2026 unders fall from $435 to $385, overs from $190.11 to $180.11
  • Pinned in scripts/test-reconcile-athenaeum.py
toolinge36c0cfClaude Fable 5.1
  • Dues kept in the statement data and read as their own bucket; ops/athenaeum/overhead.json holds the donation and software items with a pickleball share
  • court-pnl.py --overhead prints revenue, cost of sales (COGS), gross profit, overhead (opex), operating result (= EBITDA = net here)
  • 2026 to date: gross +$1,520 on $4,030 revenue; overhead $4,371; operating -$2,852
toolingb86fb6fClaude Fable 5.1
  • scripts/reconcile-athenaeum.py: reserved court-hours (club record, confirmation-email duration) vs the club's statement lines, era 1 lights passed through, posting-date matching, IMPOSSIBLE / RATE / NO SHOW / CREDIT / UNBILLED flags, overs (claim) and unders (internal)
  • scripts/court-pnl.py: club net cost vs the sheet's invoiced hours (Gene exempt) vs Venmo/Zelle/cash collected, offsets apart; invoiced equals the sheet's Invoiced column to the dollar
  • ops/athenaeum/data: every monthly statement from the Online Statement (court lines only), the club's reservation history per month, 840 confirmation durations
  • Result: $253 credited against $190.11 of overs this year; 9/1 ($17) is the only open claim; unders $435 stay internal
  • Pinned by scripts/test-reconcile-athenaeum.py and scripts/test-court-pnl.py
tooling1256a50Claude Fable 5.1
  • statements.json 2026-09 (partial, as of the club's 9/9 update): 9/1 billed 3 h for a South-only 2 h reservation (+1 h / $17), 9/4 right, 9/8 not yet posted
  • ops/athenaeum/README: login -> TrackHyperlink&URL=oms -> hidden TreeView postback via an injected script tag (Playwright's strict evaluate cannot call __doPostBack)
docs1119456Claude Fable 5.1
README: June 2025, the four games before the 2025 tab began, and 6/1/2025 as the founding date
docs552b8f6Claude Fable 5.1
README: a guest's session lives on the guest's row with a transfer credit against the host's debit (Sean Liu's three July sessions)
tooling64481b4Claude Fable 5.1
  • scripts/vet-join-dates.py reads every game column on the 2025 tab and the current sheet; a game before the Join Date, or a blank Join Date, is written as "first game M/D/YYYY"
  • Moved four rows: the three founding members (no join event) to 7/10/2025, and Spenser Jordan from 3/3/2026 to the 2/28/2026 game he played first
  • README records Jerry Farrell's correction: Join Date 5/19/2026, 2 hours in the 5/19 column, $8 transfer credit against James Ji's existing debit
toolingeca813dClaude Fable 5.1
  • scripts/backfill-join-from-export.py: a member's first joined/added event is their Join Date; written only over weaker sources and never over an earlier sheet date
  • Referrer only when the welcome message says "referred/invited/brought in by @X" with a roster mention; adders are clues, not referrers
  • Parser: "You added this group to the community" is a community event, not a member
  • Pinned by scripts/test-join-from-export.py and a new parser case
toolingfc64a64Claude Fable 5.1
  • GREEN-API history floors at the evening of 2026-06-09 PT for both groups; the phone export is the only copy of the group's first year
  • scripts/whatsapp_export.py parses the iOS format (stamps, continuation lines, U+200E system lines, media markers, mentions, member events)
  • scripts/archive-whatsapp-export.py writes export.zip + messages.jsonl + manifest.json under gs://smad-pickleball-chat-archive/whatsapp/<chat>/<date>/ and verifies by reading the manifest back
  • Bucket: us-west1, Standard, uniform access, public access prevented, versioning on (created by hand 2026-09-09 PT)
  • First archive: SMAD group 2025-06-09 .. 2026-09-09, 5,346 records; pinned by scripts/test-whatsapp-export.py
feat596ba70Claude Fable 5.1
  • googleapiclient.build() leaves ~60 MiB of cyclic garbage per call that only a full GC frees; 5 builds in one request peaked at 275 MiB, 20 at 585 MiB (tracemalloc, 2026-09-09 PT)
  • get_sheets_service() now caches per thread and credential recipe; the same 40-call experiment peaks at 68 MiB
  • Keyed on the credential env vars too, so a script loading .env between calls does not reuse a service built without it
  • clear_sheets_service_cache() for tests; scripts/test-sheets-service-cache.py pins build-once, per-recipe, per-thread and clear
docs10bd79bClaude Fable 5.1
  • Gene, 2026-09-09 PT: the test suites now carry the regression risk, so no more "Ready to commit/push?"
  • The suite must have run on the edit in this session and its result read; rule 3 (never commit untested code) is now the only gate
  • A push to main deploys, so Watch Every Push applies to all of them; hand gcloud deploys still ask (rule 2)
  • Still asks: force pushes, history rewrites, deleting sheet data, messaging the group, spending money
feat9428645Claude Fable 5.1
  • Three static columns after L and before the first game column (Gene: never at the end of the sheet, where the games live); registered in SHEET_COLUMNS first, since an unregistered header is read as a game
  • get_full_player_data() exposes join_date / referrer / join_source
  • sync-members stamps Join Date (PT) and join_source on every row it inserts; the row is sized to the whole static block
  • scripts/backfill-join-dates.py grew the live sheet with one insertDimension and filled 142 cells from welcome messages, sync-members logs, first votes and the 2025 tab; members older than the records are left blank as "member before"
  • scripts/test-sheet-columns.py pins that the three are static and that a sheet without them still maps
  • README Monitoring: the two claude.ai alert routines, the GitHub issues webhook that never delivered, and the hourly cron that replaced it (API floor is one hour)
feat6750cf7Claude Fable 5.1
  • "members" leaves the players-list matcher heads, so "/pb members sync" is no longer read as "list members sorted by sync" and refused with a sort error (Gene, 2026-09-09 PT: "don't mesh the two")
  • "players sync" / "sync players" leave the sync_members aliases; bare "/pb members" falls to the unknown-command reply
  • the sync matcher also runs ahead of the list matcher, the way booking-list runs ahead of book-court
  • scripts/test-intent-order.py pins every sync spelling, proves neither head reaches the other's commands, and keeps the list cases green
feat3023e53Claude Fable 5.1
  • zelle_sync._find_player_flexible() runs exact/reversed, first+last-word and unique-last-name through player_data._match_survey_to_roster() via build_roster_lookups(); the Zelle alias column step stays as Zelle's own
  • its exact step went through payments.find_player_by_name(), whose one-word branch matches on first name alone, so a one-word Zelle sender would have been handed to whichever member shares that first name; a one-word name now matches only via the alias column
  • payments.find_player_by_name() is unchanged: record_payment() looks players up through it and the /pb shyam credit passes a bare 'Shyam'
  • scripts/test-zelle-match.py pins all four steps, the one-word refusal, and spies on the shared matcher to prove Zelle's answer moves when it does
  • CLAUDE.md: zelle_sync row rewritten
featcf31f30Claude Fable 5.1
  • venmo_sync.find_player_by_name() and match_by_standard_amount() now call player_data._match_survey_to_roster() via _match_payer_to_roster(); find_player_by_last_name() is deleted
  • the survey's copy learned the unique-last-name rule two days before Venmo's did, so Alex Laussu was unmatched by each on a different day for one reason
  • build_roster_lookups(data) in player_data builds the matcher's lookups from raw sheet rows, plus a name -> (row_index, first, last) map; survey_sync() uses it instead of its inline loop
  • exact-name matching gains the reversed-name strategy (a Venmo profile with the names swapped lands); the amount gate is unchanged and still wraps only the last-name step
  • test-venmo-match.py pins the exact/reversed/nickname cases and spies on the shared matcher to prove Venmo's answer moves when it does; test-survey-matching.py pins build_roster_lookups()
  • CLAUDE.md: venmo_sync row rewritten, build_roster_lookups registry row
featf56b1c1Claude Fable 5.1
  • Aliases venmo sync, zelle sync, payment sweep (PAYMENT_SYNC_ALIASES, one set for parser and help); admin-only, in ACTION_INTENTS (Gene, 2026-09-09 PT)
  • execute_payment_sync() dispatches payment-sweep.yml with reply_chat_id, the commits-digest shape: the sync needs the Venmo token and a runner's credentials
  • payment-sweep.yml: reply_chat_id input; a "Report to the requester" step runs on every outcome and calls scripts/report-payment-sweep.py, which reads ops/last-payment-sweep.json and sends the counts to that chat through the publisher; a half that did not run reads FAILED, never zero
  • scripts/test-payment-sync.py pins every alias, the collisions with payment transfer/reminders, the dispatch inputs and the summary text
feat80b81a6Claude Fable 5.1
  • On the payment-sweep runner ADC is the cloud account, which cannot open a spreadsheet: "Could not fetch jokes from sheet: 403" in the run that finally thanked Alexandre Laussu (2026-09-09 PT), fallback joke sent
  • _fetch_jokes_from_sheet() now uses get_sheets_service(readonly=True, prefer_smad_creds=True, credentials_file=) -- file, then the Sheets account by env, then ADC -- the chain every other Sheets reader uses; the module's own chain is gone
  • payment-sweep.yml: the Venmo step carries SMAD_GOOGLE_CREDENTIALS_JSON by env like the Zelle step
  • scripts/test-jokes-creds.py pins the reader, the preference, the file case and the quiet fallback
infra/CIacf62baClaude Fable 5.1
  • The first auth step used SMAD_GOOGLE_CREDENTIALS_JSON, the Sheets account, and the next run answered 403 IAM_PERMISSION_DENIED pubsub.topics.publish: two service accounts, not interchangeable, as release-notes.yml already recorded
  • Authenticate to Google Cloud now uses GCP_SA_KEY (publishes the thank-you DM and the admin notice); the Zelle step gets SMAD_GOOGLE_CREDENTIALS_JSON by env, the path get_sheets_service(prefer_smad_creds=True) reads
  • Liveness row amended with the second push
infra/CI9f630d7Claude Fable 5.1
  • The one runner without google-github-actions/auth: Venmo survived by naming the credentials file, but its thank-you DM and Admin Dinkers notice publish on ADC and failed in the run that recorded Alexandre Laussu's $10 (2026-09-09 PT); the Zelle sweep reads Sheets on ADC and crashed the first time it found an email, having only ever run against an empty inbox
  • Liveness table row: a step that has only ever exercised its empty branch has not run
featf2c3b0cClaude Fable 5.1
  • SMAD_HOURLY_RATE now reaches the venmo-sync function and payment-sweep.yml: the unique-last-name fallback was gated on it and neither runtime had it, so it had never run anywhere, silently. Alexandre Laussu ("Alexandre laussu" on Venmo, "Alex Laussu" on the sheet) paid $10 on 2026-09-08 PT and stayed [UNMATCHED]
  • match_by_standard_amount(): an unset rate is an ERROR naming the variable and the payer it cost, never a quiet skip
  • is_session_fee_multiple(): the amount gate is a whole number of fees, not the fee alone (Gene): James Ji's $20 for two and the $50 monthly pre-pay are pickleball-shaped; $15 is not
  • scripts/test-venmo-match.py pins the fallback, the multiples and the error path; fails against an exact-fee gate, no gate, and a silent skip
featbd2858aClaude Fable 5.1
  • format_post_game_report(show_records=) drops the "W-L Record for Today" table the way show_matches= drops the numbered list; both keep the "N matches so far" count
  • render_match_recorded() passes both, so the confirmation is the line just written plus the running count (Gene, 2026-09-09 PT)
  • render_match_undone() keeps the remaining list, which is the confirmation, and drops the table
  • test-match-dupr: the switch on the report, and both renderers with the log read stubbed and scoped, asserting the count line present before the table absent
feat1521b1aClaude Fable 5.1
  • wl_digits(records): the widest W or L in a table decides how many digits every record in it pads to, both sides together (Gene, 2026-09-09 PT). The Top N with one 12-4 still prints 07-02 and 00-00; a post-game night of 3-1 and 0-1 prints plain W-L
  • format_wl(wins, losses, digits=1) pads both sides to `digits`; format_player_table, the post-game W-L table and format_standings each compute digits over their own rows
  • align_header(header, cells, width): shifts a header so its dash sits over the rows' dash, computed from the widest cell, so it follows the name column and any record width; a header with no mark is right-aligned as before
  • test-formatters: digit-count and rendering cases, all-or-none tables, standings both ways, dash alignment under every sort, a longer name column, mixed widths and the post-game table; mutation-checked against a right-aligned header, W-only padding and flat two-digit padding
feat6d3f0dbClaude Fable 5.1
  • Parser: "set vacation karan keswani 10/1/26" is the set-for-another-player form; it was read as a return date and refused, while resolve_player() has taken a bare name all along (Gene, DM with Karan, 2026-09-09 PT). A lone date is still self-service
  • Webhook DM admin gate: a DM is admin when the other party is a SMAD admin OR the message was typed on the bot account's phone by an admin number; chat_id alone refused Gene's command in a member's DM
  • No backdoor: a member's message carries the member's number as both chat and sender, and the sender branch is limited to phone-typed messages, so a bot API send into a member's DM is never admitted
  • scripts/test-dm-admin-gate.py drives webhook() with stubs and pins all five cases; fails against the old gate and against an "either party" gate
  • test-vacation-for: five parser cases for the bare-name form; README line for the command
feat8d7390bClaude Fable 5.1
  • format_pct(): .XXX, 1.000 for unbeaten, blank with no matches; WL_COMPANIONS makes Pct follow W-L wherever the sort puts it, in the players table, the Top N and the post-game table (now titled "W-L Record for Today")
  • format_player_table() sizes the stat columns first and gives names the rest; short_names(names, budget) shortens only a name over the budget, floors the budget at the longest unavoidable short form, and keeps a full name where a short one would collide
  • Live roster: 33 wide, 13 of 45 names shortened, every 11-character name in full (Gene, 2026-09-09 PT)
feat02a0e82Claude Fable 5.1
  • process_command filled in 'hours' when no sort was typed, hiding PLAYERS_DEFAULT_SORT; the CLI passed nothing, which is why the live check passed and the chat did not
  • test-no-joke.py drives /pb players through process_command with a fake roster and asserts the default and the explicit hrs ordering
feat38c2baeClaude Fable 5.1
  • PLAYERS_DEFAULT_SORT = LEADERBOARD_SORT; the header stays clean for the default and names the hours preset when asked for (Gene, 2026-09-09 PT)
feat45e83adClaude Fable 5.1
  • Every ACTION_INTENTS reply is joke-free, the refusal a non-admin gets included (Gene, 2026-09-09 PT: admins doing admin work do not need one); the set moved to module level so build_result() can read it
  • scripts/test-no-joke.py (8 cases)
feat92715a2Claude Fable 5.1
  • release-notes.yml gains a reply_chat_id input; with DIGEST_REPLY_CHAT_ID set, send-release-notes.py targets that chat only, answers a no-commit day with a one-line note, and keys its correlation id on the run so two asks in a day are two answers
  • picklebot dispatches it (admin only); the day defaults to yesterday and a no-year date stays in the past
  • scripts/test-commits-digest.py (12 cases); test-release-digest.py 44/44
feat2831ab4Claude Fable 5.1
  • format_court_list()/render_court_list(): one line per slot, booked rows only, a court on someone else's account named with its holder; CLI courts-list uses the same renderer; listed beside /pb reservations (the live scrape) in both help blocks
  • parse_booking_date(): a no-year date compares DATES for the next-year rollover; "9/9" typed on 9/9 rolled to 2027 and /pb cancel game 9/9 found no game
  • scripts/test-court-list.py: 13 cases
feat6caa3fdClaude Fable 5.1
  • LEADERBOARD_TOP_N 15 -> 20 (Gene, 2026-09-09 PT); the formatter test derives its header from the constant instead of a written-in "Top 15"
feata9b269bClaude Fable 5.1
  • format_post_game_report(show_matches=False) keeps the "N matches so far" header and the table; the confirmation uses it, /pb post game and the undo confirmation still list every match
  • By the twentieth match of a night the full list made every confirmation a screen long (Gene, 2026-09-09 PT)
feat8d2efd2Claude Fable 5.1
  • WhatsApp delivers a reply as typeMessage=quotedMessage with the text under extendedTextMessageData; the webhook read only textMessage and extendedTextMessage, so "/pb payment transfer ..." sent as a reply on 2026-09-08 11:53 PM PT was dropped before the prefix was checked
  • message_text() / TEXT_MESSAGE_TYPES in shared/command_prefix.py is the one reader; the webhook and picklebot's raw branch both use it
  • scripts/test-command-prefix.py: 48 cases, including the reply payload
feat7045019Claude Fable 5.1
  • cancel-game.yml never wrote GREENAPI_INSTANCE_ID/API_TOKEN; _send_dm() sends straight to GREEN-API, so every voter DM since the notify step shipped on 2026-08-21 was a 403 (8/22, 8/28, 8/30, 9/8), and no email either since email rides a successful DM
  • _notify_canceled_game_players() returns failed names; the summary gets a third state ('failed', ❌) naming the voters not reached, and "nobody voted yes" is said only when nobody voted
  • The script exits non-zero after the summary when any notification failed, so the workflow goes red and the failure reporter opens an issue; a green run with a swallowed False was invisible to every monitor
  • ops/incidents.json: 2026-08-21-cancel-game-voters-not-notified; Liveness table row; scripts/test-cancel-notify.py (13 cases)
feat2be7985Claude Fable 5.1
  • "/pb cancel game 9/9/26" answered "Please specify date and time" on 2026-09-08 11:14 PM PT; one game per date is the norm, so a date alone identifies it the way the day-of-week shorthand already did
  • Two games on the date ask for the time and name both; no game says so; the fallback message no longer demands a time
  • scripts/test-cancel-game-date.py: 10 cases
featf4c9914Claude Fable 5.1
  • format_columns_as_text() in google_sheets_utils; match_log and dupr_log call it after addSheet for their date, time, score, timestamp and id columns
  • A bulk re-sort on 2026-09-08 PT wrote rows back with USER_ENTERED, every Game Time became a time value and /pb post game found no matches; the live tabs were repaired and formatted by hand, this makes a recreated tab match
feate768b72Claude Fable 5.1
  • shared/command_prefix.py is the one definition of the prefixes; the webhook and picklebot each carried a copy
  • /phoebe and /pv are what iOS dictation made of "/pb" on 2026-09-08 PT; aliases are added only for a mis-dictation that has happened (Gene's rule)
  • The webhook forwards any slash-word; picklebot answers one that is not a prefix with "You've issued an unrecognizable slash command - only /pb and /picklebot are supported", audited as unrecognized_slash
  • Two dictated commands were dropped in silence that night, Gene edited one, and an edit reaches nothing, so a match was lost
  • scripts/test-command-prefix.py: 38 cases
feat226b1edClaude Fable 5.1
  • record_match() compares the line with the match recorded immediately before it (sides as sets, score) and refuses a repeat, naming who logged the first; two scorekeepers logged one 11-1 43 seconds apart on 2026-09-08 PT
  • duplicate_on_side() refuses "James and James" at parse time and again in record_match(); it was written as James Ji & James Ji because the overlap check only looked across sides
  • An ambiguous first name where nobody by that name voted now says "David Lin and David Sohn are not playing tonight — did someone forget to vote?"
  • scripts/test-match-dupr.py: 61 cases
feata25f0e2Claude Fable 5.1
  • "/pb post-game summary" answered "I didn't understand": the intent matched an exact list and the command was never in the help text
  • Any spelling of post game now matches, with or without report/summary; game summary and match summary join the aliases
featb510981Claude Fable 5.1
  • survey_sync wrote a 'held' row for every survey value its no-downgrade rule declined, and the sync runs daily, so the same five no-ops were re-logged every morning
  • Gene, 2026-09-08 PT: if the change did not occur, do not log it; the five rows are deleted and the held write removed
  • Held values still come back in the run's report; STATUS_HELD is gone and the Status column stays 'applied'
tooling72cab2aClaude Fable 5.1
  • Gene's 3.5 -> 4.0 (announced 08/25 PT) and John Wang 2's 3.5 -> 4.0 (/pb set dupr, 08/28 10:16 PM PT) were made before the DUPR Log existed; both rows were backdated into it from the group chat and the affected match cells recomputed
  • Registry notes the Health Log date fix and the audit result: every log stamps Pacific
feat792e912Claude Fable 5.1
  • record_weight() used a naive datetime.now(), UTC on a Cloud Function, so a weigh-in after 5 PM PT was dated the next day (the 08/26/2026 row was entered on 8/25 PT)
  • Found by the log timestamp audit; every other ledger already stamps through PST
featfbb31f2Claude Fable 5.1
  • Win Loss Log gains Winner 1/2 DUPR and Loser 1/2 DUPR at the end; record_match() fills them from the roster dicts the names resolved against, 'no score' for an unrated player, blank only when nothing captured it
  • Each side is sorted by first name then full name at record and at read, so "Winner 1" and its DUPR are one person across a night; scripts/reorder-match-sides.py put the 32 older rows in the same order, moving each rating with its name
  • scripts/backfill-match-dupr.py reconstructed the 46 older rows from the DUPR Log (a later change's Old DUPR, else the current rating); never overwrites a cell
  • Post-game report shows the rating of the night from the row, normalised through fmt_dupr() since the sheet stores the cell as a number
  • ensure_sheet_headers() in google_sheets_utils is the one grid-growing header helper; court_log and dupr_log wrap it instead of carrying copies
  • get_dupr_history() never worked (mapper built without column defs, wrong read method); fixed, found by the backfill
  • scripts/test-match-dupr.py: 46 cases with a fake Sheets service that models the grid and proves it raises
toolingcd4dd8eClaude Fable 5.1
  • The club's rate definition, from its 05/08/2026 mailing (image, kept in ops/athenaeum): Prime = weekdays 4-10 PM and weekends/holidays 7 AM-10 PM at $17; Value = weekdays 7 AM-4 PM at $15
  • Undercharges are reported to Gene and Admin Dinkers only; the email drafted for the club carries overcharges alone
  • Purpose column on the Court Log, Category column on the Payment Log, /pb court tag, /pb book court ... for, /pb guest: outside bookings and guests stop looking like dues, and the daily scrape asks for a tag on any booked court with no poll game
  • 7/3 settled from the group chat and the Pickle Poll Log Archive: North was booked the morning of for three guests, so its 4 hours were right and the July+August overcharge on the reserved basis is $153 against the $187 credited
  • New TODO: cancel an unfilled court before the club's 12-hour deadline
toolinge8abaf0Claude Fable 5.1
  • ops/athenaeum/statements.json: every court line from the emailed statements 12/2025 through 08/2026, with the club's reservation record and the credits beside each month
  • ops/athenaeum/club-reservations-2026-06-07.json: the club's own June-July history, all statuses, read-only scrape
  • ops/athenaeum/README.md: sources, the three billing definitions (reserved hours, no no-show billing, 10 PM close), the July/August line-by-line table on that basis, every past dispute and what resolved it
  • README Future Work: the five-phase plan (ingest the bill, reconcile against the Court Log, watch the Online Statement daily, P&L) and the open questions
feat795290dClaude Opus 5
  • Gene asked on 2026-09-08 PT whether set vacation took a name. It did not: the handler matched the sender's phone and the parser had no @ form
  • Mirrors set dupr / set dupr @player: the @-mention form is matched first, target non-greedy so a typed @Name splits at the last token; the target goes through resolve_player(), the one matcher for every player-taking command, so @digits, @me, @Name and a bare name all work and ambiguity is refused with the candidates named
  • Admin-only via ACTION_INTENTS: it silences that player's vote and survey reminders, which is not something one member should do to another
  • The date rule is extracted to _parse_return_date() and shared by both forms (MM/DD/YY, MM/DD/YYYY, MM/DD with this-or-next year), so the two cannot accept different shapes
  • Help (admin variant) and README list it; scripts/test-vacation-for.py pins the parser, the date rule and the handler against a fake roster
feat20d13bfClaude Fable 5.1
  • format_wl() renders 00-00, 04-02, 14-16: both sides padded to two digits, Gene's call 2026-09-07 PT; triple digits are far off and a third digit only widens that cell
  • format_standings() uses format_wl() instead of its own padding, so every W-L column agrees
  • Prose mentions (DM stats "Record:", the provisional standings list) stay unpadded
  • Word-joiner comment and README: a padded W-L is four digits on its own, so nearly every row would linkify without the joiners
  • test-formatters pins the padding: eight cases, every record up to 99 is exactly five characters
docs2e93ec3Claude Fable 5.1
  • Gene's instruction, 2026-09-07 PT: his screenshot clocks, typed times and named days are PT, travelling or not, unless he says otherwise in that message
  • Drop "in whatever zone the phone was in" and "the zone was not even known" from the screenshot bullet: the clock in that screenshot was PT and read correctly, the error was treating a screenshot time as a send time at all
featb8f74bbClaude Opus 5
  • "Last Call for Tues 9/8/26 7pm Both / Sent to 6 player(s)" said less than the Last Call report the group had already received, which lists the six players. Gene, 2026-09-08 PT: get rid of it
  • Both sends go: the count after DMs and the "everyone has voted" case. The run log keeps the count, with the skipped figure on the same line
feat284c810Claude Opus 5
  • The cancellation notice and result said "Requested from [email redacted]" / "Triggered from [email redacted]" when Shyam's vote fired the handover on 2026-09-08 PT. Gene: full name, not human-illegible numbers, and check every report that renders a player as a number
  • match_log.describe_chat(chat_id): a DM id resolves through the roster to the player's name, the admin and SMAD group ids to their names, anything else falls back to the raw id; never raises
  • The three sites found by the audit use it: picklebot's "Court cancellation triggered" notice, and both "Triggered from" lines in court-booking.py (booking result and cancel result). No other message text interpolates a chat id or phone
  • Registered beside resolve_player(); seven input-side cases in test-match-resolution.py, and a live check on this box resolved the admin group, Gene's DM id and the id from the screenshot
docs790c732Claude Opus 5
  • The table had no row for the module; the CLAUDE.md registry already names resolve_player() as the one resolver for every player-taking command
refactora1b11d3Claude Opus 5
  • Handover describes one command there, /pb shyam; the other three are the booking list. Gene's ask, 2026-09-07 PT
  • README command reference matches: same header, and the three /pb booking list commands it listed only in prose
feat0e27688Claude Opus 5
  • /pb archive player @Toby Hsieh answered 'Player "@19175282626" not found' on 2026-09-07 PT: WhatsApp delivers an @-mention as phone digits, and the archive handler matched on names alone. Gene: audit every command that takes a name and use the same matcher
  • The audit found five resolvers: archive (names only), individual reminders (find_player, whose partial match returned the FIRST hit silently on a roster with two Dereks), set dupr @player (its own digit sniff), record payment and balances (passed the raw text through to a name lookup), and /pb match (the right one)
  • match_log.resolve_player() is now the single public face: @digits, @me, @Name, or a bare name, exact / first / unique partial, ambiguity refused with the candidates named. It wraps the same _resolve_at_segment() the match tokenizer uses, so there is one implementation, not one per command
  • archive_player, remind_vote/remind_payment, set_dupr_for, record_payment and show_balances all go through it and receive the sender so @me works; find_player() is deleted
  • test-match-resolution.py: ten resolve_player cases from the input side
feat36b428aClaude Opus 5
  • 2.35.0 URL-encoded the database id (%28default%29) and took WhatsApp delivery down on 2026-08-25; every requirements.in capped it since. check-api-core-fix.yml tested 2.36.0 by reading Client()._database_string and opened #49
  • Cap dropped in all five requirements.in; locks regenerated for the functions' runtime (python 3.11, linux) with uv, since this box has only 3.13 and pip-tools cannot cross-resolve. Only google-api-core moved, 2.34.0 -> 2.36.0, plus its new dependency opentelemetry-api 1.44.0; uv writes the grpc extra's packages instead of the [grpc] marker, grpcio stays pinned in every lock
  • Verified here the same way the watcher did: a venv with 2.36.0 prints projects/p/databases/(default). check-function-deps green
  • The watcher has done its job and is removed; left in place it would re-open an issue every Monday for a release we now run
featd70eef4Claude Opus 5
  • Sun 2026-09-06 5:59 PM PT, run 34071513395: Shyam's handover cancelled North at the club, then the verifier's Search click retried for 30 s under the View Reservation dialog the postback leaves open, timed out, and the "could not verify" answer was returned as False. Shyam and Admin Dinkers were told "Failed to cancel" about a court the club had already released. Diagnosis by Mr Sandman from the run record; the verifier's first real run
  • cancel_reservation() reloads the reservations page after the postback, so no overlay survives and the grid is the server's post-cancel state, and it now returns the tri-state it computes: True / False / None
  • cancel_reservation_main() sends a distinct message for None: "Cancel submitted but NOT verified", pointing at the club's Court Reservations page. The CLI exits 0 for None: the WhatsApp copy is the alert, and a failing step would add a Workflow alert issue and a failure notice on top
  • Proven against the club from the desktop: a throwaway booking cancelled through the changed path returned True with "[OK] Reservation cancelled (verified against the club)", the line no run record had ever carried
docsd89f4ffClaude Opus 5
  • Fifth timezone failure, 2026-09-07 PT: a Sat 9/5 00:10 PT report was read off a phone status bar as Mon 6:33, and the desktop spent an hour hunting a Monday sender that did not exist
  • Send time and day come from the message, the run's created_at converted, or Cloud Logging, never from memory of the conversation
  • Gene's instruction: every session operates in Pacific Time, no exceptions
feat7235215Claude Opus 5
  • Alex Laussu took the survey as "Alex Laussu"; the sheet says Alexandre and has no email for him, so email and exact-name matching both failed: DUPR never synced and he got survey reminders for a survey he had taken. Gene's rule, 2026-09-07 PT: always fall back to an exact last-name match, then adopt the first name entered, because that is what they want to be addressed as
  • _match_survey_to_roster() gains strategy 4, unique last name, from the sheet's Last Name column (not by splitting: "John Wang 2" is a player). Two Wangs match neither. For a two-word entry the first token is tried too, so a reversed nickname lands
  • survey_sync() now uses that shared matcher instead of its own copy of the name matching, so "who has not responded" and "whose answers sync" cannot disagree again; respondents with no email are matched by name rather than listed as unmatched unread
  • First-name adoption: the survey first name replaces the sheet's, and match_log.rename_player() rewrites every Winner/Loser cell naming them, because W/L are COUNTIFs over that log keyed on First & Last Name and would otherwise read 0-0. Reported as "Now goes by", loud when the log rename fails
  • A survey DUPR equal to the sheet's ("3" vs "3.0") is no longer reported as held
  • Dry run against the live sheet: Alexandre -> Alex (+ email, SMS), Greggory Millward -> Gregg, five genuine DUPR holds
  • scripts/test-survey-matching.py: 27 checks, input-side, including the shared-last-name refusal and rename_player against a fake log
tooling1c0eb64Claude Opus 5
  • "Update CI timing metrics", "Payment sweep result" and "Court action record" are commits because a workflow has to commit them, not Picklebot upgrades; Gene said so on 2026-09-05 PT after one led the 9/4 digest
  • Same shape as the session-channel rule, in the same shared module (scripts/release_scope.py) so the digest and the dashboard cannot disagree: a commit touching only ops/ci-metrics.json, ops/last-payment-sweep.json or ops/last-court-action.json is excluded. Matched on the file list, not the subject, so the five commits that also touched the collector script stay as the code they are
  • Measured over the last 60 days: 18 such commits, all single-file. The dashboard says how many it left out, per class, computed from the filter
  • Line churn from those files no longer counts as the codebase growing
  • test-release-digest.py: three named record commits excluded, one mixed commit kept; 41/41
toolingc09fae8Claude Opus 5
  • Gene's ask, 2026-09-05 PT: with the logo centred under it, a left-aligned title and eyebrow read as misaligned. Two text-align rules, nothing else
infra/CIc3a82abClaude Opus 5
  • reminder_type=poll_only was added when poll creation merged into the daily runner, but the reminder steps gate on exclusion lists that never learned the new value, and the release-digest job gated on dry_run alone. So a poll_only dispatch at 2026-09-05 00:09 PT created the poll AND sent the 9/4 digest to all three chats, payment reminders to three players, a survey reminder, and a Venmo sync; only the vote reminder and shame report skipped, and only because a poll had been created "today"
  • Every non-poll step now excludes poll_only; the digest job runs only for the scheduled run (no input) or 'all'. The Gene Alive Check already had the gate, which is how the pattern was supposed to look
  • The 8am scheduled run is unaffected either way: it dispatches with no reminder_type
toolingdd0f696Claude Opus 5
  • Gene's ask, 2026-09-05 PT. Rendered from the 512px original at exactly 280px (docs/picklebot-logo-280.png, 110 KB) rather than upscaling the 256px copy, which is removed; the encoded cost is ~150 KB per version
  • Centred with an auto margin on its own row; the fixed width/height box still keeps the layout from shifting while the data: URI decodes
tooling4fb755bClaude Opus 5
  • Inlined as a data: URI, because the artifact's CSP allows no external images; the bucket URL the digest uses would render broken here
  • A 256px copy (docs/picklebot-logo-256.png, 94 KB) rather than the 512px one: it is shown at 140px, and every byte is paid on each of the rolling 20 versions
  • A missing file does not fail the build and does not vanish either: the page carries a greppable "logo missing" comment and stderr says so
feat02e200eClaude Opus 5
  • Gene's wording, 2026-09-04 PT, replacing "What Shipped on". Both places it appears: the digest header, and the logo's caption on a day too long for one message
  • The workflow and script headers still said the digest goes to Admin Dinkers; it goes to Admin Dinkers, Gene and the SMAD Pickleball group
  • test-release-digest.py pins the new title; 37/37
infra/CI5408c01Claude Opus 5
  • The new publish-the-logo step calls gcloud; the other ten workflows that do so pair auth with setup-gcloud (skip_install, the image ships the CLI), and this one was the exception
featfaf1473Claude Opus 5
  • The sender fetches the image by URL, so it has to be public. The previous commit pointed at GitHub Pages on the strength of a build from April; Pages is disabled for a private repo on this plan, and the URL 404s
  • The screenshots bucket already grants allUsers object-read in Terraform (screenshots_public_read) and serves the survey heatmap the same way, so the logo goes there under branding/
  • release-notes.yml copies docs/picklebot-logo-512.png into the bucket on every run, so the repo stays the source and the object cannot drift; github-deploy already holds objectAdmin on the bucket
  • Verified end to end: the 9/3 digest sent to Gene's DM as logo + caption through the real publisher, sender confirmed delivery
  • Test asserts the bucket URL; 37/37
featd7a544eClaude Opus 5
  • Gene's call, 2026-09-04 PT: everyone should see the daily upgrades. The group was added and removed on 2026-09-03 PT; it is back, so the digest goes to Admin Dinkers, Gene and SMAD Pickleball. The workflow passes SMAD_GROUP_ID from secrets.SMAD_WHATSAPP_GROUP_ID
  • The digest is now an image message carrying the 512px logo from GitHub Pages, with the digest as its caption, so it unfurls with a picture. No signature and no joke on it; the logo is the branding
  • GREEN-API caps a caption at 1024 characters. Measured 2026-08-28..09-04, four of eight days fit and four did not (up to 5,323 chars), so a day over the cap sends the logo with the title as caption and then the full list as text. Never a caption cut at 1024
  • Correlation ids gain a part suffix: release-digest-<date>-<target>-<part>, so a long day's text does not dedupe against its own logo on a re-run
  • create_image()/send_image() accept correlation_id; they could only ever get a fresh UUID before, which is fine for a one-off and wrong for anything scheduled
  • test-release-digest.py: 37 checks, both shapes pinned, six distinct ids for three targets x two parts, exactly-1024 fits and 1025 splits
docs9bcb59fClaude Opus 5
  • Gene's new logo, an Autobot face made of pickles, with the Gemini watermark painted out of the lower-right corner (the box around it was pure black, brightest pixel 3/255)
  • docs/picklebot-logo.png is the 1050px original; docs/picklebot-logo-512.png is the phone-width copy the daily digest attaches
  • docs/ because GitHub Pages serves it: the repo is private, so a raw.githubusercontent link 404s for anything that has to fetch it
featc9c7db6Claude Opus 5
  • Three "still STARTING after 20+ minutes" emails on 2026-09-04 PT (17:11, 17:30, 20:18) were each a one-second blip. handle_state_change() is fed by the 10-minute keepalive poll AND the stateInstanceChanged callback, so a blip is seen twice, seconds apart; the lock-based check read "seen before" as "persisted". Cloud Logging: the two sightings were 31s, 58s and 10s apart
  • Persistence is now a duration: the first sighting writes instance_state/transient with first_seen, later sightings compare against it, and the alarm fires only past _TRANSIENT_PERSIST_MINUTES (15). Healthy clears the record; a different transient state restarts the clock
  • get_transient_state / set_transient_state / clear_transient_state added to firestore_utils; a record with a timestamp, not a lock
  • Subject leads with impact, not the API enum: "[PickleBot] WhatsApp DOWN - instance stuck restarting (15+ min)" and the same shape for every hard state. "Picklebot WhatsApp session starting" announced a status and Gene asked what it meant. Body says what is lost while it lasts
  • test-instance-state.py: 47 checks; the first transient case is two sightings ten seconds apart, which must not alarm. Clock is pinned so elapsed time is chosen, not measured
  • Registry rows, README alarm section and ops/whatsapp-linked-devices.md describe the time-based rule and the new subjects
featb94b36eClaude Opus 5
  • "Gene and Gerry beat Vijay and Gabe seven" failed with "No player found matching 'gerry'". Gene dictates these courtside and iOS renders Jerry as Gerry. After exact, first-name and partial all fail, difflib now matches the fragment against the SESSION's players at a 0.72 cutoff
  • The cutoff is measured, not chosen: real dictation pairs score 0.75-0.91 (gerry/jerry 0.80, jon/john 0.86, tan/thanh 0.75) while the closest two REAL names in one session score 0.50. Wide gap, not a guess
  • CLUB-WIDE FUZZY WAS MEASURED AND REJECTED. Over all 65 first names "Sean" matches "Evan". A name that is not on the roster must never become an unrelated member; inside the session the worst case is someone who actually played, and the confirmation echoes resolved names so a wrong guess shows
  • A phonetic normaliser was tried and rejected too. It scores geoff/jeff at 1.00, which is better - and collapses Gene and John to 0.80, who can both be in the same game. Catching more dictation errors is not worth logging the wrong player
  • An LLM call was considered, as Gene suggested, and rejected FOR THIS PATH: webhook/shared/ is copied into all four Cloud Functions, so it would make every one of them depend on a network round trip to log a match courtside - the pytz shape - and add latency and nondeterminism to a command typed between games. The deterministic version resolves the reported case. If real dictation later produces errors this cannot reach, that trade is worth revisiting; it is not needed yet
  • A tie between two session players refuses rather than picking
  • 16 to 25 cases in scripts/test-match-resolution.py, including the measured Sean/Evan false positive as a guard: if it ever passes, club-wide fuzzy has crept back in
feat06e6305Claude Opus 5
  • "Vijay and Gabe beat Thanh and Kevin eight" was refused with "'Kevin' is ambiguous - did you mean: Kevin Chang, Kevin Cheng?" while only Kevin Chang had played. The roster has 74 players and 8 repeated first names; a night has eight players and almost never a repeat
  • resolve_by_name() now takes session_players - session['players'], the same list record_match() enforces its vote guard on - and narrows first-name and partial matches to it before falling back to the club
  • This does not weaken the vote guard, it applies it earlier: a name resolves this way BECAUSE the player is in the session, and record_match() still checks the final roster independently
  • Ambiguity that survives is still refused, never guessed. When both candidates played the message now says so - "Kevin Chang, Kevin Cheng both played. Use the full name." - because the fix differs from the club-wide case
  • Narrowing returns the original candidates when NO candidate is in the session, so an unrelated pair still gets the club-wide message rather than a false claim that they played
  • Both callers pass the roster; picklebot already had the session resolved, the CLI now resolves it up front. None or [] behaves exactly as before
  • Add scripts/test-match-resolution.py, 16 cases written from what a scorekeeper types courtside: the line that prompted this, the two ways it must still refuse, and the trailing-number trap ("John Wang 2" is a player, not John Wang with a score of 2)
feat41ee2b9Claude Opus 5
  • "Gabe Bloch & Vijay Vishwanath def. Jerry Shen & Gene Chuang 12-10" becomes "Gabe 3.0 & Vijay 3.0 def. Jerry 2.5 & Gene 4.0 12-10". Gene's format, and it fits a phone where four full names wrap
  • The rating is not decoration, it carries most of the disambiguation. Measured on the live roster: 8 first names repeat across 17 of the 74 players, and the rating separates every one of them except Jeff Chiu and Jeff Herring, who are both 3.0. The two John Wangs render as John 3.5 and John 4.0, which is the case that would otherwise be unreadable
  • A player with no rating renders as the bare first name, never 0.0 - the same rule the session table already states, because a fabricated rating reads as a real one. William Chen has no DUPR and is the live example
  • Build the name-to-DUPR lookup once and hoist it above the match loop; the table below now uses the same dict instead of rebuilding it
  • The session table keeps FULL names. It is the authoritative record, it has the width, and shortening both would leave nowhere to resolve a collision
infra/CI4952d57Claude Opus 5
  • 6842574 added a checkout step for the issue channel's helper script, and a permissions block listing actions:read and issues:write. A permissions block is a denylist by omission, so contents became none and actions/checkout@v4 was refused with the whole job failing at step 2
  • Every scheduled sweep failed from that commit until this one: 19:50Z and 22:31Z, both `schedule`, both failure. The watchdog was down - and nothing watches the watchdog, so the only signal was GitHub's own email to Gene
  • FOURTH instance of this trap here. ci-metrics declared write scopes and lost the read it needed; release-notes nearly shipped the mirror image; the 8am runner's calling job granted nothing and startup_failed. All three are named in a comment that was sitting in this very block while the bug shipped
  • The lesson is not "know the rule" - the rule was written down, by me, six lines above the mistake. It is that a permissions block must be checked against the STEPS, one at a time: checkout needs contents, the sweep needs actions, the issue channel needs issues
  • Found by Gene forwarding the failure email, two hours after the fact, which is the detection gap this whole day's work is about
infra/CI6842574Claude Opus 5
  • WhatsApp and email reach Gene; neither reaches an investigator. Two Claude Routines now fire on `issues opened` and start triaging a production alert before anyone has looked, so the watchdog files an issue as a fourth channel
  • Without it, run failures and startup_failure were the ONLY alert paths in this repo that did not file an issue - report-failure, the error-reporting sweep and diagnose-logs all do - so they were the only ones an investigator could never see. That is the same shape as the watchdog's own blindness to startup_failure: the alert existed and the reader did not
  • Dedup on a stable title, the way report-failure already does, so a flapping workflow adds comments to one issue instead of filing twenty
  • Declare issues: write. A permissions block is a denylist by omission, and omitting it here would fail the issue step at the END of an alert that had already gone out - visible only as a red run after the humans were told
  • Put the body renderer in .github/scripts/watchdog_issue_body.py rather than a heredoc inside `run: |`. The first attempt inlined it and the nested <<'EOF' with escaped quotes in a gh --jq filter broke the workflow YAML outright, which for a file whose job is noticing breakage is the wrong thing to have happen
  • Write to $RUNNER_TEMP, not a literal /tmp: it is what Actions provides, and /tmp is not the same directory for Python and bash on the Windows dev box - which is how the first version passed its own test while writing nowhere
  • Exercised on the real 2026-09-04 startup_failure payload and on a mixed two-run payload. A startup_failure entry carries the extra line that there is no step log and a re-run fails identically; a plain failure does not
docs5c7b887Claude Fable 5.1
  • Mr Sandman's caveat, and he is right: the startup_failure sweep is on the scheduler this README measures at 3-4 hours late on the median. The window-from-previous-sweep design means a failure is never missed, only reported late - a real improvement over never, and the reason it is a backstop rather than the alarm
  • One line where the watchdog is documented, so nobody reads ':41 hourly' as 'within the hour'. Same correction he had to make to his own section
infra/CIccbb126Claude Fable 5.1
  • The watchdog triggers on workflow_run: completed and gates on failure, cancelled and timed_out. It could never see startup_failure, and that is GitHub's doing, not a gap in the if: a run that fails to start emits no workflow_run event at all. Measured: the 8am runner ended startup_failure at 15:00Z on 2026-09-04 and the watchdog has no run anywhere near that minute, while every ordinary completion that day produced one. Nothing ran, nothing alerted, found by a person reading the run list
  • Add an hourly schedule and a resolve step that is the seam between two sources: for a workflow_run event it echoes the event; for the sweep it polls the runs API for startup_failure since the previous sweep that completed. Either way it emits ONE JSON list, and the existing WhatsApp and email steps read that list instead of the raw event - one notifier, two sources, no duplicated channels
  • Measure the window from the previous sweep that actually ran, not a constant. GitHub cron is 3-4 hours late here on the median; a fixed window would open a gap on every late fire, which is exactly the bug error-reporting.yml already fixed the same way. Fallback 24h when no previous sweep is visible - a noisy first sweep beats a silent one
  • Sweep EVERY workflow, not the five in the event list. A file that cannot start is broken whatever it was for, and this is the one conclusion nothing else in the repo can see
  • Declare actions: read. A permissions block is a denylist by omission and the poll would 403 without it - the ci-metrics failure, again
  • Say in the alert that nothing ran: gh run rerun on a startup_failure fails identically, and the thing to read is the run page annotation - invalid workflow file, or a called workflow needing permissions its caller did not grant
  • Add a dry_run dispatch input that resolves and prints without sending, so the sweep can be verified against a real failure without paging anyone
featac7a5c1Claude Fable 5.1
  • game-reminder, cancel-game and payment-reminder-single DMed every player and emailed nobody, with green runs, for as long as they existed. They wrote no Gmail config; email rides the DM via maybe_send_extra_notifications and the mailer answered a missing GMAIL_USERNAME with a per-email WARNING and a False nothing read. Found 2026-09-04 PT by Mr Sandman
  • Wire the same three lines daily-reminder-runner.yml already had into each: GMAIL_USERNAME into .env, and the token and client JSON beside the Sheets credentials. sync-members and survey-heatmap are credential-less too and are NOT affected - they do not route through _send_dm; he checked rather than guessing from filenames, and so did I
  • Make the config gap loud: an ERROR, once per process, naming the variable and saying every email in the run is being skipped. CLAUDE.md already has this rule - an empty config value must never take a silent branch - and the per-player warning scrolled past with nothing summarising it
  • Keep the return False and _send_dm's return True. Callers count that True as a reminder sent and email is a rider on the DM; changing it to surface email would corrupt reminder counts
  • Add scripts/test-email-notify.py. The first case asserts the ERROR fires, because a suite of silence checks proves nothing until one exercises the positive; the once-per-process behaviour and the unchanged branches follow
  • Only a player receiving mail, or that ERROR line being absent, proves this. A green run is the defect
docsd7b8cf4Claude Fable 5.1
  • Second occurrence on 2026-09-04 PT: the extension reported "OAuth session expired and could not be refreshed", Gene re-authed because he had to, and the same signature followed at once - ListAgents empty, 76 MCP tools gone, Desktop unable to reach the machine
  • Two for two, both after a re-auth, the second forced by token expiry rather than chosen. So this recurs on OAuth's schedule, not on a decision, and "do not re-auth" is not advice anyone can follow
  • Procedure: after ANY re-auth, reload the VS Code window immediately. Re-auth restores the credential and the reload re-registers the session; they are separate steps and only the first is prompted for
  • The first occurrence cost a morning because the session was doing everything asked of it locally. This rule exists so the second cost a minute
infra/CI85a51ecClaude Fable 5.1
  • The 8am Daily Reminder Runner ended in startup_failure on 2026-09-04 with zero jobs: no reminders, no poll-creation gate, no Gmail renewal, no digest. Gene found it when the group got no game-day message. Nothing alerted - a run with no jobs gives a job-keyed watchdog nothing to look at
  • Cause: a workflow called via workflow_call can hold no more permissions than the job that calls it. This repo's default token is read-only, the release-digest job granted nothing, and release-notes.yml asked for issues:write for its failure reporter - so GitHub refused to start the run
  • That issues:write line was added the night before, with a comment about not repeating the ci-metrics permissions bug. Third form of the same trap
  • Both verification dispatches of release-notes.yml passed, because dispatched directly it is the top-level workflow and sets its own token. The failure exists only in the called form, which is the form the 8am job uses - so the verification proved the wrong path
  • Fix: put the scopes on the calling job. Record the rule in CLAUDE.md beside the two permissions rules already there
  • Found by reading the repo's default_workflow_permissions setting, which the cloud session cannot see; it had the prime suspect and both systemic gaps (watchdog blind to startup_failure, lint checks reporting not startability) but not the cause
feat696cc66Claude Opus 5 (1M context)
  • Drop the SMAD Pickleball group as a digest target, one day after adding it and after a single send. A list of commit subjects is for the people who run the bot, not the ~74 who turn up to play - and that group's message volume is the reason this is a daily digest rather than one per push
  • Two targets now: Admin Dinkers and Gene by DM, each keeping its own correlation id
  • Assert the absence rather than just removing the line. The suite sets SMAD_GROUP_ID and requires the group still NOT be a target, because "we stopped sending to the whole club" is the kind of decision a later edit undoes without anyone questioning it
  • 26 to 27 checks, all green
feat83970a2Claude Opus 5 (1M context)
  • The alarm was `healthy = state == 'authorized'`, so all seven documented GREEN-API states got the notAuthorized email - the one telling Gene to pull a QR code and re-link
  • Measured from Cloud Logging, which only this box can read: `starting` appeared three times in two hours on 2026-09-03 PT and cleared in 1s, 38s and 1s. Two of them emailed him
  • Worse than noise. Re-linking is a real action, and ops/incidents.json already records an operator logging a device out as an outage in its own right. An alarm that fires hourly for nothing, and recommends a risky fix for it, is how the real one gets skimmed
  • Classify the states from GREEN-API's own documentation. notAuthorized, blocked and suspended alarm at once and each carries its OWN remedy: re-linking fixes the first, does nothing for a ban, and during a WhatsApp restriction is the wrong move
  • starting and sleepMode are documented as clearing "up to 5 minutes", so a 10-minute poll seeing one proves nothing by construction. They alarm only on a second consecutive sighting, tracked with a keyed Firestore lock: the first sighting takes it, a second inside the window cannot, and that failure IS the proof it persisted
  • An unrecognised state alarms and says it does not know, rather than reading as healthy. A state GREEN-API adds tomorrow lands there
  • Fix the subject, which interpolated the raw enum and read "Picklebot WhatsApp session starting" - a status announcement where an alarm should say what is wrong and what to do
  • Record that GREEN-API's Authorization History is NOT a record of instance state: it logs only Authorized / Not Authorized, so a suspended period shows there as Not Authorized. I read that pane and concluded suspended had never occurred; Gene observed it directly in the console on 2026-09-01
  • 15 to 41 checks in scripts/test-instance-state.py. Mutation-checked rather than assumed: reverting the transient branch turns 6 of the new cases red
docsf47d144Claude Opus 5 (1M context)
  • Mark greenapi-watch done: Gene's idea, folded into keepalive-webhook in 0a267b6. That Cloud Scheduler job already fires every 10 minutes, so the watch gets a reliable trigger at $0 and nothing new authenticates to GitHub
  • Restate the rule the first version of this plan got wrong. It applied "fold into an existing job" to the three timing-indifferent dailies, then reached for a NEW daily job for greenapi-watch - the one row where a daily cadence is useless, because a watch wants the fastest reliable trigger available
  • The question is not "does this need its own job?" but "which existing job already fires often enough?", and the 10-minute keepalive answers it for anything monitoring
  • Drops the estimate from +$0.20/month to +$0.10, one job for sync-members, and takes the PAT off the critical path for the monitoring row: the keepalive already runs in GCP, so nothing new depends on that credential
docs0246f78Claude Opus 5
  • The four open questions are answered from Meta's own pages, read on the box whose network is not behind the proxy that blocks developers.facebook.com here
  • Answer 1 closes it: "Max group participants: 8" is a plain spec under Quick facts on a page saying the Groups API is open to all businesses with an OBA. GA at 8, and SMAD is ~74, so there is no like-for-like migration
  • Flows cannot reach a group either; the vendor pages that made this look open are contradicted by Meta's own list of four supported types
  • Removed "2-4 days of review": the page gives no review duration and I had no source. The 30 days is real. An unsourced number in a decision doc becomes fact by being written down, which is what the rest of the section warns about
  • Corrected the claim that the monitoring gap was closed. That read a cron: line as measured behaviour, two hundred lines below this repo's own measurement of GitHub cron at 3-4 hours late. Points at the keepalive poll instead
  • Business Verification for a club with no legal entity is now the second gate, and the page does not say what documents it wants
feat0a267b6Claude Opus 5
  • handle_state_change() alerts on the stateInstanceChanged CALLBACK, delivered by the thing that is broken, so the one event it exists to catch is the one it can miss. greenapi-watch.yml polls instead, but on GitHub cron, measured in this repo at 3-4 hours late on the median. An alarm that fires hours late does not beat the human who found the 9/1 suspension in 1h41m
  • The poll now rides the keepalive, which Cloud Scheduler already fires every 10 minutes. No new job, and no PAT, so it does not inherit that credential's unmonitored expiry. Placed after the picklebot leg and never raising, so the ping's 200/503 keeps one meaning: are the functions being warmed
  • Both paths call the same handle_state_change(), so they cannot disagree about what counts as healthy or about who gets told
  • get_state_instance() returns None for "could not ask", never for "healthy", and the caller logs it as "I do not know", not "authorized"
  • handle_state_change() now returns alert-failed when the email did not send. It returned alerted unconditionally, and send_notification_email() returns False with only a WARNING when GMAIL_USERNAME is unset -- which it was on this function, because the deploy passed no Gmail credentials. The alarm for a six-hour outage reported success while sending nothing
  • Deploy now passes GMAIL_USERNAME and GMAIL_OAUTH_TOKEN_JSON. The client libraries were already in the webhook's requirements; only the creds were missing
  • Hourly throttle on the email, or the 10-minute poll would have sent about 36 through the 6h04m outage. Fails OPEN, and is released when healthy so a re-suspension alerts at once -- state flaps, per the 47-second re-link on 9/1
  • scripts/test-instance-state.py: 15 cases against fakes for the mailer, the lock and GREEN-API. Mutation-tested by exit code; the unreachable-API case survived at first because the outer except swallowed it either way, so it now asserts the log line rather than the absence of an email
docs23d643bClaude Opus 5
  • Document the inverse of the existing wake-the-desktop guidance: a desktop session executing normally with its cloud registration gone. Tools run, files write, git pushes - and peer discovery, SendMessage and every MCP server are gone, so nothing looks broken from inside
  • Observed 2026-09-03 PT. ListAgents listed a cloud peer and two SendMessage calls to him succeeded; at the next SessionStart the harness reported 76 MCP tools disconnected, and from that instant ListAgents was empty, SendMessage refused, and Claude Desktop said it could not reach a machine that was executing the whole time
  • Give the probe that separates it from a peer genuinely being down: RemoteTrigger returns HTTP 200 while ListAgents is empty and MCP is absent. Egress is the discriminator - plain HTTPS to claude.ai keeps working, so the session cannot tell by trying harder
  • An empty ListAgents is NOT evidence a peer is down. This session read it as "the tool only sees this machine" and reported that as fact an hour after the same tool had listed a cloud peer in the same conversation. The contradiction was in the transcript and went unnoticed
  • Record re-authenticating the VS Code extension as the SUSPECTED trigger, not the established one: the re-auth and a session restart happened together and neither the session nor the app can separate them. The distinction decides whether this is a rare avoidable action or a cost of every restart
  • The fix is on the box - reload the window, /mcp to reconnect - and both are interactive, so an agent cannot do either for itself
  • Note what this argues about the notes channel: it is git, needs nothing brokered, and went through for the hours the purpose-built delivery mechanism was down
docs28731baClaude Opus 5
  • Every message goes through GREEN-API, which automates a linked device against WhatsApp's terms. Two incidents in eight days, durations computed from the recorded timestamps rather than the null duration_minutes fields
  • Read the 9/1 suspension honestly: 34 of 57 messages that day were the two sessions talking, and d9b9a62 fixed that. Its cause: "external" looks wrong on the rules in Recording Production Outages -- the vendor reacted to us rather than breaking. Flagged for its owner, not changed here
  • Two blockers found: official groups cap at 8 participants with no add-member endpoint, and there is no poll message type at all. Neither is a swap of the publisher's backend; both are a redesign
  • Flows would delete the reason /pb shyam needs a write-ahead Court Log row and a lock, since GREEN-API replays a voter's whole selection and a Flow submit is one discrete event. But a Flow is a private form, so the live tally everyone can see is gone, and it changes nothing about the group cap
  • Four open questions with a decision gate: do not start Business Verification until the group cap and Flows-in-groups are answered. 2-4 days of review plus 30 days registered is the expensive order to discover the group cannot exist
  • Marked throughout that developers.facebook.com is blocked by the agent proxy, so none of it was read from Meta's own docs. Third-party sources only, 2026-09-02 PT
  • greenapi-watch.yml already covers the monitoring gap, checked rather than assumed, so the section says do not rebuild it
featbf14474Claude Opus 5
  • List EVERY commit. The first version capped at 15 and closed with "and 13 more", which is the one thing a release note must not do: the reader cannot tell whether the hidden part mattered. The busiest day on record, 131 commits, renders in ~6,300 chars against GREEN-API's 20,000 limit, so the cap was never buying anything
  • Keep DIGEST_HARD_LIMIT as a backstop, not a policy: a runaway day is truncated AND says so, rather than rejected by the API and received by nobody. The suite exercises that path, because no real day would
  • Title is now "What Shipped on Wed Sept 2 2026". Sept is spelled out in a month table rather than derived: strftime gives "Sep", and %-d for the day is a glibc extension that raises on Windows - the %-I trap from court_rules
  • Send to three targets: Admin Dinkers, SMAD Pickleball, and Gene by DM. An unset id is skipped with a LOUD error rather than silently dropped, and a partial delivery exits non-zero - one of three landing must not report as success
  • Give each target its OWN correlation id. Dedup keys on the id alone, so one id shared across three recipients would deliver to the first and silently drop the other two as duplicates of a message already sent. Still keyed on the day, so a re-run cannot double-send
  • Fire from Cloud Scheduler instead of GitHub cron. It ran on `schedule: 0 17
  • * *` for exactly one day and arrived at 12:39 PM PDT, 159 minutes late - and that is typical: measured across every scheduled workflow here, GitHub cron is 3-4 hours late on the median and up to 9 hours. It is now a workflow_call reusable workflow invoked as a separate job by daily-reminder-runner.yml, which 8am-daily-runner dispatches at 08:00 America/Los_Angeles - on time, DST-correct, and free because Cloud Scheduler bills per job and this reuses one
  • A separate job rather than steps inside the runner, so a failing reminder does not cost the digest and a failing digest does not cost the reminders. Skipped on a dry run, since the point of dry_run is that nobody is messaged
  • 15 to 26 test cases, all green
docs0b52939Claude Opus 5
  • Measure the problem rather than assert it: across each workflow's own schedule runs, GitHub cron is 3-4 hours late on the median and up to 9 hours (sync-members, 540 min worst). All three Cloud Scheduler jobs fire on time
  • GitHub cron is also UTC-only, so all eight workflows silently shift an hour twice a year - the same class of bug as the hardcoded PST fixed twice here
  • Cost comes from CLOUD_COST_OPTIMIZATION.md and the actual August bill, not a remembered price list: free tier is 3 jobs per billing account, the billable unit is the job-month, and we already exceed it because schedule-last-call.py creates per-game jobs. Actual $0.39 for 08/01-08/24 across 10 live jobs, so the marginal rate is near $0.10/job/month
  • Two web fetches of Google's pricing page returned truncated content and a 404, so the published rate is cited as consistent with our bill rather than as the source. The bill is the thing we can verify
  • Plan spends $0.20/month instead of $0.80 by folding the three timing-indifferent workflows into the existing 8am job and giving their own job only to sync-members and greenapi-watch, where lateness has a cost
  • Leave error-reporting on GitHub cron: it sweeps a window and widens it when it detects a missed run, so it already tolerates lateness by design
  • Record three open questions before anyone starts, the first being that the PAT these jobs authenticate with expires and nothing alerts on it - the symptom is silent, because a run that never starts leaves nothing to watch
infra/CI4b0c8e3Claude Opus 5
  • Add set -o pipefail to every teeing step that lacked it: the four deploy workflows and ci-metrics. All nine teeing workflows are now guarded
  • A step with no shell: key runs under bash -e, where a pipeline's exit status is the LAST command's - tee, which always succeeds. So the explicit `exit 1` inside each `{ ... } 2>&1 | tee` block was swallowed: the step goes green, the failure reporter never runs, no issue is opened
  • Each of those blocks already fixed the INNER version of this bug - a retry loop whose last command was `sleep`, so a deploy that failed both attempts exited 0. The outer pipe was still discarding the result they were careful to produce
  • Observed for real in release-notes.yml on 2026-09-02 PT: the send died with a 403, the traceback landed in the log, the run reported success, and it was caught only because the message never arrived
  • THE SMOKE TEST DOES NOT COVER THIS, which I checked rather than assumed before claiming it. It takes no commit sha and compares the latest CREATED revision to the latest READY one. When a deploy never happens both are the previous revision, so state is ACTIVE, created == ready, and the HTTP probe gets 200 from the old container. Fully green, old code serving
  • So it proves the service is healthy, not that this change shipped. That gap is unchanged by this commit and is worth closing separately - pinning the deployed revision to github.sha is the obvious way
feat565d764Claude Opus 5
  • The freshness hook announced "CLAUDE.md (re-read it before acting on its rules)" while the repo was 0 commits behind - about an edit the reader had just made themselves. That is worse than a missing alert: it trains you to ignore the one message that means the rules moved underneath you
  • Cause: `git diff A..B` compares the two endpoints, so it reports OUR OWN unpushed commits as changes, while `behind` is counted directionally with rev-list and was correctly 0. Measured one commit ahead: two-dot says "CLAUDE.md", three-dot says ""
  • `A...B` diffs from the merge-base to B, so it shows only what the other side added, and is empty exactly when nothing is behind
  • The bug predates the sessions-repo work and was INVISIBLE: the old emit line exited unless behind > 0, so a wrong alert computed in this state was never printed. Widening that condition so a sessions-repo note can ring with a current checkout is what surfaced it
  • Add the case no fixture expressed: AHEAD of upstream, not behind. Every existing freshness case put the clone BEHIND, so none could reach the state that was wrong in production - the author's blind spot CLAUDE.md describes
  • Mutation-checked rather than assumed: the new case FAILS against the two-dot code and passes against three-dot, so it is load-bearing
  • 92 to 93 cases, all green
featc001aa2Claude Opus 5
  • Shyam can only book a free court on his own account 48+ hours ahead (his report via Gene, 2026-09-02 PT). Inside that window there is no court of his to hand over, so a vote for such a game now triggers nothing: no cancellation, no $10 credit, he plays as an ordinary member
  • Add MEMBER_FREE_BOOKING_MIN_HOURS and member_booking_error() to court_rules, the third club rule and the first about a MEMBER'S account rather than ours. Same plain-text-or-None contract as the other two
  • Gate the UNATTENDED caller only. /pb shyam <date> typed by hand still works at any distance, exactly as it does past the cancel cutoff, because a person running it has already dealt with the club themselves
  • Check it BEFORE the cancel cutoff: 48 hours against 12, so it is the binding rule for the vote path and the one whose reason a log reader wants. Keep the cutoff check rather than folding it in - it is a different rule about a different actor, and must keep working if either constant moves
  • The case was live, not hypothetical. At 20:06 PT on 09/02 he voted for a 09/04 6:00 PM game, 45.9 hours out. The bot credited $10, wrote a handover row, and dispatched a cancellation that found no reservation - which paged Admin Dinkers as a workflow FAILURE for what was a correct no-op
  • Add scripts/test-court-rules.py: 16 cases covering all three rules, with that exact incident pinned to the minute. Boundaries derive from the constants, so they cannot silently stop testing the rule if the club changes its terms, and every case runs against a fixed `now` rather than the clock
tooling8ffdb5fClaude Opus 5
  • The Commits per Week chart had one visibly fat bar. A flex item's default min-width is auto, so every .bar-col floors at its content's intrinsic width, and exactly one column has content: the peak's "252" value label, an unbreakable 3-character monospace token
  • Measured in chromium before the fix: the peak bar pinned at 20.7px at every viewport while its siblings shrank freely - 20px at 1100px wide, 9.6px at 560px, 5.4px at 420px. 3.83x fatter than its neighbours on a phone
  • Same failure as the session-trailer URL that widened the whole page: a track sized by its longest unbreakable run
  • min-width:0 lets the column take its flex share; absolute positioning takes the label out of flow so it contributes no width at all. The label is KEPT, which was the ask - removing it was the fallback
  • Measured after: 1.00x at 1100, 845, 700, 560 and 420px, and the label stays visible, centred to within 0.0px, 3px above the bar, inside the viewport
  • Note in the CSS that bottom:100% is measured from the COLUMN, which is the same place as the bar top only because the label is emitted for the peak alone, whose bar is by definition full height
infra/CI04954beClaude Opus 5
  • The verification run reported SUCCESS and sent nothing. Two bugs, found only because the message never arrived
  • Wrong service account. SMAD_GOOGLE_CREDENTIALS_JSON is the SHEETS account (cloudscheduler.admin, datastore.user, no Pub/Sub at all); GCP_SA_KEY is the cloud one court-booking.py publishes with. Pointing GOOGLE_APPLICATION_CREDENTIALS at the Sheets account gave 403 IAM_PERMISSION_DENIED on pubsub.topics.publish. No IAM change needed - it now authenticates exactly like court-booking.yml, which is the proof it works
  • The auth action exports GOOGLE_APPLICATION_CREDENTIALS itself, so the step must not set it again or it clobbers the good one
  • set -o pipefail. A step with no shell: key runs under bash -e, where a pipeline's status is the LAST command's - tee, which always succeeds. So the send died, the traceback landed in the log, the run went green, the failure reporter never ran and no issue was opened
  • That is the Liveness or Die failure, committed the same day as extending that section: a mechanism reporting a win that never happened
  • Six of nine teeing workflows share the shape (both of these, ci-metrics and all four deploys). Only this one is observed failing; the deploy ones wrap an explicit exit 1 that the pipe also swallows, but each ends in a smoke test that may catch a no-op deploy independently, so that stays an inference
featd9b9a62Claude Opus 5
  • Replace the per-push release note with a scheduled daily digest and turn the send back on. Measured from this workflow's own run history, per-push sent a median 18 messages a day to Admin Dinkers, 59 on 09-01 and 103 on 08-30
  • Moving the session channel out was NOT enough on its own, which is why this is a digest rather than an un-pause. It removed the 34 notes-only messages of 09-01; it did nothing about 08-30, whose 103 pushes were code alone. Per-push was still a bet that an ordinary day sits under a threshold nobody can see
  • Send commit SUBJECTS, capped at 15 with an "and N more" line, plus a link to the dashboard. Not bodies: at the median 22 commits/day their bullets run to thousands of characters, and on 08-30 the subjects alone came to 6,952
  • Report a PT CALENDAR DAY, not a rolling 24 hours. GitHub's scheduler runs late routinely and by hours; a rolling window silently drops whatever fell before its start when a run slips, a calendar day reports the same set whenever the run happens
  • Send nothing on a day with no commits. A daily "nothing shipped" notification is the noise that got per-push turned off - but log it and write a step summary, so a quiet day stays distinguishable from a broken digest
  • Key the correlation id on the DAY (release-digest-YYYY-MM-DD), so a late schedule firing twice, or a hand dispatch of a day already sent, is deduped
  • Move the "which commits are a release" rule into scripts/release_scope.py, shared with build-shipping-log.py so the dashboard and the digest cannot disagree about what shipped. It was about to be a Python predicate in one and a git pathspec in the other, equivalent by inspection
  • Add scripts/test-release-digest.py: 15 cases pinning the cap, the counts, the empty day, the window and the channel filter - the last against real history, since a fixture would only prove the pathspec matches itself
  • fetch-depth: 0, because the digest walks git log and a shallow clone has one commit, so every run would report a quiet day rather than a broken one
  • Nearly repeated the ci-metrics permissions bug in mirror image: contents:read alone would have denied the issues:write the failure reporter needs, so a failing digest would have failed silently to report its failure
  • Use _day_label() rather than %-d, which is a glibc extension that works on the runner and raises on Windows - the %-I trap from court_rules.py
  • Update the two CLAUDE.md rules and commit_trailers.py that described the old body parser: the dashboard is now the only renderer that reads a commit body, so the SUBJECT carries the whole message for most readers
featf2567e6
  • 268cb2d added "Bash(git push:*)" to permissions.ask to gate push and deploy on a prompt rather than on the agent's good intentions. Removing the push half is a deliberate reversal of that, not an oversight
  • It made CLAUDE.md rule 1's standing note exception inert. That exception exists so two sessions can talk without human intervention; the harness enforces settings.json and CLAUDE.md only instructs the agent, so every note push stopped for a click anyway
  • ask beats allow in precedence, so this could not be narrowed around for pushes from the repo root. Removing the entry was the only way to honor a verbal yes
  • Not the whole story: the dialog on 2026-09-02 PT read "Contains shell syntax that cannot be statically analyzed" and was refusing a heredoc wrapped in a retry loop, which no permission rule can match. The agents now write commit messages to a file and push as a single plain command
  • gcloud functions deploy, gh variable set and gh secret set still ask. Deploys and config writes keep the gate; only push loses it
  • This file is committed, so it takes effect on both boxes
feat079ede3Claude Opus 5
  • Gene checked the rows that had linked: they render as plain text. U+2060 does defeat WhatsApp's linkifier
  • Everything else in this comment is reasoning -- a digit count and an observed failure. The claim that mattered was never tested, and was written here and in CLAUDE.md as settled. Third such claim this week, after gh being "absent" from a sandbox where it is installed and the App-installation one that cost ten hours of repo access
  • Says why a sandbox cannot check it: no GREEN-API credentials, and api.green-api.com is refused at the agent proxy. Probed, not assumed -- the proxy half is not in CLAUDE.md, which records only the credentials gap
tooling6165b72Claude Opus 5
  • Both sessions can push to SMADPickleBot-sessions, and could all along. The stub, CLAUDE.md and README all stated the cloud session's App token was scoped per repository, so the new repo had to be ADDED to the installation before it could write. Never checked, and wrong: the installation was on All repositories, which covers every private repo Gene owns, so the sessions repo was included from the moment it existed
  • The fix for that non-existent problem caused a real outage. Acting on the claim, Gene narrowed the installation to selected repositories with only the new repo ticked, which revoked access to THIS one - the cloud session lost SMADPickleBot entirely, 403 on read as well as push, for ten hours
  • Verified sequence: last good push c4695ef at 03:05 PT, first 403 at 13:14 PT before that session had attached anything, restored and confirmed three ways at 13:20 PT (fetch, REST 200, ff-only merge to aa0ac53)
  • Keep the fallback: a session that genuinely cannot push writes in the old file and says so at the top, since a cloud session cannot SendMessage either
  • This is the rule already in CLAUDE.md under "Reading Production From a Session" - probe the limit before reporting it. One push would have disproved it. Third instance of the pattern, first one to cost anything
feat5a3aec2Claude Opus 5
  • Resolve the channel's checkout in one place, .claude/hooks/sessions-dir.sh, sourced by all three note hooks. It was three inline copies kept "identical by convention", which is one careless edit from the doorbell and the gate watching different directories - the reason commit_trailers.py exists
  • Probe the canonical ../SMADPickleBot-sessions, then the lowercase ../smadpicklebot-sessions the cloud sandbox's repo attach produces, then any other casing. On Linux those are different directories, so the doorbell reported NOT CLONED about a clone sitting right beside it - the "I could not look" answer, fired for a repo that was there, in the one session it was written to serve
  • Reject the env-var workaround rather than document it: a sandbox runs every Bash call in a fresh shell, so an exported CLAUDE_SESSIONS_DIR is gone by the next call. Documenting it would have been documenting something that cannot work, and the symptom of it not working is a hook quietly watching nothing
  • Keep CLAUDE_SESSIONS_DIR winning verbatim, even pointing at nothing. A caller naming a directory is answering the question themselves; substituting a working one would hide their mistake instead of reporting it
  • Drop a fork from every prompt: the parent came from $(cd .. && pwd), and the gated path is documented to spawn no subprocess at all. $PWD is already the project dir, so parameter expansion does it
  • Report the lowercase-only case as SKIPPED on Windows, whose filesystem cannot express it. A case that cannot run is not a case that succeeded
  • Assert no inline copy can creep back into the three hooks - a divergence that passes every behavioural case on the day it is introduced
  • Fix two fixture defects found by these cases: the clone had no copy of the new helper, so every resolver case sourced nothing and read back an empty string; and the doorbell suite's fast-forward silently refused on a tree reset() had dirtied, so eight cases measured a clone two commits behind and read the setup failure as a hook defect
  • Ask BASH whether the resolved path is a repo. os.path.isdir() called a valid Git Bash /tmp path missing while every consumer of it worked
  • 82 to 92 cases, all green
toolingaa0ac53Claude Opus 5
  • Exclude commits that touched the session channel and nothing else from the Release Dashboard: 76 of 1072, 47 of them inside two days, a heatmap spike of work nobody shipped
  • Match on the file list, never the subject. Only 17 of the 76 say "Note:", so a subject matcher would leave 59 in place - the same mistake as sniffing perf work from the words in a subject, which is why PERF_SHAS exists a few lines down
  • Keep a commit that also touched code, and filter the notes file's churn by path in loc_history, so those seven contribute their code and not their chatter (+3,277 / -3,229 lines of two agents talking is not this codebase growing)
  • Reject the "does the diff add a note entry" heuristic: six of the 76 edit the protocol prose and the signpost commit deletes four entry headings while being a docs change. Neither ships anything, and no rule keeping some and dropping others could be stated out loud
  • Say how many were excluded, computed from the filter and empty when there are none. Quietly reporting a number smaller than git log gives is the same dishonesty as padding one
feat25b60dfClaude Opus 5
  • Resolve the channel through CLAUDE_SESSIONS_DIR (default ../SMADPickleBot-sessions) in all three hooks, so the doorbell, the ack hook and the reply gate act on session-notes.md over there rather than on the signpost stub left behind here
  • Ring for a note in the sessions repo even when this repo is current: the old emit condition exited unless THIS repo was behind, which was right while the notes lived here and went silent-for-the-channel the instant they left
  • Re-derive "is the clone missing" on the cached path, with a [ -d ] test and no network. A cache written while the clone existed, replayed after it did not, is silence standing in for all-clear - the scar this hook already carries
  • JSON-escape the alert before interpolating it. A Windows path makes C:\Users an invalid \U escape, so the whole object failed to parse and the host dropped it - invisible failure on the branch whose entire job is to say "I could not look"
  • Escape at the emit site, not before the cache write, so escapes cannot accumulate across the gated read-write cycle
  • Rebuild the suite's fixture as a real second git repo with an upstream: a bare directory would have pinned every case to an error branch
  • Give the doorbell suite its own clone. Sharing one with the note suites, which reset --hard to stand in for a checkout, made a pull conflict and the suite reported a note as unread after it had been read
  • Assert the fixture is running the sessions-aware hook. fire() runs the CLONE's copy, so without the copy and the assertion every new case would have gone green against a hook that had never heard of the sessions repo
  • and silence is what four of them assert
  • Report unparseable hook output as unparseable, never as silence. Both look identical to a reader and send them hunting in the wrong half of the code
  • 72 to 82 cases, all green
  • Update CLAUDE.md rule 1's standing exception, the commit-convention channel section and the shared-tree section, plus the README's fresh-sessions and release-notes sections, to name the new repo and the outage that caused the move
infra/CI0ca2c6bClaude Opus 5
  • greenapi-watch.yml has failed every 15 minutes since 2026-09-02 00:05, when WhatsApp recovered. Every one was a false alarm, and each carried "No output captured" because the step produced nothing at all
  • A HEALTHY instance has no suspendedUntil field. grep exits 1 when it finds nothing, the step runs under `bash -e` with pipefail, so that assignment killed the shell -- above the first echo, hence zero output and exit 1, which the reporter published as an outage
  • Both greps now tolerate no-match
  • The reason it shipped: I wrote and tested this DURING the outage, when suspendedUntil existed, and never ran the healthy path once. The watcher worked only while the thing it watches was broken
  • That is yesterday's silence-test rule inverted. I tested the alert case and not the quiet one, which is the same defect as testing only the quiet one: a mechanism verified in one state and asserted in both
  • Both paths are now exercised against real bodies: the live authorized response exits 0 and says so, a suspended body exits 1 and prints suspendedUntil
  • Gene caught it, from the failure notice. The notice was useless -- "No output captured" -- but it fired, and that was enough
featc4695efClaude Opus 5
  • Filtered inside format_list_players(), the one place both callers go through: /pb players and the CLI's smad-whatsapp.py. The count moves with it
  • The Top N board has always excluded them, so listing them here made the two views disagree about who the group is. They are kept as sheet rows so their balance and history survive, not because they are still playing
  • Vacationers stay. They are members who are away, and the count answers "how many of us are there", not "how many are here this week"
  • sort_by='reminders' is NOT filtered: archived players still get payment reminders while they owe, so who is being chased is a different question from who is on the roster. Hiding them there would hide money owed
  • An all-archived list says so rather than rendering an empty table
  • Mutation-tested: no filter fails 4 cases, over-filtering vacationers fails 3, filtering the reminders table fails 1. The over-filter run first reported a CRASH rather than assertions -- same trap as WJ="" earlier tonight, a crash prints no FAIL lines and reads as survived -- and was re-run properly
featf13a281Claude Opus 5

Two harness failures found while testing this, both "the instrument lied":

  • WhatsApp linkifies a run of phone-shaped characters at EIGHT digits, and "12-4 110 4.0" is exactly eight: two real roster rows rendered as tappable numbers on 2026-09-02 PT. Seven is an ordinary row today, so it gets worse on its own as matches and hours accumulate
  • A U+2060 word joiner between the stat columns ends the run, capping segments at five digits (W-L), three (Hrs) and two (DUPR). U+200B would also work but carries a line-break opportunity a monospace table cannot afford
  • It sits outside the padded field, so len() still measures the value alone and every column stays aligned. visible() strips them for anything measuring width; the header rule is one character too long per column without it
  • The suite asserts the inverse too -- that the same rows WOULD trip the linkifier without the joiners -- so the guard cannot quietly stop working
  • Only a real send settles whether WhatsApp honours U+2060. If those rows still link, the fallback is a visible separator, at the cost of a column
  • str.split("") raises, so mutating WJ to "" CRASHED the suite, and counting FAIL lines read a crash as zero failures -- the guard looked unnecessary. Judge by exit code; the test now treats an empty joiner as "no guard"
  • A .strip() in the test ate the header's leading blank rank column and compared the rule against a header two characters short, failing for a fake reason
featc6be65aClaude Opus 5
  • Reading down a ranked table the eye checks the ordering against the FIRST number it meets, so that column has to be the one the rows are ranked by. The board ranked by W-L with DPR in the first column read as a broken sort
  • Criteria the caller did not name follow in canonical DPR W-L Hrs order and are still shown: they are data worth seeing, just not what the table answers
  • The game roster (sort_by="none") keeps the canonical layout and still does not sort, so it stays alphabetical and a player can find their own name
  • Header AND column order are derived from the chain, so swapping LEADERBOARD_SORT stays green while pinning either by hand fails: 7 cases go red when columns ignore the sort, 3 when the unnamed criteria are dropped
  • One surviving mutation was checked rather than papered over: criteria=[] and criteria=CANONICAL_COLUMNS in the "none" branch produce the same column list, so it is equivalent code, not a coverage gap. Mutating what does matter -- making that branch sort -- fails as it should
featd058b80Claude Opus 5
  • One line. The board's header is sort_label(LEADERBOARD_SORT), so it moves to "Top 15: W-L > Hrs > DUPR" on its own
  • Unrated players are no longer demoted: DUPR is the last key, so a 9-0 with no rating tops the board
  • Anyone at 0-0, or with an unfilled COUNTIF, drops below every real record whatever their hours or rating
feata6e4958Claude Opus 5
  • /pb players [dupr] [w-l] [hrs] sorts by those, IN PRIORITY ORDER, so the real roster can be read under each permutation before one is picked for the board
  • The board is now format_player_table(limit=15, sort_by=LEADERBOARD_SORT): same renderer, same sort machinery, so what was read and what the group sees cannot be produced by different code. Changing it is editing that one list
  • The header is sort_label(LEADERBOARD_SORT), derived rather than written beside it. "Hours Played Since 6/25" sat over three different sorts in one evening
  • Arguments are taken literally: ['dupr'] sorts on DUPR alone, never the three-deep preset of the same name. Adding criteria nobody typed answers a different question
  • An unknown or repeated criterion is REFUSED with the reason, not dropped: a sort argument silently ignored renders a correct table answering something else, with nothing on screen saying so
  • Column order is DUPR, W-L, Hrs across every caller of the shared renderer
  • Both sort modes splice in one wl_rank(): win PERCENTAGE then matches played, following get_standings(), so the repo holds one notion of "ranked by W-L". Name is appended to every chain so a render is stable across sends
  • scripts/test-formatters.py: 40 cases including the intent matcher end to end. Mutation-tested; two cases were not load-bearing on the first pass and now are
fix972b40fClaude Opus 5
  • /pb payment transfer writes a NEGATIVE row for the payer, and record_payment() stamped Last Paid on it unconditionally: Roger covering Alexandre would read as "Roger paid today" when money left his account
  • Last Paid has TWO writers. backfill_last_paid() recomputes it from the log after aggregation archives the rows, so fixing only the live path would let the next backfill restore the debit's date, from a function named repair
  • The test is the SIGN, not the method. A method allowlist is exactly what shipped this command unable to write a row an hour ago
  • Strictly < 0: parse_balance_str() returns 0.0 for blank and unparseable amounts too, and an unreadable amount must not be treated as a debit
  • New scripts/test-payments.py drives both real functions through a fake Sheets service. Each case was checked to go red with its guard removed, and the harness asserts it reached the code: every case must append a log row
  • Dropped a stale "28 cases" count from the README's hooks section rather than update a number that goes stale again
fix81b7149Claude Opus 5
  • 'transfer' was absent from valid_methods, so the command's FIRST write, the debit, was rejected and every transfer failed closed. Shipped 2026-09-01 PT and never wrote a single row
  • Reproduced Gene's exact message, then proved the fix: 'transfer' now behaves identically to 'venmo' and 'bogus' is still rejected
  • Nothing was corrupted. Debit-first ordering did its job: the credit was never attempted, so both balances are untouched
  • Payment Log Method column in PAYMENT_MANAGEMENT.md listed 4 of 7 methods; credit and aggregate were already missing before today
  • Registry now says the Method column is an allowlist that REJECTS rather than defaults, since that is the property that made this silent until a live run
featec2f0fcClaude Opus 5
  • The main sheet holds one DUPR cell per player, overwritten in place, so every previous value was lost the moment it changed. /pb set dupr has existed all along and nothing remembered what it did, which means no rating could answer the only question worth asking of it: is this player improving
  • New DUPR Log tab, one row per change: Recorded At, Player, Old, New, Status, Source, Recorded By, Recorded By ID, W, L, Hours Played, Notes
  • THE COMMAND IS NOT THE ONLY WRITER. survey_sync() ratchets ratings up from survey answers, so a log fed only by /pb set dupr would look complete while missing an entire path. Both call record_dupr_change(), and Source says which
  • REFUSALS ARE ROWS TOO. survey_sync's dupr_held -- survey values declined because they would LOWER a rating -- existed only in one run's return value and evaporated when the run ended. It is the sole trace that a player self-reported a lower number than the sheet holds, which is exactly the signal worth keeping when asking whether ratings are real. Written with status=held
  • 'no score' for a missing rating, Gene's call and better than mine: I had blank, which reads as "nobody filled this in" -- a different claim from "this player had no rating at the time", and only one of them is true. Never 0.0 either
  • One decimal always. %g rendered 4.0 as "4", which reads as a different scale beside "3.5". Caught by rendering real roster rows rather than examples
  • Delta and matches-played deliberately NOT stored. Both are computable from the row -- new minus old, W plus L -- and a stored copy goes stale the moment an input is corrected while still looking authoritative. That is the ~73 seconds failure, which is why the rule is store the inputs and compute on read
  • W/L/Hours are snapshots AS OF the change, which is the point: 3.5 to 4.0 means something different after 12 hours than after 120
  • Recorded By ID holds the sender's phone, because the roster has John Wang AND John Wang 2 and a display name in an audit column is ambiguous by construction
  • set_dupr_for ungated per Gene: anyone may now set anyone's rating. The help loses its separate Player Ratings group and both variants show the @player form in the one DUPR group, where it belongs now that it is not admin-only
  • Logging is best-effort at every call site: the rating is already written by then, so losing it to a bookkeeping failure would be the worse outcome
feat2d4a6e3Claude Opus 5
  • The DM carried the signature twice: once as a header and once in format_dm_signature()'s footer. On a phone that reads as a doubled letterhead
  • _transfer_message() now returns the body alone, with no signature. The group reply adds the header; the DM adds the standard footer. Neither is baked into the shared text, so the two can differ without a second copy of the wording
  • Verified both renderings after the change: the group reply is unchanged from what Gene specified, and the DM now opens on the transfer line
feat52a3926Claude Opus 5
  • Gene's wording, used verbatim. The old reply ended "Two Payment Log rows, both tagged transfer-20260902-003512", which is ledger plumbing leaking into a message meant for two people who just want to know they are square
  • One message now, _transfer_message(), used for the group reply AND both DMs. A single copy rather than three that drift
  • The DM adds format_dm_signature(), which is the documented single source for a DM footer, so the help hint and joke match every other DM
  • Both parties are DMed, for different reasons: the payer is out of pocket at the court and wants to see it credited back, and the payee is the one who would otherwise keep getting payment-reminder nags for money someone else already handed over
  • Sent through whatsapp_publisher.send_dm, never a direct GREEN-API call. That is tonight's lesson: send-release-notes.py called GREEN-API straight from a runner and its ~100 messages were invisible to the sender's dedup, queue and logs, which is why nobody saw the volume that suspended the account
  • Best-effort and reported per person. The money has already moved by the time the DM sends, so a failed DM must not fail the transfer -- but a silent send failure is how you learn weeks later that nobody was told
  • 'to' added as an explicit separator: "Roger Yeh to Alexandre Laussu". Checked before anything else because it removes the guesswork entirely
  • Matched on surrounding whitespace so it cannot fire inside a name. The roster has a Toby, and "Toby Hsieh to Roger Yeh" splits at the right 'to'
  • The search-for-the-split path still exists for a pair typed without it
  • Verified against the live roster: 'to' with typed names, with @names, with @me, with a real mention's digits, the no-separator form, the Toby case, and "John to Alexandre" still refused as ambiguous. Two DMs addressed to the right numbers, and the rendered message matches the spec exactly
feat71ce5f6Claude Opus 5
  • Roger covered Alexandre at the court on 2026-09-01 and paid $20. The balances read -$10 and +$10: two wrong numbers describing one correct payment
  • Writes TWO Payment Log rows, a debit and a credit sharing one Transaction ID (transfer-<ts>-out / -in), rather than editing what is already there. The log is an audit trail, so the movement is recorded, not the history rewritten
  • THE DEBIT IS WRITTEN FIRST, deliberately. Neither half-failure is good, but they are not equally bad: crediting first and failing would record money the club never received -- Roger's $20 becoming $30 of credit across two accounts -- which corrupts the ledger. Debiting first and failing leaves the payee still owing, which is wrong but recoverable and keeps the books honest about how much money exists
  • A partial write says which row landed and prints the command to finish it, rather than claiming a transfer that is half there
  • Names take the same forms as /pb match, through the SAME resolver: typed names, @name, a real mention's phone digits, and @me. Reusing _resolve_at_segment() rather than writing a second implementation that can drift from the first
  • An all-typed pair has no separator between the two names, so the split point is SEARCHED: every position is tried and the ones where both halves resolve uniquely are kept. More than one working split is refused as ambiguous, never guessed -- the roster holds John Wang and John Wang 2, and two Dereks, and this moves money
  • Both names resolving to one person is refused too
  • Admin-only, in ACTION_INTENTS beside record_payment. Matched ahead of the record-payment rule since both start with "payment ", and the amount is parsed off the END so the names in the middle can be any length
  • The intent is in the LLM classifier list as well as the rule matcher, so a phrasing the rules miss still lands in the right place
  • Tested against the live roster: all five input forms resolve, "payment record" still routes correctly, and the dry run for the real case reads Roger $10.00 credit -> $0.00 and Alexandre $10.00 due -> $0.00
toolingc37ab11Claude Opus 5
  • The vendor said suspendedUntil = 00:01:10 PDT. The instance was still suspended at 00:03:22 and authorized at 00:05:20, four minutes late
  • restored was taken from that promised expiry when the incident was first written, which is a guess that happens to look precise -- the failure this file forbids in its own schema. It now holds the observed value, supplied by a watcher polling every 20 seconds
  • Outage: 18:01:10 to 00:05:20, 364 minutes
feata0744b1Claude Opus 5
  • send-release-notes.py called greenapi.send_message directly from a GitHub runner, bypassing whatsapp-message-sender and therefore its Firestore dedup, its Pub/Sub queue and its logs. That function recorded 6 messages across three days while this path had actually sent about 100
  • That gap is why the volume behind the 2026-09-01 WhatsApp suspension was invisible until it was measured from GitHub run counts instead of from our own logs. A sender nothing counts is a sender nothing can rate-limit
  • Now publishes via whatsapp_publisher.send_group_message with source='release-notes'
  • correlation_id is deterministic -- release-<sha> -- so re-running the workflow for a commit is deduped by the sender rather than sending twice. Same shape as venmo-dm-<txn_id>, which exists because Pub/Sub duplicated ~36% of Gmail notifications
  • GREEN-API credentials are gone from this runner entirely. The sender holds them; one fewer place the tokens exist, and the guard no longer requires what it no longer uses
  • GOOGLE_APPLICATION_CREDENTIALS added, and it was NOT set before. The workflow wrote smad-credentials.json and nothing pointed at it -- harmless while the send was a plain HTTPS call, and not once it publishes to Pub/Sub
  • google-cloud-pubsub added to the install. A missing package there fails at import, which is the pytz shape
  • Exercised the real code path with the publisher stubbed: it produces source='release-notes', correlation_id='release-<sha>', and the built message. NOT exercised end to end -- release notes are paused by RELEASE_NOTES_WHATSAPP=false and WhatsApp is suspended until 00:01 PT, so the first real proof is the first run after both are cleared
infra/CI3fd9bcdClaude Opus 5
  • Gene's call after WhatsApp suspended the account for six hours on 2026-09-01. His diagnosis, confirmed end to end before acting on it: release-notes.yml had no paths filter, so every push to main sent one WhatsApp message, and a commit to ops/session-notes.md -- the file two sessions use to talk to each other -- is a push like any other
  • Verified rather than reasoned: five commits touching nothing but that file each produced a successful run, and the run log for 9ce7288 says "Release notes sent for 9ce7288"
  • Measured: 34 of the 57 messages on 09-01 were session-notes-only commits. 47 of 101 commits since 08-31, 47%. Half the traffic was agents messaging each other, announced to Admin Dinkers as if each were a release, in bursts
  • The send is now gated on the RELEASE_NOTES_WHATSAPP GitHub Variable, set explicitly to 'false' rather than left unset, so the paused state is visible in `gh variable list` instead of working by absence
  • paths-ignore on ops/session-notes.md stays regardless of the variable, so resuming the send does not resume it for inter-session mail. GitHub skips only when EVERY changed path matches, so a push carrying real changes still announces, including one that also touches the notes
  • THE DASHBOARD IS UNAFFECTED, which is why the pause belongs here and nowhere else: the Shipping Log artifact is republished by a separate claude.ai routine on a push webhook and a daily cron. This workflow only ever sent the WhatsApp copy. Release notes keep being written; they stop being pushed at people
  • README's pinned TODO rewritten, including the part where it was wrong. It said the volume was "probably a one-off" and that throttling would trade away a signal for a spike that would not recur. It recurred the next day, nearly twice as large, and took production down. The reasoning was sound and the input was not: a two-day-old behaviour was called a one-off while it was still running. A cause that is still active is not a spike
  • The original idea -- a separate channel, splitting by audience -- is untouched and still open. The pause stops the harm; it does not give release notes anywhere to go
featf94ba13Claude Opus 5
  • WhatsApp did it, not GREEN-API, and not a device problem. Confirmed from the vendor's own documentation rather than inferred from the word: the suspended state "indicates that WhatsApp has applied temporary restrictions to your account", is "the initial stage of a WhatsApp account suspension", and replaced the deprecated yellowCard status
  • getWaSettings carries suspendedUntil = 2026-09-02 00:01 PDT, exactly six hours after the 18:01:10 start. The incident's restored is that expiry, and the notes say it is an expiry rather than an observed authorize event
  • That field is the whole difference between waiting and acting. Gene re-linked at 23:18:13, it authorized, and it suspended again 47 seconds later. Without suspendedUntil the obvious response is to keep rescanning a QR that cannot hold
  • The watcher now calls getWaSettings instead of getStateInstance and prints stateInstance and suspendedUntil, so the alert says whether to wait or to act
  • It never echoes the response body. getWaSettings returns the phone number, deviceId and an avatar URL, and this text goes into a GitHub issue. Only the two fields are extracted, with grep and cut rather than sed backreferences
  • Verified against the live outage: STATE=suspended, UNTIL=1788332470, and nothing resembling the phone number in what it prints
  • cause changed to external: a vendor restriction is what broke it. self_inflicted stays false because change failure rate is measured against deploys and no deploy caused this -- but the sending rate was ours. 57 release-note messages on 09-01, 10 in the two hours before, one per push, overwhelmingly from one session. WhatsApp states no reason, so that is the leading candidate and the notes say so rather than asserting it
infra/CI50cd103Claude Opus 5
  • The bot was down 101 minutes and nothing said so. Gene found it by typing a command at a game
  • The signal existed. smad-whatsapp-webhook logs [INSTANCE STATE] notAuthorized as an ERROR the moment GREEN-API reports it, and error-reporting.yml swept two minutes later and said nothing -- CORRECTLY. That sweep reads GCP Error Reporting GROUPS, built from exceptions and stack traces, and a plain logger.error() line never becomes one. The most consequential failure this system has was structurally invisible to the only always-on alerting it runs
  • New workflow asks getStateInstance every 15 minutes and opens an issue when the answer is not "authorized". Worst case is a quarter hour against the 101 minutes this ran unseen
  • POLLS rather than consuming the webhook's log line, deliberately: the webhook only hears about a state change if GREEN-API delivers the callback, and a suspended instance is exactly when callbacks stop being trustworthy. Asking the API does not depend on the broken thing to report itself
  • Anything other than the exact authorized string alerts, so a state nobody has seen before is loud rather than quiet
  • Credentials missing, or the API unreachable, exits non-zero and says so. That is "I do not know", not "authorized"
  • Does NOT notify over WhatsApp. The one channel guaranteed down when this fires is WhatsApp; a GitHub issue reaches both sessions and the phone
  • Tested against the live outage rather than a mock: the current getStateInstance body is suspended, and the workflow's own case statement returns ALERT on it. A positive case, not another silence test
tooling71be92fClaude Opus 5
  • Every /pb command and every outgoing WhatsApp message stopped at 18:01:10 PT. Gene found it 1h41m later when a command he typed at a game did not answer
  • Recorded with restored: null because it is NOT restored. He re-linked at 23:18:13 and it authorized -- then suspended again 47 seconds later. Live getStateInstance still reads suspended, so a QR rescan does not hold and the block is account-level rather than a device problem
  • Leading candidate is ours: release-notes.yml sends one WhatsApp message per push to main, calling GREEN-API directly from the runner and bypassing whatsapp-message-sender, its dedup, its queue and its logs. That is why the sender showed 6 messages in three days while the real figure was far higher
  • 30 successful release-note runs on 08-31, 57 on 09-01, 13 on 09-02, and 10 in the two hours before the suspension. Those pushes were overwhelmingly this session's own, one per session-note reply
  • Commit rule 7 already says to batch pushes because each republishes the dashboard. The WhatsApp cost per push was not in that reasoning and is larger
  • cause: operational, self_inflicted: false, per this file's own rule -- change failure rate is measured against deploys and no deploy caused this. The notes carry what actually happened rather than hiding behind the label
  • detected_by is the honest one: a human noticed
infra/CI7ba59adClaude Opus 5
  • The issue that started this carried the failure all along. Every comment on #29 has the FATAL line in a code block with a run link. report-failure has read $RUNNER_TEMP/step.log itself since 2026-08-30, before the failing run
  • What I actually saw was DETAILS: empty in the run's env dump -- the INPUT, which ci-metrics rightly does not pass because the action reads the log. I concluded "empty payload" from that and never opened the issue
  • Removes the third Liveness or Die category, "a true positive with an empty payload". The shape may be real; the case for it is not, and a rule carrying a fabricated example is what Never Write a Static Stat forbids one level up. It can come back if something in this repo ever actually does it
  • Corrects the claim that six workflows discard their logs. All eight that tee follow the convention; the honest number is 0 of 8, not 7 of 8. Mr Sandman retracted his half first and diagnosed it better than I could: he grepped call sites for an implementation that deliberately lives in the shared action, and the better the abstraction, the blinder that made him. I inherited his conclusion and added a worse error on top
  • Kept the live question his retraction found, which nobody has answered: each workflow tees ONE step, so a failure in checkout, auth or a dependency gate still reports "No output captured"
  • Reverts the capture step I added to ci-metrics.yml. It reimplemented the action's own fallback, against an action whose stated design is that a workflow needs no step id, no outputs and no interpolation to get wrong
  • court-booking.yml keeps a reporter, because ITS defect was real -- the whole failure handling was an echo into the log it tells you to read, and book-court-south had no reporter at all. But it now copies court.log to step.log and lets the convention work, rather than interpolating details
  • actions: read stays on ci-metrics.yml. That fix was real: three failed scheduled runs, and the workflow succeeded the moment it landed
docsed9a7cdClaude Opus 5
  • Gene authorised this. Mr Sandman's finding and his argument; neither session edits this file on the other's say-so, so it came through Gene both ways
  • Completes the taxonomy in Liveness or Die. The table rows are mechanisms reporting a WIN THAT NEVER HAPPENED. The silence rule is a mechanism reporting NOTHING while doing nothing. This is neither: a true positive with an empty payload
  • ci-metrics.yml is the case. It failed every scheduled run from 2026-08-29 and its reporter WORKED -- correct signal, correct recipient, on time, three mornings running -- opening an issue with an empty details field while the one line that explained it sat in a run log nothing read
  • Why it is the worst of the three, which is his argument and I have not improved on it: the other two are wrong in a way that can eventually be noticed. A claimed win contradicts reality; a silence outlasts its plausibility. This one is RIGHT every time, so it never accumulates the evidence that would make anyone check it
  • And "check the logs" is not a fallback here, it is the end of the road: the alert lands on whichever session is awake at 06:37 PT, and half of them cannot open a run log at all
  • court-booking.yml recorded as the same failure without even the notice: its entire handling was an echo into the log it tells you to check
infra/CI9f358bbClaude Opus 5
  • Both jobs' entire failure handling was `echo "Booking failed! Check the logs"` -- a message written INTO the log it tells you to check. An echo reaches nobody: it lands in run output no one reads on a green-looking morning, and a cloud session cannot open a run log at all
  • This is the script whose output never reaches Cloud Logging, because it runs on a runner rather than in a Cloud Function, so error-reporting.yml's hourly sweep cannot see it either. For a booking failure the run log is the only record that exists, and it was being discarded
  • Both jobs now capture the tail of court.log and open an issue through the same reporter the other workflows use, with the failure in the details field
  • Separate titles per job, so a north failure and a south failure dedupe independently rather than one masking the other
  • Permissions on both jobs, which is the same trap that broke ci-metrics: book-court-north declared contents:write, and a permissions block sets every scope you do not list to NONE, so the reporter would have 403'd on issues. book-court-south declared nothing at all and now declares contents:read plus issues:write -- it commits nothing, so read is correct
  • Correcting the survey this came from, which said court-booking.yml tees 6 and reads 0. It does read: the north job's record step tails court.log into ops/last-court-action.json, which is a BETTER channel than an issue for a cloud session, since it lands in the repo
  • The real gap is coverage, not absence. That record step exists only in the NORTH job. book-court-south has no record step, so a south-job failure left no trace anywhere -- and south is the job that books South first, which is half of every scheduled night
docs5bdc27dClaude Opus 5
  • Gene's designation, 2026-09-01 PT: the desktop session writes the workflows. A cloud session may still fix a typo it can reason about; authoring or restructuring one belongs on the box that can see the result
  • Filed under "Reading Production From a Session", because it is the capability split already documented there applied to a feedback loop rather than to an investigation
  • The reason is not skill, it is sight. A cloud session can push a workflow, watch it fail, and never learn why: it cannot workflow_dispatch (the App token carries Actions read, so a dispatch is 403) and it cannot read the run LOG (served from a blob host the sandbox is refused). It can see status and per-step conclusions, which say THAT something failed and never what
  • ci-metrics.yml is the worked example and it is in the entry. Written from the sandbox on 2026-08-29, all four commits stamped +0000. It declared a permissions block without actions:read, so the token could not read /actions/runs -- the workflow's entire purpose -- and it failed every scheduled run with an HTTP 403 the author could not open
  • Its failure reporter worked and opened an issue each morning, with an empty details field, because nothing read the step log the step was already teeing to. The alert reached the one session most likely to be awake at 06:37 PT and carried none of what it needed
  • The shape to avoid, stated as such: an author whose only feedback channel is closed to them. Same failure as every row in Liveness or Die, one level up -- not a mechanism that cannot see its target, but an author who cannot see their mechanism
  • Two rules fall out and apply to any workflow regardless of author: a permissions block is a denylist by omission, so list the read scopes too; and a failure notice must carry the failure, since half the sessions here cannot open a run log
docs978d7cdClaude Opus 5
  • Gene lost a morning to "Can't reach your computer" shown inside the Claude desktop app that was visibly open on that very computer. Turning the machine on did not help either
  • The desktop session is a bridge session with origin claude_code_vscode: the Claude Code extension inside VS Code executes it, and the desktop app only renders a transcript fetched from the service. Opening VS Code is what reattaches the bridge
  • Gave the one-call check rather than the inference: list_sessions, comparing connection_status and environment_kind. anthropic_cloud/connected keeps working while the desktop is dark; bridge/disconnected does not
  • Filed in "Reading Production From a Session" because that table already sorts sessions by reach but never said what STARTS the desktop one
infra/CI7f1ca4aClaude Opus 5
  • Every scheduled run has failed with HTTP 403 on the Actions runs API. The job declares a permissions block of contents:write and issues:write, and declaring one sets every scope you do not list to NONE -- so the token could not read /actions/runs, which is the workflow's entire job
  • actions: read was never present. This workflow has not once succeeded since it was scheduled on 2026-08-29: three runs, three failures
  • Verified rather than assumed: running the collector locally with a token that HAS actions:read returns 969 runs across 24 workflows. The script was never the problem
  • The failure issue said "CI Metrics collection failed" with an EMPTY details field, three mornings running, while the one line explaining it sat in a run log. The step already tees to $RUNNER_TEMP/step.log and nothing read it
  • A new step captures the tail into the issue on failure. Run logs are exactly what a cloud session cannot reach -- 403 on CONNECT to the blob host -- so an empty details field means the agent most likely to be awake at 06:37 PT cannot diagnose the alert it just received
  • Same gap exists in the other workflows that tee: only pull-logs.yml reads its log. Not touched here
  • ops/ci-metrics.json is NOT empty, contrary to what I first reported: it holds 21 weeks and an all-time deploy p90 of 114s over 863 runs. I probed it for keys named `weeks` and `generated_at`, which are actually `deploy_weekly` and `generated`, and read the absence of my guessed keys as absence of data. The real symptom is smaller and duller: the file has been frozen at 2026-08-29 since the day it was written by hand
docs4f25a53
  • Gene's call after I checked the figure rather than accepting it. The line said "nine of the twelve cases", and the answer is eight: an assertion that a string is ABSENT from output the hook only produces when it FIRES had been counted as a silence case, which is the opposite column
  • A fraction cannot rot as the suite grows; a count silently can. That is what "Never Write a Static Stat" prescribes, and this bullet sits in a section about mechanisms that report confidently on something they did not measure
  • Kept the old count in one sentence deliberately -- as history explaining why the form changed, not as a live figure
  • The argument is unchanged: two thirds either way
docsc35f9c2Claude Opus 5
  • Gene authorised this line. Neither session would write it on the other's say-so: a durable rule two agents can talk each other into is not durable, and both of us declined the other's request for exactly that reason tonight
  • The rule: the absence of an effect is indistinguishable from correct suppression unless something also makes the mechanism fire. A hook that does nothing passes every silence test you can write, and so does a monitor, a guard, or a dedup check
  • Measured rather than asserted. Nine of the twelve cases covering the session-note ack hook assert it stays QUIET. When the hook gained a PreToolUse pass and the suite still fired only PostToolUse, the hook went permanently silent and all nine still passed. Only the cases asserting it FIRES, and names the right session, could see it
  • Filed in Liveness or Die rather than as its own section, because it is the missing half of that table. Every row there is a mechanism reporting a win that never happened -- [OK] SUCCESS for a booking that did not exist, removeParticipant: true for a number never in the group. This is the inverse and it is quieter: a mechanism reporting nothing, plausibly, while doing nothing
  • Placed next to "assert the harness is exercising the artifact you changed", which is the same failure one step earlier: that one tests the wrong artifact, this one tests only the half that cannot fail
feat61b50a0Claude Opus 5
  • The guard asked how OLD HEAD was. Mr Sandman broke it the same hour: his workflow commits on a scratch branch, publishes, then checks out back, so HEAD was not his commit -- it was MINE from two minutes earlier, which passed the 120s age check and got named. His landed at exactly 120s
  • With two sessions committing minutes apart, "a different recent commit" is the normal case, not a corner
  • CLAUDE.md already carries the rule, written for the booking watcher that matched runs by `hour == 23 and minute >= 50` and reported confidently on the previous night's run: identity must come from the thing itself, never from a shape that recurs. Recency is a shape that recurs, and I reached for it anyway while the line was in the file
  • The hook is now registered for BOTH PreToolUse and PostToolUse. Pre records HEAD; Post fires only when HEAD is a DIRECT CHILD of that commit -- proof this command created it, rather than evidence it might have
  • No recorded HEAD means the hook cannot establish what it is looking at, so it says nothing. Same reasoning as everywhere else here: naming the wrong session is worse than naming none
  • The parent check also covers what age never could. A trailing checkout moves HEAD to a commit that is not a child of the recorded one, which was the actual mechanism of his report
  • Suite rewritten to run Pre then Post for every case, because a suite firing only Post would find the hook permanently silent and go green on a hook that does nothing. Two new cases: HEAD moved elsewhere after the commit, and no Pre pass at all
  • 72/72
  • Two fixture bugs found on the way, both the same shape as the hook bug and both caught only by running it:
  • two commits wrote identical content, so nothing staged, HEAD never moved, and the case reported the hook broken while reading a commit it had not made
  • two entries shared a heading, so the commit changed only the body, `git show` produced no `+## ` line, and the addressee case failed against a fixture that never set up an added heading The helper now asserts HEAD actually moved, and the entries differ where the case depends on them differing
feata4eaec1Claude Opus 5
  • Mr Sandman's command ended with `git checkout` back to his branch, so by the time PostToolUse fired, the working tree the hook greps no longer held the commit it was reacting to. It read HIS newest entry and told him to message himself -- the entry was From: Mr Sandman, To: Snow White
  • Exact mirror of the reply-gate bypass he had just fixed. That one is PreToolUse and read the index BEFORE the command filled it; this is PostToolUse and read the tree AFTER the command changed it. One root: the check is not synchronised with the action it is about, and in both cases the wrong answer is indistinguishable from a right one -- an empty diff looks like nothing to gate, a real session name looks like the right addressee
  • Reads the ADDED heading out of the commit now (git show HEAD -U0), not the file. An entry the commit merely CLEARED is someone else's and is not who you owe a message
  • Refuses to fire at all when HEAD is more than 120s old. A commit this command made is seconds old; anything older means HEAD moved for another reason, and silence beats naming the wrong session, because the reader acts on it
  • Two cases added. The first is his exact failure: commit an entry addressed to one session, then change the working tree underneath it, and assert the hook still names the COMMIT's addressee and not the tree's
  • The second case FAILED first for a reason worth keeping. Both my fixture commits wrote identical content, so `git add` staged nothing, the commit failed as "nothing to commit", HEAD never moved, and the case read a fresh HEAD and reported the hook broken. A harness no-op producing a false FAIL -- the same family as the 68/69 phantom, one layer down
  • _commit_entry() now puts the message in the body so two calls differ, and asserts HEAD actually moved. A fixture that silently sets up nothing is worse than a missing case: it reports on something, just not what it claims
  • 71/71
toolingaab4209Claude Opus 5
  • The suite CLONES REPO, so the fixture inherits whatever is checked out there. A detached HEAD produces a clone with no local branch and therefore no upstream -- `git branch --set-upstream-to` at line 99 fails silently -- and the freshness cases then measure a world no fixture built
  • Now refuses before building anything, exit 2, naming the state and the fix
  • The danger was never a failing case. It is that a partial score is INDISTINGUISHABLE from a real regression. I hit 68/69 verifying two hook fixes on Windows and the honest reading of that output was "Windows difference in the hook" -- one keystroke from an entry in ops/session-notes.md asserting exactly that, in the environment the other session cannot inspect to contradict me
  • Refuses rather than warns, for the reason CLAUDE.md gives for never chaining a check ahead of the action it gates: a warning printed above 68 lines of output is read after the conclusion has formed, if at all
  • Worth recording precisely, because I got the diagnosis wrong first and only measurement corrected it. Detachment does NOT cause a deterministic failure: detached at main's tip, with the fixed hook 69/69 detached at a feature commit, fixed hook 68/69 detached 3 commits back, pre-fix hook 4 failures, different set same feature commit but on a BRANCH 69/69 So the score depends on the hook version and where HEAD sits -- properties no fixture controls. Nondeterminism that yields plausible numbers is worse than a clean failure, and it is the actual argument for refusing
  • I had told the other session detachment was the cause. It is the mechanism, not a deterministic one, and the correction is going back to them
  • This is the inverse of the trap at line 99, where every fixture is pinned to origin/main and so cannot express a branch bug: there the suite FORCES a world too uniform to hold the failure, here it INHERITS one from whatever REPO happens to be. Same root -- the suite not controlling a property every case depends on
  • repo_shape_problem() takes a path rather than reading the global REPO, so it is testable. Verified against the real broken state: detached worktree exits 2 and runs nothing, normal checkout exits 0 and runs 69/69
feat014cb2d
  • `behind` was counted against `@{u}` alone, which is right only for a session sitting on the default branch. `git push -u origin <branch>` -- which CLAUDE.md mandates -- re-points @{u} at the session's own feature branch, a ref only that session pushes to, so the count sits near 0 for the life of the branch
  • Measured on the cloud sandbox: `0` reported while 37 commits behind main, across a note left for that session and a CLAUDE.md rewrite. Worse than the missing-cache bug this file already fixed -- that one did not know the answer, this one computed a confident 0 against the wrong reference
  • Now measures both and reports whichever is further behind, keeping @{u} because a session working with a shared feature branch wants to know when it moves. A branch with no upstream at all warns instead of exiting silently
  • origin/main is preferred over refs/remotes/origin/HEAD deliberately: origin/HEAD is written once at clone time, is unset entirely in the sandbox, and in the fixture -- which clones this repo with a FEATURE branch checked out -- it names that feature branch, handing back the very ref this fix looks past. The fixture caught that, not review
  • 2 cases, both silent before the fix. The suite could not previously express either: reset() pinned every fixture to --set-upstream-to=origin/main, the one configuration in which the hook worked
feat78e3ecb
  • `git add <notes>` and `git commit` in ONE invocation slipped past it. PreToolUse fires before any of a compound command, so `git diff --cached` was read while the index was still empty and the clearing was allowed. Measured: exit 0, where the identical content staged by a separate call gives exit 2
  • That is CLAUDE.md's "never chain a check ahead of the action it gates", inside the gate written to enforce the reply protocol. Not exotic either: it is how a cloud session writes every note, so the gate had never fired on one
  • The hook now asks whether the command will stage the notes file itself -- an add naming it, a blanket add, or commit -a -- and diffs against HEAD when it will, --cached otherwise, so an unrelated commit still ignores an edit in progress
  • commit -a is handled here rather than left to the sibling git guard, which refuses it today: depending on that would reopen the hole silently if it relaxes
  • 5 cases, 2 of which failed first at exit 0. One covers the `git add .gitignore` collision CLAUDE.md documents -- anchored, so a filename that merely CONTAINS `git add .` cannot satisfy it
featcde553cClaude Opus 5
  • It matched the whole JSON payload, and a PostToolUse payload includes tool_response -- the command OUTPUT. So reading any file whose prose mentions a commit fired the reminder
  • Not theoretical: it went off three times in a row while reading a session note that happened to discuss one, each time telling me to go message someone about a note I had not written
  • A PreToolUse guard can get away with matching the raw payload because it only ever sees the input. A PostToolUse hook cannot, and I reused that pattern without noticing the difference
  • Now extracts the command field first, with the same escaped-quote handling as block-git-commits.sh -- the version that exists because a naive cut at the first quote read `git commit -m "fix" && git push -f` as `git commit -m ` and let a force push through
  • Taking the FIRST "command" key is correct: tool_input carries it, and one in the response text cannot precede it
  • Regression case added, 62/62. The suite could not have caught this before: all nine ack cases passed a payload with no tool_response at all, so the field that caused the bug was never present in a fixture
  • The remaining false positive is the documented one: a command whose own TEXT contains "git commit" as data still fires. Unavoidable for a matcher, same tradeoff block-git-commits.sh accepts, and it costs a spurious reminder rather than a wrong action
featf3b35c2
  • fetch-origin.sh's header claimed UserPromptSubmit was "deliberately NOT registered in settings.json" and pointed at a README TODO for switching it on. ad9beeb registered it hours earlier and never touched the comment
  • README's Future Work carried "Per-prompt freshness check: built, measured, shelved (TODO)", describing a decision reversed the same night. It also proposed the CLAUDE.md / session-notes drift alert the hook already implements -- an entry inviting someone to build a shipped feature twice
  • Folded the still-valid measurements (68-77ms cached, ~830ms on a real fetch, the 120s gate, ~815-838ms regardless of commit count) into "Keeping Sessions Fresh Across Machines", the section that describes what actually runs, and deleted the obsolete TODO
  • Documentation only, no behaviour change. test-claude-hooks.py 45/45 on Linux
  • Reviewed by the desktop session, which verified each claim against the repo rather than the description, and re-ran the merge check independently to confirm the -74 line diff removes exactly one heading and keeps the "Give release notes their own channel" TODO added on main since the base
feat67a284cClaude Opus 5
  • Mr Sandman's design, and a better gate than the ack hook I shipped next to it. He cannot SendMessage at all -- ListAgents on a cloud sandbox lists nothing -- so a gate on "did you message" is executable by exactly one of the two boxes. A gate on "did you reply in the commit that cleared" fires on both
  • The durable half is the only half both sides can perform, so it is the only half worth gating. The ack hook stays, because on this box the message is what arrives today
  • PreToolUse(Bash): a commit whose staged diff to ops/session-notes.md removes a dated entry heading and adds none is refused. Clearing an entry removes the only evidence the author has, and from their side that is indistinguishable from never picking it up
  • Identity deliberately NOT checked. His sketch was "removes To: me must add From: me", but no session can look up its own name -- that is the first rule in the notes file -- so `me` is unavailable to a hook. Removed-without-added is the checkable shape, and it is the failure that actually happened
  • Matches DATED headings, not bare `## `, because the preamble has section headings too. That is the bug that made the ack hook say "the addressee" for every entry, caught an hour earlier
  • Fails loudly when it cannot read the diff, rather than exiting 0. Allowing on "I do not know" is the freshness hook's cached-branch bug, which stayed quiet through an unread note for exactly that reason
  • The escape hatch is explicit: withdrawing your own entry is legitimate and takes CLAUDE_NOTE_GATE_OFF=1, so it appears in shell history instead of being a silent pass the hook grants itself
  • 8 cases, written BEFORE the hook and confirmed failing first (exit 127), which is the discipline Mr Sandman prescribed in the same note. Suite is 61/61
  • They fire the WORKING-TREE hook against the fixture, not the clone's copy: a suite running the committed version goes green against code the author has not written yet
feat617f231Claude Opus 5
  • Twice tonight I acted on a note from Mr Sandman, wrote the reply into ops/session-notes.md, committed, pushed -- and messaged him neither time. Gene had to ask twice
  • The cause is structural, not carelessness. Committing and pushing FEELS like completion: terminal action, visible result, work ends there. SendMessage is a separate call with nothing chaining it to the commit
  • After the FIRST reminder I "fixed" it by writing a rule into that same file, and broke the rule within the hour. That is exactly what Mr Sandman had diagnosed an hour earlier about the timestamp command -- the prescribed form was correct and the invocation drifted from it. Documentation had been tried and had failed, so writing more would have been the third instance
  • New PostToolUse(Bash) hook fires when a commit touches ops/session-notes.md, names the addressee parsed from the newest entry, and exits 2 so the reminder reaches the model. The commit stands; nothing is undone
  • Cheap string test first, so no subprocess runs unless the command mentions a commit. Same .disabled sentinel as the other two hooks, plus its own env override
  • It does NOT verify a message was sent, and says so at the top. It cannot: the agent would be the one reporting that, and a guard claiming protection it does not provide is the failure Liveness-or-Die exists to stop. Same reasoning block-git-commits.sh uses for declining to enforce commit consent
  • Two bugs found only by running it, both of which printed plausible output:
  • sed with an em-dash inside a POSIX bracket expression. Those match BYTES and an em-dash is three of them, so it never matched
  • '^## ' finds "## What this file is for", a PREAMBLE heading, not an entry. Entry headings are dated Both degraded silently to "Addressed to: the addressee", which reads fine. The regression case asserts on the NAME, not the exit code -- the exit code was right throughout
  • 8 cases added to scripts/test-claude-hooks.py, 53/53 green. They fire the WORKING-TREE hook against the fixture rather than the clone's copy: the clone holds the COMMITTED hook, and a suite running that goes green against code the author has not written yet, which is the trap already recorded in CLAUDE.md
feat686a347Claude Opus 5
  • picklebot has no Playwright and can only READ courts/live. The one scrape it can cause is dispatching court-booking.yml's --get-reservations path, and that path scraped the club, wrote a JSON file, and left the cache untouched. So "picklebot triggers a scrape, then reads Firestore" had no wire between its two halves
  • get_reservations_main() now writes the cache from its own fetch
  • The key format moved to slots_from_reservations() in firestore_utils. It was buried inside refresh_live_courts() in smad-whatsapp.py, which is why the only way to fill the cache was to shell out to that CLI. One definition now, used by both writers, instead of a second copy in court-booking.py
  • The GUARD is the part to read. The write is gated on the DEFAULT fetch only -- never --from-date/--to-date/--all-statuses. Those ask a different question: past dates and cancelled rows. update_live_courts() uses set(), which REPLACES the document, so one historical run would have swapped the forward cache for a month of August cancellations and sent every court render back to poll option text until the next refresh. The August backfill I ran earlier tonight is exactly that command
  • Filtering stays the caller's job rather than being hidden in the helper: a fetch that asked for cancellations should not be silently laundered into "courts we hold"
  • Verified both ways against live Firestore, not reasoned about:
  • historical fetch -> "leaving the court cache alone on purpose", still 2 slots, age climbing 100.7 -> 101.0 min, so nothing was rewritten
  • default fetch -> "Cache updated from this fetch: 3 slot(s)", including 09/08|19 North + South, last night's booking reaching the cache picklebot reads
  • Closes the last open item from Snow White 2, whose session is archived
infra/CI175b1c0Claude Opus 5
  • last-court-action.json is committed only on an anomaly, and last night it was: notification_lines was null while TWO WhatsApp notifications demonstrably went out. The detector could not see its own sends
  • Cause: it greps $RUNNER_TEMP/court.log, and only the get-reservations and cancel-reservation steps ever tee'd into that file. The BOOKING step never did, so on a book-only run the log is empty, `notified` is empty, and a perfectly healthy night is filed as an anomaly. Last night's record has tail: [] for exactly that reason
  • Not every night, which is why it went unnoticed: 4 records in 14 days, and the ones with content came from runs where a teeing step happened to run too. A detector that is right only when a different step ran is not a detector
  • Both booking steps, north and south, now tee like the others
  • set -o pipefail on all four, which is the part worth reading carefully. GitHub Actions runs `bash -e`, NOT `bash -eo pipefail`, so `cmd | tee` hands the step TEE's exit status. Adding the tee without this would have made a booking script that DIED report success, and "Notify on failure" would never fire -- turning a logging improvement into a silent-failure bug of exactly the kind the file exists to catch
  • The two pre-existing teed steps had that hole already and are fixed with it
  • Not fixed here, and worth stating: the record still only reflects the NORTH job, which is the only one with a record step. Both jobs notify, so at least one send is now visible, but the file is one job's view of the night
feat4583c7fClaude Opus 5
  • Last night booked BOTH courts for Tue 09/08 7pm, confirmed at the club, and told Admin Dinkers twice that it had failed: "Partial success: 1/2 bookings completed" "Failed North - Reason: Slot already taken" and the mirror image from the other job
  • book-court-north and book-court-south run in PARALLEL, each attempting both courts in opposite order. That is the redundancy: if one runner dies the other still books. So on a healthy night each job wins one court and finds the other already taken -- by its own sibling
  • The cost is not the noise. A genuine 1/2 loss, to an actual member, printed a BYTE-IDENTICAL message. The one alarm worth waking up for was indistinguishable from the normal case, which is the whole Liveness-or-Die pattern: a check whose success and failure outputs are the same
  • verify_bookings() reconciled only entries CLAIMING success and never looked at the failures, though it had already read what the club holds. It now checks them too: a failure whose slot is in `held` is flagged held_by_us
  • Left as status 'failed' on purpose, so nothing downstream counts it as a booking THIS job made -- record_booking() still writes only real successes, and the sibling job writes that row
  • Only verify_bookings() can set the flag, and only from the club's own reservation list, so a job cannot talk itself into it
  • The notification renders those as "Held - booked by the parallel job" and derives its headline from the details it is rendering rather than from booking_summary, so the summary line and the lines beneath it cannot disagree
  • Verified through the real builder for all three cases: last night's exact input now says "Success!", a genuine 1/2 loss still says "Partial success" with the red X, and a total loss still says "All bookings failed"
  • Unrelated to the code but worth recording: ops/last-court-action.json was committed as an anomaly with notification_lines: null while two notifications demonstrably went out, so the anomaly detector cannot see its own sends. Not fixed here

August 2026 316 commits

feat137tooling54docs46infra/CI37fix27refactor13perf2
feat27a94a2Claude Opus 5
  • The court suffix in a poll option is INTENT. It is written at poll creation from that morning's scrape, and a WhatsApp poll is immutable, so a court cancelled at any point afterwards is still named there forever
  • That is not a label problem. count_courts() feeds capacity = num_courts * 4 and every "we need N more players to fill two courts or the game will be cancelled" line, so a stale suffix is a wrong PLAYER count sent to the group
  • Measured against the club's own record for August, before and after: Mon 8/17/26 7pm Both North + South cap 8 -> (none) cap 0 Thurs 8/20/26 7pm Both North + South cap 8 -> South cap 4 8/17 had both courts cancelled and 8/20 held only South. The other three August games were already right and are unchanged
  • get_courts_for_game() now consults the Court Log between the live cache and the option text. Behind the cache because a scrape is fresher for club-side changes; ahead of the option text because the ledger is fact and the text is intent
  • It is the ONLY source that can answer for a past date. courts/live is written with set() from the club's forward window, so it holds no past slots at all -- render_last_week_games() took the intent path for every game it printed
  • Two distinctions the lookup has to make, both load-bearing:
  • "the ledger knows this slot and we held nothing" (renders no courts) versus "the ledger cannot say" (falls through to the option text). Collapsing them would make a fully cancelled game indistinguishable from a date the ledger has never seen -- and every FUTURE game whose courts are not booked yet is the second kind. Verified: 9/12 and 9/18 still render from the suffix
  • the HOUR, not just the date. 08/28 6pm was cancelled and 08/28 7pm South was held; they must not lend each other a court
  • Returns SMAD rows only. Handovers are added by _apply_handover_override() alone, which is the single place that knows about them -- returning them here too would count Shyam's court twice. 9/1 still renders "North (Shyam) + South"
  • Best-effort like the handover lookup: a read failure falls through to intent rather than costing the group its report, and it can only ever report FEWER courts than intent claimed, never more
  • get_court_rows() gets a 60s cache on the READ-ONLY path, since the ledger is now read twice per game in a loop. Every write path passes its own sheets service and always reads through: a write reads to find ROW NUMBERS, and a cached row number can address a row that has since moved. _apply_writes() busts it, so the confirmation for the command that just wrote does not show the pre-write ledger
feata0eaf4fClaude Opus 5
  • Two rules the club enforces, neither of which any code knew existed: courts are released at 12:01 AM for 7 days out, and the website only cancels a reservation more than 12 hours ahead. Inside that window the club has to be phoned and only their staff can cancel -- which is what an Admin Cancelled row in their history actually is. Not the club cancelling on us; us cancelling through them. I had it backwards in the previous commit message and in the Court Log docstring, both corrected here
  • New webhook/shared/court_rules.py holds both, with booking_horizon_error() and cancel_cutoff_error() returning plain text or None -- same contract as validate_dupr(), so each caller does its own presentation. Plain constants like SESSION_HOURS: the club tells us these, we do not choose them
  • The expensive one was /pb shyam. It fires automatically on Shyam's vote, and its only time gate was "has the game started". A vote at 4pm for a 7pm game passed it, and the order of operations is: write the handover row, CREDIT his account, then dispatch a cancel the site was always going to refuse. Money moved for a court we still held and were still billed for, unattended
  • _maybe_offer_shyam_court() now applies the cutoff. The command itself does NOT, so a human can still run `/pb shyam 08/25` retroactively after phoning the club -- behaviour handle_shyam_court() deliberately allows
  • /pb court cancel refuses inside the window instead of dispatching, and says to call the Athenaeum. /pb book court refuses a date past the 7-day horizon and names the furthest bookable date -- it previously checked only that the date PARSED, so "/pb book court 9/20 7pm" was accepted 19 days early, replied "booking triggered", started a runner and a browser, and failed minutes later
  • /pb cancel game deliberately does NOT refuse: it also DMs the players, deletes the sheet column and cancels the last-call job, and all of that still has to happen. It proceeds and says the courts will not be released. This is the COMMON case -- a game is called off the day of, so for a 7pm game any decision after 7am is inside the window
  • cancel_reservation() returned True unconditionally after firing the postback. Same shape as the booking that logged [OK] SUCCESS for a reservation that was never made, which is why verify_bookings() exists; the cancel path never got the equivalent. It now re-reads the club and confirms the slot is gone
  • Three outcomes, not two: could-not-verify returns False and says so rather than claiming either result, and an empty reservation list counts as unreadable, since a failed scrape and an empty calendar look identical
  • This is what would have caught the failure silently: the callers act on that boolean to mark the Court Log cancelled, tell the group the court came back, and credit an account
  • Verified through picklebot's own entry points, not by reading placement: 20 days out and inside-12h both refused before any dispatch, and a cancel 3 days out passed the guard and reached the GitHub API (401 on a local token) -- the negative control that proves the guard is not just refusing everything
fix2f8c5b9Claude Opus 5
  • Eleven references said the script waits until 12:00:15 AM. The Athenaeum releases courts at 12:01 AM for 7 days out, and the deployed BOOKING_TARGET_TIME GitHub Variable has been 00:01:00 since 2026-02-01
  • Not a cosmetic 45 seconds: anyone following these docs would set the target BEFORE the release and every booking would fail. The docs described a window that does not exist
  • Fixed in COURT_BOOKING.md, GITHUB_ACTION_SETUP.md, copilot-instructions.md and two comments in court-booking.py, each now naming BOOKING_TARGET_TIME as the source rather than restating a literal, so they cannot drift from it independently again
  • Checked the deployed variable before believing the code over the docs -- the code's default is only a default, and vars.BOOKING_TARGET_TIME overrides it
  • Also recorded in copilot-instructions.md, because nothing stated it anywhere: a booking for a date more than 7 days out FAILS at the club. Nothing enforces that horizon yet -- execute_book_court() checks only that the date parses, so "/pb book court 9/20 7pm" replies "booking triggered", starts a runner and a browser, and fails minutes later
feat24166c7Claude Opus 5
  • Picklebot has no Playwright and cannot scrape, so every court figure it renders comes from the Firestore courts/live cache. Four ways that cache could be wrong without anyone being able to tell. Found by asking who reads it, then vetting the answer against the club's real August record
  • updated_at was WRITTEN on every refresh and never READ. A three-day-old snapshot was indistinguishable from one written thirty seconds ago
  • get_live_courts_meta() returns slots plus source, updated_at and age_minutes. get_live_courts() keeps its exact signature and return, so no caller changes, and now logs when the cache is past a day old
  • age_minutes=None means "not measured", never "fresh"
  • The threshold is a day because the Athenaeum releases courts at 12:01 AM for 7 days out, so the cache can never hold more than a week and turns over on every nightly run. A snapshot older than one cycle has missed a booking round
  • The refresh was a SIDE EFFECT of send_vote_reminders(), sitting below three early returns: the dead man switch, a missing poll date, and `if not not_voted: return 0`. The day everyone voted was a day the court cache did not refresh, and picklebot served that snapshot all week
  • New `refresh-courts` CLI command, and its own workflow step in daily-reminder-runner.yml. It exits non-zero when the fetch fails, so a green step cannot mean a refresh that never happened
  • Gated identically to the chromium install above it rather than unconditionally, so the install's "union of the steps that need it" rule stays true by construction instead of by hand
  • TWO intent fallbacks existed and I fixed the wrong one first. get_live_courts() falls back to the latest poll's courts map, which is intent at poll creation -- that one is cold-start only and now warns. The one that actually fires is parse_court_from_option() in get_courts_for_game(), reached whenever the cache has nothing for a slot, which is EVERY past date
  • Verified against the club: "Mon 8/17/26 7pm Both" renders two courts and both were cancelled; "Thurs 8/20/26 7pm Both" renders two and only South was held. num_courts drives "we need N more players", not just the label, so that is a capacity number computed from a stale string
  • render_last_week_games() takes this path for every game it prints
  • Warned, not suppressed: for a future game with no cache entry it is still the best answer available. The real fix is reading the Court Log, which holds what was actually held and which picklebot CAN read. Not done here
  • An empty write REPLACES the whole cache -- set(), not merge. Now says so out loud, naming how many slots it is dropping. apply_club_fetch() refuses this same input because it cannot tell a failed scrape from an empty calendar; this caller can, so it writes, but never silently
  • Vetted against live data rather than reasoned about: cache holds 2 slots, age 0.0 min after a refresh, 09/01 renders "North (Shyam) + South" through the handover merge, and the two August games warn and still render intent
feat298ddcdClaude Opus 5
  • I told Gene the club site showed "upcoming reservations only, can't see the past" and proposed backfilling August from Firestore poll data instead. He sent a screenshot of 21 August reservations with statuses. The limit was never real: _filter_booked_reservations() set the Status dropdown and clicked Search without ever touching the From/To pickers, so every fetch ran on the page default of today..+2mo. The controls floor at 1980
  • Same failure as claiming gh was absent from a sandbox where it is installed: a capability claim is testable, and this one took one screenshot to disprove
  • get_reservations() now takes status and a date range. --from-date, --to-date and --all-statuses on the CLI. Defaults are byte-identical, so the nightly refresh, the court probe and the cancel path are untouched -- verified: the default fetch still returns 2 rows and never sets a date
  • The cancel path keeps Booked-only deliberately. Matching an already-cancelled row there would be a false hit on a destructive action
  • Two guards, because this feeds an invoice baseline:
  • a date range that FAILS to apply raises. Every returned row is checked to fall inside the window. A Telerik picker that does not take leaves the default range in the grid and returns perfectly plausible rows for the wrong months -- silent wrong data, written into a ledger used to check a bill
  • a filter that was requested but never submitted raises rather than returning the default window as if it were the answer
  • apply_club_fetch() is status-aware. A fetch carrying User/Admin Cancelled rows records them as cancelled instead of as courts we held. An ABSENT status still means booked, which is what every caller before --all-statuses passed
  • Its summary now reports new-cancelled. It said "6 new" for a run that created 21 rows -- the 15 cancelled ones went unmentioned
  • Backfilled August: 21 rows, 6 held and 15 cancelled, 1:1 with the club's own record. Idempotent -- re-running creates nothing and confirms 21
  • Poll data would have been WRONG, not merely incomplete. The poll said Both for 8/17; the club shows both courts User Cancelled. 8/20 said Both; only South was held. 8/15 was Admin Cancelled by the club, which nothing of ours records. Poll courts are intent at poll-creation, and intent is not a holding
  • Guard 2 is what made it safe: the September rows sat outside the observed window and were untouched. Confirmed a September-only nightly fetch cancels 0 August rows
  • Hours is now SESSION_HOURS on every row, not blank. Gene: every court booked by SMAD or the crawler is 2 hours, and the code agrees -- BOOKING_DURATION defaults to '120' and the only '60' call sites are warm-up probes hard-wired dry_run=True. That is construction, not assumption. The blank was the stricter reading of "never assume a measurement" and it made the column useless for the one job the tab has
  • August therefore reads 12 court-hours held and 30 given back. No dollar figure here: ATHENAEUM_COURT_RATE is a GitHub Variable and resolves to 0 locally, which a consumer must refuse rather than reconcile against $0.00
  • CLAUDE.md said a row was "one row per court-hour" while Hours was blank, which made the two readings look equivalent. They are not: August is 6 held reservations and 12 court-hours
featb2c2250Claude Opus 5
  • refresh_live_courts() hung indefinitely on 2026-08-31 PT: 1 second of CPU across 20 minutes, browser left open, killed by hand. Gene spotted it
  • Cause was not a slow site. court-booking.py ends with, in three places: if not HEADLESS: input("Press Enter to close browser...") Run as a subprocess that blocks on stdin forever. The `except EOFError` guard only fires when stdin is CLOSED, and subprocess.run INHERITS it, so there is no EOF -- just a wait with nobody to answer it
  • Never fires in production, where HEADLESS=true. It only ever cost the local box, which is why it survived: the environment that runs it is not the environment that would have caught it
  • All three sites now also require sys.stdin.isatty(), so the pause still works when a human runs the script in a terminal and never when something calls it
  • Verified with HEADLESS=false, so the headed path was genuinely exercised: the same call that hung for 20 minutes now returns 2 slots in 8 seconds
  • Two independent hardening changes to refresh_live_courts(), both real even though neither was tonight's cause:
  • timeout 120s -> 20s, against a MEASURED 4s for a healthy fetch. A club site that cannot answer in 20s is down
  • output to a FILE, not a pipe. capture_output=True with a timeout looks safe and is not: on timeout subprocess.run kills the child and then drains the pipes UNBOUNDED, so a surviving grandchild -- Playwright runs as headless_shell -- means it never returns. The 120s timeout could not have fired even if the site had been the problem
  • a distinct TimeoutExpired branch that logs "timed out" and returns None, not {}: a fetch that did not answer is not a club with no bookings
  • Two things I got wrong while diagnosing, recorded because the pattern is the point: I said the scrape had no timeout (it had 120s), and my first process sweep missed the browser entirely because I filtered on chrome|chromium|node while Playwright runs as headless_shell
featd82eff4Claude Opus 5
  • The Game Day report showed an extra blank line between the balance table and the non-voter list, and again between that list and "See you on the courts"
  • The code was not adding a stray newline. The raw string had exactly one blank line, which is right for plain text -- but WhatsApp already inserts vertical space AFTER a closing ``` fence, so an explicit blank on top of it doubles up
  • Same quirk format_player_table() documents for the OPENING fence, where a newline straight after ``` makes the block start with an empty row. This is the other end of it, and it was not written down
  • Removed only the blanks that follow a fence. The blank before the balance block stays: it follows the plain-text attendance line, where it is needed. Gene flagged two of the three gaps and not that one, which is what identified the cause
  • format_group_stats_block() is shared, so this also tightens /pb show games, /pb game next and the Last Call summary -- consistently, since all of them end that block the same way
  • Reason recorded in a comment at the join, so it is not tidied back
featd8a92afClaude Opus 5
  • "Courts: North + South" reads as two courts on our own account. When North is Shyam's it now reads "North (Shyam) + South"
  • Not cosmetic: a court on someone else's booking is one we cannot cancel or modify, and our club scrape cannot see it either. The label is the only place that distinction surfaces to a reader
  • Attached in _apply_handover_override(), which was already reading held_by off the Court Log row and discarding it. One change reaches every renderer -- Game Day, Last Call, /pb show games, /pb game next, and the game-cancelled DM
  • Safe to put in the courts string, checked rather than assumed: count_courts() splits on +/& and de-duplicates, so "North (Shyam) + South" still counts 2; all 13 call sites either print the string or pass it to count_courts(); and there are no equality comparisons against court names anywhere, which was the real risk
  • The payment-log fallback labels it Shyam too. That path fires only when the ledger knows nothing about the date, and the credit key names him, so the holder is known without a row
  • Verified against live Court Log data for 09/01: a fetch returning "South" alone renders "North (Shyam) + South" and still counts 2, an empty fetch renders "North (Shyam)", and 09/04 with no handover is untouched
feate31d1ceClaude Opus 5
  • 7f043ef sorted on add, so the list still read out of order until the next add -- and never tidied at all if the only edits were removals. Gene added Wednesday and it stayed last, because the stored order was untouched
  • Sorting in the formatter makes /pb booking list read Monday-first whatever the variable happens to hold, so the display is correct immediately rather than after the next write
  • Safe because storage order is irrelevant to get_booking_list(), which filters by day rather than reading positionally
  • Verified against the live variable, which is genuinely unsorted right now: stored Tue/Fri/Sat/Wed, displays Tue/Wed/Fri/Sat
feat7f043efClaude Opus 5
  • /pb booking list add appended, so adding Wednesday to a Tue/Fri/Sat list put it after Saturday. The list is read by a human deciding what the week looks like, and out of order it does not answer that question at a glance
  • Sorts Monday-first, then by time within a day, so 9:00 AM comes before 7:00 PM
  • Re-sorts the WHOLE list rather than slotting the new entry in. The list was not in order to begin with, and inserting correctly into an unsorted list still leaves it unsorted
  • Safe to reorder only because get_booking_list() in court-booking.py FILTERS by day rather than reading the list positionally. Checked before writing this, not assumed -- reordering a config an unattended job reads is not a change to make on the strength of it looking harmless
  • Entries the parser does not understand sort to the END and keep their relative order among themselves, since Python's sort is stable. They are still never dropped, which is the rule _booking_list_entries() exists to hold
tooling7ba0fa3Claude Opus 5
  • New Liveness or Die row and bullet: a watcher must prove it watched the RIGHT instance, not merely that it ran. Distinct from the rows above it, which are mechanisms that watched nothing; this one watched something real and reported a confident verdict about the wrong one
  • A booking watcher armed 2026-08-31 PT matched runs by `hour == 23 and minute >= 50` with no date check, picked up the PREVIOUS night's run, and printed "VERDICT: NOTHING WRITTEN" — which is exactly what a genuine failure prints, so reading the output could not catch it. Only noticing the matched run was 24 hours old could
  • The rule that generalises: identity must come from the thing itself, never from a shape that recurs. Anchor on the watcher's own start time, a run id or a sha — the same reason court_log_id() keys on the slot rather than the write time. "The latest run", "the newest file" and "today's date" on a UTC box all fail the same way
  • Found in my own code while writing the audit that named the pattern, which is the fourth instance in one evening across two sessions and the only one where the check produced a verdict rather than a false pass
  • ops/session-notes.md: the naming claim was mine and it was wrong. It said the id problem was sandbox-specific and that the desktop could see its peers' assigned names. Verified in both directions between two sessions on this box: "Snow White" and "Snow White 2" each returned "No agent named ... is reachable", and only the generated ids delivered
  • Corrected in BOTH places it appeared. A rule fixed in one spot and left standing in another is worse than one that is wrong everywhere, because a reader who stops at the first gets the version that does not hold
  • Cleared my own stale To: all entry. Deliberately did NOT clear the two entries addressed To: Mr Sandman, despite Snow White 2 saying one was addressed to me — the file says otherwise, and deleting a note its intended reader has not seen is the exact failure the addressed-recipient rule exists to prevent
featb696ce2Claude Opus 5
  • Show, add to and remove from the BOOKING_LIST GitHub Variable -- the schedule the unattended 11:58 PM PT booking reads. Picklebot's GITHUB_TOKEN is a classic PAT with `repo, workflow`, which the Actions Variables API accepts for writes; proved with a no-op PATCH returning 204 before writing any code
  • The docs for this landed first, swept into db57ead and 268cb2d from a shared working tree while this session was on hold. They have described a command that existed in no committed code since then. This commit makes them true
  • MATCHED BEFORE book_court, which matches any text starting with "book". Left after it, `/pb booking list` parsed as BOOK A COURT -- caught in testing, and the reason that ordering now carries a comment
  • Days and times are normalised on the way in, so `7pm`, `7:00 PM` and `19:00` all mean one slot and `remove` finds what `add` wrote. Without it `/pb booking list remove Wed 7pm` would fail to match `Wednesday 7:00 PM|Both` and read as the command being broken rather than the input being spelled differently
  • An entry the parser does not understand is KEPT VERBATIM, never dropped. This configures a job that runs unattended overnight; silently deleting a line a parser did not recognise is not an acceptable way to fail
  • Duplicates and no-op removals are refused, and the resulting list is echoed on every path, so a mistake is visible in the same message that made it
  • Writes are canonical (`, ` separators). Verified against get_booking_list() in court-booking.py that both spacings parse identically, rather than assuming
  • Reads are open; add and remove are admin-only. Editing changes what gets booked, reading does not
  • Touches the GitHub Variable only. Local .env is deliberately divergent and the output says so, so nobody assumes the two were kept in step
  • Tested against the live variable with a real add/remove round trip that restored it exactly, and re-verified after ten commits from the other session merged underneath this work
docs0a9fb71Claude Opus 5
  • The commit convention opened by saying sessions hold separate filesystems. Separate memory yes; separate filesystems is false — Gene clones a LOCAL session when one stops responding, and the clone shares the checkout: same disk, no remote in between, so no merge, no conflict markers and no doorbell
  • Every protection built on that premise — the doorbell, fetch-before-prepend, From:/To: addressing — defends only against pushes racing between machines, and none of them see a shared tree
  • New section: assert scope on the DIFF, not the file list. `git add <file>` stages the whole current file, so a filename assertion PASSES while carrying a peer's lines
  • That is not hypothetical: db57ead and 268cb2d each carry a peer's uncommitted docs for a command that exists in no committed code, and the scope check ran and passed before both
  • Holding does not protect the holder: the sessions share the repository, not just the tree, so one session's commits move the other's HEAD with no notification. The held session sat at 91dabba, touched nothing, and found itself at 268cb2d
  • Assigned names do not resolve between local sessions in either direction; address peers by the id ListAgents prints, and sign session-notes with the assigned name, which outlives it
  • Corrects the README claim that permissions.ask enforces consent. It does not: two sessions probed it independently with a no-op push and neither was prompted, because auto mode bypasses permission prompts by design
  • Says so in the strongest terms available, because a future session seeing four ask rules would reasonably conclude the harness catches unasked pushes. It has never once fired. Reporting protection that does not exist is the failure Liveness or Die was written about, and the git guard's docstring had already declined to make this claim
feat268cb2dClaude Opus 5
  • permissions.ask in .claude/settings.json makes the harness prompt before git push, gcloud functions deploy, gh variable set and gh secret set
  • Added after a session pushed six times and set a GitHub Variable in one evening, every one of them unasked, while holding the rule that forbids exactly that in context the whole time
  • The gate sits outside the agent, which is the entire point: unlike a hook marker, the agent cannot set, clear or route around it, so it is the one mechanism here that is not self-certifying
  • block-git-commits.sh is left alone. Its docstring already explains why it refuses to enforce consent, and that reasoning is correct — this adds the layer it could not be
  • CLAUDE.md rules 1, 2 and 6 stay behavioural and stay binding; a permission prompt covers four commands, not the principle
  • A hook that merely said "read CLAUDE.md first" was considered and rejected: CLAUDE.md is already injected at session start, so the failure was obedience, not access
feate459c3aClaude Opus 5
  • court-booking.py writes the Court Log row at booking time, which is the only moment the DURATION is known: the club scrape returns a reservation's start and nothing else, so a row discovered by a later fetch can never say how long we held the court
  • Placed after verify_bookings(), not before, so a claim it could not confirm has already been downgraded to failed — the ledger records what the site shows, not what Submit returned
  • Only status == success is written. A SAFETY_MODE dry_run is skipped: recording a verified-but-not-submitted slot as held is the same lie that once put [OK] SUCCESS in an email for a booking that never happened
  • New Hours column is measured, never assumed. Blank on a fetch-discovered row, because a blank says "never measured" and a 2 would say "measured 2" — and only one of those is true when an Athenaeum invoice disagrees with us
  • A club fetch never overwrites a measured Hours; it only confirms the row
  • An unverified booking carries the caveat into Notes rather than looking confirmed
  • _ensure_headers() appends any declared column an existing tab lacks, growing the grid first — a tab is created with columnCount fixed at the headers of its day, so the first write past it 400s with "exceeds grid limits". A fake Sheets service has no grid and cannot reproduce that; the live tab did, on the first try
  • Hours is declared LAST because _build_row() writes positionally: a key inserted mid-dict would sit at a different index than the same column in an older tab and shift every later value one cell left
  • Verified 11 cases plus a live migration of the production tab: header added, three existing rows intact, Hours blank on all of them, idempotent on re-run
featdb57eadClaude Opus 5
  • count_courts() replaces `2 if courts and '+' in courts else 1 if courts else 0`, which was never a count — it was a guess that happened to be right while exactly two courts existed
  • That guess is why the Shyam undercount was silent rather than loud: a fetch returning "South" alone scored 1 and read as a perfectly ordinary one-court game, so a six-player game got congratulated on filling its only court while a second stood empty
  • One count now drives both the Court/Courts label and every shortfall threshold, so the header and the "we need N more players" line cannot disagree
  • The 4/6/8/10 literals are gone; each derives from capacity = num_courts * 4 and capacity + 2, the arithmetic they were frozen copies of
  • Splits on & as well as +, both already in use — format_game_canceled_dm() emits "North & South", which the old sniff scored as ONE court — and de-duplicates so a malformed "North + North" cannot invent capacity
  • Verified by replaying the old logic against the new renderer over 4 court strings x 0-14 players: 0 mismatches in 60 combinations, so no existing message changes
feat91dabbaClaude Opus 5
  • Both commands now mark their courts cancelled in the Court Log at dispatch, so the ledger stops depending on the next club fetch to notice a cancellation Gene made through the bot
  • Written optimistically before the Actions run completes, which is safe only because the club fetch is authoritative for our own rows: a cancellation that never happened self-heals on the next fetch, which sees the slot still booked and flips it back
  • record_cancellation() only ever transitions rows we hold and reports the rest in skipped — the dispatch acts on our club account, so it cannot cancel a court sitting on Shyam's, and marking it cancelled would assert something we did not do
  • A slot with no row gets one, so the ledger records that we asked, even for a booking it never observed
  • /pb shyam passes log_courts=False through the shared dispatch: it is a handover, not a giveback, and letting the same row be marked cancelled would erase the Held By=Shyam row and cost the game its second court. Asserted in the suite rather than left to a comment
  • Adds ATHENAEUM_COURT_RATE ($17/court/hr, GitHub Variable) as the cost side to the Court Log's record of what we held — env-sourced with a 0 fallback so a consumer refuses rather than reconciling against a $0.00 charge, the way a hardcoded $4/hr survived the 6/1/26 rise everywhere it was written out
  • Nothing reads the rate yet; it exists so the invoice reconciliation has one canonical source instead of a literal in whichever script is written first
  • READMEs for both commands now say to prefer them over cancelling on the Athenaeum website, which is invisible to the bot until the next fetch
feat401920cClaude Opus 5
  • The 8:00 PM PT Last Call on 08/31 went out reading "Court: South" for a game that had both courts, with no recruiting line at 6 players — the handover override was live but silently no-oping
  • Cause was credentials, not logic: find_payment_by_transaction_id() asked for a Sheets service WITHOUT credentials_file, and the reminder runners write smad-credentials.json as a FILE without exporting SMAD_GOOGLE_CREDENTIALS_JSON, so it fell through to ADC, which lacks the Sheets scope, and 403'd
  • Pass credentials_file at both payments.py service calls, the pattern match_log and health.py already use — health.py's comment documents this exact trap
  • _apply_shyam_court_override becomes _apply_handover_override and reads the Court Log first: it is the record now, it is not Shyam-specific, and its service helper had the credentials right all along
  • Matches the HOUR as well as the date, so an evening handover no longer adds a court to a morning game on the same date
  • Falls back to the date-keyed payment credit only when the ledger knows nothing about that date — that key carries no hour and would add North to both games
  • Backfilled the one pre-Court-Log handover (09/01 7pm North, from credit shyam-court-20260901) so tomorrow's game reads from the ledger
  • Verified against live production data: 09/01 now resolves North + South and the report asks for 2 more players to fill the second court; 09/04, the other hour on 09/01, and an unknown date all stay correct
featb00f547Claude Opus 5
  • The durable /pb shyam guard moves from the Payment Log credit to a Court Log row, written BEFORE the credit and before the cancellation dispatch
  • The ordering is the fix, not the storage: a record_payment() failure does not abort the cancel — it only builds an error string — so a failed credit left no dedup key at all while the court was still handed back, and GREEN-API replays a voter's whole selection on every poll touch, so the next replay re-cancelled it and re-notified the admins
  • A row that cannot be written now refuses the whole command; no durable record means no guard, and proceeding would restore exactly the unguarded repeat this ordering exists to prevent
  • The row is written 'pending' and settles to 'booked' only when the credit and the dispatch both succeeded, so a half-done run keeps blocking repeats while reading as visibly incomplete, with the reason in its Notes
  • Keeps checking the old shyam-court-YYYYMMDD payment id as a transitional second key: dates credited before the Court Log existed have no row, and dropping it outright would have re-cancelled any whose game had not yet started
  • Both lookups fail closed, so an unreadable sheet refuses rather than handing the court back twice
  • Escapes the Archive dependency the money-ledger guard carried, since aggregate_payments() deletes rows from the live log and nothing aggregates the Court Log
  • Verified 39 cases end to end including the migration case, both fails-closed reads, a failed write-ahead aborting everything, and a failed credit still leaving a dedup row
  • README's command line also corrected: it still said the webhook DMs Shyam the command to run by hand, which stopped being true in 904b17c
feat2ead367Claude Opus 5
  • One row per court-HOUR in a new 'Court Log' tab, the granularity the Athenaeum invoices at, so it serves capacity, /pb shyam dedup and invoice reconciliation from one record
  • Held By is the safety model: the club is definitive for our own account, our log is the only possible source for a court on someone else's, so a fetch may only rewrite Held By = SMAD rows and leaves the rest alone — its absence from a fetch is not evidence, since it was never visible to that fetch
  • The log can therefore only ever ADD capacity, never claim a court we do not hold, which is the direction that costs us players
  • Guard 1: an empty fetch is refused outright — a failed scrape and an empty calendar are indistinguishable, and applying one would cancel every court we hold
  • Guard 2: cancellation is scoped to the date window the fetch actually covered, so a historical row is not retroactively cancelled as it drops off the club's view
  • find_handover() raises CourtLogLookupError rather than returning None when the sheet is unreadable, the same contract find_payment_by_transaction_id() earned after a 403 made a dry run offer to credit a date twice
  • Keys on <YYYYMMDD>-<HHMM>-<court>, derived from the slot not the write time; HHMM rather than HH because the club really does book on the half hour and real scrape data has 10:30 AM slots that HH would merge
  • Nothing deletes a row — state changes are status transitions, because an audit ledger that forgets is not one
  • refresh_live_courts() reconciles into it best-effort, so a Sheets failure cannot cost the caller their court mapping
  • Verified against a fake Sheets service (37 cases, both guards, fails-closed, idempotency, dry run) and against the real reservations.json: all 10 rows keyed distinctly, re-running created 0
featf80ec58Claude Opus 5
  • refresh_live_courts() shells out to court-booking.py under Playwright, but game-reminder.yml never installed chromium and daily-reminder-runner.yml gated its install on a condition false on the ordinary scheduled run — i.e. every day
  • Nothing failed loudly: the subprocess died, {} came back, and every court render fell through to parsing court names out of the poll option text, so reminders went out looking healthy with courts as of poll creation
  • Gate the install on the union of the conditions of the steps that need it; the old hand-written condition drifted the moment a step's condition changed
  • Verified by evaluating the real workflow conditions over every reminder_type x run_poll combination: 14/14 covered now, and the old condition left 3 uncovered including the everyday scheduled path
  • refresh_live_courts() returns None on failure and {} for a fetch that found nothing, so the two stop being the same answer; the 4 call sites that use the mapping take `or {}`
  • Log the subprocess stderr at ERROR rather than a bare warning, since the usual cause is the missing chromium and it says so
  • Record the pattern in CLAUDE.md: a workflow that shells out owns the subprocess's dependencies, and check-function-deps.py gates packages, not binaries
feat2135b4aClaude Opus 5
  • /pb shyam cancels our North booking so Shyam rebooks it on his own club account; our scrape only sees OUR account, so the slot read "South" and capacity math counted 1 court instead of 2
  • At 6 voters with both courts really available, build_game_report() printed "We have 6 players for 1 court, excellent!" — it congratulated the group and stopped recruiting two short, across Game Day, Last Call, vote reminders and /pb games
  • get_courts_for_game() now merges the handover back in via _apply_shyam_court_override(), which reads the deterministic shyam-court-YYYYMMDD credit /pb shyam already writes to the Payment Log — the only record that a court is held on someone else's account
  • Fixed in the one function every caller derives courts from, so num_courts and both Court/Courts labels self-correct untouched
  • Best-effort by design: a Sheets read failure leaves the fetched string alone rather than costing a game report, and it no-ops when the fetch already names North
  • Narrow fix, chosen over the full Court Log tab proposed in ops/session-notes.md; a later move only changes where this helper looks
feat904b17cClaude Opus 5
  • The webhook now CALLS /pb shyam MM/DD/YY for each game he votes for that has not yet started. It previously DMed him the command text to run by hand
  • Replies land in his DM with Gene: chatId is his own chat, and picklebot threads that same id into the cancellation workflow, so the confirmation that arrives minutes later goes to the same place rather than to Admin Dinkers
  • Automating a command that moves money made repetition dangerous. GREEN-API replays a voter's entire selection every time they touch the poll, so this re-fires for every game still selected on every vote
  • Three guards, each closing what the others cannot:
  • try_acquire_action_lock('shyam-court-YYYYMMDD') for two calls in flight at once. Keyed, not global, so unrelated dates do not block each other. Fails OPEN, which is only safe because a durable check stands behind it
  • Transaction ID 'shyam-court-YYYYMMDD' for a repeat minutes or weeks later, read across the Payment Log AND the Archive because aggregate_payments() deletes non-aggregate rows from the live log. Fails CLOSED
  • The caller skips games that have already started. Crediting a court already played is money for nothing, and a hand-typed /pb shyam <date> must still work retroactively -- so the policy is in the caller, not the command
  • The Transaction Date is NOT the court date. It is when the credit was posted, so it cannot identify what is being repeated: two court dates credited the same evening would collide, one court date credited on two days would not match. The court date lived only inside the note text, which is why it moved into the Transaction ID -- the key venmo and zelle already dedupe on
  • A Notes fallback was written and removed. Falling back to prose when the key misses means the key is not the key, and makes "no such payment" indistinguishable from "the id was never written". The one pre-existing credit was backfilled instead, which is the migration that keying on a column implies
  • find_payment_by_transaction_id raises rather than returning None when the sheets cannot be read. The first version conflated the two, and a dry run against a date that WAS already credited reported it would credit again -- the lookup had 403'd. A missed credit is visible and someone asks for it; a duplicate is silent until an audit
  • An automated repeat that is correctly refused sends NO message: it is accurate and tells him nothing he does not know. Every failure still reports on both paths. The test is whether a message carries information, not who triggered it
  • handle_shyam_court now returns (message, benign_noop); all ten returns verified as 2-tuples by AST after a regex pass left several unbalanced
  • Delete format_shyam_court_offer_dm, now unused, and its registry row
docs073aa71Claude Opus 5
  • The paragraph said only the desktop session CAN confirm delivery. Both boxes run the same code and get_chat_history() is in the registry; the desktop has GREEN-API credentials and a sandbox has none. Verified: instance, token and group all resolve empty here
  • That matters because credentials can be granted and a capability cannot, so the wrong framing makes a temporary limit look permanent
  • Records that both sessions asserted the limit rather than testing it. The desktop held the credentials and the registry function and still said only Gene could look; the cloud session agreed and was right by accident
  • Third instance tonight, after claiming gh was absent and sending Gene to edit a setting that does not exist. Each took one command to check
docscc1fb45Claude Opus 5
  • Of the last 40 messages in that group, nearly all are release notes from this session pushes -- twelve in the 25 minutes around midnight. The Court Reservations message that closed a 66-hour outage sits between two of them
  • That is the group carrying court results, payment summaries and survey reminders. A real alert arriving between twelve release notes is one nobody reads
  • Recorded the cause before any fix: per-push release notes are not new, the volume is, and it came from commit granularity driven by conversation rather than by deploys, because every note to another session had to be committed to be readable. Probably a one-off
  • Gene likely shape: a separate channel for release notes, leaving Admin Dinkers for lower-traffic admin traffic. Split by audience rather than throttle by volume, so nothing is dropped and the per-push signal the DORA tiles depend on survives
  • Added the measurement to take before acting: how many release notes reach that group on an ordinary day. If it is one or two the pin can be pulled instead of engineered around
tooling82ef7f7Claude Opus 5
  • restored now names all three hops instead of the publish alone: published at 6:31 PM PT where the identical probe 10 minutes earlier logged nothing, consumed by whatsapp-message-sender, and delivered to Admin Dinkers per a direct GREEN-API read
  • That field feeds MTTR and the record claims restored is verified, not assumed. Until tonight the final hop was assumed
  • CLAUDE.md: only the desktop session can confirm DELIVERY. A cloud session stops at the consumer logging its own successful API call, which is one hop short -- a green publish into a crash-looping consumer is the 8/27 outage, and GREEN-API returns true for removing a number that was never in the group
infra/CI4f2a794Claude Opus 5
  • The step was continue-on-error with a bare push, so a non-fast-forward was swallowed and it still went green. The 00:00 PT run exited before its notifier -- the anomaly the record exists to capture -- raced another session's push, reported success, and left the file four and a half hours stale
  • Rebase onto whatever landed, retry three times, and FAIL if the record never lands. No continue-on-error
  • Assert pushed=1 after the loop: its last command is sleep, so without that it exits 0 having failed every attempt, which is the same swallow one level up and the bug the deploy workflows had
  • The failure message says not to read a missing record as a healthy run, because that inference was made tonight and was wrong
  • 4/4 against a real local remote: skip when healthy, commit on anomaly, recover from a push race, exit 1 when it cannot record
toolingc9f1079Claude Opus 5
  • Case 4 asserts the warning names ops/session-notes.md, but watched_base() picked the last commit touching EITHER watched file. A CLAUDE.md-only commit therefore produced a CLAUDE.md diff and the case went red with the hook working perfectly
  • Third instance in one evening of a fixture coupled to whatever history happens to hold, and this one was introduced while repairing the first two
  • watched_base() now requires the path, and the caller passes the same string it asserts on, so the two cannot drift apart -- they are the same argument
  • Ran the suite as its own step and read it before committing: 45/45. The previous commit chained them and the 44/45 arrived below the push, which is the failure recorded in d1175c3
docsd1175c3Claude Opus 5
  • Both sessions chained a check ahead of the action it gated on 2026-08-30 PT, hours apart, and neither noticed. The cloud suite printed 43/45 FAILING directly above the push it was meant to prevent; this box read a cleanup script error as noise and pushed a mangled notes file anyway
  • Every other row in that section is a mechanism reporting a false success. This one probed, ran, reported honestly and was CORRECT -- the reader was the broken part, so hardening the checker cannot fix it
  • Remedy is structural, not more checking: let the exit code gate (test && push, never test; push), or run the check as its own step and read it before deciding. A result that arrives after the decision is decoration
  • Amended the sentence above the bullets, which claimed every entry falls to running it and checking the output exists. That is no longer true of all of them
  • Written by this box at the cloud session request, since it was their failure and they did not want to grade their own homework
toolingd782f54Claude Opus 5
  • Case 4 reset to origin/main~1 and assumed that commit touched a watched file, so the "changed:" clause would appear. A commit touching only .claude/hooks/ turned it red while the hook worked correctly
  • Case 5 asserted on a state it never reached: reset() copies the working-tree hooks in, which leaves the clone dirty, so merge --ff-only aborts with "local changes would be overwritten" and the clone stays behind. Restore the committed hooks, merge, re-copy the edit
  • The two failed under opposite conditions, so one commit tripped both
  • Case 4 now derives its base from git log over the watched files
feat0d98c4fClaude Opus 5
  • The comment blamed a background poller, which reads as bad luck from something one session happened to start
  • No background process is needed. Rule 5 requires a fetch before editing and the collision rule requires one immediately before prepending to the notes file; following the documented process was by itself enough to hold FETCH_HEAD under 120s indefinitely
  • So the two rules written to prevent collisions were disabling the warning that a collision was coming
  • Comment only; the sandbox session found it, snowwhite flagged it
feate3eaa69Claude Opus 5
  • The sandbox re-clones from an environment setting neither session can edit, so it arrives pointing at the old athpicklecourt name every time. A manual set-url does not survive: it was observed reverted inside a single session
  • This hook is tracked, so it is in every clone and can fix what neither session can reach. Both sessions kept diagnosing this and calling it someone else's to fix
  • Narrow on purpose: exact match on the old path only, origin only, and it announces the change rather than mutating config in silence
  • Reports and exits rather than falling through. The first version emitted a second json object and broke two suite cases; a hook emits one. The freshness answer is at most one prompt away
  • Three cases, mutation-checked: corrects a stale origin, says so in exactly one object, and leaves an unrelated origin alone. 45/45
docs3a9da1eClaude Opus 5
  • Standing exception to CRITICAL RULE 1, granted 2026-08-30 PT: a commit whose only change is an entry in ops/session-notes.md may be pushed without asking. Per-note approval defeats the point of two sessions collaborating without human intervention
  • Scoped to that file and nothing else. Code, config, workflows, CLAUDE.md and README still ask, including a commit that also touches the notes file
  • Clearing an entry you acted on is covered too; recipient-deletes needs no approval
  • Recorded here rather than left in one session's context, or the next session reads rule 1 and goes back to asking
feat81d23f3Claude Opus 5
  • The 120s gate keyed on FETCH_HEAD's mtime, which ANY `git fetch` in the repo rewrites -- the session's own commands, a background watcher, anything. So the hook's rate limiter was driven by a file it does not own
  • Observed twice tonight: a background poller fetching every 60s held FETCH_HEAD permanently under 120s, the gate never reopened, the cached answer was returned forever, and the doorbell stayed silent through two notes from the cloud session. Gene had to say "ring!" both times
  • The failure scales the wrong way. The more git work a session does, the less likely it is to be told anything -- and an active session is exactly the one with something waiting for it
  • Gate now keys on the CACHE's mtime. The cache is written only by this hook, on the fetch path, so it means "when did I last look" and nothing else can reset it
  • New case, mutation-proven: reverting the gate to FETCH_HEAD turns exactly that case red and leaves the other 41 green. 42 cases
feat12add10Claude Opus 5
  • Raised by the cloud session, and it is right by the rule the missing-cache fix established: present-but-unparseable and absent are the same answer -- I do not know -- so both must take the slow path rather than go silent
  • A truncated cache passed the -f test, was read, put garbage in $behind, and the final numeric guard swallowed it. Same shape as the silent doorbell, though bounded and self-healing: the gate reopens within 120s
  • The cache is now read once up front and only trusted if its first field parses as a number. That also removes a duplicate read on the gated path
  • Add the five freshness cases the cloud session verified but left alone, since the reset() helper is mine to reshape: the env-var kill switch (the sentinel was covered, its pair was not), a corrupt cache, a cwd that is not a git repo, and CLAUDE_PROJECT_DIR pointing outside any repo. 41 cases now
  • The corrupt-cache case is proven, not asserted: dropping the numeric validation turns exactly that case red and leaves the other 40 green
tooling6f7665dClaude Opus 5
  • Four patterns the guard matches on were untested. All passed; they are cased now so a later edit to those arms cannot quietly drop one
  • Both kill switches: only the freshness sentinel was covered, and the two hooks share .disabled, so silencing one and not the other would not have shown up. Asserted against a command that definitely blocks
  • Three malformed-stdin cases: this runs before every Bash call, so anything but exit 0 on unexpected input takes the session down
  • Found by reading the case arms rather than extending the existing list, which is the move that found the earlier defects
  • 37/37 on Linux; the committed suite ran unchanged at 28/28 first
tooling4c4985dClaude Opus 5
  • Two sessions independently tested the freshness hook on 2026-08-30 PT, both reported green, and both missed the same defect: a missing cache answered with silence, which is the state every fresh clone starts in
  • Neither suite was careless. Both were STRUCTURALLY unable to reach that state. The cloud fixture cloned a repo, and `git clone` does not write FETCH_HEAD, so the hook always took the fetch path and always wrote a cache. This box's fixture fetched, then aged FETCH_HEAD in every case to force the same path. Different mechanisms, identical blind spot
  • So the broken state gets a named case, and the suite is committed instead of re-derived in a scratch directory each time. 28 cases: 7 freshness, 21 guard
  • The regression case is proven, not asserted. Reintroducing the bug -- dropping the cache-presence check from the gate -- turns exactly that case red and leaves the other 27 green
  • Both fixtures also silently tested the COMMITTED hooks rather than the working tree, because `git reset --hard` restores them. The suite re-copies the working-tree files after every reset
  • Needs only git and python3, so it runs on either box
tooling33b5996Claude Opus 5
  • The freshness hook exited 0 when its cache file was absent, so it answered "I do not know" with the same silence it uses for "checked, all clear" - in the hook written to enforce the opposite
  • Different mechanism from the rows already in Liveness or Die, which are unmeasured values rather than absent state
  • Verified b382022 against the exact broken state on Linux: FETCH_HEAD fresh with the cache deleted now warns, and the fast-forward case stays silent
  • Notes entry records why both suites missed it: git clone writes no FETCH_HEAD, so a clone-based fixture cannot reach that state without an extra fetch
featc873067Claude Opus 5
  • The cloud session hit the guard twice in an hour on legitimate use: a real `git add -A` in a disposable fixture repo, nothing to do with this one. Not a text false positive -- the command was genuine, just somewhere throwaway
  • Scoping the guard by parsing a leading `cd` out of the command was proposed and is REJECTED. Any command containing a cd elsewhere would then skip the guard entirely, and compound commands are the common case here -- a far easier bypass to hit by accident than the false positive it fixes. Recorded in the script so it is not re-proposed
  • Instead the deny message now names the case: if this is a throwaway repo the override IS the intended path, because the hook cannot tell a disposable repo from this one. The previous wording only offered the override without saying when it was legitimate to take
  • 21 cases still green; no matching behaviour changed
fixb382022Claude Opus 5
  • The freshness hook was dead from the moment UserPromptSubmit was registered. Registering it mid-session means SessionStart never runs with it, so no cache is ever written; every prompt afterwards found FETCH_HEAD fresh (something else had fetched) and the cache absent, took the cached branch, and exited 0
  • The cached branch answered a missing cache with silence -- reporting "nothing to say" for "I do not know", in the hook written to enforce exactly the opposite. It stayed quiet through a real note waiting in ops/session-notes.md
  • Surfaced only because a human typed "ring! doorbell!". Nothing in the mechanism would have reported its own failure, which is the whole shape the Liveness or Die rules exist to catch
  • A missing cache now takes the full fetch-and-compute path. That spends one ~830ms fetch on the first prompt after a cache goes missing, then never again, because that run writes the cache
  • Reproduced the exact failure before fixing it -- fresh FETCH_HEAD, no cache, one commit behind, silent -- and confirmed it now names the changed files. 5 cases green
tooling5206aaaClaude Opus 5
  • snowwhite's hook suite passed while exercising the COMMITTED hook rather than the edit under test, because it cloned the repo and reset after every case. Distinct from the blind-spot section: there the check ran against the wrong input, here it never ran against the artifact at all
  • Added as a table row plus a bullet: make the suite print something only the edit produces, and fail when it is absent
  • Notes entry: their stale-cache fix verified on Linux, silent after a fast-forward inside the 120s gate, 6ms on the gated path
featad9beebClaude Opus 5
  • Register UserPromptSubmit, reversing the decision to ship SessionStart only. That call was right when the justification was catching stale files: git already rejects a stale push, so the residual cost was a merge
  • ops/session-notes.md did not exist then, and it changes the trade. A note sitting unread has no failure git can catch. Tonight one sat until Gene asked why the other session had not been answered -- the latency has no upper bound while a session is idle
  • Name WHAT changed, not just a commit count. "3 commits behind" does not say a note is waiting; naming ops/session-notes.md and CLAUDE.md does. Both misses happened today: a note went unread, and this session planned commits under a superseded rule 7 because CLAUDE.md had moved underneath it
  • The file check is one git call on the fetch path only, cached with the rest, so the per-prompt path stays free
  • Stop reporting staleness that is no longer true: fast-forwarding did not refresh the cache, so the hook would insist you were behind for up to 120s after you had already pulled. If the branch ref is newer than the cache the answer is unknown and the honest output is silence -- the next fetch is at most 120s away. Compared by mtime, read with bash builtins, no subprocess
  • 67ms on the gated path here, against 68-77ms before the doorbell was added; the cloud session measured 9ms on Linux. 5 cases green: both events name the changed files, the gated path answers from cache, a pulled repo goes silent, and the kill switch still wins
  • Two test defects worth recording: the suite cloned the repo, so it silently exercised the COMMITTED hook rather than the edit under test; and `git reset --hard` restored that file on every case. It reported green against code that was not the code being changed
toolingd3d70e9Claude Opus 5
  • Promote it to CLAUDE.md rather than leaving it in a peer message a future session will never read
  • Three instances in one evening, in unrelated code, each found by the party who did not write it: a matcher's prefix collision, a truncation bug upstream of every pattern it was tested against, and a log search using terms the publisher writes rather than the consumer
  • The rule is to enumerate from the input space, not the implementation
  • Notes entry: this channel has no doorbell. The reply tonight came from fetching mid-task, not from any signal. The shelved UserPromptSubmit path is the fix, 9ms cached on Linux; left for snowwhite to take or refuse, since it is their hook and shared config
toolingd55173fClaude Opus 5
  • The file was framed as the cloud session's outbound channel, on the theory that the one-way messaging limit was the reason it existed. That is the smaller reason
  • SendMessage only reaches a session that is ALIVE. A cloud session is not running most of the time, and tomorrow's sandbox session is a different one that receives none of today's messages. Anything meant for the NEXT session, in either direction, has to be in the repo
  • So both sides write there now, and the file is strictly more durable than messaging in both directions rather than a fallback for one. Entries carry a `From: snowwhite|cloud` marker, without which a reader cannot tell an unread note from their own
  • Two rules that only matter once both sides write: the RECIPIENT deletes an entry, never the author, so nothing vanishes before the other side reads it; and fetch immediately before prepending, because newest-first means both sessions write the same first line and two prepends between fetches conflict
  • Point CLAUDE.md at the file. It is the only channel every agent reads and it said nothing about ops/session-notes.md, so a fresh session on either box could never learn the file exists -- the sessions hold separate memory. Kept to a short paragraph in the commit-convention section, beside the sentence making that claim, with the detail left in README
  • Raised by the cloud session; both gaps were real. The second corrected a wrong model rather than a missing doc
toolingf6f62feClaude Opus 5
  • Cross-session messaging is one-way: snowwhite can reach the cloud session, the cloud session cannot reply. Verified twice; the tool says its credential is not accepted for delivering to another session
  • Until now the only return path was Gene screenshotting one transcript into the other
  • ops/session-notes.md is that path: newest first, PT stamps, deleted once acted on
  • The rule that keeps it from becoming a second place to look: anything durable goes in CLAUDE.md or README instead. If an entry is still worth reading in a week it was written in the wrong file
  • Honest cost: it lands on the other session's next fetch, not instantly
docs90e4b7aClaude Opus 5
  • This was documented as an open question. It is settled: the git guard was registered by a commit pulled during a running cloud session and the very next Bash call was blocked, with no restart
  • So a guard from the other box reaches a live session as soon as its commit merges, and so does a broken one, which is why the sentinel needs neither git nor a restart
  • Kill-switch step 3 no longer hedges about needing a restart
  • The documented false positive fires immediately, not eventually: the first command after the guard went live was a plain echo, blocked for containing the pattern as text
featb32af76Claude Opus 5
  • The command was extracted from the hook JSON by cutting at the first quote of any kind, but the quote that closes a JSON string is the first UNESCAPED one. Every command containing a quote was silently truncated
  • So `git commit -m "fix" && git push -f` was seen as `git commit -m \` and the force push sailed through -- the guard failed open on the most common way to write a compound command, which is worse than no guard because it looks like one
  • Escaped quotes are now swapped for a byte that cannot appear in JSON text, the cut is made at the first survivor, then they are swapped back. Still pure bash, still no subprocess: 36ms, unchanged
  • Found by testing the sandbox session's word-boundary fix (38014b5) against cases neither of us had listed. Their three defects were real and their fix is correct; this is a fourth, in the extractor I wrote, that word boundaries could not have caught
  • Document the known false positive: this matches text, so a command that merely CONTAINS a pattern is blocked too. Not theoretical -- the regression suite for this file cannot be run inline and had to be written to a script, and this commit message had to be passed with -F for the same reason
  • 21 cases now cover it, including compound commands, quoted messages, and --force-with-lease after a quoted string
feat38014b5Claude Opus 5
  • `git add .gitignore` and `git add ./webhook/main.py` were BLOCKED: "git add ." matched as a prefix. .gitignore was edited twice the day the guard landed, so this would have been hit within a day
  • `git push -f` was ALLOWED: the pattern needed a space after -f that end-of-string never supplies, missing the shortest way to write the one thing two concurrently-pushing sessions must not do
  • One trailing space on the extracted command lets every pattern demand a boundary; two wrong blocks removed, one real hole shut
  • 21 cases regression-tested on Linux; ~2ms, level with the bash floor
featf2ac77dClaude Opus 5
  • Committed 100644 in 507d270, one commit after fixing exactly this on fetch-origin.sh. `chmod +x` is invisible to git here because core.filemode is false on Windows; it needs `git update-index --chmod=+x`
  • Inert while settings.json invokes it as `bash "<path>"`, same as the other hook -- fixed so neither is a trap if that invocation ever changes
feat507d270Claude Opus 5
  • settings.json registered .claude/hooks/block-git-commits.sh as a PreToolUse(Bash) hook, but the script never existed. It exited 127 on every Bash call, so the guard behind CRITICAL RULE 1 was enforcing nothing
  • It deliberately does NOT enforce "ask before committing". A hook cannot verify a human approved anything -- whatever marker it checked, the agent would be the one setting it. That is a guard reporting protection it does not provide, which is the failure Liveness or Die exists to stop. Consent stays a behavioural rule
  • Blocks only the mechanically detectable, each with a documented incident: git add -A/. (swept 251 unrelated deletions into a pytz hotfix), git commit -a (left ops/incidents.json out of the commit that fixed the incident), --no-verify/--no-gpg-sign, and push --force without --force-with-lease
  • 13 cases tested: all four footguns blocked, and explicit `git add <files>`, normal commit, --force-with-lease, plain push, non-git commands and a filename containing "add-A" all still allowed
  • Spawns no subprocess -- it runs on every Bash call, ~80 in a busy session. 40ms against a 25ms bare-bash floor
  • Shares the .claude/hooks/.disabled sentinel with the freshness hook, so one file silences both; CLAUDE_GIT_GUARD_OFF=1 does it for one shell
  • Registered in the shared settings.json and removed from settings.local.json, so the sandbox gets the same guard instead of a broken reference
feat6b60382Claude Opus 5
  • It was committed 100644, so it arrives non-executable on Linux
  • Inert today: settings.json invokes it as `bash "<path>"`, not as a bare path
  • Fixing it anyway because the failure mode is the expensive kind -- works on Windows, permission denied on Linux, and only if someone later changes the invocation. That is the same shape as the CRLF trap caught earlier today, which survived on a noticed git warning rather than on a test
  • One `git update-index --chmod=+x`; nothing else changes
docs9db64fbClaude Opus 5
  • LF confirmed, 0 CR bytes: the .gitattributes eol=lf rule held and bash runs it clean, which was the failure mode worth checking
  • ~503ms median over 5 warm runs, against ~830ms on Windows
  • Behind-branch path tested in a throwaway clone left 2 commits behind rather than inferred from an idle repo: correct JSON, both fields
  • Both kill switches silent and 0; outside a git repo, 0 and no crash
  • The file checks out non-executable, which is harmless only because settings.json invokes it via bash <path>; a bare path would fail here and keep working on Windows
feat65aad3bClaude Opus 5
  • Two sessions push to this repo concurrently and a session could start behind main with no signal. The hook that warns about it existed only on the local box, because .gitignore excluded all of .claude/
  • Track .claude/settings.json and .claude/hooks/ via `.claude/*` plus two re-includes, so the directory stays default-deny: a new file under .claude/ is ignored until someone adds an exception
  • Move 353 permission rules out of the shared settings.json into settings.local.json, which stays ignored. They named absolute Windows paths, a personal email and `gh auth token` -- none of it meaningful on another machine. Verified 353 unique rules before and after, none lost
  • Keep the PreToolUse registration local: it points at block-git-commits.sh, which does not exist and exits 127, so sharing it would give the other box a hook error on every Bash call
  • Add a kill switch checked before any other work: `touch .claude/hooks/.disabled` silences the hook on the next prompt, with no session restart, no settings reload and no git. The sentinel is gitignored so one box's decision is never imposed on the other
  • Run git fetch with GIT_TERMINAL_PROMPT=0 and drop the timeout from 30s to 5s, so it fails fast instead of hanging on a credential prompt -- the one way an advisory hook could hold up a turn
  • Mark *.sh as eol=lf. core.autocrlf=true would otherwise check the hook out CRLF, and bash on Linux dies on the carriage return, breaking it in exactly the cloud sandbox it exists to help
  • Register SessionStart only. The per-prompt variant is built and measured but deliberately not wired up; README Future Work records the numbers, why it was shelved, and that it should be retargeted at CLAUDE.md drift if revisited
feat6f62b3dClaude Opus 5
  • The rule matched the exact name only, so .envbak, .env.bak, .env.local and .env.save were untracked but NOT ignored -- one `git add -A` from committing every credential in the file
  • Not hypothetical: a stray .envbak was created in the repo root on 2026-08-30 PT by a copy command with an unset shell variable. A staged-scope assertion caught it before the commit; this file did not
  • .env* with !.env.example, so the tracked template survives
  • Verified by recreating the accident: with a real .envbak present, `git add -A` staged only .gitignore
docsa60d3baClaude Opus 5
  • The bullet said the binary was absent, contradicting the section added later that says it is present. Verified 2026-08-30 PT: /usr/bin/gh v2.45.0, "token in GH_TOKEN is invalid", and a workflow dispatch returns 403 Resource not accessible by integration
  • The distinction is operational: "missing" invites installing it, which cannot help; "cannot authenticate" points at a push-triggered workflow or handing the task to a session with a real token
  • Nothing in the repo may depend on gh either way; that part stands
  • The contradiction propagated into a peer session's handoff, and the repo is the only channel back to it
feat54fc647Claude Opus 5
  • Group Info is what 45 members actually read, so make it the master and let the welcome DM follow it, instead of keeping a second copy in sync
  • The copies had already diverged three ways: by 2026-08-30 PT Group Info said Friday 4pm / Saturday 10am, SMAD_WELCOME_MESSAGE said 6pm / 10:30am, and BOOKING_LIST had 7:00 PM / 9:00 AM. Three sources, three answers
  • Add get_group_description() to shared/greenapi.py. It returns None rather than "" on failure so a caller can tell "could not read" from "deliberately empty" -- a new member must never receive a blank welcome because GREEN-API had a bad minute
  • GREEN-API still exposes no way to WRITE a group description, rechecked 2026-08-30 PT: getGroupData returns it, but UpdateGroupSettings carries only two permission booleans and UpdateGroupName sets the subject. Group Info stays hand-edited; SMAD_WELCOME_MESSAGE is demoted to a fallback
  • Warn, never rewrite, when the prose names a different poll day than POLL_CREATION_WEEKDAY. Silently editing the sentence would hide the one disagreement worth seeing
  • Make POLL_DAY_LABEL public: sync-members needs the same blank-falls-back-to- Sunday rule, and a second copy of it is what config.py exists to prevent
  • Group Info now names sessions coarsely -- "Tuesday evening, Friday evening and Saturday morning" -- so moving a start time an hour cannot falsify it. A precise copy of a value owned elsewhere drifts; a coarse one does not
feat19f1763Claude Opus 5
  • The dry run logged "[DRY RUN] Would send welcome DM to <name> (<phone>)" and nothing about the message, so it previewed the recipients but not the content
  • That made it useless for confirming SMAD_WELCOME_MESSAGE renders correctly, which matters now that the text carries a {poll_day} placeholder filled from POLL_CREATION_WEEKDAY
  • The old line also sits inside the "member was added" loop, so on a day with no new members the dry run exercised none of the welcome path at all
  • Log the rendered body once before the loop instead: it prints on every dry run, and an unresolved "{poll_day}" in the output is proof the workflow is not passing POLL_CREATION_WEEKDAY through
refactor0643423Claude Opus 5
  • Two of the three were wrong: new members were told Friday 6pm and Saturday 10:30am for games BOOKING_LIST has had at 7:00 PM and 9:00 AM since 2026-08-30 PT. Player-facing, in the one message a new player acts on
  • Deleted rather than synced. The weekly poll already carries that week's real schedule, so a second copy in the welcome prose had nothing to offer but a chance to drift — the cheapest copy to keep accurate is the one that does not exist
  • The SMAD_WELCOME_MESSAGE variable is already updated; that is a GitHub Variable edit, not a deploy, so the wrong times stopped going out at once. This commit is the code comment and README that described the old state
  • Narrow the README TODO to what is actually left: the 8am in the same sentence is still a literal, tracking the Cloud Scheduler cron rather than a variable. Low priority because it is correct — unlike the times it sat next to
  • Keep the drift story attached to that TODO as the argument for deleting a copy rather than syncing it, if the hour ever does go wrong
tooling2673207Claude Opus 5
  • New CRITICAL RULE 8: convert to PT before stating, comparing or recording a time. Distinct from Important Rules 3 and 4, which govern code; this one governs conversation, reasoning and committed records
  • Machine sources are UTC and rarely say so: GitHub created_at shows no offset, Cloud Logging ends in Z, and a cloud sandbox's own clock is UTC, so "today" there can be tomorrow in San Marino
  • The nightly booking reads 06:58Z, which is 11:58 PM PT the previous day. It was recorded as "06:58 PT", moving the incident onto the wrong calendar day and understating a 66.6-hour outage by 7 hours, on a record that feeds the MTTR tile
  • Fourth timezone defect: fixed -08:00 offset, hardcoded PST label, UTC dashboard footer, now this
feat37c3651Claude Opus 5
  • Create the POLL_CREATION_WEEKDAY GitHub Variable, which did not exist: three separate 'Sunday' defaults (two workflow `|| 'Sunday'`, one os.environ.get) agreed by luck, so the "single source" the docs described was never real
  • Replace the literal Sunday in SMAD_WELCOME_MESSAGE with a {poll_day} placeholder, filled in config.py from POLL_CREATION_WEEKDAY
  • Pass POLL_CREATION_WEEKDAY into sync-members.yml, the only consumer of WELCOME_MESSAGE; without it the substitution would have silently used the code default and the variable still would not have driven the message
  • Use .replace() rather than .format(): the text is operator-edited prose and .format() would raise on any brace someone types
  • Fall back to Sunday when the variable is blank, matching the workflow gate, since an empty GitHub Variable is falsy in ${{ vars.X || 'Sunday' }}
  • Add POLL_CREATION_WEEKDAY to .env so local CLI runs track the same value
  • Log a TODO for the game times in the same sentence, which are drifted: the message says Friday 6pm and Saturday 10:30am, BOOKING_LIST says Friday 7:00 PM and Saturday 9:00 AM
  • Label the dates PT. They come from `gh variable list`, whose 19:51:44Z is 12:51 PM PT the same day, so the date held — but only because the edit landed before 5pm PT. Unmarked, the next reader cannot tell which zone
docs71c5b8dClaude Opus 5
  • TEST_WORKFLOWS matches no existing workflow, so the tile honestly renders "no automated suite yet" rather than a fabricated 0
  • The trap is later: a suite added under any other name keeps the collector matching nothing, and that message stops being true
  • Same risk for DEPLOY_WORKFLOWS on a rename; a name-drift lint over both sets would close it
infra/CI7f417ddClaude Opus 5
  • A healthy nightly booking no longer leaves a daily record commit; it commits when the job failed or went green while notifying nobody, which is exactly the failure that hid for four days
  • Kept rather than dropped because the desktop is off overnight and court-booking.py runs on a GitHub runner, so its output never reaches Cloud Logging and nothing else can see it
  • CLAUDE.md: which session reads production how, what a cloud session can and cannot do, the push-triggered tools, and the rule that an empty log search proves nothing unless the term could have matched
  • CRITICAL RULE 5 said to use gh run watch; that token is invalid in a cloud session, so name the REST endpoints that do work there
  • README: TODOs for a per-function config gate and /pb shyam idempotency
toolingc672e12Claude Opus 5
  • Introduced by a32da25, the same timezone consolidation behind the 8/27 pytz outage: it made email_service import shared config, which turned a long-harmless import order in court-booking.py into a frozen ''
  • Blast radius measured, not estimated: 25 hours of sender logs show no court-* message at all, across a nightly booking run
  • Restored is verified by the probe publishing where the identical probe ten minutes earlier logged nothing
fix15da41eClaude Opus 5
  • court-booking.py imported email_service three lines before load_dotenv(); email_service imports webhook.shared.config for PST, so ADMIN_GROUP_ID froze to '' before .env was read, and the later import returned the cached empty value
  • Proven, not inferred: .env held a 23-character value while the process logged ADMIN_GROUP_ID is EMPTY
  • Every court notification with no explicit reply_chat_id was dropped in silence, the nightly booking result included
  • Document the rule in CLAUDE.md, including the transitive-import trap
  • Query 25 hours of the sender to establish the real blast radius
feat1ce8b29Claude Opus 5
  • The probe proved the skip: --whatsapp-notify was passed, the block was entered, and neither the success nor the error line was logged, which only happens when `if chat_id:` is false
  • An empty recipient now logs ERROR naming which side is empty
  • Measure .env, the file the code actually reads: the previous record reported the secret in the record step's own env and was read as if it answered for the runner. Length only, never the value; 0 (present but empty) and null (line absent) are different bugs
featc067cb4Claude Opus 5
  • An agent token gets 403 on workflow_dispatch (Actions: read), so use the push trigger this repo already uses for the same reason
  • Gate cancel, booking and the south job on workflow_dispatch: on a push every input is empty, which made the booking step's "no inputs" branch and the south job's condition TRUE. Without this a probe file books courts
  • Nightly booking is unaffected: Cloud Scheduler dispatches via REST, so every real run is already workflow_dispatch
feat7f8505fClaude Opus 5
  • The earlier "nothing was published" conclusion was not supported: the search used publisher strings against the consumer's logs, while the same report showed it handling 3 POSTs
  • Query "Processing message", which whatsapp-message-sender writes for every message, so an empty result finally means something
  • Tee the read-only get-reservations path too: it shares the recipient logic, so it can prove the runner can notify without touching a court
  • Record admin_group_id_present in the committed run record
feat909d7b9Claude Opus 5
  • The 23:39Z cancellation published nothing: whatsapp-message-sender received no court-cancel message at all, so this was never a routing or WhatsApp-side problem
  • The line that would say why is in the GitHub run log, served from a host the agent sandbox is refused, so tee it and commit ops/last-court-action.json the way the payment sweep does
  • An empty ADMIN_GROUP_ID now logs ERROR instead of skipping silently, since telling the admins is a guarantee rather than a nicety
feat76cede6Claude Opus 5
  • Notify admins when a cancellation is DISPATCHED, not only when it finishes: if the Actions run dies in between, that notice is the only record a court was given back
  • Booking and cancellation results now go to the caller AND the admin group, deduped so an admin-issued command posts once
  • Admin copy carries the origin chat, so it does not read as though an admin did it
  • Sends are independent: losing one recipient must not lose the other
  • Fold three copies of the group-vs-DM send branch into _notify_whatsapp
  • Query whatsapp-message-sender for why the 23:39Z cancellation result never reached the admin group; it should have under the old fallback
fix6734811Claude Opus 5
  • handle_shyam_court() hardcoded reply_chat_id='', and both the dispatch and court-booking.yml only pass it through when non-empty, so the async "Cancellation Result" message was never sent anywhere
  • Thread the caller's chatId in, with the same ADMIN_GROUP_ID fallback the rest of the dispatcher uses
  • court-booking.py already picks send_dm vs send_group_message off the id, so a 1:1 DM caller gets the reply in that chat
fixfa5a6deClaude Opus 5
  • _is_admin_dinkers_member() read GREENAPI_INSTANCE_ID and GREENAPI_API_TOKEN as module globals; this module imports only ADMIN_GROUP_ID at module level, so the reference raised
  • Nothing was charged: the crash is in the permission check, which runs before any credit or cancellation
  • The test set main.GREENAPI_INSTANCE_ID, creating an attribute production lacks, so it manufactured the missing thing and passed
fix3ebbd4fClaude Opus 5
  • The variable reached picklebot but not this function, so HOURLY_RATE was 0 and the offer DM was suppressed rather than quoting a credit /pb shyam would refuse to post
  • Diagnose the same window to confirm that was the cause, not assume it
featb5c6813Claude Opus 5
  • Webhook sends the ready-to-run /pb shyam MM/DD/YY for each game he selects, so the offer arrives while he is looking at the poll
  • Every vote, every game: re-votes and past games included. A duplicate DM is not a duplicate command call, and un-vote/re-vote is how this gets tested in production
  • Credit quoted from HOURLY_RATE x SESSION_HOURS; sends nothing when SMAD_HOURLY_RATE is unset rather than promising a credit the command would refuse to post
  • Best-effort: the vote is recorded before this runs, so a GREEN-API failure can never cost him his vote
infra/CI2d296abClaude Opus 5
  • Add .github/actions/smoke-test: state is ACTIVE, the new revision is also the newest ready one, and for HTTP functions a real GET
  • Revision-ready is the pytz check: a module that raises at import never binds its port, so its revision never goes ready
  • Pub/Sub functions get no HTTP probe; without allow-unauthenticated IAM answers 403 before the container, which proves nothing
  • Webhook accepts 503 with its own body: its keepalive reports on picklebot, and all four redeploy together when shared/ changes
  • Replaces "Verify deployment" steps that echoed state without ever comparing it, so they could not fail
docs0bc7dc8Claude Opus 5
  • CRITICAL RULE 7: docs land in the same commit, not the same "change"
  • Rule 8: split by change, never by file type; drop "split by what deploys", which was a directory rule wearing a change rule's clothes
  • Record why the old advice was wrong: reverting code without its docs leaves docs describing behaviour that no longer exists, which is the drift rule 7 forbids, and the split never avoided a deploy anyway
  • The 63% "mixed" stat conflated unrelated work swept together with one change spanning code and docs; only the first is a defect
feat6708438Claude Opus 5
  • Add SESSION_HOURS=2 to shared/config.py - one session's length, the multiplier turning a vote into hours and hours into an invoice
  • venmo_sync: standard amount is HOURLY_RATE * SESSION_HOURS; skip the last-name match when the rate is unset instead of using a stale $10
  • /pb shyam credit is HOURLY_RATE * SESSION_HOURS, not a literal $10; refuses rather than posting a $0.00 credit when the rate is unset
  • /pb shyam now accepts a DM from an admin as well as from Shyam, via _is_admin_dinkers_member(), which fails closed on a GREEN-API blip
  • Pass SMAD_HOURLY_RATE to the picklebot function; it only reached sync-members, so the new guard would have refused in production
docse812df4Claude Opus 5

Undocumented since it shipped: absent from README, the picklebot README and the function registry. Records the two things that are not obvious from the name - the North court and the $10 are hardcoded, so a change to the arrangement is a code change, and the non-admin path matches the sender's roster name against "shyam", so nobody else can run it from a DM.

feata8e6f37Claude Opus 5

It shipped in 32a480b two weeks ago and appeared in no help output, so the only way to find it was to read main.py. Admin help only, matching the command's own permission check.

feat1efdc3fClaude Opus 5

Cancelling a game deletes the sheet column, releases the court bookings and DMs everyone who voted yes. A punchline under that reads as flippant about news nobody wanted.

docs5302e66Claude Opus 5
Record the DUPR ratchet in the function registry
feat3a18c11Claude Opus 5

The daily survey sync wrote any differing survey answer to the main sheet, including a lower one. John Wang 2 was set to 4.0 by hand and the sync put him back to his old self-reported 3.5 two days later. The survey answer is a point-in-time self-report and stays in the survey sheet as history; the main sheet is the current rating. A survey value is now written only when it raises the rating or the sheet holds nothing usable, and an unparseable answer never overwrites a real one. Held values come back as dupr_held and are printed in the sync report - a sync that quietly declines to act reads the same as one that found nothing.

docsc3d5e93Claude Opus 5

Batching pushes only helps if the routine stops firing per push. It is configured at claude.ai/code/routines, not in this repo, so this records the options: path-filter to the files the page actually renders, or drop the webhook and keep the daily cron.

docs1de72ddClaude Opus 5

Every push to main republishes the Release Dashboard artifact into a rolling window of 20 versions labelled by position, not identity. A 21st appeared at 08:47 on 2026-08-30 and was gone by 09:21. This session pushed 25 times in 99 minutes against 1-2 on a normal day, which recycles the whole window in under two hours - so a link shared that morning pointed at different content by lunchtime. The pin was not drifting; the label was never bound to a version. Commit fine-grained as before, but accumulate and push once per coherent chunk.

infra/CIe02d60fClaude Opus 5

The regex matched only zelle's "0 recorded, 0 skipped" wording, so venmo reported null counts while its own log said "Recorded: 0, Skipped: 44". Claiming unmeasured for something measured is the same lie as claiming 0 for something unmeasured, inverted. A crashed run still yields null.

tooling8d15eccClaude Opus 5

It imported yaml, passed locally, and failed on the runner where PyYAML is not installed - the same missing-dependency bug this checker exists to stop shipping, in the checker itself. CLAUDE.md requires depending only on what is guaranteed to exist. Matches the `uses:` directive by regex instead, which also avoids the comment false positive. Both rules unit-tested on synthetic workflows.

feat6308f49Claude Opus 5

Both patterns shipped and cost real time, so this is enforced rather than documented: a report-failure step with no captured output, and a `... && break` retry loop with no check after it. Parses the YAML rather than grepping - a substring match on "report-failure" flagged this checker's own comments.

infra/CI209bc0eClaude Opus 5
Tee the remaining failure-prone steps
infra/CI6b1138aClaude Opus 5

The retry loop ends on `sleep`, so a deploy that failed both attempts exited 0: green step, no failure issue, nothing deployed. All four workflows. Verified under bash -eo pipefail, which is what runners use - double failure now exits 1, success still exits 0. Also tees the deploy output so the failure issue carries the gcloud error rather than a link.

infra/CI0825f7fClaude Opus 5

A report reading only "Failed on <sha> - [run log]" is useless to the reader who most needs it: GitHub serves run logs from a host an agent sandbox is refused, so five round-trips went into building ways to read a failure rather than reading it. The cause was one missing pip dependency. The action now reads $RUNNER_TEMP/step.log when no explicit details are given, so a workflow needs one tee on its risky step - no step id, no outputs, no interpolation to get wrong, which took three attempts on pull-logs. When nothing was captured it says so and names the fix.

featc7a2a55Claude Opus 5

google.auth.transport.requests imports the requests package directly, and `pip install google-auth` does not pull it in. The install step went green and the script failed at import; it worked locally only because the repo already has requests. The error said "google-auth not installed", which was false and sent the investigation at an interpreter mismatch that turned out to be a red herring - roster-widths uses the same pip/python3 pairing and runs fine. It now reports the ImportError itself.

infra/CIfc5107fClaude Opus 5

Two attempts to carry the failure detail via steps.outputs into the issue produced an empty detail both times. Committing a file is the one channel verified working end to end here, and it is the convention the rest of the repo already uses. Commits on failure too - a failed pull's output is the part worth reading.

infra/CI7ac37a7Claude Opus 5

The capture only wrapped the python call, so the early "secret is not set" exit reported nothing and the issue could not explain itself. A trap with process substitution was tried first and raced - tee had not flushed when the trap read the file. Running the whole step through a pipeline is deterministic; both the failure and success paths verified.

infra/CIf4f72e0Claude Opus 5

"Pull Logs failed" carried only a run-log link, and GitHub serves those from a host the agent sandbox is refused - so the failure report was unreadable by the one reader it exists for. Two runs failed and the reason was invisible.

toolingf0c9dc1Claude Opus 5

On Windows, Get-Content piped to gh secret set can prepend a byte-order mark. The secret then looks correctly set and json.loads rejects it at use time - a failure that surfaces only when someone needs logs.

feat5cde916Claude Opus 5

Setting an environment variable on a cloud environment is a web-UI operation - the CLI has --cloud and --environment but no command for env config. GitHub Secrets can be set from a terminal, so this route needs no browser: gh secret set GCP_LOG_VIEWER_KEY < claude-log-viewer.json Costs a push round-trip instead of running in-process, and buys independence from anyone being at a keyboard - the case that failed on 08/30, when the alert arrived at 21:19 and the only person who could pull logs was asleep. Runs the same scripts/pull-logs.py a local run uses, so output and redaction cannot diverge between the two routes. diagnose-logs.yml reimplemented its own queries and drifted to three of the four functions.

feat40ab4eaClaude Opus 5

gcloud writes keys to the current directory, which is the repo root, and claude-log-viewer.json did not match the existing *-credentials.json rule - a git add . would have committed a live private key.

tooling43b2b96Claude Opus 5

google.auth.default() wants a file path, and a remote sandbox has no file to point at - the key arrives as an environment variable. Without this, setting GCP_LOG_VIEWER_KEY would have looked configured and changed nothing. Local ADC still works unchanged, so one script serves both machines. Both credential paths fail loudly and write nothing.

docs9672210Claude Opus 5
Document pull-logs.py as the no-new-credentials route
tooling7602fb9Claude Opus 5

A remote session can reach logging.googleapis.com but holds no credentials, and GitHub serves run logs from a blob host the sandbox is refused. The local VS Code session has both the credentials and the repo, and git is already the channel between them: run this there, commit the result, read it here. Committed output is scrubbed harder than a transient log view - emails and phone numbers redacted, entries truncated. One file, overwritten; git history is the archive rather than a pile nobody prunes. Exits non-zero and writes nothing on bad credentials, an API error or an unknown service, so a file that exists came from a query that ran. An empty result is reported as production being quiet, never as silence.

docs85fb066Claude Opus 5

Measured from a remote session: the GCP logging, error-reporting and monitoring APIs are reachable and only lack credentials, while GitHub run logs and artifacts are blocked outright - the proxy answers 403 to CONNECT for both blob hosts. Two different problems, and only the first is worth solving directly. Records the exact gcloud commands for a scoped read-only service account, with the trade-offs stated: read-only and revocable, but broader than the current truncated issue route, and a long-lived key where federation would be better. Also queues a Datadog evaluation. The present stack is assembled from parts and each part has already failed in a way that looked like success.

infra/CIf4ddc8eClaude Opus 5

GitHub serves run logs and step summaries from a blob host the agent sandbox cannot reach - the proxy answers 403 to CONNECT for both results-receiver.actions.githubusercontent.com and the artifact blob host. So a result that lands only there is invisible to the session that needs it: after the 08/30 sweep this workflow went green and nobody could confirm whether it had recorded anything. ops/last-payment-sweep.json carries the return codes, parsed recorded/skipped/ unmatched counts and the log tail, committed only when it changes. A missing log yields null counts, never 0.

tooling18d7ebbClaude Opus 5

External: Google Sheets returned 503 during a cold start. 9 minutes, nil impact The entry records the detection gap as the real failure. Three layers should have surfaced it and none did: error-reporting judged freshness against a fixed 70-minute window while its cron ran 7 times in 28 hours; diagnose-logs queried three of four functions, omitting this one; and no agent session can receive a GCP alert email. Gene forwarded it ~10h later, which is how it was found at all.

  • by luck, since no payment was pending.
fixf5273b7Claude Opus 5

Three comments asserted the 04:15Z Sheets 503 cost a payment its sync and its thank-you. It did not. The last Venmo payment was 08/29 00:33 PDT and was acknowledged at 00:33; nothing arrived at 21:15, so the sync had nothing to record. The claim was inferred from timing and written as fact. The failure mode is still real - a pending payment would have been lost silently comments now say so.

  • so the sweep and retries stand. But nothing was lost that night, and the
featadf0e09Claude Opus 5

It promised "the daily scheduled sync will catch anything missed". No such sync existed, so a swallowed failure was simply lost - which is what happened on 08/30. payment-sweep.yml now provides it.

feat75e70b8Claude Opus 5

A single Sheets 503 during a cold start on 2026-08-30 04:15Z lost a payment's sync and its thank-you. There were no retries at any level: the client library was not asked for any, and the outer handler deliberately does not re-raise. execute(num_retries=3) makes the client back off and retry 5xx/429 itself, so a transient failure is absorbed before it becomes either a lost sync or a Pub/Sub retry storm - the storm being the cost incident 0fda64c fixed by swallowing. Eleven call sites across venmo_sync and zelle_sync.

infra/CI7c6fde6Claude Opus 5

A payment email's sync gets one shot: venmo-trigger fires on Pub/Sub and swallows exceptions, so a transient failure loses the sync and the payer gets no thank-you. sync_venmo_to_sheet re-fetches the last 50 transactions and dedupes against the aggregate watermark, so re-running is idempotent and recovers it. Runs 14:40 UTC, ahead of the 16:00 UTC reminder job, so overnight payers are acknowledged first thing. Separate from daily-reminder-runner on purpose: that workflow also creates the weekly poll and books courts, and a payment sweep must not be able to delay them. Push-triggered on ops/payment-sweep.txt as well, because GitHub schedules are best-effort - an hourly cron in this repo managed 7 runs in 28 hours - and the GitHub App cannot use workflow_dispatch. Zelle runs too. It is rare here, under five payments in 2026, but it has the same swallow-and-ack bug and a sweep covering one method is a gap.

feat3e9bf4eClaude Opus 5

sync_zelle_emails() was reachable only from inside venmo-trigger, so nothing outside that Cloud Function could re-run a Zelle sync. Mirrors sync-venmo.

infra/CI1aca73eClaude Opus 5

The issue body printed inputs.minutes, which is empty on a push trigger, so a run driven by "minutes=900" in ops/diagnose.txt announced itself as 120 minutes. Now reports the resolved window and the services queried.

infra/CIf422c54Claude Opus 5

The service loop covered three of the four deployed functions; venmo-sync-trigger was absent. A diagnose request for it would have queried services it does not run in, found nothing, and reported an empty result - which reads as "no errors" rather than "wrong question". Adds a services= override in ops/diagnose.txt for targeting one function.

infra/CI7f99641Claude Opus 5

The window was a fixed 70 minutes, commented "slightly wider than the hourly cadence, so no gap". The cadence is not hourly: over one 28h stretch this workflow ran 7 times instead of 29, with gaps to 6.5h, because GitHub schedules are best-effort. Errors firing inside a skipped hour were seen by the next sweep, judged stale, and never reported - the run going green either way. That is exactly how a venmo-sync-trigger error at 2026-08-30 04:19Z was lost: the next sweep ran 05:29Z and missed the window by about a minute. The window is now the real gap since the previous successful run, plus 10 minutes' grace, falling back to 24h when the run API cannot be read - noisy beats silent. A gap over 95 minutes is called out in the report, since a late sweep is itself a monitoring failure. Adds actions:read to query the runs.

docs911d9b6Claude Opus 5

Prompts are this project's requirements and they do not survive the session, so nothing durable records acceptance criteria - commit messages describe what was built and verified, never what was asked. That is the blocker for a test suite rather than a planning nicety: tests written from existing code lock in current behaviour, and four confident wrong behaviours found in one session would have been enshrined as correct. Records the Linear evaluation (free tier, first-party MCP so tickets are readable by every future session), the proposed working model, and the open questions on sequencing against the repo restructure and peeps.

docsad5bd34Claude Opus 5

Naming files in git add does not scope a commit; it adds to what is already staged. Three wrong commits here from that: git add -A swept 251 unrelated deletions into a pytz hotfix, git commit -a left ops/incidents.json untracked so the incident record missed its own fix, and a git rm of nine files landed in a commit describing a one-line change. The rule existed for all three; nothing enforced it. Also records how to split a pushed tip safely - prove the tree is unchanged, push with --force-with-lease pinned to the old sha, never below the tip.

featc34411cClaude Opus 5

Kept revoke-archived-access.py despite having no references - it is recurring maintenance, not a completed migration.

  • gcloud: 0-byte file from a stray shell redirect, empty since its only commit; every apparent reference is the CLI command name
  • temp_check.py, unicode_report.json: one-off leftovers, no references
  • star-wars-grad-night.py: a school fundraiser mailer, not PickleBot
  • jokes.csv, memes.csv, memes.json: seed data superseded by the Jokes sheet
  • migrate-rate-change.py, restore-archived-players.py: both self-described one-time migrations that have run; git history keeps them if ever needed
toolingc99f857Claude Opus 5

It runs daily in sync-members.yml, scraping the group into the Jokes sheet jokes.py reads, so its commits are product work however the folder is named. Moving it would break a live workflow with no test behind it - that move belongs in the repo restructure.

toolinge28e006Claude Opus 5

The classifier read subject lines, so 159 of 589 feat commits (27%) were internal work - dashboards, workflows, ops data - filed as features no player benefits from. The repo already separates these directories; nothing was using them. Path decides only when there is no explicit prefix. Location says WHERE the work was and a prefix says WHAT KIND, and they are orthogonal: an optimisation can live in CI. Deciding by path first relabelled `perf: skip redundant Cloud SDK install` as infra because it edits .github/. feat 589 to 429 (72% to 53%), infra/CI 9 to 116, new tooling chip at 46.

docs2381d95Claude Opus 5
Disambiguate rule 7 references from commit-convention section 7
docsfe2152fClaude Opus 5
Renumber the commit-convention sections after inserting perf:
docs9e9f84aClaude Opus 5
Document the perf: commit prefix
toolingadc3b90Claude Opus 5

An unrecognised prefix was classified by the first word after the colon, and "skip" was in the fix list - so `perf: skip redundant Cloud SDK install`, a 14% deploy speedup, was filed as a bug fix while the only other perf: commit landed in feat. That remainder rule now trusts only unambiguous repair verbs, moving three commits out of fix. Optimisation is neither feat nor refactor: no new capability, but behaviour does change. Detected by prefix; the February booking optimisations predate the convention and are listed by SHA, since the words do not separate the cases. Excludes two debatable ones - 0fda64c pairs a cost saving with a retry-storm fix, 383b45a disables an optimisation to stop modal blocking.

tooling015d89aClaude Opus 5

Now 83s (271 runs, p90 102s) instead of a 92s lifetime average. 30 days rather than 7: 7 of the last 28 calendar weeks had no deploys at all, so a 7-day tile would read "not measured" a quarter of the time, and when it did resolve it could rest on one cold run - the week of 05/11 had a single deploy at 143s. When volume is healthy the two agree within 1s.

tooling63083b3Claude Opus 5

The all-time mean is dominated by history and lags a pipeline change indefinitely: it read 92s for over a week after e51f142 took the real figure to 82s. deploy stays for the record; deploy_30d is what the dashboard shows.

toolinge9586e1Claude Opus 5

Empty weeks sit out rather than counting as zero - a week with no deploys has no deploy time, and interpolating would invent data. Weighted by run count, not a plain average of weekly means: volume ranges 1 to 147 per week and correlates -0.65 with duration, so an unweighted line reads up to 14s high (101s vs the true 92s). Lines wear an ink token rather than a third hue, so they read as a smoothing of the two series rather than a third one.

toolinge5e968bClaude Opus 5

Mean headlines the tile; p90 stays in the label because the mean is pulled by outliers and p90 is what shows a slow tail becoming normal. The chart nests the mean inside the p90 bar rather than stacking them. They are one measure at two percentiles, so a true stack would draw a 206s total that does not exist; read this way the upper band is p90 minus mean, which is real. One hue at two steps for the same reason, plus the legend two series require.

infra/CIa079d1dClaude Opus 5

fetch-depth: 0, because the default shallow clone has one commit and the first-commit date sets the scan start. window_days input now narrows a scan rather than defining one.

tooling0373833Claude Opus 5

Two bugs, both mine. The window was an arbitrary 90 days, and the weekly series was recomputed each run - so every week ageing past the window was dropped for good. GitHub's 90-day retention covers logs and artifacts, not run metadata, which is what timings come from: January runs are still queryable, so the cap bought nothing. Default is now the whole project, derived from the first commit rather than a constant, and the weekly series accumulates - weeks outside a scan are carried forward, never dropped, so this survives GitHub eventually purging them. 21 weeks now, 2026-02-16 to 2026-08-24, from 863 deploy runs over 226 days. Adds mean_s alongside median and p90.

tooling53d1477Claude Opus 5
  • Footer used a naive datetime.now() and labelled it PT. On a UTC build box it printed 21:52 PT while the eyebrow printed 14:52 PT on the same page
  • Footer credited release-notes.yml with republishing the artifact. That was true until 47f13f4 removed the republish steps; a claude.ai routine does it, because the Artifact tool is unavailable inside GitHub Actions. Module docstring said the same and is corrected too
  • "peak commits all-nighter" counted every commit in the day, so it reported 34 on Aug 21 under a moon icon for mostly afternoon work. Now counts only 9pm-5am, the same window the heatmap uses, attributing after-midnight commits to the evening that started them: 14 on Aug 25, verified against git log
  • Deploy Time P90 chart scaled its axis to max_s (148s) while plotting p90 (121s peak), so headroom came from a value never drawn. It also shadowed dmax from the daily chart
toolinga45061fClaude Opus 5

The file reported window_days: 90 while holding 46 days. GitHub caps the runs list at 1000 items however you paginate - page 11 returns nothing against 3082 runs on main - so paging deeper cannot work. The scan is now partitioned by week and each partition paginated on its own. 367 deploy runs over a real 90 days, up from 322 over 46. p90 is 110s, not the 106s the short window suggested. Adds covers_from, covers_days and complete, so nothing can render a window the data does not support, and warns loudly if any week hits the cap.

tooling01772bfClaude Opus 5

Chicago style, so prepositions stay lowercase: "Commits by Time of Day", "Lines of Code over Time".

toolingb14ab66Claude Opus 5

Weeks with no deploys render as empty columns, not zero-height bars: a gap means nobody shipped - the vacation is visible in it - and a 0 would read as an instant deploy. Tile relabelled to "deploy time p90".

tooling9d86c0eClaude Opus 5

A single p90 says whether deploys are slow now; the series says whether they are getting slower, which is what actually creeps up and blocks shipping. Bucketed by ISO week from runs already fetched, so five weeks of history exist immediately rather than accruing from today.

tooling85e35f0Claude Opus 5

The mix was cut to most_common(3), which hid 31 commits and made infra/CI invisible on the whole page - 9 spread over 8 months never places in a single month's top three. Totals were always right; the month headers were not showing what they summed to. Chip and count now render through one cat_chip() used by both legends, wrapped so a line break cannot land between them - "docs" and its 12 were splitting across lines, leaving a chip that looked unlabelled next to an orphaned number.

tooling448fe29Claude Opus 5

"Not just the ones that shipped ... the rest are docs, ops and tooling" framed non-deploying commits as normal, which now argues against the one-commit-one- deploy policy. The figures were already computed; only the editorialising was stale. States the split and leaves the reader to judge it.

docs76662e3Claude Opus 5

A number in prose is computed at build time or not written. The dashboard carried "about 73 seconds" for months against a real p90 of 108s. If it cannot be computed, leave it out - no approximations, no "about N".

tooling9a99148Claude Opus 5

Says what the page is and where the Commit Log is. Drops the commit count and deploy-time sentence: figures belong in tiles, where they are computed and scoped, not in prose nobody re-reads. Removes deploy_prose, now unused.

infra/CI0979e3eClaude Opus 5

Retention is 3 days, not the 7 that looks natural: these arrive at ~4/day and burst to 9, so a 7-day window closes nothing for a week and settles at ~26 open a burst cannot leave an agent with nothing to read. Only closes, never deletes, and only touches the machine label. Dropping the label exempts an issue permanently.

  • worse than the problem. KEEP_LATEST spares the newest 3 regardless of age so
infra/CI99aff74Claude Opus 5

Diagnostic dumps and roster reports are CI output parked where an agent can read it, not work items. 15 of 20 open issues were these, burying the 5 real ones. The roster-widths FAILED issue is deliberately left unlabelled - that one is a work item.

tooling6c2892eClaude Opus 5

Not a metric worth dashboard highlighting. The figure survives in the models-line prose, so nothing is lost, and the grid returns to 5 columns for a clean 5/5 at 10 tiles.

infra/CI5fa88ffClaude Opus 5

The workflow I added yesterday was the only one of 18 without a failure reporter. It runs unattended on a schedule, so a broken collector would leave the dashboard serving stale numbers as current - the silent no-op the metric exists to catch.

docs9a78349Claude Opus 5

A mechanism that reports success must first prove it ran. Five instances in this repo of code that looked correct and touched nothing real, including a monitor that watched six pushes with a binary that was not installed. Rules: probe before watching, fail loudly and write nothing, never report 0 for unmeasured, depend only on git/curl/python3, and treat an API success flag as a claim rather than proof.

tooling593ece2Claude Opus 5
  • Replaces the hardcoded "~73s" tile with the measured p90 of 108s (324 runs, median 83s), and the lede's invented "about 73 seconds" with the real median
  • Adds a test-suite tile that reads "no automated suite yet" until one exists. Never 0 - a zero would read as instant, not as unmeasured
  • Reads the committed JSON, not the API: the page is rebuilt by a cloud routine whose GitHub access is not guaranteed, and a panel that silently shows nothing on a failed call looks like everything is fine
  • Tile grid 5 to 4 columns. 5 was chosen for exactly 10 tiles; there are 11 now, 11 is prime, and 5 left the last tile stranded. 4 gives 4/4/3 and ~215px per tile so the longer labels stop wrapping
infra/CI633ab77Claude Opus 5

Runs at 06:37 PT, just ahead of the 07:00 PT dashboard rebuild, so the page reads fresh numbers. Commits ops/ci-metrics.json only when it changes. Pushes with the default GITHUB_TOKEN, which GitHub does not use to start new workflow runs - that is what stops release-notes.yml, which has no paths filter and messages the Admin group on every push to main, from announcing a metrics commit every morning. Switching to a PAT would remove that protection.

toolingf91b456Claude Opus 5

Deploy duration and test duration are what gate deploy frequency; neither was measured. Over 90d: 324 deploy runs, median 83s, p90 108s, max 148s - the dashboard's hardcoded "~73s" understates p90 by nearly half. Stdlib only, no gh: the production-alert monitor was built on gh and silently did nothing for six pushes because the binary is absent from that environment. Fails loudly and writes nothing on no-token, bad repo or unreachable API, so a collector that produces nothing cannot look like a quiet week. Tests are null until a suite exists, rendered as "not measured" rather than 0.

docsbddefd6Claude Opus 5

Fine-grained commits and high deploy frequency going forward. The pattern to stop is mixed commits: 265 of the 422 that never deployed swept docs, tooling and workflows together. Rule 7 still holds - split the commits, push them together, so docs never reach main without the code.

tooling4d98ae1Claude Opus 5
  • "Commit log", not "Release log": only 367 of 788 commits touch deployed code, so more than half of this list never shipped. It would also collide with send-release-notes.py, which sends real release notes on every push
  • No "Picklebot" prefix, matching the other section headers on a page already titled Picklebot Release Dashboard
  • Subtitle states the deploy split and teaches the click-to-expand, which is not otherwise discoverable
  • Drop the sticky bar's -14px top margin. It was tuned to sit under the old layout and overlapped the second row of type chips once a header was above it
toolingc393c17Claude Opus 5
  • One meta line per commit (date, time, category, sha, model), then the subject as the disclosure control. Body opens on click
  • Months are <details>, newest open. 321 phone screens to 26
  • Sticky month index with counts, highlighting the month you are in, and an Expand all that opens months and bodies so Ctrl+F reaches everything
  • Index links open the month they target; a closed <details> otherwise swallows the jump and looks broken
  • Commits with no body are plain text, not a control that opens nothing
  • Bodies ship visible and are hidden by CSS gated on a js class set before first paint, so no-JS degrades to the old expanded log rather than to dead links
tooling1a71b19Claude Opus 5
  • Asterisk and its footnote removed entirely; timing provenance now stated in full inside each row's panel instead of a marker needing a legend
  • Panel carries When, how it was timed, cause, detected_by, trigger/fix shas and the notes field, which the dashboard never surfaced before
  • Panels render outside the scrolling table. As colspan rows they inherited the table's min-width and the prose was clipped at the box edge
  • Rows ship hidden and are un-hidden by the same script that wires the buttons, so no-JS readers see everything rather than nothing
toolingda81b7eClaude Opus 5
  • Date column is MMDDYY with no separators: 6 fixed characters, narrower than "Aug 27" and it never grows. "Time to restore" abbreviates to TTR, expanded once in the panel subtitle so the header can stay short
  • Backfill the GREEN-API outage to 08/25, as Gene remembered. Confirmed against the authorization log in INVESTIGATION-2026-08-25-group-events.md: not authorized 22:41:10, re-linked 22:51:24, so 10m14s with real timestamps instead of an undated 6-minute estimate
  • It was never an external outage. That log shows the session continuously authorized from 06/04 12:12:57 to 08/25 22:41:10, so no vendor-side drop happened: Gene logged out the Firefox linked device while reverting the mystery group-admin actions, and that device WAS the GREEN-API session. The entry claimed external cause, unknown timestamps and "noticed by chance" — all three wrong
  • New cause "operational": an outage we caused by an action rather than a change. self_inflicted stays false because change failure rate is measured against deploys and there was no deploy, so CFR is unaffected
  • The table printed "upstream" for anything not self-inflicted, which blamed GREEN-API for our own logout. Those rows now show their real cause
  • The asterisk footnote renders only when a row actually carries an asterisk. No entry lacks timestamps now, so it was explaining a marker that no longer appears
  • MTTR moves 11m to 12m: the 6-minute guess became a measured 10m14s. Worse number, real number
toolingb06e4f2Claude Opus 5
  • The filter was defined twice, in scripts/build-shipping-log.py and send-release-notes.py, kept "byte-identical by convention". That is one careless edit away from the dashboard and the WhatsApp release notes disagreeing about what a commit body says, and nothing would have caught it
  • Single definition now lives in scripts/commit_trailers.py as is_session_trailer(); both renderers import it. Verified they resolve to the same function object, not two copies that happen to match
  • Not in webhook/shared/: CI copies that directory into every Cloud Function at deploy time, and build tooling has no business shipping to production
  • send-release-notes.py derives the scripts/ path from its own __file__ rather than the working directory, so the import holds wherever it is invoked from. Confirmed from a foreign cwd
  • Dropped the now-unused `re` import from send-release-notes.py
  • Re-ran the full check after the refactor: 8 pattern cases pass, artifact links still spared, 0 session URLs render, and the phone view is still at 0px overflow at 320/390/430
  • CLAUDE.md records where the rule lives and why, so the next agent extends the shared module rather than inlining a third copy
toolingac46435Claude Opus 5
  • The page itself scrolled horizontally on mobile: at 390px the document was 517px wide, so the view could be dragged left/right and the header clipped. Measured with Playwright at 320/375/390/430/768/1024 -- now 0px overflow at every width
  • Two independent causes. The outages table is five columns and rendered 472px; it now scrolls inside its own .tscroll box, so the columns are all reachable but the page never moves
  • The other was a grid blowout: `1fr` is minmax(auto, 1fr) and floors at the widest unbreakable run in the column, so one long token in one commit body widened the whole document. entry-main gets min-width:0 and long runs may break mid-token, so layout no longer depends on what anyone types into a commit message
  • Restore the Claude-Session render filter in both renderers, reverting 7df9eb4 at Gene's request. History is still untouched -- 94 commits keep their trailer, it is dropped at render time only
  • That trailer was the token blowing out the grid, and in the release notes it is not a bullet, so it was being glued onto the end of the previous bullet and putting a bare URL mid-sentence in the WhatsApp message. Verified both before and after
  • The pattern also catches a bare session URL, which a squash-merged PR body can carry, and deliberately spares claude.ai/code/artifact links
  • Give the table an edge-shadow scroll cue in both themes. iOS hides overlay scrollbars until you are already swiping, so a styled scrollbar alone cannot tell you the table scrolls; the shadow is drawn everywhere and clears itself at each end
  • CLAUDE.md said the trailers were left unfiltered and pointed at the wrong path for send-release-notes.py; both corrected
featb617b60Claude Opus 5
  • build_result() appended a joke to every picklebot reply except tell_joke. The Match Recorded reply already runs long -- confirmation plus the whole session's match list -- and it is read courtside between games, so the joke was one more line to scroll past.
  • Replace the inline `!= 'tell_joke'` test with a module-level NO_JOKE_INTENTS set, so the next exclusion is one word rather than another chained comparison. Currently {tell_joke, record_match}.
  • match_undo, post_game_report and every other intent keep their joke; the CLI's render_match_recorded() never had one.
  • CLAUDE.md: NO_JOKE_INTENTS added to the picklebot Action Result Messages registry, with the rule to extend the set rather than special-case inside build_result().
docsd3a003cClaude Opus 5
  • The earlier entry claimed the pin holds exactly where you put it. That is wrong and is now replaced
  • Version numbers are positions in a rolling list of 20, not stable ids. Version 20 was 557cb08 on 8/27 and 7df9eb4 on 8/29. Confirmed by mapping a dropdown's relative ages onto actual push times
  • The pinned CONTENT moved on its own: pinned to 557cb08 on 8/27, and two days later incognito served 4e39bcc from 8/28 23:30, with nobody re-pinning. Drift was forward, so readers got newer rather than stale content, but it was neither requested nor announced
  • Mechanism unattributable from outside. Position-based pin, the pinned version ageing out of the 20-slot window, or something re-pinning all fit; the artifact API reports only that viewers see an earlier version, never which one
  • Practical consequence recorded for the Substack link: the share always renders, but it is not a fixed snapshot and must not be promised as a specific version
  • The earlier "pin holds" finding came from one observation across a single republish minutes apart. True over that interval, wrong as a general claim -- drift only shows up over hours and many publishes
tooling7df9eb4Claude Opus 5
  • Stopping at the source is the whole ask. Old commits keep their trailers in the dashboard and release notes, and age out on their own
  • Both renderers are byte-identical to their pre-filter state, verified by diffing the working tree against 73105a1
  • CLAUDE.md updated to match, so it does not describe filtering that no longer exists
  • Unchanged: no new commit carries a Claude-Session trailer
tooling1d62b43Claude Opus 5
  • The trailer never answered the question it existed for. The cloud session is reachable from both the Claude iPhone app and the desktop app, so one session id covers both clients by construction, and opening the link does not say which wrote the commit
  • Removed from the shared commit convention in CLAUDE.md, with the reasoning recorded so the VS Code session stops too and nobody reintroduces it. This commit is the first without one
  • 94 historical commits still carry a trailer, so both renderers now filter it rather than history being rewritten. In the dashboard it had been rendering as a stray prose line under each commit; in the release notes it is not a bullet, so it was being glued onto the previous one
  • The filter matches trailers only. A bullet in 577d3b7 that describes the convention is prose and correctly survives
  • Authorship still separates agent work from Gene's, which is what `git log --pretty='%h %an %s'` and the author rule are for
refactor73105a1Claude Opus 5
  • Header now reads "<sig> - Daily Nags Shame Report for MM/DD/YY"
  • Also updated the daily-reminder-runner step label, which echoed the old name; leaving it would have made the workflow log disagree with the message it announces
featbce0e3aClaude Opus 5

Fixed a rendering bug found while testing: dropping the voting section left three blank lines where it had been, because the separator was unconditional. Verified all three states render correctly -- games left, no games left, and no poll at all -- plus the fail-open path.

  • has_upcoming_games() in player_data returns (has_games, reason), True while any game in this week's poll has not yet started
  • Gates the daily vote reminder: once the last game has begun a vote can no longer change anything, so the reminder is pure noise
  • Gates the shame report's non-voter list for the same reason. The report still goes out, because balances are still actionable -- only the voting section disappears
  • Deliberately does not fall back to next week. The question is whether the poll people are being nagged over still has a game in it
  • Fails open: a sheet error returns True and the normal path runs. Over-nagging once is recoverable; a week of silence nobody notices is the failure that would go undiagnosed
  • Both automated paths are the CLI (send-vote-reminders, shame-report), so gating there and in render_shame_report covers everything the daily runner does. Manual /pb poll remind <name> is left ungated -- an admin naming one player is an explicit act, not an automatic nag
feat8d8eb9bClaude Opus 5
  • Six user-facing timestamps now say "PT": release notes, the shared command_formatters timestamp, /pb jobs scheduled times, the booking email subject, and both schedule-last-call strings
  • PT is correct on both sides of the DST line, so it cannot go stale the way the hardcoded "PST" did, and it is shorter in the 32-column tables
  • Logs and diagnostics keep %Z. Correlating a local timestamp against a UTC Cloud Logging entry needs to know whether the offset is -7 or -8, which is exactly the detail PT drops
  • Resolves an inconsistency I introduced earlier today: the dashboard build stamp already said PT while everything else said %Z
  • Convention recorded in CLAUDE.md so the next edit does not reintroduce a hardcoded label
feat39d025cClaude Opus 5

Verified no hardcoded zone label remains in any formatted output, and the release message renders correctly as PDT.

  • Release notes no longer append a pickleball joke; the unused generate_pickleball_joke import goes with it. The jokes module is untouched and still used by eight other files
  • Rendering the message without the joke exposed "02:41 AM PST" in August. The label was a hardcoded literal, so every release note sent between March and November has claimed the wrong zone
  • Five user-facing timestamps had the same hardcoded label and now derive it with %Z: release notes, the shared command_formatters timestamp, picklebot's scheduled-job times, the booking report subject, and both strings in schedule-last-call
  • Left alone deliberately: a comment, an argparse help string, and court-booking's "PST/PDT" log line, none of which are formatted timestamps
docs4e39bccClaude Opus 5
  • The accidental group video call is timestamped in the group chat: bot match-recorded reply at 8:44 PM, call starting 8:44 PM, running 14 minutes with 3 people joining, next deliberate /pb match at 9:19 PM
  • Same sequence as 8/25, where /pb match went out at 8:20:53 PM and the first mystery event landed at 8:23:05 PM -- a 2m12s gap, with the three events spanning 6m31s and the next deliberate command at 8:51:47 PM
  • Both nights the pocket stayed active across a multi-minute window rather than firing once, and went quiet as soon as the phone came back out
  • Three members joining the 8/28 call rules out a subtle misfire: this is sustained interaction with the app through fabric
docs01b0cd2Claude Opus 5
  • On 8/28, during a game, Gene's pocketed phone started a WhatsApp video conference to the SMAD group and a separate video call to Albern Pucan
  • This falsifies the argument that ruled pocket interaction out. That argument was that removing a participant, detaching a group from a community and setting a message timer each need a deliberate confirm, so three through fabric was implausible. Starting a group conference and placing a call are the same class of multi-tap confirm-gated action, and the pocket did both
  • Conditions match 8/25 exactly: WhatsApp foregrounded between games, phone pocketed, game played. Gene sent /pb match at 8:20:53 and 8:51:47 with the three mystery events in between
  • Albern has now been hit twice, and "Albern sorts near the top of an alphabetical list" explains why better than any theory involving our code or GREEN-API, neither of which prefers a particular player
  • The GREEN-API conclusion was elimination, not evidence. The elimination no longer holds, so it is no longer the leading explanation -- though a linked device remains capable of all three actions
  • Records the mitigation Gene adopted mid-session: close WhatsApp or lock the phone before pocketing it. No code change can prevent a logged-in phone being operated through a pocket
feat09a022bClaude Opus 5
  • The undo reply was a bare trophy with no title, unlike every other picklebot response. It now reads "<sig> - Undo last Match Recorded" 🏆
  • Appends the session list after the removal, so the scorekeeper can confirm the right match went rather than trusting the one-line summary
  • An emptied session still renders its block: "no matches recorded for <game>" is the useful answer after undoing the only match, not silence
  • Shared render_match_undone() in command_formatters, used by BOTH picklebot and the CLI, which previously each formatted their own one-liner. Mirrors render_match_recorded() so the two confirmations look like siblings
  • Best-effort session block, same as the record path: a match-log read failure must never cost the caller the confirmation that the undo actually happened
  • Dry run renders "(DRY RUN)" in the header and skips the block, since nothing was removed
feat68ed3f9Claude Opus 5

Verified the error is now single and accurate for both ambiguous and unknown names, and that scored, unscored and numeral-name lines still parse unchanged.

  • When a line carried a score and the player parse failed, the code retried with the full text, gluing the score back onto the last name. "Gene and Gabe beat Derek and Mark six" reported "'Derek' is ambiguous — did you mean: Derek Chang, Derek Liu?; No player found matching 'Mark six'" -- the second half pure noise from the retry
  • _tail_is_roster_name() has already established the tail is not part of a name, so stripping it was correct and the failure is about the players. Return that error rather than retrying
  • Matters now that first names are the expected input for dictation: ambiguity is the common failure, and the fix is in the half that was being buried
feat1e6499eClaude Opus 5

Gene dictated "/PB match Gene Chuang and John Wang 2 beat Gabe and Ryan six" and got "No player found matching 'ryan six'". Two causes, both now fixed. Verified against 11 cases including both collisions: "beat Ryan and John Wang 2" keeps the player and takes no score, while "beat Ryan and John Wang 2 nine" takes the score AND keeps the player. Also covers digit and word scores on @-lines, zero, the 14 cap, and 15 correctly rejected.

  • iOS dictation spells scores out. WORD_NUMBERS maps zero..fifteen, and the tail matcher accepts either a digit or a word, longest-first so "fifteen" cannot be shadowed
  • Typed lines skipped score parsing entirely. The reason was sound: the roster holds both "John Wang" and "John Wang 2", so stripping a trailing number could silently resolve to a different real player. But the @-mention path already solved that with _is_roster_name(), and the same guard works here -- _tail_is_roster_name() now checks the last one to four words on typed lines, which is long enough for "John Wang 2" and short enough not to swallow the other side
  • Score parsing is now one code path for both @-mention and typed lines rather than an @-only special case
featf19287eClaude Opus 5
  • SEPARATOR_RE gains best, b and w alongside the existing beat, def, defeat(s/ed), over, d and >. The short ones are what people actually thumb-type between games
  • Ordered longest-first so "beat" and "best" win over "b"; all eleven tokens verified splitting correctly, plus case-insensitivity and the no-separator case
  • A single letter can collide with a middle initial: "John W Smith beat X Y" splits on both " W " and " beat ", giving 3 parts. _parse_teams() requires exactly 2, so that is a loud error rather than a silently wrong roster -- the right side to fail on, and now recorded in the code
  • "Record for this session" gains a DPR column, joined from the roster by name. A player with no rating, or a missing roster entirely, renders the cell blank: a fabricated 0.0 would read as a real rating
  • Roster fetch is best-effort in both callers, so a sheet failure drops the column rather than costing the scorekeeper their report
  • Table renders at 24 columns, inside the ~32 mobile budget
feat1277a09Claude Opus 5

Verified: the subcommand registers and its help renders; validation rejects 7.5, 3.7 and "abc" before any sheet access; validate_dupr accepts 2.0/3.5/6.0 and rejects out-of-range, wrong-increment, non-numeric and empty.

  • New `smad-whatsapp.py set-dupr "<name>" <rating>`, so the command exists in both surfaces as the no-duplicate-code policy requires
  • Moved the 2.0-6.0 / 0.5-increment rule into shared.player_data.validate_dupr(), beside set_player_dupr() -- which does NOT validate, so adding a second caller would otherwise have meant a second copy of the range rules, free to drift
  • validate_dupr() returns plain text and each surface adds its own presentation: WhatsApp markup in picklebot, a log line in the CLI. picklebot's _validate_dupr() is now a four-line wrapper over it
  • Name resolution reuses resolve_by_name() from match_log, the same fallback picklebot uses for a typed @Name, so both report "'Derek' is ambiguous" rather than guessing. There are no @-mentions on a terminal, which is the one deliberate difference between the two surfaces
  • --dry-run is already a global CLI flag, so it works here without a per-command argument
feat9d1d668Claude Opus 5

Routing checked against 12 inputs: all four @-mention spellings, the four self-service spellings, out-of-range, non-numeric, and the no-rating case. Resolution checked against the real shared resolvers with a stub roster including two Dereks and a name ending in a digit.

  • New admin-only intent set_dupr_for. Setting your own rating stays self-service and unchanged; setting someone else's writes another player's row, so it goes in ACTION_INTENTS with the other admin commands
  • The @-mention form is matched BEFORE the self-service pattern. That pattern treats everything after "set dupr" as the rating, so "set dupr @1626... 3.5" would otherwise have been rejected as an invalid rating
  • Resolution reuses resolve_by_phone() and resolve_by_name() from match_log, the same resolvers /pb match uses, so @-mentions behave identically across commands. Digits go to the phone resolver, anything containing letters to the name resolver -- which keeps "John Wang 2" working and reports "'Derek' is ambiguous" instead of guessing
  • Rating validation extracted to _validate_dupr() and shared by both handlers, so the 2.0-6.0 / 0.5-increment rules cannot drift apart
  • "set dupr @someone" with no rating now says the rating is missing instead of reporting the mention itself as an invalid rating
  • Listed in the admin help block only, and verified absent from the non-admin one
feat9f51e1bClaude Opus 5
  • Single occurrence, in format_vote_shame_block(); no docs or tests referenced the old wording, so nothing else needed updating
tooling557cb08Claude Opus 5
  • Adding the MTTR and change-failure tiles took the count from 8 to 10, and a 4-column grid rendered that as a ragged 4/4/2 with two orphans
  • 5 and 2 are the only column counts that divide 10 evenly, so the phone breakpoint goes straight to 2 rather than through 3 or 4, either of which would reintroduce a short last row
  • Breakpoint raised from 640px to 820px: five tiles need the room, and below that width a 5-column grid squeezed the longer labels
  • Verified by rendering the page in headless Chromium at three viewports and measuring tile positions: 1200px and 900px both give rows of [5, 5], 420px gives [2, 2, 2, 2, 2]. Screenshots eyeballed for wrapping
  • Noted in the CSS that the two DORA tiles only render when ops/incidents.json has entries, so 5 columns can leave a gap -- the trade for two tidy rows in the normal case
tooling1b751b0Claude Opus 5
  • The eyebrow is text-transform: uppercase, so 24489b8 rendered as 24489B8. At 12px with .14em tracking the B is indistinguishable from an 8 -- Gene's screenshot of the live page reads "BUILD 2448988"
  • A hash that cannot be matched against `git log` output defeats the only reason to stamp it, so the SHA now opts out of both the transform and the wide tracking via a .sha span
  • Renders "Build 24489b8", character-for-character what git prints
tooling24489b8Claude Opus 5
  • Drops the same-year special case. A single trailing year reads as though both dates share it, which is exactly how the range would lie from 1 Jan 2027: "Jan 14 - Mar 5, 2027" backdates a project whose first commit is 2026-01-14
  • The previous commit handled that with a conditional, which was correct but left a branch that only ever executes once a year -- the kind that is discovered wrong rather than tested. Writing both years unconditionally costs six characters and removes the branch
  • Renders "Jan 14, 2026 - Aug 27, 2026" today, "Jan 14, 2026 - Mar 5, 2027" next year, with no path that can drop a year
tooling3cca0b8Claude Opus 5
  • Drops "built with Claude Code" from the eyebrow: it said nothing a reader could act on
  • Puts a build stamp in its place, so the page identifies which build you are looking at without opening the Share dialog
  • claude.ai assigns the artifact version number at publish time, AFTER this HTML is generated, so the script cannot know its own version. The publishing routine can pass it as ARTIFACT_VERSION and the eyebrow reads "Version 21 - 14:49 PT"; with none supplied it stamps the commit instead, "Build b8d8184 - 14:49 PT", which pins the build to a verifiable point in git rather than showing nothing
  • Build clock is forced to Pacific. datetime.now() follows the host and CI runs in UTC, which would have stamped every republish seven hours off
  • Date range now carries the year on both ends when it spans years. The reported bug turned out not to be one: the first commit is 2026-01-14, confirmed against the GitHub API, so "Jan 14 - Aug 27, 2026" is correct today. It breaks on 1 Jan 2027, when one trailing year would silently backdate the project's start, so the fix is kept for that
docsb8d8184Claude Opus 5
  • Gene observed the dropdown reading "Version 19 - 18 minutes ago" while the latest was Version 20, minutes after the instrumented test. That is exactly the pin/live split the API reported (758 vs 759 commits), so two independent measurements agree
  • Removes an earlier suspicion, recorded in this file, that the dialog was defaulting its display to Version 1. It reports the real pin; the speculation was mine and it was wrong
  • The original "reverts to Version 1" sighting stays unexplained but is documented as not the normal behaviour, with what to capture if it recurs
  • Adds the practical step: to publish the newest build to readers, pick the highest version in the Share dropdown. The gap grows by one per push
docs5a9e9f2Claude Opus 5
  • Instrumented push fc5b660 and read the artifact either side of the republish. Live went 758 -> 759 commits while the pin stayed on 758: the report changed from "viewers currently see this version" to "viewers see a pinned earlier version, not this live version"
  • So the pin does not reset to Version 1, and does not advance either. Before the push the two only coincided because the pin had last been set to what was latest at the time
  • Explains the apparent auto-refresh: the owner always sees the live version, and the pin governs only what other people opening the link get. Watching it update after a commit is the owner view, not the share
  • Confirms no auto-repin exists: repin-shipping-log.py was never committed and no Playwright automation touches claude.ai
  • Practical consequence recorded for the Substack link and any bookmark: stable and safe, never broken or reset, but every push puts the public share one version further behind
  • Left open, because the data does not explain it: the Share dialog showing Version 1 on open, which looks like a display default rather than the pin
docsfc5b660Claude Opus 5
  • Records an OPEN question in README Future Work: does republishing reset a shared artifact's version pin, or does the pin hold while only the Share dialog displays Version 1?
  • This commit is the instrument. Pushing it triggers a republish of the dashboard, so the pin state can be re-read immediately afterwards and compared against a fingerprint captured beforehand
  • Before-state, recorded 21:36Z: the artifact reported "viewers currently see this version" for a build with 758 commits, the mean-time-to-restore and change-failure tiles, and the Production outages table -- after surviving 15 republishes that day without reverting to Version 1
  • States plainly what that evidence does not cover: it is one snapshot, and if the pin had been moved by hand earlier it says nothing about republish
  • Separates the settled part (public shares do not track Latest; the pin is manual) from the open part, so the two are not conflated again
  • Notes the stale second artifact, whose share would look identical to a pin stuck in the past
  • Entry gets crossed off with the result once the read comes back
docs101fd99Claude Opus 5
  • Records the convention in CLAUDE.md so it survives compaction and reaches the local VS Code session too
  • The reasoning is Gene's and it is right: an agent cannot judge which of its own pushes are dangerous, because if it could it would not ship the broken one. Both self-inflicted outages in ops/incidents.json came from pushes that looked routine -- a dependency consolidation and an email-subject change
  • States the limits plainly so this is not mistaken for monitoring: persistent=true is ignored and every arm caps at 30 minutes, it only exists inside a live session, and it only re-arms when a turn happens
  • Repeats the harder lesson: a green deploy is not a healthy service. The 8/27 deploy reported success and the container crash-looped at import
  • A daily CronCreate job re-arms it at 8:47am PT, after the 8am reminder runner, so an overnight failure is caught. Session-only, expires in 7 days
  • The durable layers stay the real defence: the dependency gate before every deploy, and the hourly error sweep that needs no session at all
feata32da25Claude Opus 5

I called this done twice and it was not. The first pass removed 7 module-level `PST =` definitions; the second fixed one straggler in webhook/main.py. Neither touched the aliases or the inline constructions, so the zone was still being rebuilt in 20+ places under six different names: PST, _PACIFIC, PACIFIC_TZ, pst, pacific, pst_tz. Verified: config.py is the only place the zone is constructed; all seven shared modules resolve PST to the same object; `.localize()` still returns PDT, so the 12 callers that need a pytz object rather than ZoneInfo are safe; the dependency gate passes on all four functions; both CLIs and the dashboard build still run.

  • Converted every remaining construction across 13 files to the single `PST` from shared/config.py, and renamed all aliases so there is one name, not five spellings of the same object
  • Added SCHEDULER_TIMEZONE to config: the same zone as an IANA string for the Cloud Scheduler API, which was defined twice
  • Removed 8 `import pytz` statements that AST proves are now unreferenced, including four inside function bodies that a module-level grep misses
  • Removed three unreachable `datetime.now(PST) if PST else ...` fallbacks in venmo_sync, left over from `PACIFIC_TZ = None`. Config builds PST unconditionally, so the naive-datetime branch could never run
  • Left alone on purpose: court-booking's `pytz.timezone(timezone_name)`, where the zone is a function parameter rather than this constant, and the two `pytz.UTC` uses
feat21e6a1cClaude Opus 5

Both self-inflicted outages in ops/incidents.json were Claude-pushed deploys, so the guard worth having is the one that runs before the deploy, not a sweep that notices afterwards.

  • scripts/check-function-deps.py walks the import graph from each function's main.py into webhook/shared/ and fails when a module-level import is absent from that function's requirements.txt
  • Wired as a gate into all four deploy workflows, positioned after checkout and before the deploy step, so a missing dependency blocks the release rather than reaching production
  • Module-level imports fail the build; imports inside a function body are reported only, since they break just when that path runs and several are deliberate (matplotlib is CLI-only). --lazy fails on those too
  • Regression-tested against the real outage: removing pytz from whatsapp-sender's requirements makes it exit 1 naming pytz and the service, and restoring it passes. Current HEAD is clean on all four
  • Import names that differ from package names are an explicit map, and `google` is treated as the namespace package it is; a guess there would either miss a break or block a good deploy
  • Documented in CLAUDE.md, since the trap is invisible from the function folder: its own main.py never mentions the package
infra/CI2d95dc6Claude Opus 5
  • error-reporting.yml now runs on an hourly schedule, not only when someone pushes ops/errors.txt. Every alerting path in this repo was pull-only: the push trigger needs a human to commit, and a Claude session has no inbox and does not execute between turns, so the alert email could never reach it. Gene was the alerting system
  • An issue is durable, so a session that starts hours later can still read what broke while nobody was watching
  • The sweep files an issue only when an unresolved group has fired within the last 70 minutes -- slightly wider than the cadence so nothing slips between sweeps. Without that gate one long-lived error would open 24 issues a day
  • Push and manual dispatch still always report, since someone asked
  • Gate verified against five synthetic payloads: no groups, fresh OPEN, stale OPEN, fresh RESOLVED, and mixed stale+fresh
  • Off-minute cron (:23), ~40s per run, roughly 480 Actions minutes/month
toolingac4d2ecClaude Opus 5
  • ops/incidents.json was left untracked: `git commit -a` stages tracked files only, so the new file never entered the previous commit
  • Effect was a silently incomplete feature, not a break. The dashboard's missing-file fallback did its job, so the MTTR and change-failure tiles and the outage table simply did not render
  • Second staging mistake today. The earlier one swept 251 unrelated deletions into a hotfix via `git add -A`. Both come from not checking what is actually staged before committing; `git status --short` and `git show --stat` after the fact are the cheap guards
tooling35a83d2Claude Opus 5
  • Added ops/incidents.json as the outage log, with the schema and the rules embedded in the file so an agent reading it has the contract in hand
  • Seeded three outages with times taken from Cloud Logging and deploy runs, not memory: the 8/24 google-api-core Firestore break (15 min), the 8/26 GREEN-API session drop (6 min, upstream), and today's pytz import crash that I caused (11 min)
  • build-shipping-log.py now renders a mean-time-to-restore tile, a change failure rate tile, and a Production outages table
  • Change failure rate counts only self-inflicted incidents against deploys in the same window -- an upstream outage is listed but must not score against our change process. Currently 3.8%: 2 of 53 deploys in 90 days
  • MTTR averages only incidents with a real measured duration. The GREEN-API drop has a known duration but no captured start or restore, so it renders with an asterisk instead of an invented timestamp
  • Tiles render only when the log has entries: an empty log means "not measured", and a 0 would read as "never broken"
  • Dashboard build tolerates a missing or malformed incidents file, verified both ways -- it runs on every push and must not fail to render because the log is mid-edit
  • Documented the practice in CLAUDE.md, including that detected_by reading "a human noticed" is a monitoring gap rather than an incident detail
feat731f541Claude Opus 5

Caught by the local VS Code session, which left it deliberately rather than edit a file this session was mid-refactor on.

  • handle_state_change() built its own pytz.timezone('America/Los_Angeles') at line 647 instead of using PST from shared.config -- the last straggler in that file after the PST consolidation
  • It worked only because `import pytz` still survived at line 36. That made it a latent NameError: removing the now-unused import would have crashed handle_state_change() at runtime, and that function is the GREEN-API session-loss alerter -- so the code that reports an outage would itself have died silently during one
  • Removed the now-unreferenced `import pytz` (AST-verified unreferenced, not grepped), which closes that trap rather than leaving it armed
  • webhook/ now has one timezone definition: PST in shared/config.py, plus four module-local aliases in shared/ still to convert
docs577d3b7Claude Opus 5
  • Document that several Claude sessions work this repo at once, a local VS Code one plus a cloud one reachable from both Claude Desktop and the iPhone app, and that they hold separate filesystems and separate memory
  • Put the convention in CLAUDE.md because it is the only file every agent reads; no agent's private memory can coordinate the others
  • Require agents to author as Claude via -c flags rather than changing repo config, so %an separates agent commits from Gene's own while user.name stays Gene Chuang for his manual work
  • Require a Claude-Session trailer naming the surface and the run, cloud keeping its claude.ai session URL and local using CLAUDE_CODE_ENTRYPOINT over CLAUDE_CODE_SESSION_ID
  • Record that the entrypoint is the durable identity and survives restarts while the session id pins one run
  • Rule out forcing TZ on commits, with the reasoning, so it does not get re-added: git stores epoch plus the committer's offset, mixed offsets are normal, and build-shipping-log.py already forces Pacific and uses format-local, which ignores the stored offset entirely
  • Restate the bullet-point body rule that the release-notes parser depends on, and the fetch-before-editing rule that concurrent pushes make necessary
fix4b749abClaude Opus 5

b0c2683 moved the PST constant into shared/config.py and imported pytz there. config.py is imported by every function, but whatsapp-sender's requirements did not include pytz -- its own main.py never referenced it. The function failed at import: File "/workspace/shared/config.py", line 12, in <module> import pytz ModuleNotFoundError: No module named 'pytz' whatsapp-message-sender is the Pub/Sub consumer that delivers every WhatsApp message, so nothing was being sent. venmo-trigger already had pytz; webhook and picklebot already had it. Only this one was short. Added to requirements.in and pinned in the compiled requirements.txt at the same 2026.3.post1 the other three functions use. pytz has no dependencies of its own, so the hand-added pin matches what pip-compile would emit. The general lesson is recorded in requirements.in: shared/config.py is imported by all four functions, so ANY dependency added there becomes a dependency of all four.

featb0c2683Claude Opus 5

Vote and balance reminder emails now carry today's PST date: SMAD Pickleball - Vote Reminder 🗳️ - 08/27/26 An identical subject every day made Gmail thread the copies, so the newest nag hid under older ones, and a repeated subject to the same recipient is exactly what spam filters score on. One-off mails (Last Call, Payment Received, Game Day) are unchanged -- they do not repeat. Implemented as append_date= on the shared maybe_send_email() / maybe_send_extra_notifications(), with subject_date() as the single date formatter, rather than pasting strftime at each call site. Two duplications found while doing it, both fixed rather than noted: PST was defined in 7 modules. The copies had drifted: firestore_utils used a fixed timezone(timedelta(hours=-8)), an hour off for the ~8 months a year California is on PDT, skewing every human-readable timestamp it wrote. Now one definition in config.py -- the module that imports nothing else, so anything can take it without a cycle -- and every other module imports it. Four now-unused `import pytz` lines dropped (AST-verified unreferenced, not grepped). email_service.send_payment_reminder() built its own HTML body, its own subject, and sent through its own send_email(): three copies of what webhook/shared already owns, and already drifted -- its wording differed and it had no nag count and no pre-pay hint. It now delegates to format_payment_reminder_dm() + maybe_send_email(), so the legacy `smad-sheets.py send-reminders` path (documented in SMAD_SETUP.md, which is why it was not simply deleted) sends the same message as picklebot and send-balance-dm. 48 lines removed. Verified: all ten touched files compile; the five shared modules resolve to the same PST object; subject rendering checked for dated and undated paths and across five consecutive days; the reminder body rendered from the exact dict email_service now builds, including a one-word name.

feat80441ecClaude Opus 5

The pre-pay hint that closes the balance block runs several lines and butted straight into the "Not Voted for This Week's Poll" header, so the two read as one paragraph on a phone. A blank line now separates the two sections. Lines within each section stay single-spaced -- a table and the blurb explaining it belong together, which is what the previous all-single-newline rule was protecting. Checked all four branches: poll with non-voters, no poll this week, nobody carrying any balance (no table at all), and everyone in credit (no pre-pay hint, so the break falls right after the code fence).

feat25ee905Claude Opus 5

"$10 bal" and "$10 cred" were built as one string and right-aligned as a unit. Because "bal" is a character shorter than "cred", every balance row sat one column right of every credit row, so the dollar figures never lined up with each other -- which is what the group sees each morning in the shame report. Figure and suffix are now separate columns: figure right-aligned so 10 and 140 agree on the units digit, suffix left-aligned so it cannot move the number. Also visible in the same report: the table rendered at 35 columns and wrapped "Nags" onto its own line on a phone. "Pay Nags" is 8 characters over a 1-digit column; the block title already says these are payment nags, and the vote table keeps its own "Vote Nags" label. Header is now "Nags" and the table is 31 columns, inside the ~32 budget that format_player_table() already works to. Checked against the reported data plus all-owing, all-credit, single-row and 4-digit cases; the widest lands on exactly 32.

tooling54130ddClaude Opus 5
  • Record the request sent to [email redacted] on 8/26, with the message text, the exact UTC window, and the support channels available since GREEN-API has no ticket system
  • Note that the API token must never appear in support correspondence, the instance ID being sufficient to identify the account
  • Explain why the question about staff access to the instance's browser session matters beyond this incident, and what each possible answer would imply for the standing exposure
  • Point the investigation doc's remaining-option line at the tracking file so the reply lands in one place
docs4721344Claude Opus 5
  • Confirm visually that web.whatsapp.com exposes Remove from community in the group info panel, so the GREEN-API session is mechanically capable of all three events even though their API has no community method
  • Note all three actions live in one group info panel, which matches the human-paced gaps far better than an API client firing sub-second calls
  • Retire the caveat on the original Desktop-ruled-out argument: the two-hour-late rendering is explained by the machine sleeping through the events and syncing on wake, so two independent findings now corroborate each other
  • Add an attribution table showing every alternative closed by evidence rather than assumption, leaving the GREEN-API browser session as the only candidate both online and capable
  • State plainly that this is the evidence limit, since WhatsApp never records which device made a change, GREEN-API keeps no API-request audit log, and no webhook type covers these events
  • Flag that logging into WhatsApp Web for this test added a Chrome linked device that should be logged out or recorded in the baseline
docsa7e400dClaude Opus 5
  • Record that the dev machine was asleep from 19:31 to 22:25 on 8/25, proven by a clock jump from 02:31:17Z to 05:25:04Z with no intervening power event, so all three events fall inside a sleep gap and WhatsApp Desktop there could not have acted
  • Eliminate the family-prank hypothesis on that basis: the computer was asleep, so nobody at home could have used it
  • Eliminate remote access independently: RDP is disabled, no remote-access software is installed, no terminal-services session events exist in the window, and the last wake source was the USB controller rather than the network
  • Note the NIC is wake-armed so a Wake-on-LAN is possible in principle, but none fired, since there is no wake event and the clock provably did not advance
  • Warn that the absence of logon events is worthless as corroboration, the Security log holding no 4624 events at all, and that it was nearly cited before being checked
  • Add a method note: read the Windows power log first for any did-this-happen-here question, since a clock jump settles it before any malware hunt
  • Frame the question that now decides the case: GREEN-API's console and API cannot detach a group from a community, so it turns on whether WhatsApp Web can do it at all, which is checkable in thirty seconds from the Desktop app
tooling505175bClaude Opus 5
  • Correct the credential name: WhatsApp two-step verification takes an alphanumeric Password, not a six-digit PIN, changed in the August 2026 release
  • Record that all three verification methods are registered: phone number, Password, and recovery email
  • Document WhatsApp's own Trusted devices feature and why it is not the linked-device list: it is a login-convenience list of devices allowed to skip the 2FA challenge, holding only the iPhone
  • Note that Desktop and the GREEN-API proxy cannot appear there, since companion devices are paired by QR and never perform an account login, which is why the Desktop app offers no such option
  • Warn that the trusted list should stay minimal, each entry being a device permitted to bypass the 2FA challenge, so populating it reduces security rather than adding it
  • Rename this file's own baseline wording from trusted to known-good, since trusted device now collides with a real WhatsApp feature meaning something different
tooling9ddb64eClaude Opus 5
  • Note 2FA is on with a recovery email set, and that the credential belongs in a password manager rather than the repo, .env, or Secret Manager, being an account credential and not app config
  • Record that enabling it left the GREEN-API session untouched, verified authorized with settings intact and a send confirmed
  • Flag as unverified whether re-linking the GREEN-API device now prompts for the credential, since both 8/26 re-links predated 2FA
  • Restate that 2FA gates number re-registration only, so it neither gates companion-device linking nor limits an already-linked device
feat8a51da4Claude Opus 5
  • Add handle_state_change() to the webhook, handling the stateInstanceChanged notification ahead of the message branches since it carries no messageData
  • Alert by email rather than WhatsApp DM: when the state is notAuthorized the WhatsApp session is exactly what is broken, so a DM would be queued or dropped and the outage would stay silent
  • Log the state at ERROR level when unhealthy so log-based alerting can key off it independently of email
  • Enable stateWebhook on the instance, which was off, leaving session drops undetected; the bot was offline for six minutes on 8/26 and was noticed only by chance
  • Add ops/whatsapp-linked-devices.md recording the trusted-device baseline, the audit steps, the restore procedure, and the two traps that cost time during the investigation: last-active never moves for an API-driven session, and the Firefox entry is the bot rather than an intruder
  • Record in the investigation doc that device attribution is settled: Firefox is the GREEN-API proxy, proven by logout, reproduction, and re-link; no unknown device exists
  • Note that the authorization history rules out a reconnect as the trigger, the session having been continuously authorized from 6/4 to 8/25 22:41
  • Note that no GREEN-API community method exists, so the community detach cannot have come from an API call even though the other two events could
  • Record both wrong turns from this session so the next one does not repeat them
  • Correct CLAUDE.md: GMAIL_USERNAME is defined in email_notify.py, not config.py, which a test caught as an ImportError
docs10eb8e2Claude Opus 5

The project cannot be rebuilt from `terraform apply` alone, and nothing tracked that. Enough of the live infrastructure was created by hand that a new project -- or disaster recovery -- is a manual runbook nobody had written down. Records the four gaps with file:line evidence (secret values, the two IAM roles granted by hand in July, errorreporting.user, the processed_messages TTL), the current rebuild order, and a plan for a bootstrap module plus a secret manifest. Two things worth keeping visible. Because apply is all-or-nothing, an IAM resource Terraform cannot grant blocks every unrelated infra change until it is commented out -- so these gaps cannot even be represented in the config. And the chicken-and-egg behind them is arguably correct: letting github-deploy self-grant would let CI award itself any role, so the fix is a human-run bootstrap step, not wider CI permissions.

docs5761dbbClaude Opus 5
  • Clear our code of the Albern removal on four independent lines of evidence: his Vacation Return is 9/8/2026 rather than 12/31/2099, he was on vacation and so never archive-eligible, pb_commands holds zero archive intents ever, and Cloud Logging shows no picklebot activity in the event window nor any removal call in the whole retention period
  • Close the GREEN-API webhook log as a dead end, proven rather than inferred: Gene's known-manual reverts produced the same silence as the mystery events, and the instance has never received any group-metadata notification type
  • Note the gap in the handoff's elimination argument, where human-paced gaps are used to rule out an API client but then point to GREEN-API, which acts on sub-second API calls
  • Correct the handoff's suggested check: command-usage is not a CLI subcommand, so pb_commands was read directly via get_firestore_client()
  • Add a Future Work TODO for local-session-only checks, covering terraform plan before an infra edit lands plus the Cloud Logging and Firestore reads a remote session cannot run
docsd0fcc1dClaude Opus 5

The doc treated "no /pb archive command near the removal" as evidence. That signal searched the group chat export, but archive is normally run by DM to the bot, which a group export cannot see. Kevin Cheng and Nardo Manaloto are the counter-example: both were archived by DM'd commands invisible to the export, then removed by hand when the archive failed to remove them. Marked invalid rather than deleted, so the next reader does not re-derive it. Those two removals are now explained, which leaves Albern as the only unexplained removal in the group's 89-event history. Adds the cheapest decisive check, which had been missed: Albern's Vacation Return cell. 12/31/2099 means code archived him and the auto-remove path took him out; his real date means no archive touched him. The auto-remove path has been live since 8/20, before the incident. Also records why he could be archive-eligible at all -- is_on_vacation() goes False once the return date passes, so a lapsed vacation makes a player a candidate again.

docse4dea47Claude Opus 5

Written so the work can continue in a local session that has gcloud, the .env and the project checkout -- none of which exist in the remote sandbox this was investigated from. Records the exact timestamps recovered from the iOS chat export, what each candidate was eliminated by, the two open questions that could still overturn it, and the gcloud/Firestore queries to run next. Two traps are called out explicitly because both produced a wrong conclusion during the investigation: the WhatsApp Desktop UI renders late-synced system events roughly two hours out of order, so only the phone export can be trusted for timing; and a GREEN-API webhook arrives identically whether that session performed an action or merely observed it, so silence in the logs is not exoneration.

feate526a8eClaude Opus 5

Verification already sat outside both booking loops, so it never delayed the next item on the booking list. The timing risk was elsewhere: the email and WhatsApp reports are sent AFTER it, get_reservations() paginates with `while True` and waits on networkidle, and the GHA job allows 10 minutes. An unbounded check on a slow grid could have burned the job timeout and sent no report at all -- worse than the wrong report it was added to prevent. try/except does not cover a hang. Capped at VERIFY_TIMEOUT_SECONDS (default 90, env-overridable) via asyncio.wait_for. On timeout the claimed bookings are marked unconfirmed and the run continues to the report.

feat9434752Claude Opus 5

A booking report came back "[OK] SUCCESS" for North 09/01 7:00 PM and no reservation existed. The success was never evidence of anything. book_court() ended its try-block with an unconditional `return True`, and three failure paths logged an error and then fell into it: the "Make Reservation" button not being found, the click returning false, and the click throwing. True became status 'success' became [OK] SUCCESS in the email. Even the happy path checked nothing -- its idea of confirmation was a log line pointing at a screenshot whose capture is commented out. Not safety mode: the Cloud Scheduler job posts only {ref: "main"}, so the workflow passes SAFETY_MODE=False and this was a real submit that did not take. The likely mechanism is the parallel north/south jobs, which both book both courts: two dialogs open on the same slot, both clear the "already taken" pre-check, both submit, the server accepts one, and the loser reported success. If the grid cannot be read, or comes back empty, nothing is downgraded -- it is marked unverified. An unreadable list is not evidence a booking failed, and inventing that verdict would be this same bug facing the other way. RESERVATIONS_URL hoisted to a constant; it was pasted in two places and verification needs a third. Verified the reconciliation against site-holds-nothing, holds-it, holds-one-of-two, wrong-court, read-throws, empty-list and nothing-claimed, including 7:00 PM vs 07:00 PM normalization. Not exercised end to end against the live site from this session.

  • Those three paths now set last_failure_reason and return False.
  • verify_bookings() reloads the reservations grid after the booking window closes and reconciles every claimed booking against what the site actually holds. Not found means failed. It runs after the 12:01 AM race, so it costs no latency where latency decides who gets the court.
  • SAFETY_MODE returns now flag last_was_dry_run and record status 'dry_run', so a run that deliberately submits nothing stops reporting success.
  • Email says SUCCESS only for a confirmed booking; otherwise SUBMITTED (NOT CONFIRMED), FAILED, or DRY RUN.
infra/CI5ae7ce3Claude Opus 5

The note still described the rank cell as "73." with a trailing period, which the previous commit removed. Left as-is it would send the next reader looking for a column that no longer exists.

feat2bf87c4Claude Opus 5

At 73 players the rank cell was "73." -- three characters where "73" is two -- which put the full player table at 33 monospace columns and wrapped it on a phone. The names were never the problem; the period was. Right-aligned digits already read as a rank column without it. Measured against the live sheet (73 players, longest name "Greggory Millward" at 17 chars): /pb players (all 73) 33 -> 32 was wrapping, now fits Top 15 leaderboard 32 -> 31 8-player game roster 32 -> 31 It also closes a latent trap: the Top 15 sizes its name column to whoever is in it, and a 17-char name entering it took that table to 33. Now that case lands on 32.

feataedc920Claude Opus 5

The report covered game rosters and /pb players but skipped the Top 15 leaderboard, which is the table most people actually see (Group Stats, Game Day report) and is not implied by either. It ranks by hours, so the names it contains -- and its width -- have nothing to do with who is on a given game roster. It also carries a 3-char rank cell ("15."), one wider than an 8-player roster's "8.", so it can wrap on a name that a roster renders fine.

fix93728ddClaude Opus 5

The first run went green and reported "0 players ... fits", which is the worst possible answer: a credential failure dressed up as a clean bill of health. Two causes, both fixed: stderr is captured to a file and posted as an issue on failure, because this session cannot reach GitHub's run-log host -- otherwise a failing run is just as silent as the wrong answer it replaces.

  • Config went into .env, but only smad-whatsapp.py calls load_dotenv(). A bare `python3 -` script never reads it, so SPREADSHEET_ID was "" and get_full_player_data() swallowed the API error and returned []. Pass the values as step env vars instead.
  • Nothing checked the result. Now an empty SPREADSHEET_ID or an empty roster exits non-zero, and logging is forced on so the error player_data swallows actually reaches stderr.
feat6b11febClaude Opus 5

The roster and leaderboard size their name column to the widest name present, so whether a report wraps on a phone is decided by who is on the sheet -- a fact that exists only in production. There was no way to check it without reading the sheet by hand. Reports the longest names, the worst-case 8-player roster, which single names tip a roster over on their own, the real upcoming game rosters, and the full /pb players table. It measures the output of the real format_player_table() rather than re-deriving the column math, so the answer cannot drift from the renderer. Push-triggered on ops/roster.txt and reported as an issue, the same pattern as diagnose-logs and error-reporting, because the App has Actions read but not write and run-log bodies are unreachable.

feat81000dbClaude Opus 5

The roster printed its own layout with a label on every row ("Danny Mata DUPR 2.0 Hr 06"), which spent the width on repeated labels and left no room for win-loss. It also zero-padded hours, so 6 hours read as "06". Delegate to format_player_table() -- the renderer the Top N leaderboard already uses -- so both share one header row and one set of column widths. That frees the space for a W-L column and lands the roster at exactly 32 monospace columns, one line per player on a phone. Roster order is preserved via a new sort_by="none": the list is alphabetical so a player can find their own name, and the Game Day report it sits in carries the hours leaderboard a few lines further down. Re-sorting the roster by hours would print the same ranking twice. A voter with no roster row still gets a line rather than being dropped, so the roster cannot disagree with the "Players (N)" count above it.

featdb59b9fClaude Opus 5

Logging a match confirmed only the lines just sent, so a scorekeeper logging a session one match at a time had no way to see what was already recorded without asking for a separate report. Reuse the post-game report module instead of a second renderer: The block is best-effort and deliberately skipped when it would mislead: dry runs (nothing was written), all-failed batches, and an empty session. A match-log read failure logs a warning and still returns the confirmation -- knowing the match was written matters more than the list. The old "Logged against the <game>" line becomes fallback_note, rendered only when there is no block, since the block header already names the game.

  • format_post_game_report() renders headerless when sig is empty, the same convention as format_show_games(), so the block embeds in another message without a second title line.
  • render_match_recorded() is the new shared entry point for both the picklebot handler and the CLI: confirmation + the headerless session block from get_session_matches().
  • render_post_game_report() takes an optional session; the picklebot handler already resolved one, so this avoids a second sheet read.
featb8881f3Claude

Lists every match logged for the last game played, then each player's record for that session. Session records are deliberately not lifetime ones: someone who went 3-1 tonight reads that as tonight's result, and their overall record is already in /pb standings and /pb stats my. get_session_matches() keys on Game Date AND Game Time, so a 7pm and a 9pm game on the same date never merge into one report, while a session logged without a time label still matches. It also strips the leading apostrophe those cells are written with, so a hand-typed or re-exported row cannot silently fail to match. The intent sits above the record-match branch on purpose. That branch matches the prefix "match ", so "match report" would otherwise be parsed as logging a match against a player named "report" -- verified, along with "/pb match @a @b beat @c @d 9" still routing to record_match. Read-only, so it is available in the SMAD group as well as Admin, and added to both the CLI and picklebot per the shared-code policy.

feat73f08c7
diagnose: multi-term search; 7-day sweep for archive/removal activity
feat2c9652d
diagnose: free-text log search; 48h sweep for group removals
infra/CI27208a2Claude

The four function deploys could fail with no trace anywhere readable: run log bodies are served from a host the Claude Code session cannot reach, and GitHub only emails on failure, never on the subsequent fix -- so a red email is not evidence anything is still broken, and there is no green one to say otherwise. Twice today a failure was only noticed because it was relayed by hand. Written as a composite action rather than the same block pasted into four workflows, which is the duplication this repo already has a policy against. It also dedupes: a repeat failure comments on the existing open issue instead of opening another, so a flapping deploy cannot bury the tracker. Each workflow was validated after editing -- the reporter is the last step, guarded by failure(), and the job carries issues: write -- and reverted rather than written if any check failed.

feat994ca28Claude

Every Python traceback starts with 'Traceback (most recent call last):', so all three open groups rendered identically and the report could not be used to decide anything. Uses the last non-empty line, which carries the exception type and message, and adds the affected service.

infra/CI6f41729Claude

github-deploy cannot modify the project IAM policy, so Terraform applying that binding fails with 'Policy update access denied'. Apply is all-or-nothing, so leaving it in blocks every unrelated infra change -- which it already did. Same situation as monitoring.admin and logging.admin, which the file already records as hand-granted. The gcloud command is in the comment.

infra/CI7b20067Claude

The plan job reports its own failures, which left apply as the one place a Terraform error could still vanish -- run log bodies are served from a host the session cannot reach, so a failed apply was just a red X. Adds pipefail (same reasoning as the plan step, where tee was masking the exit status) and an issue containing the error and permission lines.

infra/CI4a4f7e9Claude

The listing workflow returned SERVICE_DISABLED: Error Reporting shows up in the console because ingestion is automatic, but the REST service was never enabled, so nothing can list or resolve groups programmatically. Grants github-deploy roles/errorreporting.user, which covers groups.list/get/update. errorreporting.admin would additionally allow deleting all project error data, which nothing here needs.

featd761a3bClaude

The session has no route to GCP Error Reporting notifications, so an outage is only noticed when someone relays a Slack alert. This reports open groups as a GitHub issue the session can read, and can mark them RESOLVED once a fix is out. Resolve only ever touches groups whose lastSeenTime is older than quiet_minutes. Marking a still-firing error resolved would hide a live outage, so those are reported and skipped rather than closed.

feat1e18b20Claude

Push-triggered runs now read 'minutes=N' from ops/diagnose.txt, so the window can be widened without a workflow_dispatch this session cannot make.

featd4511cbClaude

game-reminder.yml had been broken since 2026-02-10. It passed three positional arguments -- date, time, court -- to a parser that accepts two, so every run died on "unrecognized arguments: Both". It worked in February, so court was removed from send_game_reminder() at some point and the workflow was never updated. send_game_reminder(sheets, game_date, game_time, dry_run) has no court parameter at all, so the input was dead as well as fatal; it is gone. Date and time are now optional: leave both blank and the workflow runs --send-on-game-day, which auto-detects today's games and reports why it skipped if there are none. Both invocation shapes were checked against argparse rather than assumed. /pb game remind dispatches that workflow instead of reimplementing the reminder. send_game_reminder marks attendance in the sheet and fans out DMs; a second copy in picklebot would be two things to keep in step. Admin-only, and "dry run" previews to Admin Dinkers without messaging the group or touching the sheet. Verified: all four command phrasings parse, "game cancel next" still routes to cancel_game rather than being shadowed, blank date/time omits the inputs so the workflow auto-detects, and a missing token or non-204 response surfaces as an error rather than a false success.

feat06e2d1dClaude

/pb game next stopped after the game block, so previewing the game day format meant dispatching the game-reminder workflow from the GitHub UI. It now carries the same modules as the reminder -- Top N leaderboard, weekly attendance, balances and the non-voter list -- via the same format_group_stats_block(), so the two cannot drift. Composed in render_next_game rather than format_next_game: the latter is a pure formatter over already-fetched data, while the extra modules need their own lookups. Stats are appended only when there is a game, so the "no poll data" and "no games" replies are untouched. Every added module is best-effort and independently wrapped. The next game is what was asked for; weekly attendance, the poll date, the non-voter list and the stats block can each fail without costing the caller their answer. Verified for all five failure paths plus the no-games case.

feate333411Claude

smad-whatsapp.py and webhook/shared/command_formatters.py both began with EF BB BF. Python strips a leading BOM itself (PEP 263) so nothing was broken at runtime, but tools that read the bytes do not: ast.parse() rejects U+FEFF outright, which is how this surfaced. Both were introduced on 2026-08-21 -- fc45723 and eec2b05 -- which is the signature of Windows PowerShell 5.1, where Set-Content -Encoding UTF8 and > redirects write UTF-8 with a BOM by default. Only the first three bytes of each file changed; the diff is one line per file and the remaining content is byte-identical. Verified after: both compile, both ast.parse from raw text, command_formatters renders, and smad-whatsapp.py imports. Adds a .gitattributes note so the cause is recorded where someone editing from a shell will see it. Use -Encoding utf8NoBOM (PowerShell 7+).

feat476189aClaude

The daily shame report and the game day reminder both fired at 8am and both carried a balance block, so on a game day the group got two near-identical nag lists minutes apart. Now the shame report skips game day entirely, and the game day report's group stats gain the non-voter list, rendered after the balance/credit block by the same format_vote_shame_block() the shame report uses -- so the two cannot drift. non_voters=None (the default) omits the section, which keeps existing callers such as /pb games unchanged; an empty list still renders the "everyone voted" line. The distinction matters: "no data to show" and "nobody to shame" are different states. Computing the list is wrapped -- if the poll date or sheet lookup fails, the section is dropped and the reminder still goes out. The reminder is the point; the list is a bonus. Verified: suppressed on game day, still sent on an ordinary day, and the pre-existing poll-creation-day rule still applies (including when both fall on the same day).

featd6a73faClaude

Locks the root requirements (53 packages) the same way as the four Cloud Functions. This one matters more than its "CLI only" label suggests: it is installed by 10+ workflows including court-booking, which races other club members for courts at midnight. An unpinned build there resolves "latest" at run time, so a bad release lands mid-race with no warning. Verified in a clean venv -- playwright, venmo-api, matplotlib, googleapiclient, pubsub, scheduler, storage and firestore all import, and api-core resolves to 2.34.0 with the correct (default) path. Also adds a weekly check for a google-api-core release that is safe to unpin. It does not compare version numbers: it installs each candidate and reads Client()._database_string, which is where the bug appears, and opens an issue only when a release is genuinely fixed. Silence means nothing to do. That check exists because a stale cap is the failure mode this repo keeps producing -- a gitignore rule that matched nothing for six months, secrets orphaned by a half-finished migration, a scheduler job removed from code but not from the project. A cap nobody revisits is the same shape. Probe logic validated locally: 2.34.0 reads (default), 2.35.0 reads %28default%29.

featf0ee6c7Claude

Completes the rollout started with whatsapp-message-sender: webhook (47), picklebot (53) and venmo-trigger (53) now pin every transitive dependency. requirements.in is the edited file; requirements.txt is generated, and Cloud Functions installs from it exactly as before. Each lock was installed into a clean venv and checked: google-api-core resolves to 2.34.0, Firestore builds projects/p/databases/(default) rather than the URL-encoded form, and picklebot's module-scope imports all load -- pubsub, storage, scheduler, firestore, googleapiclient, service_account, cloudevents. Note that google-cloud-firestore resolves to 2.29.0 in every lock. It shipped the same day as the api-core regression and looked like the culprit, but it is fine; capping api-core is what matters.

feat8f48401Claude

The recipient lines already end in "\n", so prefixing group stats with "\n\n" produced three newlines and rendered as two blank lines. Verified across all four shapes -- survey-available list only, no-survey list only, both lists, and nobody to DM -- plus the no-group-stats case, which must not leave a dangling newline.

feat0d62f52Claude

Adds requirements.in as the edited file and generates requirements.txt with all 38 transitive dependencies pinned. Cloud Functions still installs from requirements.txt, so the deploy is unchanged. Unpinned builds resolve "latest" at build time, which made every deploy a bet on whatever shipped that day. google-api-core 2.35.0 -- a transitive dep named in no requirements file -- URL-encoded the Firestore database id and took WhatsApp delivery down within a minute of the deploy that resolved it. A cap on the direct dep would not have helped, because the direct dep was not the problem. Verified by installing the lock into a clean venv: api-core resolves to 2.34.0, Firestore builds projects/p/databases/(default) rather than the encoded form, and the runtime imports load. Rolling this out one function at a time so a bad lock cannot take all four down at once.

feate9b9e74Claude

The audit write sat between process_command and send_whatsapp_message, so every command paid for a Firestore round trip before picklebot attempted to answer. Exceptions were swallowed, but latency was not. The google-api-core 2.35.0 regression made that concrete: with every Firestore call failing, each command burned a failing write before even trying to reply. Auditing should never be able to delay or degrade the thing it is auditing. Duration is still measured where the command finishes, so the metric still describes command time rather than reply time. The failure path audits immediately because it re-raises and never reaches the reply. Verified: reply precedes audit on success, a failure is still recorded with ok=False, and an audit that raises leaves the reply already sent and the request returning 200.

feat7d9b01bClaude

google-api-core 2.35.0 URL-encodes the Firestore database id into the resource path -- projects/P/databases/%28default%29 instead of (default) -- so every Firestore call fails with "400 Invalid database id". That took WhatsApp message delivery down: whatsapp-message-sender could not claim messages for dedup, so replies stopped being delivered and /pb returned nothing. Errors began 02:54:29Z, within a minute of the first deploy that rebuilt with the new library, and were still recurring at 03:01:57Z. Nothing in this repo changed to cause it. Every requirements file left google-cloud-firestore and its transitive deps unpinned, so any deploy would pick up whatever pip resolved that day. The trigger was a routine push touching webhook/shared/, which rebuilds all four functions. Isolated by bisecting rather than guessing. google-cloud-firestore 2.29.0 also shipped 2026-08-24 and looked like the obvious suspect, but it is innocent: with api-core 2.34.0 even 2.29.0 builds the path correctly, and with 2.35.0 firestore 2.28.1 is broken. google-cloud-core is irrelevant in both directions. Verify any future bump with Client(project='p')._database_string.

fix643a1c0Claude

picklebot hardcoded claude-3-haiku-20240307, which has been retired upstream. Every natural-language command was returning 404 not_found_error, which is what tripped the Cloud Run ERROR alert. Fast-path commands were unaffected -- parse_intent_fallback runs before the API call, so /pb help and friends never touched it. Only commands the fallback parser cannot handle were broken, which is why this stayed invisible. Moves the model to ANTHROPIC_MODEL in shared config, defaulting to claude-haiku-4-5-20251001. Env-overridable so the next retirement is a variable change rather than a code deploy. Also widens the log diagnostic to whatsapp-message-sender: picklebot returned 200 for every command, so a missing reply is downstream of it.

feat0189141Claude

General-purpose 'what is production saying right now' reader: severity >=WARNING plus container lifecycle lines and request-status counts for both HTTP functions, reported as an issue because run log bodies are served from an unreachable host. Truncates every line rather than dumping payloads, which carry PII.

feat39616d4Claude

send_game_reminder bailed on an empty roster with a bare logger.warning and return (0, 0). Nobody was told, so a game day report could fail to go out with no trace anywhere -- which is precisely why "why didn't the game day report send?" was unanswerable earlier today, and it was made worse by INFO logging being discarded in production at the time. Now notifies the Admin group, and distinguishes the two causes, because they call for different responses: The notification is wrapped: a failure to reach the Admin group must not turn a skipped reminder into a crash.

  • marked_names empty: no 'y'/'2' in the date column, so either nobody voted or the column does not exist.
  • marked_names non-empty but nothing matched the roster: a name mismatch between the date column and the player list. That is a bug, not a quiet week, and previously looked identical to the first case.
featdb84b44Claude

Both payment sync paths wrapped maybe_send_extra_notifications in "if sms_reminder:". That made sense when SMS was a real channel, but Twilio is gone and the function now sends email only -- so anyone who had not opted into SMS received no payment notification at all. Not a downgraded one: nothing. zelle_sync's own comment above the gate still read "# SMS + email", which is where the assumption came from. The guard was redundant as well as wrong: maybe_send_email already no-ops for a player with no email on file, which is the check that actually matters. Verified that a player who declined SMS but has an email now gets one, and that a player with no email is still skipped.

feat85a7b17Claude

call_api logged only the status code, which is what made the archive removal bug expensive to find: picklebot was deployed without GREENAPI_INSTANCE_ID/API_TOKEN and removeGroupParticipant returned a bare 403, indistinguishable from GREEN-API refusing an authenticated request. Fixed in 9781931 by mounting the credentials; this makes the next such failure legible instead of opaque. Adds the response body (capped at 300 chars) and, on 401/403 with empty credentials, says so explicitly. The URL is still never logged -- the API token is a path segment in it.

infra/CI9eebb0cClaude

Provisioning a Firestore TTL field runs a server-side field operation that took over three minutes. That exceeded the Terraform job's old timeout-minutes: 3, killed the apply mid-run, and left a stale state lock in GCS which then blocked every subsequent plan. Recorded next to the resource so the next person does not rediscover it the same way.

infra/CI548e664Claude

Run log bodies are served from a host that is unreachable from the Claude Code session, so a failing plan is currently a dead end without opening the browser. On failure the plan job now opens an issue containing the error and lock lines only -- not the full plan, which can echo resource attributes and whose state reads a secret version -- plus the force-unlock command, since a killed apply leaving a stale lock is the likeliest cause.

infra/CI1021becClaude

The plan piped into tee, so the step's exit status was tee's. A failed plan reported success, wrote no tfplan, and the failure only appeared two jobs later as "Download Plan Artifact: failure" -- pointing at artifacts rather than at the plan error that actually occurred. That is exactly how the last run presented, and it cost a diagnostic round trip. Adds set -o pipefail, preserves the real exit code, echoes the tail of a failed plan into the job summary, and sets if-no-files-found: error on the upload so a missing tfplan is reported where it happens.

infra/CId791127Claude

The apply for the pb_commands TTL ran 3m15s against timeout-minutes: 3 and was killed mid-apply, so the run reported "cancelled" and the TTL may not have converged. Firestore TTL and index field operations provision a field index server-side and routinely exceed three minutes; the previous apply, which only touched a log exclusion, finished in 17 seconds. The old limit was tuned for fast resources. Plan 3 -> 6, apply 3 -> 15. State is in GCS with locking, so a killed apply can also leave a lock behind -- the next run will surface that as a state lock error rather than silently doing nothing.

feat95a4affClaude

Cloud Logging cannot answer who uses /pb and how often. The webhook deliberately keeps payloads out of the logs because they carry PII, so no log line records a sender -- and the INFO lines that recorded the command itself were being discarded entirely until the previous commit. Counting usage from logs is not fixable; it needs a durable record. One document per invocation in pb_commands: raw text, parsed intent, sender name and phone, chat type, success/error, duration and dry-run flag. Raw text is kept -- it can contain player names, but Firestore is reachable only by the service account and those names already live in the sheet. Duration also exposes cold starts. Wrapped at the single process_command call site rather than inside it, so duration and failures are captured uniformly including exceptions, and log_command swallows everything: auditing must never break the command a user asked for. /pb usage reads it back, admin-gated, since it shows who ran what. The TTL (400 days) is declared in Terraform rather than set with gcloud like the processed_messages one, which is invisible to Terraform -- the same drift that left dead Twilio secrets and orphaned SMAD_SPREADSHEET_ID in Secret Manager.

infra/CI46bedbaClaude

The keepalive job GETs the webhook every 10 minutes and the webhook GETs picklebot in turn, about 288 request log lines a day. Over a 30 day sample that was 4,549 of picklebot's 4,663 lines -- 97% noise, burying the entries anyone actually needs. For legibility, not cost: ingestion is a few MB a month against a 50 GiB free tier and Cloud Logging does not appear on the bill. Kept narrow because an exclusion drops entries at ingestion and they cannot be recovered. Only status=200 is dropped, so a failing keepalive still lands in the logs -- the GET handler now returns 503 when picklebot is unreachable, which is the case worth keeping. Only GET, so inbound POST webhooks are untouched. ?action=confirm is preserved, being a person clicking a confirmation link rather than a health check.

fixd47c912Claude

logging.basicConfig() is a no-op when the root logger already has a handler, and functions-framework installs one before these modules are imported. The level therefore stayed at WARNING and every logger.info() call was dropped -- roughly 128 call sites, 54 in the function files plus 74 in shared/ which CI copies into each. Confirmed against production rather than inferred: in Cloud Logging every INFO string returns zero hits ("Processing command", "Parsed intent", "Message queued to", "Fast-path parsed") while WARNING and ERROR strings from the same functions are present ("Failed to remove ... from WhatsApp", "Claude API error"). Reproduced locally, and force=True restores INFO. This is why /pb usage cannot be counted -- the lines that would record it were generated and thrown away -- and why recent debugging had so little to work with.

featfde6cd3Claude

The selector is confirmed working -- 4663 log lines for smad-picklebot in the window -- and retention reaches back to 2026-01-29, so July is queryable after all and the earlier 30-day assumption was wrong. But "Processing command" still matches nothing, so the phrase or the payload field is wrong rather than the usage being zero. Samples message shapes (text up to the first colon, digits collapsed) so the log vocabulary is visible without putting player names into a public issue, and probes other known picklebot log strings to separate a wrong phrase from a wrong field.

feat4ada34cClaude

The first run returned 0 rows for every window, including the unbounded "oldest retained entry" query. That is the signature of a filter that matches nothing, not of a service nobody uses -- a wrong resource label returns 0 rows indistinguishably from genuine non-use, and reporting "you never use /pb" off that would have been wrong in a way that quietly decides the min-instances question. Now probes each candidate selector (service_name, function_name, configuration_name, logName) and each plausible payload field (textPayload for stderr, jsonPayload.message for structured logging), reports which matched, and prints total log volume for the service so a zero count can be told apart from a broken query.

feat8484fc4Claude

There is no durable /pb command log -- invocations exist only as "Processing command:" lines in Cloud Logging -- so counting them has to happen where GCP credentials live. Push-triggered rather than dispatch-only because the GitHub App has Actions read but not write, and results come back as an issue because run log bodies are served from a host the session cannot reach. Both are workarounds for the same sandbox limits, written down so the shape of this workflow is not mysterious later. Reports command verbs only. Full command text can embed player names ("/pb payment remind Jane Doe"), and the webhook already keeps payloads out of logs for that reason. Also reports the oldest retained entry, since the _Default bucket holds 30 days and July is likely already purged.

feated82349Claude

The GET keepalive returned 200 unconditionally, including when its picklebot leg failed. Cloud Scheduler only checks HTTP status, so a broken ping -- wrong PICKLEBOT_URL, timeout, picklebot down -- left the job green while picklebot quietly went cold. That matters because picklebot is the expensive half: 9 dependencies and 6 secrets read at instance startup, against 7 and 3 for the webhook. It is the cold start the ping exists to prevent, and the failure mode hid exactly that. Now returns 503 when picklebot answers >=400, is unreachable, or when PICKLEBOT_URL is unset -- the last being a misconfiguration that would otherwise warm only the webhook with nothing saying so. Scheduler records a failed run and retries.

feat2e99bb1Claude

Removes a 204KB GitHub Actions log export that has been tracked since February. Its filename is a mangled Windows path -- d:\code\SMADPickleBot \temp_logs.txt with the backslashes eaten and the colon replaced by U+F03A, a private-use lookalike -- so a redirect created a file in the repo root instead of writing to a path. That invisible character is why the existing `dcode*temp_logs.txt` rule never matched it: nothing matches `dcode` when a codepoint sits between `d` and `code`. The rule was written, looked right, and did nothing for six months. .gitignore had accumulated four such entries, each naming one dump after it caused trouble, including temp_logs.json which I added the same way earlier today. Replaced with rules for the class. git check-ignore confirms both dumps are now caught; memes.json and unicode_report.json stay tracked. Neither file contained a credential: the Athenaeum username and password in the CI log are *, along with 678 other values GitHub redacted, and the high-entropy strings that triggered the scanner are Cloud Run mount aliases, runner image hashes and resource paths. History is left intact -- rewriting it would invalidate every SHA for no security gain.

infra/CIf2a55d5Claude

iam.tf grants the compute service account roles/secretmanager.secretAccessor at the project level, so every Cloud Function can already read every secret. The per-secret bindings in secrets.tf grant a role the member already holds and change nothing. The list reads as least privilege and is not: all four functions run as the same default compute service account, so per-secret bindings cannot restrict one function relative to another -- there is only one identity to bind. That would require a service account per function and dropping the project-level grant. This is worth writing down because the list is missing GREENAPI_WEBHOOK_TOKEN, which looks like an access gap and is not; the README claimed exactly that in the previous commit. Corrected there too.

infra/CI89e5593Claude

Listed the live secrets rather than inferring them from the repo, which turned up three that nothing consumes: Records the remaining 8 in a README inventory table with what mounts each and why it is a secret, so the next audit does not have to reconstruct this. Also notes that GREENAPI_WEBHOOK_TOKEN is mounted by the webhook but has no Terraform IAM binding, and that Secret Manager bills for reads as well as storage -- cold starts, not secret count, drive that half. Drops the one-shot Twilio cleanup workflow; the deletions were done directly with gcloud.

  • TWILIO_MESSAGING_SERVICE_SID: a fourth Twilio secret. The removed{} blocks in secrets.tf named twilio_from_number, which never actually existed, and never mentioned this one -- so it was invisible from the repo either way.
  • SMAD_SPREADSHEET_ID and SMAD_SHEET_NAME: left behind by the item 4 migration in CLOUD_COST_OPTIMIZATION.md. That change moved the functions to --set-env-vars sourced from GitHub Variables but never deleted the Secret Manager copies, and since nothing referenced them any more they were undetectable by grep.
infra/CId513413Claude

Terraform never owned TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER, so they survive in Secret Manager after the config cleanup and can only be removed with gcloud -- which has to run where GCP_SA_KEY exists. The secret names are a hardcoded allowlist rather than an input, so this cannot be repurposed into an arbitrary secret-deleter, and it requires typing DELETE to do anything. Idempotent: an already-absent secret is reported rather than failing. The exit status carries the outcome so the result is readable from the API without the log body. Safe to delete once it has been run.

featcce35f3Claude

The Twilio account has been closed — the number was never approved for A2P 10DLC — and nothing in the codebase referenced TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN or TWILIO_FROM_NUMBER: no Python, no workflows, no requirements entry. Only three `removed{}` blocks in secrets.tf still named them. Those blocks were already applied, so Terraform holds none of these in state and dropping them changes no infrastructure. The secrets still exist in Secret Manager and have to be deleted with `gcloud secrets delete`; a note in secrets.tf records that, and that a future SMS channel should provision its own rather than reuse them. Also drops temp_logs.json, a 33KB Cloud Run config export that was committed by accident, and gitignores it. Checked before removal: it contains secret *names* in service annotations, not values.

infra/CI95b4abbClaude

Actual GCP billing for Aug 1-24 2026 shows Cloud Scheduler at $0.39 and Secret Manager at $0.49, against docs that claimed Cloud Scheduler was "$0.00/month, fully covered by free tier" and Secret Manager ~$0.30. The free tier is 3 jobs per billing account, and the 3 permanent jobs do fit inside it. But schedule-last-call.py creates per-game jobs every week -- 2 last-call jobs per game plus 1 lights reminder per night game -- so the live count runs well past 3 and has done for a long time. Three docs asserted $0.00 while reasoning only about the permanent jobs. Also records the GCP subtotal from real billing rather than estimates: $0.88 month-to-date, $1.06 forecast. Cloud Scheduler and Secret Manager are the only non-zero lines.

infra/CI8cadfedClaude

Cloud Scheduler jobs in this project were created by hand and imported into Terraform afterwards, per the import commands at the top of scheduler.tf. So removing a resource block is not proof the live job is gone: if it was never imported, terraform apply reports success and changes nothing, and terraform plan cannot tell the difference because Terraform does not know about resources outside its state. This runs gcloud where the credentials actually live. The exit status carries the answer so the outcome is readable from the API without the log body, which GitHub serves from a host the sandbox cannot reach. Deleting requires explicitly selecting action=delete.

feat45d6b60Claude

Poll creation had its own Cloud Scheduler job, 8am-sunday-poll-creation, which POSTed to the same workflow file as 8am-daily-runner and differed only by passing reminder_type=poll_only. That was never two runners: it was one workflow with two triggers whose steps were gated into disjoint sets. Both fired at "0 8" on Sunday, so the reminder run read poll state while the poll run was still creating it. A "Resolve run mode" step now decides from the PST weekday, and the poll steps gate on that instead of on reminder_type. They already sat above the reminder steps, so poll creation completes before any reminder runs and the race is gone structurally rather than worked around. Dispatching with reminder_type=poll_only still forces poll creation on any day. The poll steps get continue-on-error so a flaky Athenaeum scrape cannot abort the job and silently drop the payment, survey and shame reminders, which an isolated run could not do before. Timeout goes 5 -> 20 minutes since one run now does both halves including the Playwright install. POLL_CREATION_WEEKDAY becomes a single source: the GitHub Variable gates the workflow and is written into .env for the vote-reminder guard, so the weekday is no longer duplicated against a cron expression. This also drops the scheduler back to 3 jobs. Cloud Scheduler's free tier is 3 per billing account, and adding the Sunday job had quietly made it 4 while gcp-scheduler/README.md and CLOUD_COST_OPTIMIZATION.md both still claimed "exactly 3 jobs, fully within free tier". Removing the resource from Terraform does not delete the live job; that needs a terraform apply, which should show exactly one destroy.

feat155b7a1Claude

Vote reminders no longer go out on the day the weekly poll is created. A reminder that morning is about the poll being replaced, or about one that does not exist yet. The guard leads with a weekday test rather than asking whether a poll was created today. The poll-creation job ("0 8 * * 0") and the daily reminder job ("0 8 * * *") fire on the same minute every Sunday, and the reminder job normally wins that race: it reaches vote reminders in a minute or two while the poll job is still installing Playwright and scraping reservations. At that point the newest poll on record is still last week's, so a created-today check alone would not fire. That check is kept as a secondary signal for polls created ad hoc on other days. POLL_CREATION_WEEKDAY (default Sunday) must stay in sync with the sunday_poll cron in infra/terraform/scheduler.tf. Setting it blank falls back to the created-today check only. Picklebot's "/pb poll reminders" is deliberately untouched, so an admin can still send one by hand. Also sorts the not-voted-this-week list by last name when players are tied on vote nag count, instead of by first name.

fix9781931Claude Opus 5
  • Add GREENAPI_INSTANCE_ID and GREENAPI_API_TOKEN secrets plus SMAD_WHATSAPP_GROUP_ID to the picklebot deploy, which had none of them
  • Root cause of every failed archive removal: picklebot called removeGroupParticipant with an empty instance and token and got HTTP 403, while DMs and email still worked because those go through whatsapp-message-sender, which does have the credentials
  • Verify removal against the live participant list instead of trusting the response flag — GREEN-API returns removeParticipant true even for a number that was never in the group, so it proves nothing
  • Retry the membership check once after 2s to absorb WhatsApp propagation lag
  • Log an explicit error when GREEN-API credentials are missing, instead of surfacing an opaque 403
  • Add get_group_participant_ids() to the shared GREEN-API helpers
  • Report the real removal count in the CLI archive summary, which previously printed SUCCESS regardless
  • Give the archive DM its own try block so a bad number or email can no longer skip the group removal
featbb8a0b4Claude Opus 5
  • Send Gene a DM right after the weekly poll posts, listing the poll question and its options, as a nudge to pin it in the group
  • GREEN-API exposes no pin method — its groups API covers membership, settings and metadata only, and its service methods stop at delete/edit message — so pinning stays manual
  • Vote reminders, last call and the shame report all tell players the poll is pinned to the top, so a missed pin makes three messages wrong; the DM lands at the one moment it can be acted on
  • Add format_poll_pin_reminder() to the shared formatters
  • Wrap the send in try/except so a DM failure can never fail poll creation
  • Note the DM in the create-poll dry-run output
feata429f4dClaude Opus 5
  • Title the fallback report "Week of MM/DD/YYYY Upcoming Games" so it names the week it covers
  • Drop the per-game roster and its "we need N more players" warning from that report via a new show_players flag — right after the Sunday poll the only voter is whoever created it, so both lines are noise
  • Fix the weekly attendance window: "This wk" showed the calendar week ending that Sunday, already played out, while the games listed above it were the week after. The fallback report now shifts to base_offset=1, so This wk is the poll's week and Last wk is what actually just happened
  • Deduplicate the weekly attendance loop, which was copy-pasted in three files, into shared get_weekly_attendance_counts(base_offset=0)
  • Shorten the group stats titles to fit one mobile line: "Top 15: Hours Played Since 6/25" and "Player Bal/Cred and Nags", both without a trailing colon
  • Drop the trailing colon from the vote shame title too
  • Put the first row of every monospace table on its opening fence — a newline straight after the fence makes WhatsApp open the block with a blank line
  • Show the "only creditors" congratulation in Group Stats, not just the shame report
  • Move the shame report's all-square line onto its own line instead of hanging it off the title
  • Remove the blank line between group stats and the signature
feat45c216bClaude

Size the table columns from actual content instead of fixed widths, and abbreviate the DUPR header to DPR. Hrs and W-L reserved 4 and 5 columns for values never wider than 3, and the rank field reserved 4 for "15.". At 36 columns the table overflowed the ~32 monospace columns a phone renders, wrapping W-L onto its own line. It now measures each column from its widest real value and comes to 32, with every header sitting squarely over the values it describes. All five columns are retained. Widths adapt: the rank narrows to 2 with single-digit ranks, and W-L grows to 4 once someone reaches ten wins.

feat1c762f4Claude Opus 5
  • Add the report date to the header as Daily Shame Report for MM/DD/YY, using PST so the date matches the group's day rather than UTC
fixd77ef2fClaude Opus 5
  • Move the no-debtors congrats line under the Player Balance/Credit header instead of under the report title, where it referred to nothing
  • Put it inside format_balance_block behind a congrats_if_no_debtors flag so the header and its caption cannot drift apart; Group Stats stays unchanged, having no shaming framing to correct
  • Drop the blank line between the balance table and the vote list, and between the vote list and the closing blurb, so the two lists read as one block
  • Fix the blurb typo: want show up becomes want to show up
  • Remove the hardcoded Sunday check from the send guard, leaving only the poll creation day skip; the weekday was a proxy for the real condition and would have pardoned the wrong day if poll day ever moved
  • Update the workflow log line and picklebot README, which both said Sundays
featbab05edClaude Opus 5
  • Add a line after the shame report title when there are creditors but no debtors, so the balance block does not read as a shame list when everyone is actually in credit
  • Keep the existing all-square message for when nobody has any non-zero balance at all
feat68d9919Claude Opus 5
  • Add /pb shame and a shame-report CLI command: who owes money and who has not voted for this week's poll
  • Post it to the group from the 8am daily runner, after Venmo sync and payment reminders so balances and nag counts are same-day fresh
  • Pardon everyone on Sundays, and on any day the poll was created, so nobody is shamed before they have had a chance to vote; viewing it on demand still works every day
  • Extract format_balance_block() out of the Group Stats block so the report and Group Stats render balances from one place
  • Add column headers to the balance table (Name, Bal/Cred, Pay Nags) and drop the per-row nags suffix the header now covers, keeping it 31 chars wide
  • Add format_vote_shame_block(): non-voters reverse-sorted by vote nags, with vacationers and archived players already excluded upstream
  • Split the closing blurb from the vacation command, since WhatsApp italics do not span newlines and the underscores were rendering literally
  • Update README, picklebot README and CLAUDE.md
fix4ed157cClaude Opus 5
  • Remove the balance column and the show_balance parameter from format_player_table, making it pure playing stats: rank, name, DUPR, hours, W-L
  • /pb players drops from 43 to 37 characters wide, which was wrapping on phones
  • Balances remain available via /pb balances and the group stats balance block
  • Update README, picklebot README and CLAUDE.md
feate2f5dbaClaude Opus 5
  • Add format_player_table(), the single renderer behind /pb players and the Group Stats leaderboard, parameterized by show_balance and a limit for the Top X cutoff
  • Add a W-L column to both, so match records show up next to hours and DUPR
  • Render a missing W or L as 0 via format_wl(), since a blank COUNTIF result is not a real record
  • Keep sorting on hours played descending in both; there is not enough match data yet to rank on win-loss
  • Drop the balance column from the group leaderboard, which goes to the whole group
  • Return wins and losses from get_full_player_data(), read from the main sheet W/L columns at no extra API call, as None when the formula has not been filled down
  • Replace the hardcoded 15 with LEADERBOARD_TOP_N so the cutoff and the heading cannot drift apart
  • Leave /pb players reminders on its own table, unchanged
  • Update README, picklebot README and CLAUDE.md
fix1ca94f8Claude Opus 5
  • Rename Win Loss Log column 1 from Date to Game Date and populate it with the session's date; it previously held the date the row was written, duplicating Recorded At, so the game's date was never stored at all
  • Force Game Time to text: USER_ENTERED was coercing "8pm" into an 8:00 PM time value, same class of bug as the score being read as a date
  • Anchor Match ID to the game instead of the write time, and make the trailing number the match's sequence within that session, so 20260821-8pm-1 is the first match of the 8/21 8pm game
  • Add next_match_id() to derive that sequence from existing rows
  • Keep Recorded At as the write timestamp, which is what it always was
  • Update CLAUDE.md with the column semantics and the text-coercion rule
feat0f476ddClaude Opus 5
  • Add optional match score to /pb match: enter only the LOSING score, winner derived from league rules (to 11, win by 2 from 10-10, hard cap 15) via new derive_score()
  • Reject a losing score above 14, since 15 is the cap
  • Write the score cell as forced text: USER_ENTERED coerces "11-9" into a date while leaving "15-13" as a string
  • Support @me in match lines, resolving to the sender via their phone, because WhatsApp will not let you @-mention yourself
  • Support typed @names including multi-word ones like @Jerry Shen, so the scorekeeper can write @gene for themselves
  • Treat @ as an explicit player delimiter, so mentions, @me and typed names mix freely on one line
  • Only score-parse lines that use @, and never when the last @-segment is itself a roster name, so "John Wang 2" is not read as John Wang plus a score of 2
  • Fix two regexes whose \b had been written as a literal backspace character, which broke every phone-mention lookup and the typed-name and-splitter
  • Drop the now-unused MENTION_RE from match_log
  • Update README, picklebot README and CLAUDE.md for scores, @me and @names
feat45bc743Claude Fable 5
  • New Commits per day panel right after Commits per week: one thin bar per calendar day across the whole project, month ticks beneath, shares the existing hover tooltip
  • New Commits by hour panel: the time-of-day data collapsed onto one horizontal axis, 24 cells left to right, labelled every third hour
  • Heatmap thresholds refactored into a shared heat_thresholds/heat_level pair and switched to quartiles pinned at the smallest present value. The earlier cutoffs bunched at the top and flattened the middle: the hour strip read 1/35/41/61 so hours with 25 commits looked the same as hours with 1. Now 1/29/38/44 for the strip and its own scale for the grid
  • Corrected a wrong assumption in the process: the hour strip does have empty hours, 4am through 7am have zero commits in the entire history
  • Verified both panels in light and dark themes
feat90843a4Claude Fable 5
  • 24 hour rows x 7 weekday columns, matching the reference layout
  • Shading thresholds computed from the quantiles of non-empty cells at build time rather than hardcoded, so they stay meaningful as the repo grows. Current data (max cell 19, 108 of 168 cells non-empty) yields 0, 1+, 4+, 7+, 12+ where the reference sample used 4/8/11/14, which would have caught only 4 cells
  • Sequential single-hue green ramp with its own dark-theme steps, tokenised as --hm0..--hm4
  • Cells are keyboard focusable and share the existing tooltip, showing day, hour, and commit count
  • Panel subtitle surfaces the busiest hour and the share of commits between 9pm and 5am
  • Verified in both light and dark themes
fix0e86a32Claude Fable 5
  • Health Log entry lines now read: 08/21/2026: 195.4 lbs | 226h | 3.5
  • health.py now passes credentials_file like payments and match_log do. Without it the CLI fell through to ADC, which lacks the Sheets scope, and the blanket except turned the 403 into an empty log that was indistinguishable from having no entries. Production was unaffected since it gets creds from the env var
  • Verified against the real log: 14 entries render correctly
fixa9a0eb7Claude Fable 5
  • Doubles only: exactly 2 players per side, singles and uneven teams rejected
  • Every player in a match MUST have voted to play in that session or record_match hard-fails. Votes generate payment invoices, so an unregistered name would put the match log out of sync with billing
  • Validation lives in record_match so the bot and the CLI enforce it identically
  • Session resolution: matches attach to the most recent game that has already STARTED, and that session stays current until the next game starts. No time limit, so a session can be logged any time before the next one begins. Replaces the previous today-only lookup that picked the first game of the day and went blank the next morning
  • current_session and session_label moved into shared/match_log.py so both entry points use one implementation
  • Removed /pb record: it read as a write command next to record weight and record payment. Own record now shows in /pb stats my, everyone else in /pb standings. record and records now route to standings so old habits still land somewhere useful
  • Dropped the now-meaningless --game-time CLI flag and the unused format_player_record formatter
  • Verified: unregistered players rejected in single and batch form via both bot and CLI, valid four-voter match passes, mixed batch reports per line, and the session footer only shows when something was actually logged
  • Docs updated in README, picklebot README, CLAUDE.md
feat0dbf15fClaude Fable 5
  • New Win Loss Log sheet, one row per match (win/loss only, no scores); every row names all four players so head-to-head and partner stats stay computable later
  • New webhook/shared/match_log.py: record_match, undo_last_match, parse_match_line, get_player_record, get_standings, get_all_records (60s cache), ensure_match_log_sheet (real addSheet, unlike the health-log pattern which cannot create a tab)
  • Name input via WhatsApp @-mentions: verified against 15 real group messages that mentions arrive as raw phone digits inline in the text with no structured metadata, so we parse @(digits) and resolve against the roster Mobile column. Exact resolution, no name ambiguity
  • Typed names still work with explicit ambiguity errors (the roster has two Dereks and two Marks)
  • Batch entry: one match per line, so a scorekeeper logs a session in one message and fires one notification burst instead of ten
  • Main sheet W and L columns registered in SHEET_COLUMNS FIRST (find_date_columns treats undeclared headers as game dates), then inserted after Vote Reminders as COUNTIF formulas over the log, so one match updates all four players with no extra writes
  • insert_member_alphabetically seeds the same formulas for new members
  • Picklebot: /pb match, /pb match undo, /pb record [@name], /pb standings, wired into the fallback parser, Claude intent catalog, dispatch and help
  • CLI: match-record, match-undo, standings for testing without sending WhatsApp messages
  • Stats block gains a Record line (omitted for players with no matches) across all six call sites
  • Verified end to end on the real sheet: tab auto-created, 4 test matches logged and W/L formulas computed correctly, standings and streaks correct, undo removed all 4, and game history/vote parsing/poll games all still intact. Test data removed; the log ships empty
  • Docs: README, picklebot README, CLAUDE.md registry
refactoreec2b05Claude Fable 5
  • format_group_stats_block shows 15 players instead of 20, header updated to match
  • CLAUDE.md registry entry updated
featfc45723Claude Fable 5
  • New next shorthand resolves the earliest upcoming game from the current poll, in both picklebot (cancel game next / game cancel next) and the CLI (cancel-game next)
  • Reports back clearly when there is no upcoming game to cancel, instead of failing to parse
  • All cancel-game paths now DM and email every player who voted yes, with the game time and the courts released
  • Notification runs FIRST, before the sheet column is deleted: the votes live in that column, so afterwards there is no record of who was expecting to play
  • Voters with no player record are surfaced in the admin summary rather than silently dropped
  • New shared formatter format_game_canceled_dm() in command_formatters.py
  • Fix pre-existing step numbering in cancel-game output (was 1/3, 2/3, 3/4, 4/4)
  • Verified: parser returns next for both phrasings with no regression on day-of-week or date+time, no-next-game path reports correctly, and a dry run against a real game notified all 5 voters before deletion
  • Docs updated in README, picklebot README and CLAUDE.md registry
fix0e5903cClaude Fable 5
  • GREEN-API getChatHistory has no offset/cursor and caps at what it retains: 1,309 messages back to June 1 for this instance, identical whether you request 3,000 or 10,000
  • The old 500 default silently stopped at July 14, skipping six weeks of history; script default is now 2,000 so a manual run always hits the ceiling
  • Daily workflow run passes --count 300, about 2-3 weeks of buffer at this group ~16 msgs/day, so missed runs are covered but jokes deleted from the sheet stay deleted once the source ages out
  • Host-only filtering let non-jokes through once the deeper scan reached them (a Facebook photo album, two personal timeline posts, a group invite); now filters on content-type paths: instagram reel/p, facebook reel/watch, youtube shorts/watch, tiktok video, reddit post, and short links, while rejecting groups, media, events, stories and personal posts
  • Deep scan added 14 more jokes; sheet now at 150 entries, reruns confirmed idempotent
feat0c63a57Claude Fable 5
  • Add scripts/refresh-jokes.py: pulls SMAD group chat history via GREEN-API, keeps links to Instagram, Facebook, fb.watch, YouTube, TikTok, Reddit and X, and appends new ones to the Pickleball Jokes sheet
  • Dedupe on normalized URLs: strips tracking params (igsh, igsi, mibextid, fbclid, fs, utm_*), www, trailing slash and fragment, so the same reel shared twice counts once
  • Resolves WhatsApp @-mentions from raw phone numbers to player first names, so numbers are never stored in the sheet or re-posted in future jokes
  • Skips Picklebot own messages (sendByApi) since those links came from this sheet
  • Skips bare links with no caption, which jokes.py would ignore anyway
  • Wire into sync-members.yml after survey sync, continue-on-error so a cosmetic scraper failure cannot fail the member sync or trip the watchdog; job timeout 3m to 5m
  • First run added 10 jokes (126 to 136 entries), rerun confirmed idempotent
  • README scheduled jobs table and CLAUDE.md jokes.py entry updated
docs338baf6Claude Fable 5
  • Remove Twilio SMS row: Twilio was removed from the codebase in July (WhatsApp DM + email only now)
  • Drop the obsolete Twilio-verification justification from the GitHub Pro row
  • Note that Claude Code Pro also covers the cloud routine republishing the Release Dashboard
  • Total updated from ~$40 to ~$36/month
docsb299b0eClaude Fable 5
docs: clarify peak tile captions as peak commits week and all-nighter
featd6f360cClaude Fable 5
  • Swap emoji: commits gets the package, deploys/week gets the rocket (launches)
  • New tile: % of commits co-authored by Claude Code
  • New tile: % fix commits, a proxy for DORA change failure rate
  • Grid goes to four columns in two rows, grouped as scale/quality on top and velocity below; 2 columns under 640px
refactor6cac3e6Claude Fable 5
  • Replaces the centered max-content grid with plain text-align center, which reads better in the wide 3-across tiles
refactor80f6e75Claude Fable 5
  • Tile becomes a grid with a centered max-content column, so the number and caption keep one shared left edge while the block sits centered with equal space on both sides
  • Replaces the asymmetric left padding, which left a large empty gap on the right once tiles went from 5-across to 3-across
  • Slightly more vertical padding so the wide tiles read less like flat rectangles
perfb56cb08Claude Fable 5
  • loc_history() now returns weekly line counts AND Python churn from one git log --numstat walk instead of two
  • numstat needs blob content, so on the cloud routine blobless clone each pass re-fetched all history over the network; the build went from 1s locally to a 120s+ timeout in CI and the run never published
  • Also reverted the routine fetch to a full --unshallow (no --filter=blob:none)
  • Local build now 1.1s, down from 2.0s
feat5383edbClaude Fable 5
  • New deploys/week tile counts only commits touching deploy-triggering paths (webhook/ and the deploy workflows); counting all commits would overstate frequency about 2x since roughly half touch docs, CLI, or Terraform only
  • Uses the same trailing 90-day window as the peak tiles
  • Stat grid becomes two rows of three to fit six tiles
  • New Lines of code over time panel: SVG line chart of net lines per week, total and Python, computed from cumulative git numstat (lands within 0.2% of the true current count)
  • Panel subtitle surfaces the churn behind the curve: Python lines written vs deleted
  • Add --accent2 token for the second series in all three theme states
docscf7ea5dClaude Fable 5
  • Reflects measured picklebot deploy after the skip_install change (was 82-94s, now 73s)
  • Updated in both places: the intro paragraph and the stat tile
refactor7b6ecccClaude Fable 5
  • Lines-of-code tile moves from last to first position
docs1ef8bceClaude Fable 5
  • Page title and h1 renamed from PickleBot Full Shipping Log
  • README link text and Scheduled Jobs reference updated to match
perfe51f142Claude Fable 5
  • setup-gcloud was downloading and installing the Cloud SDK on every run (~29s) even though ubuntu-latest ships gcloud preinstalled
  • skip_install: true reuses the runner gcloud; auth step still supplies credentials
  • All four deploys only use core gcloud functions commands, no alpha/beta components
  • Expected: ~90s to ~60s per deploy; Cloud Build container build (~34s) is the remaining floor
  • gmail-watch-renewal left unchanged: manual-dispatch only, so the saving is negligible and it cannot be verified without renewing a real Gmail watch
refactordf11a40Claude Fable 5
style: rename busiest day tile to peak all-nighter
refactorc11bc11Claude Fable 5
  • Peak week uses a calendar to denote the week window
  • Busiest day uses a crescent moon for the all-nighter connotation
refactoref34661Claude Fable 5
  • Tile padding goes from 14px all round to 20px left / 12px right
  • Content stays left-justified but no longer hugs the tile border
featfffd0ddClaude Fable 5
  • Peak week and busiest day now use a trailing 90-day window instead of all time, which was permanently pinned to the January MVP sprint and said nothing about current pace
  • Tiles label the window and resolve ties to the more recent week/day; falls back to all-time if the repo goes quiet for 90 days
  • Non-breaking spaces keep the date and window label from splitting across lines
  • Emoji sized to 22px so their glyph height matches the stat numbers
fixe6510edClaude Fable 5
  • Commits tile uses the rocket (launch reads clearer than a package box)
  • Push-to-live tile uses an alarm clock, which matches a duration stat
fixc59c765Claude Fable 5
  • Corner absolute positioning padded the tiles into rectangles and the icon floated over the number line
  • Emoji now sits inline right after each stat with a space, sized slightly smaller than the figure
  • Drop the now-unneeded position relative on the tile
feat8ad1dbbClaude Fable 5
  • Semantic icon per tile: package for commits, flame for peak week, bolt for busiest day, rocket for deploy speed, laptop for lines of code
  • Positioned as subtle top-right corner marks so they decorate without competing with the numbers
  • Marked aria-hidden since they are decorative and the caption already names each stat
fixb5b5045Claude Fable 5
  • Replace auto-fit grid (which wrapped the 5th tile) with explicit 5 columns
  • Collapse to 3 columns under 780px and 2 under 480px
  • Tighten tile padding, number size, and captions so five fit the 860px content width
feat7d98b37Claude Fable 5
  • New code_stats() in build-shipping-log.py counts lines by category from git ls-files (tracked files only, binaries skipped)
  • Categories: Python, Documentation, CI/CD workflows, Terraform, Config & data, Shell & batch, Web
  • Renders a Codebase panel with horizontal bars plus a 37k lines headline tile
  • Recomputed on every republish so the numbers never drift from the repo
  • Add --stats flag to print the table for README updates
  • README: refresh stale 3/9/2026 Codebase Size table (30,874 to 37,031 lines) and link the live report
docs48cfa77
  • Add prominent link to the live shipping log report at top of README
  • Introduced as both Shipping Log and Release Notes with a note that it auto-republishes on every push
feate191aafClaude Fable 5
  • Add redact() pass in build-shipping-log.py applied to every commit subject, bullet, and prose line
  • Emails become [email redacted], phone numbers become [phone redacted]
  • Prevents crawling/harvesting from the published artifact page
  • Applies retroactively to all history on every regeneration and automatically to future commits
feat40c472fClaude Fable 5
  • Intro paragraph now spans full content width (removed 62ch max-width that made it look cut off)
  • New opening: PickleBot manages a 60-strong group at the Caltech Athenaeum, with Substack post link
  • Specify January 2026 for the first commit date
  • Add theme-aware accent-colored link styling
  • Replace models-line negative-margin hack with normal margins
refactor47f13f4Claude Fable 5
  • Remove claude -p republish steps from release-notes.yml — the Artifact tool is disabled by design in GitHub Actions contexts (documented in artifacts.md), so CI publishing is impossible
  • Shipping log now republished by claude.ai cloud routine (Republish PickleBot Shipping Log) fired by GitHub push webhook + daily 7am PT cron safety net
  • Restore release-notes.yml to shallow checkout and 3-minute timeout
  • Deleted unused CLAUDE_CODE_OAUTH_TOKEN secret and SHIPPING_LOG_ARTIFACT_URL variable from GitHub
  • README: remove those config rows, document the cloud routine in Scheduled Jobs
feat5c3f41dClaude Fable 5
  • Add scripts/build-shipping-log.py: renders full git history to self-contained HTML report (dynamic stats, weekly cadence chart, month-grouped timeline, Claude model progression)
  • release-notes.yml: build shipping-log.html then republish the Claude artifact via headless claude -p (haiku, Artifact tool only)
  • Republish step retries up to 3x with PUBLISHED/FAILED marker since headless tool loading can be flaky; skips gracefully if CLAUDE_CODE_OAUTH_TOKEN or SHIPPING_LOG_ARTIFACT_URL unset
  • checkout fetch-depth 0 for full history; job timeout 3m to 8m
  • Script forces TZ=America/Los_Angeles on POSIX only (Windows C runtime cannot parse IANA names and falls back to UTC)
  • Gitignore CI-generated shipping-log.html
  • README: document CLAUDE_CODE_OAUTH_TOKEN secret + SHIPPING_LOG_ARTIFACT_URL variable, add Playwright re-pin TODO to Future Work
feat0851fb1Claude Fable 5
  • Your Stats block now shows Vote Reminders: X and Payment Reminders: Y after DUPR (raw sheet counts)
  • When balance is due, pre-pay $50/mo hint appears after the Venmo link with the payment nag count
  • Zero-count case drops the you-have-received clause to read naturally
  • No duplication in payment reminder DMs (they pass show_balance=False)
  • Updated CLAUDE.md registry entry for format_player_stats_block
featb59d8fcClaude Fable 5
  • Vote reminder DM now includes: You have received X vote reminders now. (sheet Vote Reminders count + 1, singular/plural handled, bad values fall back to 1)
  • Vacation command example changed to /pb set vacation MM/DD/YY (your return date)
  • First sentence now says SMAD Pickleball group (was SMAD group) before the invite link
  • Updated CLAUDE.md registry entry for format_vote_reminder_dm
feat021c126Claude Fable 5
  • format_payment_reminder_dm now appends nag-count/pre-pay note after the Venmo line: You have received X payment reminders, consider pre-paying $50/mo...
  • X = sheet Pay Reminders count + 1 (counter increments after send, so it includes the DM being read)
  • Blank line before the note; singular/plural handling; blank/garbage counts fall back to 1
  • Added in both branches (with and without stats block)
  • Updated CLAUDE.md Shared Function Registry entry
fixf349792Claude Opus 4.6

The previous "fix" incorrectly assumed venmo-api bakes timezone offset into epoch values. It actually returns standard UTC epochs, so plain fromtimestamp(ts, tz=PACIFIC_TZ) is correct. The offset correction was subtracting/adding 7 hours, producing wrong transaction dates. Also fixed Danny Mata and Ryan Hsu Payment Log timestamps manually. Added warning logs for Gmail/People API token refresh failures instead of silent except-pass swallowing.

refactor5ca8584Claude Opus 4.6

Deduplicate weather string formatting into _format_weather_line(). Humidity now displays as separate line with 💧 icon after wind.

docs04c67d7Claude Opus 4.6

Reflect venmo last-name fallback, timestamp fix, humidity in weather, Top 20 leaderboard rename, bal/cred/nags formatting, dynamic PST/PDT.

feat9c6c094Claude Opus 4.6
feat: add humidity % to weather forecasts in game reports and /pb weather
fix5778850Claude Opus 4.6
  • Add unique last-name fallback for standard payment amounts (HOURLY_RATE*2) when exact name match fails (e.g. "Daniel Mata" → "Danny Mata")
  • Fix Venmo API timestamp double-offset: the venmo-api library returns epochs with local tz offset baked in; subtract it before converting to Pacific time. Works on both Windows (PST) and CI (UTC).
  • Fix thank-you message timezone label: use dynamic PST/PDT instead of hardcoded "PST"
refactorb601840Claude Opus 4.6
  • Remove "Already voted" section from Last Call group summary (players already visible in the Players list)
  • Rename leaderboard to "Top 20 - Hours Played Since June 2025"
fix73e489bClaude Opus 4.6

Docstrings and comments still said "2h before" after the constant was changed to 1. Updated docs to reference HOURS_BEFORE_GAME instead of a hardcoded value, and made the log message use the variable at runtime.

fix3b2d38eClaude Opus 4.6

set_player_vacation() and set_player_dupr() were not passing credentials_file to get_sheets_service(), causing silent write failures in CI workflows where env var credentials aren't set but smad-credentials.json exists on disk. This caused sync-members to fail clearing Issa's 12/31/2099 archive date every day, yet still send a "Welcome back" DM each run.

  • Pass CREDENTIALS_FILE to get_sheets_service() in both functions
  • Guard welcome-back DM: only send after successful vacation clear
feat507d8b3Claude Opus 4.6
  • Change leaderboard from top 15 to top 20
  • Add balance/credit section with aligned columns and reminder nag counts
  • Move weekly attendance after leaderboard, before balances
  • Add --send flag to games command for posting to SMAD group
  • Add games report step to poll-creation and daily-reminder-runner workflows
  • Remove format_games_with_suggestions (replaced by render_show_games)
  • Change help hint prefix from "Reminder" to "Protip"
refactor426e86bClaude Opus 4.6
  • Remove fetch_live_courts() (single-game CLI-only fetch)
  • Remove get_court_bookings() (stale Firestore poll cache)
  • Add refresh_live_courts() — fetches ALL courts from Athenaeum, updates Firestore courts/live doc, returns date|hour→courts mapping
  • Add update_live_courts() / get_live_courts() in firestore_utils
  • Update get_courts_for_game() to support new "MM/DD/YYYY|HH" key format
  • CLI always calls refresh_live_courts() before display/send
  • Picklebot reads from Firestore (kept fresh by CLI refreshes)
  • Update CLAUDE.md: docs-last rule, registry updates
docs5c0d8ddClaude Opus 4.6
docs: require updating *.md before committing code changes
fix9fa1364Claude Opus 4.6
fix: update stale "top 10" comment to "top 15"
docs924e4b3Claude Opus 4.6
docs: update CLAUDE.md registry for courts, top 15, signature, help hint
feat0ce12abClaude Opus 4.6
  • Expand leaderboard from top 10 to top 15 with DUPR ratings
  • Add court info (from Firestore) to /pb games, next game, and all render functions
  • Add help hint to group messages (Game Day, Last Call) via format_dm_signature()
  • Update help hint wording
  • Remove meaningless "Played this week!" streak=1 line
feat486c37eClaude Opus 4.6

Add unified personal stats block to Game Day, Vote Reminder, Payment Reminder, and Last Call DMs: this week's games, hours + rank, streak, milestone (50hr), favorite day, DUPR, and balance. Add group stats block to Game Day and Last Call group messages: top 10 leaderboard by hours and weekly attendance (this/last/2wks ago). Add standard DM footer with Picklebot self-help commands (/pb players, /pb balance, /pb set vacation, /pb help) via format_dm_signature(). New shared functions:

  • get_player_game_history(): reads all date columns, computes streak, favorite day, weeks since last game per player
  • format_player_stats_block(): unified personal stats for all DMs
  • format_group_stats_block(): top 10 + weekly attendance for group msgs
  • format_dm_signature(): standard sig + help hint + joke footer
  • PICKLEBOT_HELP_HINT: centralized help text constant
feat32a480bClaude Opus 4.6

Cancels North Pickleball Court reservation for the given date and records a $10 credit for Shyam in Payment Log. Looks up game time from poll data automatically. Only executable by Shyam or from admin group.

infra/CI184324dClaude Opus 4.6

Adds a second schedule trigger 30 min after the primary to handle transient GitHub Actions runner-not-acquired failures. Sync is idempotent so the backup run is a no-op if the primary succeeded.

fix578fa38Claude Opus 4.6

The vacation exception code used `booking_date` as a local variable, which overwrote the main() parameter (None for scheduled runs). This caused prepare_bookings() to enter MANUAL mode instead of BOOKING LIST mode, using a hardcoded default date and failing with a strptime type error. Rename to `game_date` to avoid shadowing.

July 2026 45 commits

feat22deps9fix6infra/CI5refactor2docs1
featcf3c379Claude Opus 4.6

Courts are now fetched in real-time from the Athenaeum website instead of relying on Firestore data stored at poll creation time. This handles court cancellations between poll creation and game day. Falls back to Firestore if the live fetch fails. Also pluralizes "Courts:" label when both courts are booked.

feat144f9a9Claude Opus 4.6
picklebot: sort players reminders by payment reminders first
feat09d3456Claude Opus 4.6

Players with an outstanding balance see their amount and Venmo link when setting vacation, with a reminder they'll still get payment reminders until paid.

feat50e15e8Claude Opus 4.6

Payment reminders still go out during vacation if player has a balance.

feat55368d1Claude Opus 4.6

Nobody uses it — they just vote "Can't play this week" instead.

featdc6f97bClaude Opus 4.6

Also rescheduled existing Cloud Scheduler jobs for today's 6pm game (4pm→5pm) and tomorrow's 8am game (6am→7am).

featde932a8Claude Opus 4.6

Tailors the player need message in game reports based on how many courts are booked: warns about cancellation when under capacity, encourages rotation pairs when close, and celebrates when courts are full.

featb01816dClaude Opus 4.6

Vacation now triggers the dead man switch (is_gene_dead returns True), suppressing poll creation, vote reminders, game reminders, and court booking. Vacation-triggered suppression logs [VACATION MODE] without sending admin notifications. Court booking has an exception: if the game date (invoke_time + 7 days) is after vacation return, booking proceeds to reserve courts for post-vacation games.

feat7da7489Claude Opus 4.6
poll: remove angle brackets from court suffix in poll options
feat6117a2fClaude Opus 4.6

Court bookings now appear in poll option text (e.g. "Mon 7/21/26 7pm <North>") and sheet column headers, so players see which court is booked at a glance. get_courts_for_game() also parses court from the option text as a fallback.

feat8043597Claude Opus 4.6

Court mapping (game option → court name) is captured from Athenaeum reservations during poll creation, stored in Firestore alongside the poll document, and displayed in build_game_report() as 🏟️ Court: North.

docsc24d363
Update Claude Code plan from Max ($100/mo) to Pro ($20/mo)
fixe8a4feb
  • Last call no longer nudges players who already responded to this week's poll: a blank game cell for a poll responder is an implicit 'no', so only poll non-responders (Last Voted < poll creation, or never voted) are candidates
  • Added shared parse_last_voted() and player_responded_to_poll() helpers; get_vote_reminder_recipients() refactored onto them so vote reminders and last call use one Last Voted source of truth (verified: recipient list unchanged)
  • New members' Last Voted now set to the join date instead of next Sunday, giving a correct free first week no matter which day polls are created (today is always >= any poll that existed before they joined, and before the first poll they can see)
feat6e3f9a8Claude Opus 4.6

New members joining the WhatsApp group can't see chat history including the current poll, so vote reminders before the next poll creation (Sundays) are pointless. Set Last Voted to next Sunday instead of today.

featfe4eb14
  • Removed 31-day cap in handle_set_vacation() — return date now used as-is after future-date validation
  • Removed related capped-warning message and help/status text mentioning the max
infra/CIaf7c46cClaude Opus 4.6

Missing env var caused HOURLY_RATE to default to 0, producing broken Invoiced formulas (=O*4+P*0 instead of =O*4+P*5) for new members.

feat3004153Claude Sonnet 4.6

If getContactInfo returns a short all-caps name like "RY", treat it as initials and try Google Contacts for the full name before accepting it.

infra/CI151c0b7Claude Sonnet 4.6

Newer unpinned google-cloud-firestore + google-api-python-client versions have heavier gRPC dependencies, pushing usage to 266 MiB against the 244 MiB (256 MB) limit. 512 MB gives headroom; still well within free tier.

infra/CI59960b2Claude Sonnet 4.6
  • monitoring.tf: replace notification_rate_limit (log-based only) with auto_close=1800s — metric-based alert policies use auto_close for incident lifecycle, not notification_rate_limit
  • iam.tf: remove monitoring.admin and logging.admin bindings for github-deploy SA; Terraform SA lacks resourcemanager.projectIamAdmin so it can't set project IAM for those roles. Granted manually via gcloud on 2026-07-14. Added comment documenting this.
infra/CI25cf29eClaude Sonnet 4.6

Required for Terraform to create: Roles were also granted manually via gcloud before this commit to unblock the pending Terraform apply.

  • google_monitoring_notification_channel (monitoring.admin)
  • google_logging_metric (logging.admin)
infra/CI9c0f7e7Claude Sonnet 4.6

Code changes — logger.warning → logger.error for user-facing failures: Terraform — monitoring.tf: GitHub Variable ALERT_EMAIL set to [email redacted].

  • firestore_utils.py: Firestore read failure (caused 7/14 vote reminder bug)
  • player_data.py: Poll Log Archive read failure (critical poll date lookup)
  • greenapi.py: GREEN-API non-200 response (WhatsApp message silently not sent)
  • venmo_sync.py: Failed to queue payment WhatsApp/admin notification
  • zelle_sync.py: Failed to record Zelle payment (record_payment returned error)
  • picklebot/main.py: Failed to send payment thank-you
  • webhook/main.py: PICKLEBOT_URL not configured (commands silently fail)
  • google_monitoring_notification_channel: email alert to ALERT_EMAIL
  • google_logging_metric: counts ERROR-severity Cloud Run log entries
  • google_monitoring_alert_policy: triggers on any error, rate-limited to 1 email/hour, includes runbook link and common causes in documentation
  • variables.tf: add alert_email variable
  • apis.tf: enable monitoring.googleapis.com and logging.googleapis.com
  • terraform.yml: pass TF_VAR_alert_email from ALERT_EMAIL GitHub Variable
feata44545eClaude Sonnet 4.6

Credentials.from_service_account_file() without explicit scopes creates unscoped credentials that fail all Firestore requests with 403. The Sheets service correctly passes scopes= but Firestore never did. This means store_poll_creation() has silently failed every time the CLI created a poll, leaving the Firestore polls/ collection permanently empty. Going forward, CLI poll creation will successfully write to Firestore. Also granted roles/datastore.user to [email redacted] on smad-pickleball (done manually via gcloud — the SA previously only had cloudscheduler.admin).

feate64bf53Claude Sonnet 4.6

poll_created is already date-only (time=00:00:00) via date_only=True. last_voted is currently parsed with a date-only format so also has no time, but make it explicit to guard against future format changes that might include a time component.

feat53e5773Claude Sonnet 4.6

When a player votes on a poll that has already been rotated to the Archive (e.g. accidentally tapping last week's pinned poll), get_poll_created_date() now also checks Pickle Poll Log Archive before falling back to the most-voted current poll. This gives correct attribution and prevents writing the current week's poll date for an old poll_id entry. Also moved the `if not poll_earliest: return None` guard inside the else branch so an empty current-log result doesn't short-circuit the Archive lookup. Data fix: manually corrected Sushil Anand's row 35 Poll Created Date in Pickle Poll Log from 07/13/26 (bogus vote_timestamp) to 07/05/26 09:14:41 (actual creation date of poll 3EB0E63CEC085595E54242, confirmed from Archive).

fix746077fClaude Sonnet 4.6

Two defensive fixes for when Firestore is unavailable: 1. player_data.py get_poll_created_date() fallback: pick the poll with the most entries rather than the one with the latest earliest date. The real poll has 20+ voter rows; a stray single-entry (e.g. vote-removal with an unknown stanzaId) won't win. 2. main.py vote recording: when Firestore lookup fails for a specific poll_id, fall back to the most-voted poll in the Poll Log before writing vote_timestamp as poll_date. Prevents bogus "new poll" rows that were the root cause of the 7/13 date contamination.

fixde8873dClaude Sonnet 4.6

github-deploy already has roles/datastore.owner (superset of datastore.user) so the IAM change was redundant. Also Terraform SA lacks projectIamAdmin rights so it would fail every run. Root cause fix (firestore_utils.py project=smad-pickleball) is sufficient.

fixd83d124Claude Sonnet 4.6

Root cause: github-deploy SA connected to Firestore using creds.project_id ('stockaccounts') instead of the GCP project where Firestore lives ('smad-pickleball'). This caused a 403, falling back to the Poll Log sheet which returned 7/13 instead of the correct 7/12 poll creation date. Result: players who voted on 7/12 (same day as poll) were reminded again. Fixes:

  • firestore_utils.py: use GCS_PROJECT_ID env var (default: smad-pickleball) instead of creds.project_id for the Firestore client project
  • iam.tf: grant github-deploy SA roles/datastore.user in smad-pickleball so Firestore reads actually succeed in GitHub Actions
fix133ba73Claude Sonnet 4.6

The 2026-07-11 change (dec8a7e) swapped to North=4, South=3 based on faulty reasoning. Live booking of Monday 2:00PM|South confirmed it booked North court instead, proving court_id 3=North, 4=South. Original mapping was correct all along.

depsdd2d7e1Claude Sonnet 4.6

Unpinned functions-framework, requests, flask, pytz, venmo-api, and all google-* packages across all 4 webhook requirements.txt files. cloudevents kept at ==1.* in venmo-trigger and whatsapp-sender: 2.x moved `from cloudevents.http import CloudEvent` to a different import path — would break both Cloud Functions without a code migration.

deps963a587Claude Sonnet 4.6

Both are already on latest (venmo-api 0.3.1, matplotlib 3.11.0).

deps9f6feebClaude Sonnet 4.6

Verified all APIs used (Credentials, build(), PublisherClient, CloudSchedulerClient, firestore.Client, storage.Client/blob/upload) are stable across version ranges including storage 3.x. Kept venmo-api pinned (unofficial library) and matplotlib as floor (>=3.7).

deps87239beClaude Sonnet 4.6
deps: unpin pytz to always use latest (was 2025.2, latest is 2026.2)
depsd3c4650Claude Sonnet 4.6
deps: unpin python-dotenv to always use latest
depsfb7793fClaude Sonnet 4.6
deps: unpin playwright to always use latest
refactordcb4ba1Claude Sonnet 4.6

Removes browser-save artifacts and debug output accidentally committed in e5afc42 (Twilio removal): .gitignore additions:

  • Court Reservations - The Athenaeum_files/ (58 browser-saved assets)
  • =9.0.0 (pip artifact)
  • reservations.json (debug output)
  • temp_run_log.txt (debug log)
  • Court Reservations*/ (browser save folders)
  • reservations.json, temp_run_log.txt (debug outputs)
  • =* (pip version-pin artifacts)
  • *.eml (email files dropped for inspection)
  • dcode*temp_logs.txt (temp CI logs)
refactora0022baClaude Sonnet 4.6

CLI now calls webhook/shared/greenapi.py directly (synchronous) instead of the third-party whatsapp-api-client-python package. This ensures CLI test runs exercise the same code path as Cloud Functions, making local regression testing meaningful.

  • Removed get_whatsapp_client() and wa_client parameter from all functions
  • Replaced wa_client.* calls with _wa.send_message/send_poll/etc.
  • Fixed response format: .code/.data objects → plain dict or None
  • Removed whatsapp-api-client-python==0.0.46 from requirements.txt
  • Updated docs to reflect new architecture
fixdec8a7eClaude Sonnet 4.6

court_id 3 maps to South and 4 maps to North on the Athenaeum site. The previous swap caused single-court |South bookings to silently book North instead. Both-court bookings were unaffected (both IDs get used). Backlog: add post-booking confirmation verification to detect silent failures when Make Reservation does not actually complete.

feata00cf82Claude Sonnet 4.6

Adds _find_player_by_zelle_alias() so players who pay via a business account (e.g. "Franks Landing Llc" → Bill Chui) can be matched by putting the exact email subject sender name in their Zelle sheet column. Matching order: exact name → Zelle alias column → first+last word → unique last name.

depse5afc42Claude Sonnet 4.6

Twilio A2P 10DLC approval was not obtained. SMS forwarding is removed entirely. Notifications now use WhatsApp DMs (GREEN-API) and email (Gmail API) only. Removed: maybe_send_extra_notifications() now sends email only (no behavior change for players without SMS opt-in, which was everyone since Twilio was never active).

  • webhook/shared/sms.py (deleted)
  • smsplan.md (deleted)
  • TWILIO_ACCOUNT_SID/AUTH_TOKEN/MESSAGING_SERVICE_SID from config.py, .env.example, 4 GHA workflow .env blocks, deploy-picklebot.yml --set-secrets and --set-env-vars, secrets.tf IAM bindings, README.md secrets tables, CLAUDE.md registry, webhook/picklebot/requirements.txt, root requirements.txt, picklebot README.md
  • format_phone_for_sms() from phone_utils.py
  • maybe_send_sms() call from email_notify.maybe_send_extra_notifications()
  • SMS_ENABLED env var from all workflows and deploy config
feat745cdfeClaude Sonnet 4.6

normalize_us_phone() only accepted 10/11-digit US numbers, causing format_phone_for_whatsapp() to return None for international numbers (e.g. HK +852 numbers). This silently dropped DM, group removal, and email notifications for any player with a non-US mobile. Fix: format_phone_for_whatsapp() now falls back to raw digits for international numbers (7-15 digits per E.164). format_phone_for_sms() remains US-only (Twilio is US-configured).

featbbca6b0Claude Sonnet 4.6

When slot verification detected 'already taken' or a date mismatch, the code returned False immediately without closing the BookMgrModalOverlay dialog. This left the modal open, causing it to intercept pointer events to #txtDate, which timed out all subsequent bookings in the same session (30s each). Triggered tonight when the book-court-south parallel job raced to book South@6pm first; north job opened the dialog, detected 'slot already taken', and abandoned it — blocking 8pm|Both + West Tennis 6pm + 8pm. Fix: close the booking dialog (click 'close' or Escape) before returning False on both the 'already taken' and 'date mismatch' paths.

feat12a0338Claude Sonnet 4.6

The book-court-south parallel job runs whenever BOOKING_LIST contains |Both. Previously it would also process single-court entries (|West, |East, |North, |South) and attempt duplicate bookings — the reverse_court_order logic only skipped reversal but still booked the court. Now single-court entries are skipped entirely in the backup job since the primary job handles them. Fixes: tennis court double-booking with Friday 6:00 PM|West in BOOKING_LIST.

feat69f8d72Claude Sonnet 4.6

Adds West Tennis Court (id=1) and East Tennis Court (id=2) to COURT_IDS. Adds 'west'/'east' aliases to COURT_ALIASES for BOOKING_LIST shorthand. Both alias remains pickleball-only. Tennis courts are always single-court bookings.

depsfca9c8dClaude Sonnet 4.6

Tested locally with --get-reservations (login + reservation fetch succeeded).

deps012e545Claude Sonnet 4.6

Symlink-following vulnerability in set_key()/unset_key() — not exploitable here (project never calls set_key), but trivial to fix.

June 2026 27 commits

feat9infra/CI8fix6docs4
infra/CIec0562fClaude Sonnet 4.6

book-court-south is a backup job that books in reverse order (South→North) to race against book-court-north (North→South) when booking both courts. When BOOKING_LIST has only single-court entries like |South or |North, both jobs targeted the same court and caused a duplicate booking. Fix: book-court-south now only runs when BOOKING_LIST contains 'Both'. Single-court entries use book-court-north only.

docsc397423Claude Sonnet 4.6

README: add GMAIL_OAUTH_TOKEN_JSON to the cross-platform secret duplication table (was missing — it exists in both GCP SM and GHA), explain the renewal workflow syncs both places, and add a runbook for when Zelle sync shows "Gmail service unavailable". secrets.tf: grant github-deploy SA secretVersionAdder on GMAIL_OAUTH_TOKEN_JSON so gmail-watch-renewal.yml can push refreshed token versions to Secret Manager. Without this IAM binding the gcloud secrets versions add step in the workflow would fail with 403.

infra/CIc44e89dClaude Sonnet 4.6

Previously only updated the GitHub secret. Cloud Run reads GMAIL_OAUTH_TOKEN_JSON from GCP Secret Manager, so the token was going stale after the first expiry cycle (the GHA-only update never reached the deployed function). Now updates both: Also adds GCP auth + Cloud SDK steps and bumps timeout to 5m.

  • GitHub secret GMAIL_OAUTH_TOKEN_JSON (for CI/CD runs)
  • GCP Secret Manager GMAIL_OAUTH_TOKEN_JSON (for Cloud Run venmo-sync-trigger)
fix3824b74Claude Sonnet 4.6

'South Court' (two words) wasn't in COURT_ALIASES, causing it to fall through to the unknown-court fallback which defaults to both courts.

infra/CIf541e70Claude Sonnet 4.6

Booking script is idempotent — if the court is already booked, a retry fails gracefully. Safe to retry on transient GitHub API errors.

feat0a0bc78Claude Sonnet 4.6
Update vote reminder opt-out copy to show exact /pb set vacation command
docs18c76d3Claude Sonnet 4.6
  • Add sync-members GitHub cron schedule to Scheduled Jobs table
  • Add Monitoring & Alerting section explaining workflow-watchdog.yml: runner-pool exhaustion problem, 3-channel alerting (WhatsApp group, Gene DM, email), why no auto-retry, if: cancelled() fix, and Terraform retry_config rationale
infra/CIda15f4fClaude Sonnet 4.6

Uses Gmail OAuth via Python stdlib (no checkout/pip install needed): refreshes token via oauth2.googleapis.com, then sends via Gmail API v1. Reuses GMAIL_OAUTH_TOKEN_JSON + GMAIL_OAUTH_CLIENT_JSON secrets already available from other workflows.

infra/CI46ec7d4Claude Sonnet 4.6
watchdog: also DM Gene (ADMIN_PHONE_ID) on workflow failure
infra/CI063d72dClaude Sonnet 4.6

Problem: when GitHub's runner pool is exhausted, the queued job is cancelled before any steps run, so internal 'Notify on failure' steps never execute. Today's 8am daily run sat queued for 15 minutes with runner_name="" then was cancelled silently. Changes:

  • workflow-watchdog.yml: new workflow triggered by workflow_run on failure/cancelled/timed_out for all 5 automated workflows. Sends WhatsApp alert to Admin Dinkers via GREEN-API (pure Python stdlib, no checkout or pip install needed). Includes re-run command.
  • All 5 automated workflows: change Notify on failure condition from 'failure()' to 'failure() || cancelled()' (catches step-level cancellations when a runner is assigned).
  • scheduler.tf: add retry_config to daily runner and Sunday poll jobs (retry_count=2, 5-min window) for transient GitHub API errors. Court booking intentionally set to retry_count=0 to avoid duplicate court reservations.
fix8ebe16dClaude Sonnet 4.6

Players who vote via WhatsApp poll on game day get stored as 2 (not 'y') by get_vote_column_value. mark_game_played only collected 'y' rows, so game-day voters were excluded from the group message and individual DMs. Fix: collect both 'y' (update to 2) and already-'2' rows when building the playing roster. Sheet update still only applies to 'y' rows; '2' rows are already correct.

fixdce455cClaude Sonnet 4.6

Previously used fallback_next_week=True which only looks at next week when the current week has zero future games. On Sunday morning after poll creation, the current week's last game (e.g. Sun 10:30am) is still future, so fallback never triggered and next week's newly-created poll games were missed. Now fetches week_offset=0 and week_offset=1 and merges, so all upcoming games across both weeks are scheduled regardless of what day it is.

infra/CI40b5820Claude Sonnet 4.6

Runs automatically after members sync every day at 8am PST, keeping DUPR ratings and SMS numbers up to date from survey responses.

featf11acf6Claude Sonnet 4.6
  • Active sheet players no longer in WhatsApp group → auto-archived (sets 12/31/2099 vacation, revokes sheet access via archive_player_rows)
  • Archived players who rejoin the group → already handled (to_restore)
  • existing_phones changed from set to dict for player lookups
  • group_phones set built during participant scan for O(1) departure check
  • Result dict gains 'archived' list; summary log shows departed count
infra/CI62d4071Claude Sonnet 4.6
Schedule sync-members daily at 8am PST; add Gmail token for Contacts lookup
fix08ce675Claude Sonnet 4.6
Grant sheet viewer access when sync adds new member with email from Contacts
fix94c4359Claude Sonnet 4.6
  • Switch from searchContacts (text search, format-sensitive) to connections.list with local phone normalization — reliably matches contacts stored as [phone redacted], [phone redacted], etc.
  • Return (name, email) tuple so email is populated in the sheet on sync
  • insert_member_alphabetically writes email column when contact has one
  • Update CLAUDE.md return type annotation
docs081b1c2Claude Sonnet 4.6
  • /pb set vacation: document MM/DD (no year) format
  • /pb members sync: note Google Contacts fallback when GREEN-API has no name
  • config.py shared module row: add HOURLY_RATE, OLD_HOURLY_RATE, RATE_CHANGE_DATE
docsc8ac68cClaude Sonnet 4.6
  • Add Google Drive API node to architecture diagram (grant/revoke viewer access)
  • Update Dead Man Switch section: add Early Warning (Days 9-10) and Gene DM/email
  • Add Player Archive System section (sentinel 12/31/2099, Drive access lifecycle)
  • Update shared modules table: google_sheets_utils Drive API functions, payments.py backfill_last_paid and aggregate_payments
feat4c46141Claude Sonnet 4.6

Checks all current Drive permissions on the spreadsheet against archived players (vacation=12/31/2099 with email on file) and revokes any access found. Dry-run confirmed 0 archived players had access, so no revocations needed.

featfb69135Claude Sonnet 4.6

survey_sync(): grant viewer access to every matched player's email via Drive API (idempotent; no notification email sent) archive_player_rows(): revoke sheet viewer access for each archived player's email after setting vacation=12/31/2099 set_player_vacation(): grant access back when un-archiving (was 12/31/2099 → new real date); revoke when archiving via vacation command (new date = 12/31/2099) google_sheets_utils.py: add get_drive_service(), grant_sheet_viewer_access(), revoke_sheet_viewer_access() (lists permissions to find ID, then deletes) infra/terraform/apis.tf: add drive.googleapis.com (already enabled via gcloud) format_survey_sync(): show "Granted viewer access to N player(s)."

feat0608b20Claude Sonnet 4.6
  • get_vote_reminder_recipients() and get_survey_reminder_recipients() now return 3-tuple (would_remind, on_vacation, archived); archived players (vacation=12/31/2099) go to a separate list instead of appearing under "On Vacation"
  • format_reminders_preview() adds separate "Archived" section (names only, no return date)
  • _build_slot_rankings() excludes archived players entirely from survey slot availability (inactive players don't count for booking)
  • All callers in smad-whatsapp.py and picklebot/main.py updated to unpack 3-tuples; admin summaries show Archived section separately
feat73dd14aClaude Sonnet 4.6
  • send_dead_man_switch_notification(): now notifies Admin Dinkers group, Gene's WhatsApp DM, and Gene's email independently (all non-fatal)
  • send_gene_alive_check(): added Admin Dinkers group alert alongside Gene DM + email; shows days until switch activates
feat9031448Claude Sonnet 4.6

Sends Gene a WhatsApp DM + email on the 9th and 10th day without voting (while not on vacation) to warn him the dead man switch will activate in 2 or 1 day(s) respectively.

  • smad-sheets.py: add check_gene_warning_needed() — returns (should_warn, days_since_vote, last_voted_str); True only on days 9 or 10, not on vacation
  • smad-whatsapp.py: add send_gene_alive_check() + gene-alive-check command
  • daily-reminder-runner.yml: add Gene Alive Check step (skipped on poll_only)
fix4bf9ddaClaude Sonnet 4.6

The old MAXIFS formula matched on Venmo username, but aggregate rows use 'aggregate-LastName' as the venmo key — so MAXIFS returned empty after aggregation ran.

  • record_payment(): write Last Paid date stamp directly to main sheet after each payment (non-fatal; survives future aggregation runs)
  • insert_member_alphabetically(): drop MAXIFS formula; new players get empty Last Paid, filled by record_payment() on first payment
  • backfill_last_paid(): new function — scans Payment Log + Archive (skipping aggregate rows) for max transaction date per mobile; writes dates for players with history, clears stale formulas for the rest
  • payments-management.py: add 'backfill-last-paid [--dry-run]' command
featdfd2910Claude Sonnet 4.6

Eliminates the '2026 Pickleball Archive' sheet. Archived players now stay in the main sheet with Vacation Return = 12/31/2099, keeping them out of reminders and polls while preserving payment history in-sheet.

  • archive_player_rows(): sets vacation=12/31/2099 (RAW) instead of moving rows
  • is_archived(): new helper — checks vac_date.year >= 2099
  • get_payment_reminder_recipients(): archived players skip vacation gate so outstanding balances still surface
  • sync_group_members(): detects archived players by phone; clears vacation (reactivates) when they rejoin the WhatsApp group
  • Removed: _load_archive_sheet_data, _try_restore_from_archive, _add_date_columns_to_archive_sheet, insert_member_alphabetically_from_data
  • config.py: OLD_HOURLY_RATE + RATE_CHANGE_DATE for 6/1/26 rate change
  • sheet_columns.py: old_hours_2026 + new_hours_2026 columns
  • restore-archived-players.py: one-time migration (run 6/1/26)
  • migrate-rate-change.py: one-time rate change migration script
featbf953f2Claude Sonnet 4.6
  • config.py: add HOURLY_RATE (single canonical read of SMAD_HOURLY_RATE)
  • smad-sheets.py, smad-whatsapp.py: import from config instead of reading env var directly — eliminates duplicate os.environ.get calls
  • Fix wrong comment in smad-sheets.py (said default 8.0)
  • .env + SMAD_SETUP.md: 4.0 -> 5.0
  • GitHub Variable SMAD_HOURLY_RATE set to 5.0
  • GitHub Variable SMAD_WELCOME_MESSAGE: $8/session -> $10/session, Saturday 1pm -> Sunday 8am

May 2026 7 commits

feat4docs1infra/CI1fix1
feat112161aClaude Sonnet 4.6

Terraform will destroy 1pm-saturday-poll-creation and create 8am-sunday-poll-creation on next apply.

  • scheduler.tf: rename resource/job to 8am-sunday-poll-creation, schedule 0 8 * * 0 (was 0 13 * * 6)
  • Update all references: README.md, gcp-scheduler/README.md, daily-reminder-runner.yml comment, command_formatters.py vote DM text
docs10418cdClaude Sonnet 4.6
  • Add contacts.readonly to OAuth consent screen setup steps
  • Fix Security section (was wrong about gmail.send not existing)
  • Add troubleshooting entry for adding new OAuth scopes
infra/CI16aaf11Claude Sonnet 4.6

Google People API was manually enabled for Google Contacts lookup in player sync. Codify in Terraform so it's tracked as infrastructure.

feat864f991Claude Sonnet 4.6
  • Add lookup_contact_by_phone() to email_notify.py using People API (reuses gmail OAuth token, now includes contacts.readonly scope)
  • sync_group_members falls back to Google Contacts when GREEN-API returns no name; logs warning if still unresolved
  • Welcome DMs say "Hi there!" instead of "Hi Unknown (1926)!"
  • Add contacts.readonly to setup-gmail-watch.py SCOPES so renewals preserve the scope; updated GMAIL_OAUTH_TOKEN_JSON secret
feat0689507Claude Sonnet 4.6
  • maybe_send_email now sends to any player with an email address (no sms_reminder check)
  • send_notification_email respects EMAIL_DISABLED as a true global kill switch
  • _execute_archive simplified back to maybe_send_extra_notifications
  • EMAIL_DISABLED GHA variable set to false (explicit default)
fix10e49e3Claude Sonnet 4.6
  • _execute_archive now sends email to any player with an email address, bypassing the sms_reminder gate (archive is too important to skip)
  • Changed reason 1: "last 3 months" -> "last month"
  • Changed reason 2: "not voted" -> "not voted in the weekly games poll"
feat9ae2eeaClaude Sonnet 4.6

Falls back to MM/DD format, using current year and bumping to next year if the date has already passed. Fixes error when user omits year.

April 2026 19 commits

feat12fix5docs1infra/CI1
feat55ee9b4Claude Sonnet 4.6
  • Add "not these times" to CANNOT_PLAY_PHRASES so vote processor skips it when matching date columns, causing all unselected dates to receive 'n' (same behavior as "Can't play this week")
  • Fix smad-whatsapp.py date_options filter to use CANNOT_PLAY_PHRASES instead of hardcoded "can't play" string
feat640a571Claude Sonnet 4.6
  • New option: "Want to play but not these times. DM Gene your openings"
  • Appended after "Can't play this week" as the last poll option
  • Vote processor safely ignores it (no matching date column)
feat302f7afClaude Sonnet 4.6
  • Reads 2026 Senior Parents Email List from Google Sheets
  • Groups families by shared emails (handles multi-kid households)
  • Sends one personalized email per family with parent first names
  • Handles compound last names (e.g. "Tran Chuang, Jacqueline")
  • --dry-run sends only to [email redacted] / [email redacted]
  • 220 students → 207 unique families, 10 with multiple kids graduating
feat42b0b9cClaude Sonnet 4.6
  • Reverts compact proportional-font format (1. Name (DUPR X.X, Xhr))
  • Restores original triple-backtick block with column-aligned padding
  • Format: padded prefix + DUPR + Hr columns for monospace rendering
feat8286fc0Claude Sonnet 4.6
  • GREEN-API strips backtick characters so monospace formatting is impossible
  • Switch from column-aligned format to: N. Name (DUPR X.X, Xhr)
  • Works cleanly in proportional font without needing monospace
  • Drop column padding, zero-padded hours, and backtick wrappers
feata8347d6Claude Sonnet 4.6
  • Replace triple backtick code block with single backtick per line
  • Triple backtick blocks not rendering as monospace in WhatsApp client
  • Single backtick inline code has broader WhatsApp client support
  • Each player entry wrapped: `1. Derek Ong DUPR 2.5 Hr 02`
fix3ebdb6aClaude Sonnet 4.6
  • Revert need_section to single \n (double was a regression)
  • Keep blank line before Players section for WhatsApp monospace rendering
fix7349accClaude Sonnet 4.6
  • Add blank line before Players section in build_game_report()
  • Add blank line after code block before need-players text
  • WhatsApp requires blank line separation before ``` for monospace rendering
  • Regression from 4d8aca2 which removed the blank line when unifying game report
feat26e1a28Claude Opus 4.6
  • Change archive "no games" threshold from 3 months to 1 month
  • Use max(last_voted, vacation_return_date) for vote recency check
  • Set Last Voted to vacation return date when setting vacation
  • Update archive preview message to say "1 month"
fixc60f40dClaude Opus 4.6
  • Add get_aggregate_watermark() to skip transactions older than last aggregation
  • Read transaction IDs from both Payment Log AND Archive for dedup (was Payment Log only)
  • Add payment_date param to format_payment_thank_you() showing "on Apr 16, 2026 at 5:53 PM PST"
  • Thread transaction timestamp through venmo-sync and zelle-sync thank-you messages
  • Update CLAUDE.md shared function registry
feat3992fd2Claude Opus 4.6
  • Add _now_pst() helper in payments.py for consistent PST timestamps on all platforms
  • Rename Date column to Transaction Date in PAYMENT_LOG_HEADERS and sheet_columns.py
  • Update record_payment() to write PST timestamps (Transaction Date = caller-provided or Recorded At)
  • Update aggregate_payments() to write PST timestamps (both columns = _now_pst())
  • Preserve full Venmo transaction timestamp (Unix epoch to PST) instead of truncating to date-only
  • Simplify Venmo date parsing: fromtimestamp(ts, tz=PACIFIC_TZ) replaces manual UTC offset workaround
  • Preserve full Zelle email Date header timestamp (RFC 2822 to PST) instead of truncating to date-only
  • Add migrate-timestamps CLI subcommand for one-time UTC to PST conversion and header rename
  • Migration uses batchUpdate to avoid Sheets 60 writes/minute quota
  • Migration is idempotent: detects already-converted rows and skips them
docsd93879dClaude Opus 4.6
  • Add feature bullet under Payment Management
  • Expand CLI section with full explanation: invariant (Paid/Balance unchanged), idempotency, dedup precedence, algorithm steps
feat013fa5eClaude Opus 4.6
  • New aggregate_payments() in webhook/shared/payments.py creates one aggregate row per invoiced player (method=aggregate, amount=invoiced-balance) and archives individual rows to a new Payment Log Archive sheet
  • Idempotent: re-running updates existing aggregates in place; dedup on archive uses txid -> recorded_at -> date+mobile+amount
  • SUMIF-driven Paid/Balance columns stay unchanged since aggregate sums to prior Paid
  • Extended get_full_player_data() to return parsed invoiced + paid (via canonical parse_balance_str accounting-format parser)
  • Added recorded_at to PAYMENT_LOG_COLUMNS (fixes pre-existing drift), widened Payment Log range A:I -> A:J, added aggregate to valid_methods
  • New format_aggregate_payments() shows per-player aggregate amount in dry run
  • Wired up CLI (python payments-management.py aggregate [--dry-run]) and picklebot (/pb aggregate payments)
  • Updated CLAUDE.md + README.md
feat6c8578dClaude Opus 4.6
  • Fallback regex now accepts "-N" for refunds/credits (e.g. to zero a player's balance)
  • Previously "/pb record payment Derek Chang -4 venmo zeroing balance" failed to parse
  • record_payment() itself already allows any amount — only the picklebot intent parser was restrictive
infra/CI0091ffeClaude Opus 4.6
  • Removed the "Show next week's games (after poll creation)" step from daily-reminder-runner.yml
  • Games summary with zero votes isn't useful — wait for players to vote before surfacing anything
  • Poll itself is still posted; last-call scheduling still runs afterward
feat6e0626aClaude Opus 4.6
  • Archive now requires 3 criteria instead of 4 (no games 3mo, no votes 1mo, not on vacation)
  • Drops the "has not completed survey" gate so inactive players who happened to fill out the survey can still be archived
  • Updates get_archive_candidates() to skip survey_status lookup
  • Updates format_archive_preview() and format_archive_notification() (email + DM) to list the new 3-criteria set
  • Updates CLAUDE.md Shared Function Registry entry
fixdf63b5cClaude Opus 4.6
  • Check result.get('removeParticipant') is True instead of truthy dict — GREEN-API returns {"removeParticipant": false} on failure which would have been treated as success
  • Include the response payload in the failure log for easier debugging of transient errors
feat2d021ccClaude Opus 4.6
  • Track which survey respondents matched a sheet player during sync
  • Add unmatched list to survey_sync() result for respondents whose email/name doesn't match anyone in the main sheet
  • Add not_responded list to survey_sync() result for sheet players who haven't taken the survey (excluding vacation)
  • Update format_survey_sync() to display both new sections after the updates list
  • List reminded players as bullets in Survey Reminders admin summary, matching Vote Reminders format
  • Add On Vacation section to Survey Reminders admin summary, sorted by return date
  • Add Pip Package TODO to README Future Work section with full plan and known caveat
fix06c8c19Claude Sonnet 4.6
  • venmo_sync/zelle_sync: compute new_balance = pre_balance - amount at record time instead of re-reading sheet (SUMIF may not recalculate before read)
  • Deploy workflows: replace per-file cp list with cp -r webhook/shared/. so any new shared module is automatically included — missing player_data.py caused parse_balance_str import to fail silently, leaving old broken float() parsing (which crashes on accounting format "$ (8.00)") deployed

March 2026 86 commits

feat47fix20infra/CI10docs5refactor4
fixabf9bd3Claude Opus 4.6
  • Add parse_balance_str() to player_data.py as single source of truth
  • Sheet uses accounting format: "$ (8.00)" for credits, not "-8.00"
  • venmo_sync and zelle_sync were not handling parentheses → fell back to 0.0
  • Replace all inline balance parsing in player_data, venmo_sync, zelle_sync, smad-sheets
  • Fix admin group notification in venmo_sync to use format_balance_text()
  • Add parse_balance_str() to CLAUDE.md Shared Function Registry
feat8fa0a6bClaude Opus 4.6
  • Move "If you don't want..." paragraph right after group URL
  • Update copy: Saturday 1pm poll, self-service /pb set vacation command
feat719b22fClaude Opus 4.6
  • New format_balance_text(): "$X.XX" for positive, "$X.XX credit" for negative
  • Fix format_payment_thank_you showing "$-3.00" instead of "$3.00 credit"
  • Refactor game day DM, /pb balance mine, /pb payment remind to use shared function
  • Add to CLAUDE.md Shared Function Registry
fixbf309b0Claude Opus 4.6
  • When a specific game datetime is given, search this week first then next week
  • Fixes issue where games on next week's poll couldn't be targeted
  • Error message now shows available games from both weeks
featddd73e7Claude Opus 4.6
  • Send admin group + Gene DM when player clears vacation
feat263c549Claude Opus 4.6
  • /pb joke is available to all players, not admin-only
  • Move Fun section before Actions header so it's clearly in the self-service area
feat93a9904Claude Opus 4.6
  • Add /pb dupr to check current DUPR rating
  • Add /pb set dupr <rating> to update rating (2.0-6.0, 0.5 increments)
  • Add set_player_dupr() to shared player_data.py
  • Add _notify_admin_and_gene() helper for admin group + Gene DM notifications
  • Send admin notifications on /pb set vacation and /pb set dupr changes
  • Both commands available to all players in DMs, SMAD group, and Admin Dinkers
feat8241531Claude Opus 4.6
  • Add /pb vacation to check current vacation status
  • Add /pb set vacation <MM/DD/YY> to set vacation (max 31 days, caps with warning)
  • Add /pb clear vacation to clear vacation mode
  • Add set_player_vacation() to player_data.py for sheet writes
  • All 3 commands available to all players in any chat (not admin-only)
  • Accepts both MM/DD/YY and MM/DD/YYYY date formats
fix9e36c35Claude Opus 4.6
  • Add greenapi.py to deploy-picklebot.yml shared module copy list
  • Without it, remove_player_from_group() crashed with ModuleNotFoundError
  • Add try/except around per-player archive operations in both picklebot and CLI
  • Prevents one player's failure from aborting the entire batch
infra/CI2e32fbbClaude Opus 4.6
  • Add SMAD_WHATSAPP_GROUP_URL to deploy-picklebot.yml --set-env-vars
  • Without this, archive notification DMs had empty group URL
  • Update README env vars table to reflect picklebot usage
feat8c78646Claude Opus 4.6
  • Add /pb archive players to find and archive inactive players matching all 4 criteria (no games 3mo, no votes 1mo, no survey, not on vacation)
  • Add /pb archive player <name> to force-archive a specific player bypassing criteria
  • Archive action: move row to archive sheet, remove from WhatsApp group, send DM + email notification
  • Sync archive sheet columns on poll creation (add game columns to both sheets)
  • Sync archive sheet columns on game cancel (remove from both sheets)
  • Check archive sheet first during members sync — restore returning players instead of creating new rows
  • Add remove_group_participant() to greenapi.py
  • Add get_archive_candidates(), archive_player_rows(), remove_player_from_group() to player_data.py
  • Add format_archive_preview(), format_archive_notification() to command_formatters.py
  • Centralize SMAD_WHATSAPP_GROUP_URL in shared config
  • Add CLI subcommands: archive-players and archive-player with --execute flag
  • Update CLAUDE.md Shared Function Registry and README.md
feat3ef7e4aClaude Opus 4.6
  • Add 'credit' to valid_methods list in payments.py record_payment()
fixe1c1f84Claude Opus 4.6
  • Accept any payment method (court-credit, etc.) not just zelle/cash/check/venmo
  • Handle $ prefix on amounts (e.g., $8)
  • Update Claude prompt to list court-credit as example method
featdbd6f95Claude Opus 4.6
  • Reuse handle_my_stats() instead of duplicating health log formatting
  • Weight record response now shows confirmation + full stats with history
feat1363efaClaude Opus 4.6
  • Add webhook/shared/firestore_utils.py with shared Firestore client (file creds → ADC)
  • Store poll creation record in Firestore immediately after sendPoll() succeeds
  • get_poll_created_date() now checks Firestore first, falls back to Pickle Poll Log sheet
  • Fixes edge case where vote reminders fail when no votes logged yet for new poll
  • Add google-cloud-firestore to root, webhook, and picklebot requirements
  • Add firestore_utils.py to picklebot deploy copy step
  • Update CLAUDE.md Shared Function Registry and README.md shared modules table
infra/CI0cf82a3Claude Opus 4.6
  • Poll creation steps now ONLY run when reminder_type=poll_only
  • Removed all DOW-based checks from the 8am daily runner
  • Saturday 1pm Cloud Scheduler job (poll_only) is the sole poll trigger
  • Prevents accidental poll creation during daily 8am runs on Saturdays
fixb1f98f4Claude Opus 4.6
  • Remove get_players_for_game() — was missing email/sms_reminder fields
  • Use get_full_player_data() as single source of truth, filter by mark_game_played() names
  • Eliminates redundant sheet read (3 reads → 2)
  • Fix extra blank line between player list and balance in game day DM
  • Update DUPE_CODE_REFACTORING.md
infra/CIe58f6f5Claude Opus 4.6
  • Change DOW check from Sunday (7) to Saturday (6) in daily-reminder-runner.yml
  • Add new Cloud Scheduler job 1pm-saturday-poll-creation (poll_only dispatch)
  • Update scheduler.tf with new job and import comment
  • Update README.md and gcp-scheduler/README.md
infra/CI855143bClaude Opus 4.6
Make survey reminders daily instead of even days only
fixad22a59Claude Opus 4.6
Fix extra blank line in vote reminder before Games This Week
feat35ebdb0Claude Opus 4.6
  • Simplify to one-time survey explanation with DUPR/SMS/Email opt-in
  • Remove outdated session schedule and bar hours text
fixdf433f8Claude Opus 4.6
  • Remove extra blank line between first sentence and Games This Week in vote reminder
  • Remove extra blank line between player list and "We need X more" in game reports
  • Remove blank line between signature and joke across all formatters
  • Remove summary player list fallback in build_game_report — always use detailed DUPR/hours format
featd6f719aClaude Opus 4.6
  • Move PICKLEBOT_SIGNATURE to webhook/shared/config.py as single source of truth
  • Remove hardcoded copies from smad-whatsapp.py, picklebot/main.py, whatsapp-sender/main.py
  • Fix venmo_sync.py and zelle_sync.py using wrong sig "SMAD Pickleball" (missing emojis)
featc69c21cClaude Opus 4.6
  • Add format_payment_reminder_dm() shared formatter for consistent payment reminders across WhatsApp/SMS/email
  • Include last game date, emojis (💰💸), and Venmo link in payment reminder DMs
  • Fix missing 💰 emoji in format_payment_thank_you after payment amount
  • Refactor smad-whatsapp.py send_balance_dm to use shared formatter
  • Update CLAUDE.md Shared Function Registry
feat6d2fb3bClaude Opus 4.6
  • Replace internal EMAIL_ENABLED variable with direct EMAIL_DISABLED check
  • Zero references to EMAIL_ENABLED remain in the entire repo
feat124aea1Claude Opus 4.6
  • Email was silently disabled in production because EMAIL_ENABLED variable was never set in GitHub, causing empty string to override the code default of true
  • Flip to EMAIL_DISABLED: email is ON by default, set EMAIL_DISABLED=true to turn off
  • Remove EMAIL_ENABLED from all 6 workflow files and 2 Cloud Function deploy commands
  • Delete EMAIL_ENABLED GitHub Variable (no longer needed)
  • Update docs: CLAUDE.md, README.md, picklebot README, DUPE_CODE_REFACTORING.md
fix61cc61aClaude Opus 4.6
  • Monkey-patch venmo_api v0.3.1 timestamp parser to handle trailing Z without fractional seconds (e.g. "2026-03-18T14:23:05Z")
  • Add 24-hour lookback filter to Zelle Gmail query — Gmail Watch triggers on any email but messages.list returns ALL labeled emails, causing old payments to be re-recorded
  • Add weather.py to venmo-trigger deployment copy step — command_formatters.py imports it transitively for Zelle thank-you messages
feat684b7d1Claude Opus 4.6
  • Schedule lights-reminder Cloud Scheduler job at 8pm for games at 6pm+
  • New send-lights-reminder.py sends email to [email redacted]
  • New lights-reminder-runner.yml workflow triggered by Cloud Scheduler
  • Add cancel_lights_reminder_job() for game cancel cleanup
  • Fix _create_scheduler_job positional arg bug (dry_run hitting workflow_file)
  • Extend schedule-last-call.py cleanup to also delete lights-reminder-* jobs
feat44085ceClaude Opus 4.6
  • Add webhook/shared/zelle_sync.py: parses Zelle email subjects ("Name sent you $X.XX"), records payments via record_payment()
  • Flexible name matching: exact → skip middle names → unique last name (handles nicknames like Gabriel→Gabe)
  • Dedup by transaction ID (zelle-gmail-{msg_id}) going forward
  • Add transaction_id parameter and Recorded At timestamp to payments.py record_payment()
  • Extend venmo-trigger Cloud Function to run Zelle sync after Venmo sync
  • Make Venmo sync optional (skip if VENMO_ACCESS_TOKEN not set)
  • Update Gmail Watch to include both Venmo + Zelle labels
  • Update deploy workflow to copy zelle_sync.py and sms.py to venmo-trigger
feat23188c1Claude Opus 4.6
  • Add secretAccessor IAM binding for Gmail OAuth token in secrets.tf
  • Update Terraform README secrets count (6 → 7, add Gmail)
  • Add EMAIL_ENABLED, GMAIL_USERNAME, GMAIL_OAUTH_TOKEN_JSON, SMS_ENABLED to picklebot README
feat3052278Claude Opus 4.6
  • Add webhook/shared/email_notify.py with Gmail API OAuth, whatsapp_to_html() converter, maybe_send_extra_notifications() unified dispatch
  • Replace 10+ paired maybe_send_sms()/maybe_send_email() calls with single maybe_send_extra_notifications()
  • Add format_payment_thank_you() shared formatter with joke= param to command_formatters.py
  • Add payment thank-you DMs (WhatsApp + SMS + email) to CLI and picklebot record commands
  • Remove "Thanks," from all message templates, make all signatures bold
  • Add emoji to email subjects (Vote Reminder 🗳️, Balance Reminder 💰, Last Call 🥃, etc.)
  • Protect URLs from italic regex in whatsapp_to_html() — extract before formatting, restore as <a> tags
  • Render triple-backtick code blocks as monospace <pre> in email
  • Collapse inline send loops to _send_dm(), merge duplicate last call loops (~80 lines removed)
  • Fix missing import os in payments.py causing /pb payment record crash
  • Update 5 workflows with EMAIL_ENABLED, GMAIL_USERNAME, GMAIL_OAUTH_TOKEN_JSON
  • Update CLAUDE.md registry, README.md config tables, DUPE_CODE_REFACTORING.md Phase 15
fixc199fb8Claude Opus 4.6
  • Hash survey respondent names + availability for cache invalidation
  • Response count alone missed edits to existing survey entries
  • Add cache-busting timestamp query param to heatmap URLs
  • Prevents WhatsApp from serving stale cached images
fix5e5cb7bClaude Opus 4.6
  • Use fallback_next_week=True so games from upcoming poll are found
  • On Sundays after poll creation, "this week" is Mon-Sun of the old week
  • Games like Mon 3/16 are in next week's range
fix11373c4Claude Opus 4.6
  • Resolve day_of_week param before date/time validation in cancel_game dispatch
  • Was failing because dispatch checked for date/time before execute_cancel_game ran
feat260c7a0Claude Opus 4.6
  • /pb game cancel mon, fri, etc. resolves to this week's game
  • CLI: python smad-whatsapp.py cancel-game mon
  • Looks up matching game from current poll by day of week
  • Errors if no game or multiple games on that day
fixa019be9Claude Opus 4.6
  • Initialize votes_by_option with all date columns so games appear before anyone votes
  • Fix schedule-last-call.py finding no games right after poll creation
  • Fix /pb games showing "No games scheduled" when no one has voted yet
  • Pre-populate Gene Chuang with 'y' for all new game columns during poll creation
  • Change last-call eve trigger from 7pm to 8pm PST
fix47687e1Claude Opus 4.6
  • Remove future_only=True from render_show_games() so past games are included
  • Past games already render correctly with checkmark and no weather via is_past flag
feat4d8aca2Claude Opus 4.6
  • Add build_game_report() shared renderer for all game info blocks
  • Unified date format: DoW MM/DD/YYYY @ time (bold) everywhere
  • Used by Game Day Reminder, Last Call, Vote Reminder, Next Game
  • Full player list always shown (removed 8-player truncation)
  • Add get_weather_for_games() batch weather fetch (single API call)
  • Vote Reminder: 1 API call for all games instead of per-game
  • Last Call: weather fetched once, reused for summary + all DMs
  • Game Day Reminder: weather fetched once via build_game_report()
  • Refactor format_show_games() to use build_game_report() per game
  • Refactor format_next_game() to use build_game_report()
  • Reduce player list name-to-DUPR padding from 2 spaces to 1
  • Add Phase 14 to DUPE_CODE_REFACTORING.md
  • Update CLAUDE.md function registry
docs83fb2f3Claude Opus 4.6
  • Add Firestore distributed lock and correlation ID dedup to Payment Management
  • Update Firestore description in architecture diagram
  • Remove court arg from game reminder CLI example and scheduler table
  • Update venmo trigger line count 124 → 187
fix765a8f7Claude Opus 4.6

Pub/Sub push subscriptions deliver ~36% of Gmail Watch notifications twice, causing concurrent venmo-sync runs that race past the sheet-level dedup check (TOCTOU) and send duplicate WhatsApp messages. Layer 1 - Firestore distributed lock in venmo-sync-trigger prevents the second instance from running at all (transactional acquire, 90s TTL, graceful fallback). Layer 2 - Transaction-based deterministic correlation_ids (venmo-dm-{txn_id}, venmo-group-{txn_id}) flow through to whatsapp-sender's Firestore dedup as a safety net. Also removes hardcoded court line from game day reminder (was always wrong when a court gets canceled).

featfec2028Claude Opus 4.6
Add poll name and group link to Last Call DM
feat89dad23Claude Opus 4.6

Vote reminder DM now shows actual poll name ("Can you play the week of M/D/YY?") derived from poll creation date, with group URL on same line. Jokes now combine text + URL column from sheet.

featcec3c30Claude Opus 4.6

Consolidated Pickleball Memes into Pickleball Jokes sheet. Jokes now combine text + URL column when present.

docsc467173Claude Opus 4.6
Remove License section and add codebase size table to README
infra/CIeb835dfClaude Opus 4.6

The sender uses ADC for auth but needs the spreadsheet ID env var so jokes.py can fetch from the Pickleball Jokes sheet instead of falling back to hardcoded jokes.

infra/CI546e29fClaude Opus 4.6
Send vote reminders daily instead of game days only
refactor5ce9b2cClaude Opus 4.6
Simplify vote reminder DM text and remove group link
docsa77e734Claude Opus 4.6

Remove deleted github-actions-token from GCP secrets list (11 secrets), update GitHub Secrets count to 17, fix cost estimate.

feat4a84f3aClaude Opus 4.6

Replace direct number sending (from_=) with messaging_service_sid for A2P 10DLC compliance. Campaign pending approval; SMS_ENABLED remains false until approved.

infra/CId217fe7Claude Opus 4.6

GITHUB_TOKEN and github-actions-token held the same PAT. Switch Terraform to use GITHUB_TOKEN for scheduler headers and CI access, eliminating the duplicate. The github-actions-token secret can now be deleted from GCP.

featd18c6e6Claude Opus 4.6

Sentry was not useful for error logging — Cloud Logging is sufficient.

  • Delete webhook/shared/sentry_init.py
  • Remove sentry-sdk from all 4 requirements.txt files
  • Remove init_sentry/flush_sentry/capture_exception from all cloud functions
  • Remove SENTRY_DSN from all deploy workflow --set-secrets
  • Remove sentry_init.py cp from deploy workflows
  • Remove Terraform IAM binding + removed block
  • Update all documentation (CLAUDE.md, README, terraform README, etc.)
featd11d3dcClaude Opus 4.6
  • Import and call generate_pickleball_joke() in send-release-notes.py
  • Add google-auth, credentials file, and spreadsheet ID to release-notes workflow so jokes are fetched from the Google Sheet (falls back to hardcoded)
fixcacd411Claude Opus 4.6

google.auth.default() needs GOOGLE_APPLICATION_CREDENTIALS to find the credentials file. Cloud Functions have ADC automatically, but CLI and GitHub Actions workflows need the fallback to smad-credentials.json.

infra/CI80f18d2Claude Opus 4.6

The (default) Firestore database was created manually but never imported into Terraform state, causing 409 errors on every apply since 3/1.

feata0694deClaude Opus 4.6

Schedule two Last Call jobs per game: 7pm the evening before (new) and 2 hours before game time (existing). Also fix bug where Sunday scheduling found no future games because it didn't fall back to next week's poll.

feat9ce2f88Claude Opus 4.6

Centralize joke logic into webhook/shared/jokes.py (reads from Jokes Google Sheet with 1hr cache, falls back to hardcoded). All Picklebot WhatsApp messages now end with *signature* followed by a random joke. Removes duplicate static-only joke code from picklebot main.py and whatsapp-sender. Updates deploy workflows to copy jokes.py, and adds it to CLAUDE.md and README shared module registries.

featf58b74dClaude Opus 4.6
  • Add joke via build_result() so every picklebot WhatsApp response gets one (except /pb joke and /pb meme)
  • Route book_court responses through build_result() for consistent joke handling
  • Strip @ prefix from Venmo usernames when writing to main sheet
docs59c058cClaude Opus 4.6
Document warm-up phase disable in profiling results
feat383b45aClaude Opus 4.6

Modal overlay from failed warm-up attempts was not being dismissed, blocking all subsequent booking clicks and causing 30s timeouts.

featd6e4d27Claude Opus 4.6
Show all columns in stats my Health Log (weight, hours, DUPR)
feat72968b2Claude Opus 4.6
Use health emoji for stats my in help text and README
fixf05753eClaude Opus 4.6

Name not Player Name, Hours Played not Total Hours.

feat4672939Claude Opus 4.6
Show historical Health Log entries in /pb stats my
feat3a07370Claude Opus 4.6
Show date in weight record results and stats my command
feat346ebf7Claude Opus 4.6
Move health commands to read-only section in help and READMEs
feat4dd0cf7Claude Opus 4.6
  • Added Weight column to Google Sheet after DUPR
  • record_weight() now also updates main sheet Weight column
  • handle_my_stats() reads current stats from player dict (not Health Log)
  • Added Health Log to systems diagram
featcebabc1Claude Opus 4.6
  • get_player_health_log() reads Health Log entries by player name
  • /pb stats my shows sender's weight history (reverse chronological)
  • Removed record_weight from ACTION_INTENTS so it works in all groups
infra/CI6e9c9feClaude Opus 4.6
Fix missing health.py in picklebot deploy shared module copy
feat3a99a95Claude Opus 4.6

Records player's weight to Health Log sheet with date, name, weight, total hours played, and DUPR rating. Uses sender phone identification to auto-detect the invoking player.

fix5614098Claude Opus 4.6

cancel_reservation_main now returns success bool and exits 1 when reservation not found, so cancel-game correctly reports "not found" instead of false "canceled" status.

fix01e1438Claude Opus 4.6
Fix Game section icon to paddle in help and README
feat32713f1Claude Opus 4.6
Add /pb game cancel to help command output
feat95e0bb5Claude Opus 4.6

Automates 3 manual steps into one operation: 1. Delete game date column from sheet (auto-adjusts formulas) 2. Cancel both court reservations via court-booking.py 3. Cancel Cloud Scheduler last-call job CLI: python smad-whatsapp.py cancel-game "03/05/2026 7:00 PM" [--dry-run] Picklebot: /pb game cancel 3/5 7pm

refactor7cd0412Claude Opus 4.6
Simplify gitignore: exclude entire docs/ directory from ignore rules
featd48962fClaude Opus 4.6
Add header photo to GitHub Pages landing page
feat8b98f32Claude Opus 4.6

Simple landing page describing SMAD Pickleball as a community group with opt-in SMS notifications for game reminders, vote reminders, and payment reminders. Added .gitignore exception for docs/*.html.

docs4a65d6bClaude Opus 4.6

Add proper headings, code fences around profiler output blocks, and consistent formatting to prevent setext heading misinterpretation.

feat7d44348Claude Opus 4.6
Add security-audit.md to repo (repo is now private)
infra/CI365f692Claude Opus 4.6

Every-6-days schedule caused a gap when short months or paused daily runner meant the watch expired before the next renewal. Every other day (even days) provides ample buffer against the 7-day expiration.

feat0d216efClaude Opus 4.6

Vote reminder DMs now use the same formatter as /pb games and last call, showing weather, DUPR, and hours. Past games are filtered out via future_only=True. Removed format_upcoming_games_text since it's no longer used anywhere.

fix529aaf8Claude Opus 4.6

BOOKING_LIST entries like "Monday 6:00PM|South" passed the raw court name "South" to book_court(), which only recognizes full names like "South Pickleball Court". Apply COURT_ALIASES to resolve shorthand names, and default to booking both courts on unrecognized input.

feat59cf9b3Claude Opus 4.6

Extract shared _build_slot_rankings() and _format_slot_line() helpers used by both format_booking_suggestions() and format_survey_availability(). Shows "X available (Y total)" when vacation players exist, appends * to vacation player names. Uses full names instead of first names. Booking suggestions still exclude days with existing games.

fix7f7996bClaude Opus 4.6

Content-based dedup (SHA-256 of message content) was blocking users from receiving the same bot response within 24h — e.g., /pb help was permanently blocked after first send. Switched to correlation_id-based dedup so each intentional command gets through while Pub/Sub retries are still blocked. Also added unclaim_message() to clean up dedup on send failure so retries work.

refactora309b8cClaude Opus 4.6
  • Fix: picklebot memory limit 512MB→1024MB (was crashing with 544 MiB used)
  • Fix: handle_last_call now falls back to next week via prepare_last_call()
  • Extract prepare_last_call() to shared/player_data.py (CLI + picklebot)
  • Extract 4 render_* functions to shared/command_formatters.py (CLI + picklebot)
  • Remove duplicate game handler code from both CLI and picklebot
refactorf373d82Claude Opus 4.6

Consolidates duplicate _get_games_message() and next-week fallback logic from both CLI and picklebot into shared functions. Fixes /pb next game not falling back to next week when all current games have passed.

feat4ea19e1Claude Opus 4.6

Extract survey_sync() to shared/player_data.py (2-phase: email then name match with email backfill) and format_survey_sync() to shared/command_formatters.py. Both CLI and picklebot use the same shared functions with zero duplicate code.

February 2026 245 commits

feat152fix34infra/CI31docs12refactor11perf3tooling2
featf379c6fClaude Opus 4.6

Extract build_action_success_message() and build_action_failure_message() in picklebot to eliminate duplicate success/failure message building between the direct command handler and confirmation-link handler. Also includes the BOOKING_LIST removal and consecutive reservation consolidation from the previous commit's smad-whatsapp.py changes.

featf416cb6Claude Opus 4.6

create-poll now always uses actual Athenaeum court reservations instead of falling back to the static BOOKING_LIST env var. When --from-reservations is not provided, it auto-fetches from Athenaeum via subprocess. Consecutive hourly slots on the same day are consolidated into a single poll option (e.g., Mon 5pm + Mon 6pm → Mon 5pm).

feat2649062Claude Opus 4.6

Prevents duplicate WhatsApp messages by hashing message content + recipient before sending. Uses atomic Firestore create() — if the same content was already sent to the same recipient within 24 hours, it's skipped. Documents auto-expire via TTL policy on the expires_at field.

feat9d67119Claude Opus 4.6
Add /pb players sync and /pb sync players command aliases
feat3fd39c6Claude Opus 4.6
  • Delete update_vote_in_sheet() from smad-sheets.py (dead code, 120 lines)
  • Add get_vote_column_value() shared helper: returns 'y', 2 (game day), 'n', or None (past game — skip update to prevent cheating balances)
  • Remove process_cannot_play_override() call that stripped valid future game votes when "Can't play this week" was also selected
  • Update DUPE_CODE_REFACTORING.md with Phase 5 details
  • Update CLAUDE.md shared function registry
featee1fb9bClaude Opus 4.6

Game-day votes now write 2 (hours) since the column processor already ran. Shared via get_vote_column_value() in player_data.py — both webhook and CLI use the same logic. Also removed the cannot-play override that was stripping valid future game votes when "Can't play this week" was also selected.

feat7753d67Claude Opus 4.6

The override assumed players who selected both "Can't play this week" and a game date had forgotten to uncheck dates. In practice, players use it to mean "can't play today" while still voting for future games. update_poll_date_columns() already handles this naturally — "can't play" has no matching date column so it's skipped. Game date selections now pass through to the sheet correctly.

feat22ac077Claude Opus 4.6

They already get a game-day reminder that includes their balance. Uses get_todays_games() from poll votes instead of broken last_game_date field. Shows skipped players in both CLI and picklebot previews.

featb1d61deClaude Opus 4.6
  • Add court/jobs catch-alls in fallback parser so unrecognized subcommands return help text instead of falling through to Haiku
  • Add safety guard: reject Haiku action intents (book_court, cancel_job, etc.) unless original text contains matching keywords
  • Add 'court reservations' to list_reservations exact matches
  • Fix jobs cancel to use regex extraction, add 'job cancel' variant
infra/CI592c531Claude Opus 4.6

Pub/Sub at-least-once delivery was causing duplicate payment notifications when multiple instances processed the same message concurrently.

feat1262320Claude Opus 4.6

Twilio 888 number not approved yet — disable all SMS sends until SMS_ENABLED=true is set in GitHub Variables. Wired into all workflows and picklebot Cloud Run deploy.

feat5582c80Claude Opus 4.6
Show player names instead of phone numbers in SMS dry-run logs
fix27aa7dcClaude Opus 4.6

Google Sheets auto-shifts absolute column references when columns are inserted, causing the SUM range to exclude newly added game date columns. Now re-reads headers after insertion and resets every player's Hours formula to start from the dynamically determined first date column.

feat5e1b52fClaude Opus 4.6

Last Call now DMs players who haven't taken the availability survey AND haven't voted (in addition to the existing survey-available non-voters). The summary is always posted to the SMAD Pickleball group and admin group, even when there are zero DM recipients (previously skipped due to early return).

feat02f081bClaude Opus 4.6
  • /pb players reminders: shows Bal, Pay Reminders, Vote Reminders sorted by vote reminders descending
  • Fix false vote reminders: consolidate duplicate get_poll_creation_date() into shared get_poll_created_date() with poll_id param
  • Game day player list: 1 space between DUPR and Hr, rename Hrs to Hr
fixd597dbaClaude Opus 4.6

get_poll_created_date() was picking the MAX poll date across all rows, which included a corrupted entry (Jeff Herring's row had vote timestamp as poll date: 02/22 instead of 02/21). This made 11 players who voted on 2/21 appear as non-voters and receive false reminders. Fix: use earliest date per poll_id instead of max across all dates. Also consolidated duplicate get_poll_creation_date() from webhook/main.py into the shared function with optional poll_id and date_only params.

feat1058721Claude Opus 4.6
Update docs for /pb players [dupr] option
feat394deebClaude Opus 4.6
  • /pb players dupr: sort by DUPR rating (highest first), then hours
  • Add trailing emojis to Read-only and Actions headers in help text
feat1481747Claude Opus 4.6
Add emojis to Read-only and Actions section headers in help text
feat679e728Claude Opus 4.6

Rename verb-first commands to noun-first pattern (e.g. book → court book, remind payment → payment remind, sync members → members sync). Group help text by category (Balance, Games, Info, Survey, Court, Jobs, Members, Payment, Poll, Fun) with emoji headers and per-command emojis. Old command names still work via Claude Haiku NLP + regex fallback aliases.

feat281f347Claude Opus 4.6
  • New last-call-runner.yml workflow dispatched by Cloud Scheduler
  • New schedule-last-call.py creates/cleans scheduler jobs per game
  • daily-reminder-runner.yml schedules jobs after Sunday poll creation
  • poll-creation.yml schedules jobs after manual poll creation
  • Add google-cloud-scheduler dependency
  • Update README with last-call scheduling docs and architecture diagram
feat83dcb4aClaude Opus 4.6
  • format_last_call_summary now includes weather, player list with DUPR/hours, and need indicator
  • format_last_call_dm adds joke after sign-off (each DM gets its own random joke)
  • DM copy: "Next game is coming up soon!" and "if you can make it to this game!"
  • Move survey availability sentence to just before vote CTA in DM
feat0e0cf63Claude Opus 4.6

Was filtering out 8pm and 9pm slots. Wed 8pm had 8 available players but wasn't showing in suggestions.

featf3b0828Claude Opus 4.6

Silence urllib3/sentry_sdk transport loggers that spam ~636 SSL retry lines per day across all Cloud Run services. Sentry still retries and delivers events — only the noisy WARNING logs are suppressed. Also adds section 26 to profiling-results.md documenting the full infrastructure audit (Cloud Run services + GHA workflows).

feat3f65019Claude Opus 4.6

Was watching INBOX, causing every email to trigger a Venmo sync (48 spurious invocations today). Now watches only the Venmo label (Label_8840251291188778873), which is applied by an existing Gmail filter on from:[email redacted].

feat1a7bd47Claude Opus 4.6

Retry mechanism now does a full page reload (fresh ViewState) between attempts instead of just retrying in-place. Also documents all February midnight booking run timings, showing cross-month postback is the root cause of the Feb 22 failure.

feat41d688bClaude Opus 4.6

Cross-month date entries (e.g., Feb→Mar) can fail silently when the ASP.NET postback doesn't fire. Retries up to 3 times with 2s timeout per attempt instead of failing after a single 3s wait.

featd29ca95Claude Opus 4.6
Restore vacation skip in survey reminders
docs122f96aClaude Opus 4.6
Update DUPE_CODE_REFACTORING.md for completed logging migration
feat2b270d7Claude Opus 4.6

CLI tools (smad-sheets, smad-whatsapp, payments-management, setup-gmail-watch, send-release-notes) use format="%(message)s" for identical output. email_service fallback log functions use getattr(logger, level). court-booking browser console handler now uses structured log(). Only remaining print() is the structured JSON log() function in court-booking.py (correct pattern for Cloud Run).

feate8df5caClaude Opus 4.6
Convert print() to logger across all webhook modules
feat0d283cfClaude Opus 4.6
  • Delete parse_date_from_header(), use parse_game_option_date() everywhere
  • Extract get_todays_games() to shared/player_data.py
  • Extract _send_dm() helper in CLI for WhatsApp DM sending pattern
  • Update DUPE_CODE_REFACTORING.md: close all remaining TODO items
docsc63ad70Claude Opus 4.6
Update DUPE_CODE_REFACTORING.md to reflect completed COL_* migration
refactorf8e204dClaude Opus 4.6

Migrated all 8 remaining functions (update_vote_in_sheet, mark_game_played, find_player_row, find_date_column, show_balances, send_reminders, register_player, add_date_column) to use ColumnMapper for header-based column lookup. Deleted all 16 COL_* constant definitions.

featb694e21Claude Opus 4.6

Vacation players should still receive survey reminders since the survey is about general availability, not weekly game participation.

featb92a73eClaude Opus 4.6
Update survey reminder to mention DUPR rating and SMS opt-in
refactorbe19d61Claude Opus 4.6

Extract duplicated vote reminder DM into shared format_vote_reminder_dm() in command_formatters.py. Both CLI and picklebot now include upcoming games and joke in vote reminders. Add next-week fallback for upcoming games lookup (same pattern as games command).

feat19d8b7dClaude Opus 4.6
Remove colons from DUPR and Hrs labels in game player list
feat6a4b8dcClaude Opus 4.6
Format DUPR rating as X.X in list-players display
fixee5d732Claude Opus 4.6

Old 2025 sheet "Hours Played" column already includes these games, so don't add them again to hours_2026.

feat3b18bfeClaude Opus 4.6
  • Add last-week-games command to CLI and picklebot
  • Refactor CLI and picklebot games handlers into shared _get_games_message()
  • Fix DUPR column alignment in player list (pad to fixed width)
feat36893b5Claude Opus 4.6

The games command now checks next week if all this week's games are in the past (e.g., on Sundays after a new poll is created).

feat64fe0b1Claude Opus 4.6

Shows top 10 survey-based time slot suggestions after the games list, excluding days that already have games, filtered to 4+ players and 7pm max.

featfcbf2d2Claude Opus 4.6

Emoji is stripped before writing sheet column headers and when matching poll votes to columns, so vote tracking is unaffected.

feata138bf6Claude Opus 4.6

Poll creation now fetches real court reservations from the Athenaeum website via Playwright, ensuring the poll reflects what's actually booked (including ad-hoc bookings and excluding cancelled courts). BOOKING_LIST remains as fallback when --from-reservations is not provided.

infra/CI4d33f92Claude Opus 4.6

12:58 AM was 57 minutes past the 00:01 AM booking target, causing the script to think it missed tonight's window and wait until tomorrow — then get cancelled by the 3-minute job timeout.

docs5659b8dClaude Opus 4.6
Update cost table: add GCP Secret Manager, GREEN-API, Claude Code
featcf2530eClaude Opus 4.6

Players with sms_reminder=yes in Google Sheet receive SMS copies of WhatsApp DM reminders. Adds shared sms.py module with maybe_send_sms() called from all 8 DM send sites (6 CLI, 2 picklebot). Deduplicates phone formatting into phone_utils.normalize_us_phone(). Cleans up Terraform secrets: removes resource blocks, uses removed{} blocks + string-based IAM bindings (Terraform reads, never owns).

featf136650Claude Opus 4.6
Filter Best Times to 4+ available players in survey results
featc389299Claude Opus 4.6
  • Add 'SMS Reminder' to SHEET_COLUMNS and SURVEY_COLUMNS
  • Add sms_reminder field to get_full_player_data() and get_survey_data()
  • New CLI command: survey-sync - syncs DUPR and SMS Reminder from survey responses to main sheet (matched by email, supports --dry-run)
  • Column added to 2026 Pickleball sheet after Mobile (col G)
feat1ddfe8bClaude Opus 4.6

format_player_list_with_hours now displays "DUPR: X.X Hrs: 02" instead of "2 hrs played". Affects all game summaries: Game Day Reminder, Show Games, Next Game, and Last Call.

fixa45e863Claude Opus 4.6
  • Payment reminders now only skip players with a game today that hasn't started yet (was skipping all game-day players regardless of time)
  • /pb reminders payment now responds with numbered player list and amounts instead of generic "workflow has been started"
  • Add smsplan.md with Twilio SMS forwarding implementation plan
featdf4fc52Claude Sonnet 4.6
  • Availability grid: Mon/Tue/... → Mo/Tu/We/Th/Fr/Sa/Su (2-letter)
  • Best Times list uses same 2-letter abbreviations
  • List Players: remove Last Played column from display (model unchanged)
fixd7a0b05Claude Sonnet 4.6

execute_send_reminders was dispatching the workflow with no inputs, causing it to always run both. Now passes reminder_type input correctly (vote -> vote_only, payment -> payment_only).

feat9805db2Claude Sonnet 4.6

Admin Dinkers already receives a separate Game Reminder Summary. Sending the full group message there too was redundant. Dry-run preview to Admin Dinkers is kept.

infra/CI62bd712Claude Sonnet 4.6

2 min warmup is enough for GHA + Python. Booking still hits the 1 AM window. Updated GCP scheduler, Terraform, and workflow comment to match.

infra/CI71ec477Claude Sonnet 4.6

3 min for all jobs except court-booking (10 min, needs Playwright/Chromium). Prevents runaway jobs from burning free tier minutes silently.

infra/CI328b9beClaude Sonnet 4.6

Terraform was running 400+ min on bad days burning most of the free tier. 5 min is more than enough for plan+apply; if it hangs something is wrong. Also corrects stale comment claiming two nightly court booking triggers.

refactor12578dbClaude Sonnet 4.6

Avoids confusion between the weekly games poll and the availability survey.

feat5599af3Claude Sonnet 4.6

SURVEY_COLUMNS['rating'] was col 17 (session rating), but DUPR is col 18. Added 'dupr' key at col 18 and 'games_per_week' at col 19. get_survey_data() now returns 'dupr' field per respondent. New CLI command send-dupr-reminders DMs survey respondents missing DUPR score.

feat6c9fef3Claude Sonnet 4.6

Survey respondents' self-reported names are unreliable (e.g. "James" vs "James Lee"). Now builds email→canonical_name map from main sheet and joins on survey respondent email. Falls back to survey name if no email match.

feataea3623Claude Sonnet 4.6

Fixes disambiguation for players with same first name (James, John, etc.)

feata9b7a32Claude Sonnet 4.6

When sync-members adds a new WhatsApp member to the spreadsheet, send them a DM with the SMAD group description (stored as GitHub Variable SMAD_WELCOME_MESSAGE) so they get onboarding info automatically.

fixe0c576eClaude Sonnet 4.6
  • Read header row at runtime; use ColumnMapper + col_index_to_letter to build formula references dynamically — no more hardcoded A=First/C=Vacation/E=Mobile etc.
  • Sort by (first_name, last_name) composite key to handle multiple members with the same first name correctly
  • Hours formula uses cols.first_date_index to find the actual first game column
  • Also repaired James Ji's row: moved from row 50 (inserted as Unknown) to row 24 (correct alphabetical position) with correct formulas in all columns
feat07ac7b6Claude Sonnet 4.6
  • sync-members.yml: add reply_chat_id input, pass --reply-chat-id to CLI
  • execute_sync_members(): pass chat_id as reply_chat_id to workflow
  • cmd_sync_members(): send summary message to reply_chat_id on completion
  • Triggered message updated to mention a completion message is coming
featdf1849cClaude Sonnet 4.6
Remove Last Voted, VR, PR columns from Players command display
feat4ed937bClaude Sonnet 4.6
  • Inserted DUPR column after Last Name in 2026 Pickleball sheet
  • Populated 9 DUPR ratings from availability survey (email-first, name fallback)
  • Added 'dupr' to SHEET_COLUMNS and get_full_player_data() player dicts
  • format_list_players() now shows DUPR rating between name and hours
feat6ba819aClaude Sonnet 4.6
  • New shared get_last_call_data() matches survey availability to poll votes (email-first)
  • find_game_by_datetime() helper to target a specific game by date+hour
  • get_votes_from_main_sheet() now tracks 'n' votes in no_voters_by_option — only blank cells (truly haven't voted) receive Last Call DMs; explicit 'n' votes are excluded
  • format_last_call_dm() includes game date, weather, and player list with hours
  • format_last_call_summary() uses 🥃 emoji; /pb help entry added
  • CLI: python smad-whatsapp.py last-call ["2/21/26 10:30am"]
  • Picklebot: /pb last call [datetime] — sends DMs directly (no GHA dispatch, time-sensitive)
refactor2714ff1Claude Opus 4.6
  • Add get_survey_reminder_recipients() to player_data.py
  • Add format_survey_reminder_message() to command_formatters.py
  • Extend format_reminders_preview() to support 'survey' type
  • CLI uses shared functions instead of inline logic
  • Picklebot dry-run uses _build_reminders_preview('survey') — same as vote/payment
  • Fix admin summary: report vacation skips separately from no-phone skips
  • Remove dead handle_survey_results() from picklebot
feat0d90288Claude Opus 4.6
Send cached heatmap inline after text instead of as a race condition
feat86cef3bClaude Opus 4.6
  • Add smad-pickleball-screenshots bucket resource with uniform access and public read
  • Grant github-deploy SA storage.objectAdmin for heatmap uploads
  • Grant compute SA storage.objectViewer for picklebot cache checks
  • Add google-github-actions/auth step to survey-heatmap.yml (root cause of missing heatmap)
  • Remove blob.make_public() incompatible with uniform bucket access
feat3dc7764Claude Opus 4.6
Cache heatmap by response count, skip regeneration when data unchanged
feat9f6c92c
Move heatmap to dedicated survey-heatmap.yml workflow, update README
feat54ca4e6
Make heatmap async: picklebot dispatches GHA workflow, image sent back to chat
feata6cb5b6
Auto-regenerate survey heatmap in daily runner before survey reminders
feat4e56201
Show player names in Best Times list for survey results
infra/CI5d14191
Pass SURVEY_URL env var to picklebot Cloud Function
feat2831ba0
Use SURVEY_URL from config instead of hardcoded URL in availability results
featf296fa0
Add survey edit link to availability results header
feat8495133
  • New shared module survey_heatmap.py with matplotlib renderer
  • CLI command: survey-heatmap (generates PNG, uploads to GCS)
  • Picklebot sends heatmap image alongside survey results text
feated96526
Invert availability grid axes: days across top, times down side
feat99a1066
Merge Rathskeller note into same paragraph
feat51d7382
Add Rathskeller Bar note to survey reminder message
feat4b10e74
Skip players on vacation when sending survey reminders
infra/CI6c15dd4
Fix survey_only dispatching game, vote, and payment steps
feat2172051
Add freezing emoji to survey reminder message
feat22a3b54
  • Add send_survey_reminders intent to picklebot (admin action command)
  • Dispatches daily-reminder-runner with survey_only (bypasses even-day check)
  • Dry run shows survey status without sending
  • Survey reminders in daily runner only send on even days of month
feat3c5bbfd
  • Add get_survey_data() and get_survey_status() to player_data.py with email-based matching (42/52 players) and name fallback
  • Add format_survey_status() and format_survey_availability() formatters with availability grid display
  • Add /pb survey and /pb survey results picklebot commands
  • Add survey-status, survey-results, send-survey-reminders CLI commands
  • Add survey reminder step to daily-reminder-runner.yml with survey_only option
  • Store SURVEY_URL in .env, .env.example, and GHA variable
  • Add SURVEY_SHEET_NAME and SURVEY_URL to shared config
  • Include email field in player data dict for survey matching
featb6b4a99Claude Opus 4.6

Previously action commands (book, cancel, reminders, etc.) were only allowed from Admin Dinkers group, test chat, or admin DMs. Now SMAD group admins are detected via GREEN-API and granted admin access.

fix134becbClaude Opus 4.6

The record_payment branch had a local 'import re' at line 388 which caused Python to treat 're' as a local variable for the entire function, breaking re.sub/re.match calls earlier in the function.

fixfb41563Claude Opus 4.6

The exact-match parser returned {"raw": "2/20/26 4pm north"} but the handler expected separate date, time, court keys. Added _parse_raw_booking_params() to split the raw string into structured params, fixing both /pb cancel reservation and /pb book commands.

feataf94cddClaude Opus 4.6
  • deploy-whatsapp-webhook.yml, deploy-picklebot.yml, deploy-venmo-sync.yml, deploy-whatsapp-sender.yml
  • Each triggers only on its own paths + webhook/shared/ changes
  • Added retry with 30s delay to handle GCP 409 throttle errors
  • Updated all doc references across README, DUPE_CODE_REFACTORING, Terraform, etc.
infra/CI5a4b0dcClaude Opus 4.6
Add phone_utils.py to venmo-trigger shared module copy list
infra/CIedc303fClaude Opus 4.6
Add payments.py to venmo-trigger shared module copy list
infra/CI88f5fffClaude Opus 4.6
Run deploy jobs sequentially to avoid GCP 409 throttling errors
feat7f47ca8Claude Opus 4.6

Eliminate ~450 lines of duplicate code across 10 phases:

  • Move CANNOT_PLAY_PHRASES, PPL_COL_* constants to shared/sheet_columns.py
  • Create shared/phone_utils.py for format_phone_for_whatsapp()
  • Replace get_player_data() with shared get_full_player_data() (9 call sites)
  • Migrate COL_* hardcoded indices to ColumnMapper in all CLI scripts
  • Remove importlib hack from payments-management.py entirely
  • Consolidate get_old_2025_hours() and ensure_payment_log_sheet()
feat39dd128Claude Opus 4.6

Add --send flag to next-week-games CLI command to post formatted game schedule to the SMAD group. Add step to daily runner workflow that runs after poll creation on Sundays, ensuring new game columns exist before reading them.

feat7bbc304Claude Opus 4.6

"show next week games" was misrouted to show_games because next_week_games was missing from the LLM intent list and the phrase wasn't in exact matches.

feat6e87b62Claude Opus 4.6

Delete 4 duplicate functions from smad-whatsapp.py (~300 lines) that duplicated shared module implementations: parse_game_date_from_header, get_poll_votes_from_sheets, get_games_from_poll, and get_upcoming_games_text. Migrate all callers to use get_votes_from_main_sheet() and get_games_from_votes() from player_data.py. Add future_only param to get_games_from_votes() and week_offset param to get_votes_from_main_sheet() for next-week support. Add next-week-games command to both CLI and picklebot. Move upcoming games formatting to shared format_upcoming_games_text() in command_formatters.py. Add shared function registry to CLAUDE.md to prevent future duplicate code.

feat7a62d49Claude Opus 4.6
  • Delete cleanup_old_poll_logs() from webhook/main.py — it was redundant with archive_poll_log() which already runs at poll creation (Sunday 8am)
  • This fixes the Sentry error: naive/aware datetime comparison on every Sunday vote because strptime() produced a timezone-naive datetime
  • Add LoggingIntegration(event_level=None) to prevent logger.error() calls in handled try/except blocks from creating Sentry events (~45 call sites would burn through the 5K/month free tier quota)
infra/CI2963bedClaude Opus 4.6

The SENTRY_DSN secret was created via gcloud before Terraform could manage it, causing a 409 conflict on terraform apply. Using a declarative import block (Terraform 1.5+) to adopt the existing resource into state. This is a no-op after the first successful apply.

feat4dd2e77Claude Opus 4.6

Integrate Sentry SDK into all 4 Cloud Functions for proper error tracking, replacing the custom gha-error-monitor that produced non-actionable alerts. Sentry provides real stacktraces, error grouping, and a dashboard on the free tier (5K events/month). Sentry integration: gha-error-monitor decommission:

  • Create shared webhook/shared/sentry_init.py module (no-op if DSN unset)
  • Add sentry-sdk>=2.0.0 to all function requirements.txt
  • HTTP functions (webhook, picklebot): auto-capture via init()
  • Pub/Sub catch-all (venmo-trigger): explicit capture_exception + flush
  • Pub/Sub re-raise (whatsapp-sender): flush before raise
  • Add SENTRY_DSN to --set-secrets in deploy-webhook.yml
  • Add SENTRY_DSN secret + IAM binding to Terraform
  • Remove Cloud Function + IAM from functions.tf
  • Remove GITHUB_WEBHOOK_SECRET from secrets.tf
  • Remove deploy job from deploy-webhook.yml
  • Delete webhook/gha-error-monitor/ source code
  • Update README, CLOUD_COST_OPTIMIZATION, DUPE_CODE_REFACTORING, Terraform README, CLAUDE.md, .gitignore
infra/CIe3ad116Claude Opus 4.6

Adds comprehensive Configuration Management section to README with canonical source tables for all 15 GitHub Secrets, 9 GitHub Variables, and 9 GCP Secret Manager secrets. Documents the 4 cross-platform duplicated secrets with rotation procedure. Updates mermaid diagram to show GCP SM, GitHub Secrets/Variables, and .env as separate config sources. Moves GMAIL_USERNAME and NOTIFICATION_EMAIL from Secrets to Variables table in GITHUB_ACTION_SETUP.md.

feat396a9ecClaude Opus 4.6
  • Add 12-Factor Config rule #8 to CLAUDE.md
  • Delete terraform.tfvars (was duplicating GitHub Secrets)
  • Remove 5 unused Terraform variables
  • Move SMAD_SPREADSHEET_ID, SMAD_SHEET_NAME, GMAIL_USERNAME, NOTIFICATION_EMAIL from GitHub Secrets to GitHub Variables
  • Update all 9 workflow files: secrets.* → vars.* for moved values
  • Fix Terraform CI: add -input=false, grant CI SA secret access
  • Add Terraform ignores to .gitignore
feat12ac8c6Claude Opus 4.6
  • Move 3 Cloud Scheduler jobs from PowerShell script to Terraform (scheduler.tf)
  • Bootstrap Terraform: create GCS state bucket, import all existing GCP resources
  • Consolidate 7 scheduler jobs down to 3 (within free tier, $0.00/month):
  • 8am-daily-runner: polls (Sun), reminders, Gmail watch renewal
  • 1155pm-daily-court-booking: parallel North/South court booking
  • keepalive-webhook: keeps Cloud Functions warm
  • Split court-booking.yml into parallel North/South jobs
  • Add poll creation step to daily-reminder-runner.yml (Sundays only)
  • Delete setup-scheduler.ps1 (replaced by Terraform)
  • Add webhook GET keepalive handler
  • Make CLAUDE.md drift prevention rule project-agnostic
  • Update all documentation to reflect new architecture
feat9fb2230Claude Opus 4.6

When converting 'y' to 2 on game day, also update the Totals row formula for that column from COUNTIF("y") to SUM so it correctly totals the numeric hours.

fix1969292Claude Opus 4.6
  • Poll title "week of" now finds next upcoming Monday instead of going backward to the current week's Monday (e.g., Saturday 2/14 now shows "week of 2/16" instead of "week of 2/9")
  • Time formatting preserves minutes when non-zero (10:30am not 10am)
feat6938362Claude Opus 4.6
Add money bag emoji to payment thank you message
feat6671772Claude Opus 4.6

Replace "Booking may have failed" with specific reasons: "Slot already taken" when the booking dialog returns empty date/time (slot reserved), or date/time mismatch details when verification fails. Adds last_failure_reason tracking to AthenaeumBooking class.

infra/CIee8800aClaude Opus 4.6
Complete GCS cleanup: lifecycle policy and unused bucket removal
docs10f88f8Claude Opus 4.6
Add rules for workflow monitoring and permission clarity
feat0fda64cClaude Opus 4.6
  • Fix venmo-sync retry storm: catch exceptions and return gracefully instead of raising (which caused Pub/Sub to retry with exponential backoff, amplifying to 3,275 invocations/day)
  • Move SMAD_SPREADSHEET_ID and SMAD_SHEET_NAME from Secret Manager to plain env vars (reduces secret accesses from 19 to 13 per cold start)
  • Add Artifact Registry cleanup policy (keep 5 recent, delete >7 days)
  • Add CLOUD_COST_OPTIMIZATION.md documenting analysis and action plan
featf437216Claude Opus 4.6

Tries parse_intent_fallback() first (instant string matching) and only calls Claude Haiku when the fallback returns 'unknown'. Eliminates ~0.5-2s API latency for all standard /pb commands.

feat3c0a432Claude Opus 4.6
  • Move Options (dry run) after Actions section, use natural language wording
  • Add NLP tip with speech bubble emoji to both admin and non-admin help
  • Update emojis: games 🏓, status 🪫, jobs ⏰ in help menus and response titles
refactord430e13Claude Opus 4.6
Rename /pb next to /pb next game with paddle emoji, update record payment emoji
feat50edc31Claude Opus 4.6
Enrich booking triggered message with date, time, duration, and court details
feat6f5c4fbClaude Opus 4.6
  • map_court_name() now accepts 'both'/'b'
  • execute_book_court passes court directly (was converting 'both' to '')
  • execute_cancel_reservation dispatches two requests for 'both'
  • Fixed scheduling path court param too
feat6a4efbdClaude Opus 4.6

Picklebot sends "south"/"north" but court-booking.py expected full names like "South Pickleball Court". Added alias mapping.

featea1ce47Claude Opus 4.6

Booking and cancellation now send actual success/failure results back to the requesting WhatsApp chat via Pub/Sub, instead of just "triggered!".

feat0a1f8a0Claude Opus 4.6
  • Add abbreviated day name (Mon, Tue, etc.) to reservation dates in both WhatsApp and CLI display formats
  • Change "sent to WhatsApp" to "sent here" in picklebot response
feat3d24970Claude Opus 4.6
  • Pass reply_chat_id from picklebot through GitHub Actions workflow to court-booking.py so async results go to the DM/group that requested them, not always to Admin Dinkers
  • Use send_dm() for @c.us chats, send_group_message() for @g.us
feat2213bb5Claude Opus 4.6
  • Picklebot dispatches to GitHub Actions which runs court-booking.py --get-reservations --whatsapp-notify to scrape Athenaeum and send formatted reservation list back to WhatsApp via Pub/Sub
  • Add format_reservations_whatsapp() and --whatsapp-notify CLI flag
  • Add get_reservations input to court-booking.yml with mutual exclusion
  • Add list_reservations intent, fallback parser, execute function
feat7effb44Claude Opus 4.6
  • Add cancel_reservation intent with date/time/court params
  • Dispatch to GitHub Actions court-booking.yml with --cancel-reservation flag
  • Add cancel step to workflow (mutually exclusive with booking step)
  • Admin-only, supports dry-run preview
feat98febdcClaude Opus 4.6
  • Create webhook/shared/config.py as single source for env var reads
  • Add get_cli_sheets_service() to google_sheets_utils.py
  • Replace ~40 duplicate os.environ.get() calls across 12 files
  • Standardize variable names (MAIN_SHEET_NAME→SHEET_NAME, ADMIN_DINKERS_GROUP_ID→ADMIN_GROUP_ID)
  • Update CI/CD to copy config.py to all Cloud Function shared folders
feate444d74Claude Opus 4.6
  • Move get_poll_created_date(), is_on_vacation(), get_vote_reminder_recipients(), get_payment_reminder_recipients() to shared player_data.py
  • Move format_reminders_preview() to shared command_formatters.py
  • Remove duplicate parse_date_string(), get_poll_created_date() from both CLI and picklebot
  • Remove 4 duplicate non-voter detection loops from CLI
  • Replace 65-line _build_reminders_preview() in picklebot with shared calls
  • Fix Windows console encoding: reconfigure stdout to UTF-8 at startup
  • Remove all 6 scattered try/except UnicodeEncodeError workarounds
feat4425621Claude Opus 4.6
  • Extract format_dead_man_switch_message() to command_formatters.py
  • CLI and court-booking both use shared formatter instead of duplicated 8-line message
  • Sending backends remain different (direct API vs Pub/Sub) by design
docse5fce96Claude Opus 4.6
Document Phase 7: unified game display formatting
feat38caeddClaude Opus 4.6
  • Add shared format_hours() and format_player_list_with_hours() to command_formatters
  • Update format_next_game() and format_show_games() with weather + monospace player list with hours
  • All callers (picklebot, CLI, game reminder) use shared formatter with get_full_player_data()
  • Remove duplicate _format_hours() from CLI
  • Weather shown for upcoming games only; past games show checkmark
feat850d194Claude Opus 4.6
Update command emojis: paddle for next-game, money bag for record payment
fix3a56f66Claude Opus 4.6

get_player_balances() only returns name and balance, missing the mobile field needed for phone number matching. Switch to get_full_player_data() which includes mobile, first_name, and last_game_date.

fix3f79ca7Claude Opus 4.6

The shared/__init__.py unconditionally imported whatsapp_message and whatsapp_publisher, but whatsapp-sender only has greenapi.py in its shared/ folder. This caused ModuleNotFoundError on every message send.

tooling82929c3Claude Opus 4.6
  • 🚀 Rocket for commit subject
  • 👾 Space Invader for level 1 bullets (- )
  • ⚡ Lightning for level 2 sub-bullets ( - )
  • 🔸 Orange Diamond for level 3 sub-sub-bullets ( - )
tooling9c85776Claude Opus 4.6
Fix release notes to join continuation lines into single bullets
feateab6b9aClaude Opus 4.6
  • Add /pb my balance command: players can check their own balance
  • Enable DM support for /pb commands (read-only for players, admin for SMAD group admins auto-detected via GREEN-API getGroupData)
  • Create webhook/shared/greenapi.py: shared GREEN-API helper module with call_api, get_group_admin_ids, get_chat_history, send_message
  • Refactor all GREEN-API callers to use shared module:
  • webhook/main.py: DM admin check uses shared get_group_admin_ids
  • smad-whatsapp.py: getChatHistory uses shared get_chat_history
  • whatsapp-sender: URL building uses shared get_api_url
  • release-notes.yml: converted from bash to Python script using shared send_message (send-release-notes.py)
  • Add record_payment to picklebot ACTION_INTENTS (was missing)
  • Add GREEN-API secrets to webhook Cloud Function deploy
  • Update DUPE_CODE_REFACTORING.md (Phase 6) and README.md
infra/CI731aab2Claude Opus 4.6
  • Rocket for main bullets, lightning bolt for sub-bullets
infra/CI4bf272eClaude Opus 4.6
  • Process each commit individually to separate subject from body
  • Body lines become sub-bullets instead of new top-level entries
  • Add rocket emoji to end of title
infra/CI03c86faClaude Opus 4.6

Sends WhatsApp release notes to Admin Dinkers on every push, not just deploys. Removes duplicate notify-deployment job from deploy workflow.

featd605877Claude Opus 4.6
Move help command emojis to beginning of each line
featbb92866Claude Opus 4.6
  • sync-members: after inserting new player rows, update all SUM/COUNTIF formulas in the Totals row to cover the new rows
  • /pb help: move emojis from end of line to beginning
feateca9cddClaude Opus 4.6

Game day reminder DMs already include the player's balance, so sending a separate payment reminder is redundant. Now checks if last_game_date is today and skips those players. Admin summary shows who was skipped.

infra/CI6294b5cClaude Opus 4.6

Cloud Scheduler handles the 8am trigger via workflow_dispatch. The leftover cron caused duplicate runs (8:00 AM + 8:20 AM).

infra/CI887fc97Claude Opus 4.6
Add player_data and command_formatters to picklebot deploy copies
feat10a1a3bClaude Opus 4.6

Extract data fetching and display formatting from picklebot into webhook/shared/player_data.py and webhook/shared/command_formatters.py. Both CLI and picklebot now use the same code for all 7 display commands. Adds 5 new CLI commands (list-players, deadbeats, balances, games, status) and fixes next-game to use main sheet instead of unreliable poll log.

refactora5993efClaude Opus 4.6
  • Fixed-width zero-padded dates for Last Played column
  • Title changed from Players to List Players
fixaa22426Claude Opus 4.6
Fix list players header spacing and separator width
infra/CI4e71011Claude Opus 4.6
Remove extra blank line in deploy notification
feat23c58beClaude Opus 4.6
  • Rename hours columns to Hrs 2026/Hrs 2025
  • Rename Last Paid to Last Played (shows last game date)
  • Strip time from last played values (2/10/26 7pm → 2/10/26)
  • Use monospace block for deploy notifications on WhatsApp
infra/CI4a1cc12Claude Opus 4.6
  • Wrap version/time/changes in ``` code block for cleaner rendering
  • Skip continuation lines from wrapped commit message text
  • Fixes broken bullet formatting on multi-line commit bodies
fix13297dfClaude Opus 4.6
  • Fix Google Sheets accounting format parsing (parentheses notation for negatives) in smad-whatsapp.py and picklebot get_full_player_data(), get_player_balances()
  • Show credits in game reminder DMs, payment admin summary, and picklebot balance commands
  • Rename list players columns: Hrs Played 26/25, Last Paid, Last Voted
  • Strip day-of-week prefix from last played dates
  • Add matching emojis to all picklebot command response headers
feat69ce5b2Claude Opus 4.6

Games later in the week (e.g. Saturday) were missing weather data because Open-Meteo was only queried for 3 forecast days.

feat9913db5Claude Opus 4.6
  • Add --send-on-game-day flag to send-game-reminder and send-vote-reminders (auto-detects today's games from poll sheet, skips if no games)
  • Create daily-reminder-runner.yml: game day reminders, vote reminders (game days only), Venmo sync + payment DMs (daily)
  • Add money emojis to payment reminder DMs
  • Remove Cloud Scheduler game reminder code from smad-whatsapp.py (get_scheduler_client, create_game_reminder_job, scheduling in create-poll)
  • Remove google-cloud-scheduler from requirements.txt
  • Clean up game-reminder.yml (remove cron triggers, scheduler job deletion)
  • Clean up poll-creation.yml (remove GCP auth, scheduler env vars)
  • Update README systems diagram and scheduled jobs table
refactor1fce0c8Claude Opus 4.6
Rename game day title to 'Game Day Reminder!' in group and DM messages
refactor4f384f8Claude Opus 4.6
  • Shared helper used by both get_upcoming_games_text() and cmd_next_game()
  • Removes ~40 lines of duplicated poll parsing, date filtering, and player aggregation
feat61fa3bbClaude Opus 4.6
  • Add reminder_type choice input (both/payment_only/vote_only) to workflow
  • Split monolithic step into independent conditional steps
  • Add get_upcoming_games_text() to show scheduled games with player lists
  • Vote reminder DMs now include "need X more!" to incentivize voting
infra/CI4b0dc22Claude Opus 4.6
Fix deploy notification to show subject + indented sub-bullets
feat4645ec8Claude Opus 4.6
  • Extract weather forecast code into webhook/shared/weather.py
  • smad-whatsapp.py imports from shared module (no duplication)
  • Picklebot webhook gets /pb weather command using same shared code
  • CI/CD copies weather.py to picklebot folder at deploy time
  • Add emojis to all /pb help commands
feat436cecfClaude Opus 4.6

New command: `smad-whatsapp.py weather [date] [time]` Defaults to current time in PST if no args provided.

feat00cf6e0Claude Opus 4.6

Fetches wind speed and gusts from Open-Meteo API. Gusts only shown when they exceed sustained wind by more than 5 mph.

infra/CI59b514bClaude Opus 4.6

Cloud Scheduler jobs weren't created this week due to missing GCP auth in poll-creation. As a workaround, add GHA cron schedule triggers for the remaining two games. Also refactors the workflow to resolve game parameters (date/time/court) in a single step that handles both cron and workflow_dispatch triggers.

fixe4327b3Claude Opus 4.6

Admin Dinkers summary now includes weather, hours, and monospace player list instead of the old plain-text format.

feat5cb8056Claude Opus 4.6
  • Weather: Open-Meteo API for Pasadena at game time (emoji, temp, rain %)
  • Hours: each player's 2026 hours played, monospace-aligned
  • Shared sections: group and DM templates reuse game_info + players_section
  • Fix: add GCP auth to poll-creation workflow for Cloud Scheduler access
feata83fa91Claude Opus 4.6
  • Create google_sheets_utils.py: unified get_sheets_service() replacing 7 duplicate implementations
  • Create column_utils.py: single col_index_to_letter() replacing 3 duplicates
  • Update 7 Python files to import from shared utilities instead of local copies
  • Clean up unused imports (json, Credentials, build, SCOPES) from CLI scripts
  • Update CI/CD to copy new utilities to picklebot and venmo-trigger
  • Add refactoring results report to DUPE_CODE_REFACTORING.md (-1,060 net lines)
  • Update README.md shared modules table and future work section
featfccd6d4Claude Opus 4.6

Removes webhook/picklebot/shared/ and webhook/gha-error-monitor/shared/ from git tracking. These are now created by CI/CD during deployment by copying from the canonical webhook/shared/ source. This eliminates source-level duplication and drift risk. Changes:

  • .gitignore: Ignore all webhook/*/shared/ folders
  • deploy-webhook.yml: Add mkdir -p and full file copy for picklebot, venmo-trigger, and gha-error-monitor shared modules
  • Delete tracked copies from picklebot/shared/ and gha-error-monitor/shared/
feat13196dfClaude Opus 4.6

Updates gha-error-monitor whatsapp_message.py and whatsapp_publisher.py to match the canonical webhook/shared/ versions. These copies had drifted and were missing newer functions (polls, images, updated message types).

featc733468Claude Opus 4.6

Adds --get-reservations to view all booked court reservations and --cancel-reservation to cancel a specific booking by date/time/court. Parameterizes login() to accept different landing URLs.

fixd42c7bbClaude Opus 4.6
  • Return error when poll_created date can't be determined instead of silently including all players
  • Strip time from poll_created for date-only comparison (matches CLI)
  • Use last_voted < poll_created check (matches CLI logic)
  • Show poll creation date in preview output
feat1796715Claude Opus 4.6

Show who would receive reminders and who is on vacation instead of just saying "would trigger workflow".

feat8eca60eClaude Opus 4.6

All /pb actions now execute immediately without requiring a confirmation link click. Dry run mode still previews without executing.

fix0aaae8aClaude Opus 4.6

The dry_run flag was being passed to send_whatsapp_message for the reply, which suppressed the preview/response. Now dry_run only prevents the action itself, not the reply back to the chat.

infra/CI5d755ebClaude Opus 4.6

Show commit body (bullet points) in Admin Dinkers notification, not just the subject line. Filter out Co-Authored-By lines.

featca7242aClaude Opus 4.6
  • Add name-based fallback when Venmo username not in spreadsheet
  • Auto-fill Venmo column on successful name match for future payments
  • Fix payments-management.py caller to match current sync function signature
  • Remove poll posting to Admin Dinkers group (only post to SMAD group)
infra/CId7a4b00Claude Opus 4.5

Use jq split to extract first line of each commit message instead of processing all lines which caused duplicate bullets and empty lines.

feat6ca231eClaude Opus 4.5
  • Change /pb games and /pb next to use numbered lists (1. 2. 3.)
  • Filter out Co-Authored-By lines from deploy notification messages
infra/CI83e946aClaude Opus 4.5

The __init__.py imports whatsapp_message and whatsapp_publisher which were not being copied to the venmo-trigger/shared folder.

feat780d4a4Claude Opus 4.5
  • Add sync-members CLI command to sync WhatsApp group with spreadsheet
  • Add /pb sync members picklebot command (triggers GHA workflow)
  • Add deploy notification to Admin Dinkers after successful webhook deploy
  • Add enhanced logging for group events to discover join message types
  • New GHA workflow: sync-members.yml
feat1bdf658Claude Opus 4.5

Without delay=0, press_sequentially has ~3s per character delay, making date entry take 30+ seconds instead of <1 second.

fix5b7a461Claude Opus 4.5

fill() sets value directly without triggering keyboard events, breaking the date postback. press_sequentially() simulates typing to properly trigger form events.

feat6556370Claude Opus 4.5
  • Changed from query_selector to locator for #txtDate
  • Locators auto-retry when elements become stale, preventing "Element not attached to DOM" errors after grid refresh
  • Changed select_text() to press('Control+a') for locator compatibility
  • Changed type() to fill() which is preferred for locators
fixc587333Claude Opus 4.5

Increased wait from 0.35s to 1.0s after warm-up grid refresh to prevent "Element not attached to DOM" error when switching from warm-up date to real booking date.

perf311c9f8Claude Opus 4.5

During idle wait before 12:01am, perform dry-run bookings on tomorrow's date to cache browser assets (iframe JS/CSS/fonts). Changes: Benefits:

  • Add dry_run parameter to book_court() method
  • Add warm_up_booking() method that books both courts dry-run
  • Call warm-up after login, before wait_until_booking_time()
  • Second court booking ~0.5s faster due to cached iframe assets
  • Warm-up overhead (4-5s) happens during idle wait, not critical path
  • Uses grid refresh instead of page reload to preserve cache
perf18dc72dClaude Opus 4.5

Navigate directly to protected booking page URL instead of /member-login. Server redirects to login, then back to booking page after auth - saving one navigation step.

featc66e71aClaude Opus 4.5
  • New `/pb record payment <player> <amount> <method> [notes]` command
  • Added Mobile column to Payment Log for SUMIF matching
  • Mobile numbers stored as numeric values (matching 2026 Pickleball format)
  • New shared payments.py module for picklebot payment recording
  • Updated venmo_sync.py and payments-management.py for consistency
featebeca4aClaude Opus 4.5
Add Venmo direct link to payment reminder messages
docse6e9a61Claude Opus 4.5
Document webhook token authentication in READMEs
feataf72c65Claude Opus 4.5

Validates Authorization header (Bearer token) on incoming webhook requests. Token configured via GREENAPI_WEBHOOK_TOKEN secret and webhookUrlToken setting in GREEN-API dashboard.

docsfd4a829Claude Opus 4.5

Tested skipping the date postback wait with direct invoke - server requires calendar to be in correct date state before booking dialog opens. The date parameter in LaunchLockedReserver() is not sufficient.

refactor92e02e2Claude Opus 4.5

The hybrid search was a fallback from before direct invoke was tested. Direct invoke has been proven reliable and is faster.

  • Remove USE_DIRECT_INVOKE env var and fallback logic
  • Remove hybrid JS search code (~240 lines)
  • Remove orphaned else block for "slot not found" errors
  • Direct invoke is now the only booking method
perfb721981Claude Opus 4.5

TODO #1 optimization: The page reload after navigation was redundant. Testing confirmed date entry works correctly without it. Savings: ~0.8s per booking run

docs8e5b4d1Claude Opus 4.5

Document 3 potential optimizations to investigate: 1. Eliminate page reload for first court (~1.0s savings) 2. Skip date postback with direct invoke (~3.0s savings) 3. Test param 7 for duration control (reliability improvement)

featc75c563Claude Opus 4.5

Performance: Direct invoke 0.01s vs Search+Click 0.09s per court

  • Call LaunchLockedReserver() directly instead of searching DOM
  • Court ID mapping: '3'=North PB, '4'=South PB
  • ~0.35s faster per booking on average
  • Falls back to hybrid JS search if invoke fails
  • Default: USE_DIRECT_INVOKE=true (env var to disable)
fixe8441c5Claude Opus 4.5

The previous approach of checking rowText failed because rowText contains all text in the table row, which could match incorrect times. Now we: 1. Extract the time directly from the onclick function call (LaunchLockedReserver's 6th argument) 2. Compare the extracted slotTime with the target time 3. Log the onclick time for verification This fixes the bug where searching for 2:00 PM would click 12:00 PM.

fixaf8be11Claude Opus 4.5
  • Use negative lookbehind (?<!\d) to prevent "2:00 PM" matching "12:00 PM"
  • Previous regex (^|\n|\t)TIME(\s|\t|$) was too restrictive
  • Add availableSlotCount to track actual bookable slots
  • Update forensics log to show available slots (more useful than cell count)
fixe5df6f2Claude Opus 4.5

The substring check `rowText.includes("2:00 PM")` matched "12:00 PM" because "2:00 PM" is contained within "12:00 PM". Changed to regex with word boundary to ensure exact time matching.

feat6dddd07Claude Opus 4.5
  • Update email_service.py to use Gmail API instead of SMTP
  • Add gmail.send scope to setup-gmail-watch.py
  • Update workflows to use gmail-token.json instead of GMAIL_APP_PASSWORD
  • Single OAuth token now handles both Gmail Watch and sending
infra/CI6cf937b
Add workflow for single-player payment reminder
fix842eadfClaude Opus 4.5
  • Remove venmo-trigger/shared/ from git (CI/CD copies it)
  • Update payments-management.py to import from webhook/shared/ source
  • Update deploy-webhook.yml to copy __init__.py and create dir
infra/CI429005c
Add pubsub.publisher role for github-deploy SA in Terraform
infra/CI2e18feb
Fix court-booking: add Google credentials for Dead Man Switch
feate0316ce
Add security-audit.md to gitignore
featc25cf68Claude Opus 4.5
  • Remove full webhook payload logging (contains PII)
  • Restrict CORS origin to GREEN-API domain
  • Remove sensitive error response logging from Claude API
  • Switch GCS screenshots from public URLs to signed URLs (7-day expiry)
  • Already applied: removed allUsers from GCS bucket IAM
fix58a7cccClaude Opus 4.5

Remove unconditional venmo_sync import from shared/__init__.py since it requires venmo_api which is not available in all Cloud Functions. Code that needs venmo_sync should import directly from shared.venmo_sync.

refactor79e79feClaude Opus 4.5
  • Add webhook/shared/sheet_columns.py as single source of truth for column definitions
  • Implement CI/CD copy pattern for sharing modules across Cloud Functions
  • Fix Dead Man Switch bug (was fetching wrong column range A:M instead of full sheet)
  • Update picklebot, webhook, and venmo_sync to use ColumnMapper
  • Add gitignore entries for CI/CD-copied shared modules
  • Document shared modules pattern and future pip packaging plans in README
fix50711eeClaude Opus 4.5
  • Use main sheet (2026 Pickleball) as source of truth for votes instead of unreliable Pickle Poll Log (missing webhook callbacks, cleared weekly)
  • Fix vote counting: check for 'y' OR number > 0 (hours played for past games)
  • Fix week filtering: Monday 00:00 to Sunday 23:59 of current week
  • Add next-game command to smad-whatsapp.py CLI
  • Add critical rules to CLAUDE.md for deployment permissions
featc1772abClaude Opus 4.5

Phase 2 of WhatsApp Pub/Sub refactoring - decouple services from GREEN-API: court-booking.py: picklebot: Documentation: Cleanup:

  • Replace send_booking_whatsapp_notification() with Pub/Sub publisher
  • Replace send_dead_man_switch_notification() with Pub/Sub publisher
  • Remove GREENAPI_* config variables
  • Add GCP auth to workflow, remove GREENAPI secrets
  • Replace send_whatsapp_message() with Pub/Sub publisher
  • Replace send_whatsapp_image() with Pub/Sub publisher
  • Add shared/ folder with whatsapp_publisher.py and whatsapp_message.py
  • Update requirements.txt with google-cloud-pubsub
  • Remove GREENAPI secrets from deployment
  • Update README.md with new Pub/Sub architecture diagram
  • Add WhatsApp Pub/Sub layer to systems diagram
  • Update picklebot README with new architecture
  • Remove test_pubsub_whatsapp.py (testing complete)
featd454862Claude Opus 4.5

Implements event-driven architecture where clients publish messages to Pub/Sub without knowing about GREEN-API. The whatsapp-message-sender Cloud Function consumes messages and delivers via GREEN-API. New components: Migrated services (no longer need GREEN-API secrets): Features:

  • webhook/shared/whatsapp_message.py: Message schema/envelope
  • webhook/shared/whatsapp_publisher.py: Fire-and-forget publisher
  • webhook/whatsapp-sender/: Consumer Cloud Function
  • infra/terraform/pubsub.tf: whatsapp-messages topic + DLQ
  • gha-error-monitor: Uses send_dm() and send_group_message()
  • venmo_sync: Uses send_whatsapp_thank_you_pubsub()
  • include_signature flag: Prepends "SMAD Picklebot🥒🏓🤖"
  • include_joke flag: Appends random pickleball joke
  • Signature/joke logic encapsulated in consumer only
feata078c5eClaude Opus 4.5

Automatically stops court booking, vote reminders, game reminders, and poll creation if Gene hasn't voted in 10+ days and isn't on vacation. Payment reminders continue to collect debts. Sends WhatsApp notification to Admin Dinkers when triggered with instructions to reactivate.

docs00c02e9Claude Opus 4.5
  • Run #75: Dry-run with safety_mode=True (14.78s total)
  • Run #76: Real booking with safety_mode=False (16.96s total)
  • Both runs verified date at 0.80s via adaptive polling
feat5dbfb91Claude Opus 4.5
  • Replace fixed 1.0s wait with adaptive polling (0.8s min + 100ms polls)
  • GHA workflow SAFETY_MODE now defaults to False (real bookings)
  • Manual triggers can override to True for dry-run testing
featbed6f09Claude Opus 4.5
  • Move SAFETY_MODE check after slot verification (allows testing full click→iframe→verify flow without submitting)
  • Add dialog dismissal in SAFETY_MODE to enable testing both courts
  • Increase calendar wait from 0.9s to 1.0s for more reliable date postback
  • Document profile results: 13.682s total for both courts
docsabaedebClaude Opus 4.5

CLAUDE.md: profiling-results.md:

  • Add CRITICAL RULES section at top (survives session compaction)
  • Rule 1: Never commit/push without explicit user permission
  • Rule 2: Never commit untested code
  • Rule 3: Re-read CLAUDE.md after session compaction
  • Add test results for hybrid JS search + slot verification
  • Search time: 1.1s → 0.04s (25-27x faster)
  • Slot verification: 0ms overhead (URL parsing only)
  • Total booking time: 19.3s → 13.3s (31% faster)
featf22a9e2Claude Opus 4.5

Optimization #10: Hybrid JS slot search Optimization #16: Slot verification in booking dialog Also:

  • Use JS evaluate() to find matching slot index (~0.04s)
  • Use Python click() to trigger modal (required for dialog)
  • Previous pure JS approach broke modal; this hybrid works
  • Search time: ~1.1s → ~0.04s (26-27x improvement)
  • Parse date/time from iframe URL query parameters
  • Verify slot matches expected date and time before submitting
  • Aborts if wrong slot was clicked (prevents wrong-time bookings)
  • Combined with header verification (#14) = double safety check
  • Added urllib.parse import for URL parsing
  • Updated optimization table in profiling-results.md
  • Added detailed documentation for both optimizations
featf719f2eClaude Opus 4.5
  • Update comment: 0.85s failed, 0.9s is minimum safe value
  • Add v8 (0.75s) and v9 (0.85s) test results to profiling-results.md
  • Both failed with date verification catching wrong date
feata16d41fClaude Opus 4.5
  • Add --dry-run flag that overrides SAFETY_MODE env var
  • Consistent with other CLI commands that use --dry-run naming
  • When set, previews booking without actually submitting
feat1a15599Claude Opus 4.5
  • Add date verification after 0.9s postback wait to prevent wrong-date bookings
  • Verify schedule header (.currdate a) shows expected date before grid refresh
  • Abort immediately if date mismatch detected (fixes bug where parallel jobs could book on wrong date due to slow postback)
  • Unify CLI and scheduled booking paths - both now use identical flow: page reload → date entry → 0.9s wait → verify → grid refresh → search
  • Remove use_grid_refresh parameter from book_court() (no longer needed)
  • Remove dead code: else branch with 1.0s sleep, did_target_reload variable
  • Add unified path profiler report to profiling-results.md
feat5c57b42Claude Opus 4.5

After entering a date and waiting for postback, verify the schedule header (.currdate a) shows the expected date before proceeding with grid refresh. If the header shows the wrong date, abort immediately to prevent booking on the wrong date. Bug discovered: Two parallel midnight jobs both reported success booking 02/11/2026, but one actually booked 02/04/2026 because its date postback didn't complete in time. The 0.9s wait wasn't enough under server load. Fix: Check schedule header after 0.9s wait. If wrong date, return False. Cost: ~0.02s (query_selector + inner_text).

feat0b503d9
Add UTF-8 charset meta tag to fix mojibake in HTML responses
fix4a067acClaude Opus 4.5
  • Remove send_vote_reminders input (workflow doesn't accept it)
  • Only mark action as executed if it actually succeeded
fixd97064cClaude Opus 4.5

Store timestamps as UTC instead of PST to avoid timezone comparison issues that caused immediate expiration.

featacc1ad9Claude Opus 4.5
  • /pb reminders vote - send vote reminders to all
  • /pb reminders payment - send payment reminders to all
  • Updated help text and docstring
feat494a7e7Claude Opus 4.5
Change game reminder title to "Game Day!" with paddle emoji
fixa2372adClaude Opus 4.5

Previously fetched one joke and reused for group + all DMs. Now each DM and the group message get their own random joke.

fix3fb0baaClaude Opus 4.5

String '2' broke SUMIF formulas in 2026 Hours Played column.

fix4f27a8bClaude Opus 4.5
  • Add grid refresh after date entry to fix lazy loading bug (run #69)
  • Evening slots (7pm) weren't loading into DOM without refresh
  • Optimize postback wait: 0.8s failed (131 cells), 0.9s works (149 cells)
  • Add forensics logging for cell count on every booking attempt
  • Document all timing tests in profiling-results.md
infra/CIa9267c8Claude Opus 4.5

The auto-update requires GH_PAT_TOKEN to have 'repo' scope. If it fails, the renewal still succeeds - just needs manual secret update if token gets revoked in the future.

infra/CI20c80cbClaude Opus 4.5

The workflow now saves the refreshed OAuth token back to GMAIL_OAUTH_TOKEN_JSON secret after each successful renewal. This prevents token expiration from requiring manual re-auth.

featf5034bbClaude Opus 4.5
  • New game-reminder.yml workflow triggered by Cloud Scheduler
  • Sends group message to SMAD + Admin groups with player list and joke
  • Sends DM to each player with balance and payment reminder
  • Marks attendance in sheet ('y' -> '2') when reminder is sent
  • Scheduler jobs auto-delete after running (one-time reminders)
  • Poll creation now schedules game reminders for each game
  • Added mark_game_played() to smad-sheets.py
  • Updated README with feature docs and systems diagram
featfc672c5Claude Opus 4.5
  • Vote reminders, payment reminders, and payment thank you notes now include a random joke
  • Jokes fetched from Pickleball Jokes sheet with fallback to 3 hardcoded jokes
  • Fixed show-votes to use Last Voted column as source of truth for non-voters
fix8e3c56bClaude Opus 4.5
  • Changed "haven't voted" logic to check Last Voted column instead of Pickle Poll Log
  • GREEN-API webhook is unreliable, so Last Voted (manually updated) is authoritative
  • Also excludes players on vacation from non-voter list
docsca1d003Claude Opus 4.5
  • #9: Skip first page reload for scheduled runs (lines 1752-1753)
  • #11: Pre-login before target time (jobs invoke at 11:55pm)
fix00867b9Claude Opus 4.5

Reliability fixes for intermittent "Element not attached to DOM" errors: 1. Increase calendar wait from 0.75s to 1.0s 2. Add JS fallback for slot click 3. Add debug logging for slot search failures These fixes address the midnight booking failures from 02/01/2026.

  • 0.75s was borderline, sometimes DOM wasn't fully rendered
  • 1.0s ensures stable DOM before slot search
  • +0.25s upfront is better than +1s retry penalty
  • If element.click() fails with "not attached to DOM"
  • Falls back to executing stored onclick via page.evaluate()
  • Logs available court names and slots on failure
  • Helps diagnose future issues
docse36619dClaude Opus 4.5
  • Final profiler report: 14.16s total for both courts
  • North: 2.66s, South: 1.80s (faster due to skip date entry)
  • Added TO-TRY list for future optimizations: #9 Skip first page reload (~0.9s) #10 Hybrid JS slot search (~0.8s) #11 Pre-login before target time (~1.5s) #12 Session persistence (~0.5s)
feata1a0248Claude Opus 4.5

Also documents failed optimization attempts: Total booking time: 23.5s → ~14s (~40% faster from baseline)

  • Add skip_date_entry parameter to book_court() function
  • Skip date typing + 0.75s calendar wait for 2nd court since grid refresh already has the correct date set
  • Saves ~0.8s on 2nd court booking
  • #7: Reduce calendar sleep to 0.6s (DOM unstable)
  • #8: JS date entry (server doesn't receive JS-set date)
featec41debClaude Opus 4.5

Use autoRefreshresBooking() AJAX call instead of full page reload when booking the second court for the same date. Reduces refresh time from ~1.3-2.8s to ~0.35s. Total booking time improved from 23.5s to ~13.8s (~41% faster). Tested AJAX wait times: 0.5s (works), 0.2s (fails), 0.35s (works).

fixa41661aClaude Opus 4.5

JS-based clicking doesn't trigger the modal properly. Only native Playwright element.click() works reliably. Keep optimizations #1-3 which reduced total time from 23.5s to 15s.

featc2a0ebfClaude Opus 4.5

Removes 50ms delay between browser actions. Per-court booking time improved: North 3.1s->2.7s, South 3.1s->2.2s

feat120f1e7Claude Opus 4.5

Reduces navigation time from ~2.0s to ~1.2s (-0.8s)

infra/CIe0d0a7e
Add --profile flag to manual booking workflow (temporary)
feata5e1c24Claude Opus 4.5

Removes temporary debugging code that was slowing down booking:

  • Debug screenshots after slot click and Make Reservation
  • 0.5s sleep after form submission
  • Debug logging for onclick execution
featf7042f2Claude Opus 4.5
  • Log onclick execution details for slot click
  • Take screenshots after slot click and Make Reservation click
  • Log iframe content after form submission to verify confirmation
fixaa3db0cClaude Opus 4.5
  • Add time format normalization to match both "07:00 PM" and "7:00 PM"
  • Use JavaScript onclick execution instead of element.click() to avoid "Element is not attached to the DOM" errors on second court booking
  • Move `import re` to module level, remove redundant local import
  • Change page reload from networkidle to domcontentloaded
feat74e20a2Claude Opus 4.5
  • Add Profiler class for step-by-step timing with --profile flag
  • Reduce calendar wait from 1.0s to 0.75s (tested, 0.5s causes stale DOM)
  • Profile key steps: browser setup, login, navigation, booking operations
  • Display timing summary with percentages at end of run
feat37558b3Claude Opus 4.5
Remove Zelle payment option from reminders
refactor5cce2afClaude Opus 4.5
  • Move SMAD_SPREADSHEET_ID and SMAD_SHEET_NAME to GCP Secret Manager
  • Rename court booking jobs: north/south/sweeper
  • Add reverse_court_order input for south job (books South first)
  • Change vote-payment-reminders to every other day (*/2)
  • Fix webhook deployment to use --set-secrets for sheet config
featf42800bClaude Opus 4.5
  • Remove 0.5s dialog close waits (not needed for booking success)
  • Remove 0.5s post-reload wait (networkidle sufficient, parallel jobs handle courts)
  • Set calendar wait to 1.0s (safe for 7pm slots at bottom of schedule)
  • Remove inline confirmation detection (async verification via Gmail API later)
fix17fdedcClaude Opus 4.5
  • Revert optimized row-based search to cell iteration with time verification
  • The tr:has(td:has-text()) selector was matching wrong slots
  • Restore 0.5s waits after closing confirmation dialogs
  • Revert slow_mo to 50ms, increase calendar wait to 1.5s for testing

January 2026 177 commits

feat100infra/CI38fix27refactor8docs4
infra/CI5575da7Claude Opus 4.5

Third backup job that starts after schedule transitions to bookable. With ~41s GHA warm-up, script starts ~12:01:11 AM and gets fresh bookable schedule naturally through login flow (no reload needed).

feata900c94Claude Opus 4.5

At 12:01 AM, schedule should transition from red to green automatically. The reload may be unnecessary overhead.

fix7d12da9Claude Opus 4.5

0.6s causes elements to not fully render (onclick not available).

feat52bfd70Claude Opus 4.5

Testing with slow_mo=20ms - explicit waits may compensate.

feat968ed4cClaude Opus 4.5

Testing more aggressive timing - explicit waits should provide stability.

fix9bb7d64Claude Opus 4.5

JS evaluate() doesn't trigger the postback needed by Telerik RadComboBox to refresh the calendar. Reverting to click/select/type with delay=0.

fix62b4ff5Claude Opus 4.5
Revert booking page to networkidle (schedule iframe needs full load)
fix50fdbe0Claude Opus 4.5
Revert slow_mo to 50ms (10ms too aggressive for schedule render)
feat7af9a70Claude Opus 4.5
  • Use domcontentloaded instead of networkidle for faster page loads
  • Use JS to set username/password directly instead of fill()
fixee1d363Claude Opus 4.5
Revert calendar wait to 0.75s (0.6s too short for schedule render)
feat65d2bf7Claude Opus 4.5
Aggressive speed optimizations: slow_mo=10, JS date entry, 0.6s calendar wait
feat7638390Claude Opus 4.5
  • Target specific time row directly instead of iterating all 139 cells
  • Remove 0.5s dialog close waits (backup job handles South court)
feat977c706Claude Opus 4.5
Add 0.5s wait after page reload for subsequent court bookings
feat09408ddClaude Opus 4.5
Reduce slow_mo from 100ms to 50ms, dialog close from 0.75s to 0.5s
feat5213bc5Claude Opus 4.5
Change interactive delays from 0.5s to 0.75s (0.5s too aggressive)
feat89d3c33Claude Opus 4.5
Reduce interactive delays from 1s to 0.5s for faster booking
feat6ca2fbfClaude Opus 4.5
  • WhatsApp: Show elapsed seconds for each court booking
  • Email: Add "Booking Time" field with elapsed seconds
feat9cb7bc4Claude Opus 4.5
  • Skip redundant page reload for first court (~1.7s saved)
  • Reduce calendar wait from 2s to 1s (~1s saved)
  • Change default BOOKING_TARGET_TIME from 00:01:01 to 00:01:00
  • Update documentation to reflect new default
infra/CI8c3f827Claude Opus 4.5

Moved the following from GitHub vars to secrets: These contain WhatsApp group IDs and spreadsheet IDs that should not be publicly visible in the repository settings.

  • ADMIN_DINKERS_WHATSAPP_GROUP_ID
  • ADMIN_PHONE_ID
  • PICKLEBOT_TEST_CHAT_ID
  • SMAD_SPREADSHEET_ID
  • SMAD_WHATSAPP_GROUP_ID
  • SMAD_WHATSAPP_GROUP_URL
featc40a6b9Claude Opus 4.5

This ensures primary job books North→South while backup books South→North, maximizing chances of getting both courts if server is slow.

  • Add --reverse-court-order flag to court-booking.py
  • Add reverse_court_order input to court-booking.yml workflow
  • Update backup scheduler job to book South first, then North
feat077e45fClaude Opus 4.5
  • Add 2025 Hours Played column from OLD 2025 sheet
  • Reorder columns: 2026 Hrs, 2025 Hrs, Balance
  • Sort players by 2026 hours descending
  • Format Last Voted date as M/DD/YY to match other dates
feat09daa15Claude Opus 4.5
  • Add /pb players command to list all players with stats
  • Rename CLI column "2026 Hours" to "Hours Played"
  • Add Last Played and Last Voted columns to CLI list-players
  • Include hours from OLD: 2025 Pickleball sheet (Tues 1/6/26, Sat 1/3/26)
  • Include Last Played date from old sheet when not in current sheet
  • Both CLI and picklebot now show consistent 2026 hours totals
feat41e99c6Claude Opus 4.5
  • Remove default values from Python code (require env var)
  • Use GitHub vars instead of hardcoded values in deploy workflow
  • Update documentation with placeholder values
  • Remove default from terraform variables
  • Added SMAD_SPREADSHEET_ID and SMAD_SHEET_NAME as GitHub variables
feat9bc43c1Claude Opus 4.5
  • Add Pickleball Jokes sheet support (like memes)
  • Remove Claude API joke generation to avoid bad AI jokes
  • Move joke to after signature in payment reminders
  • Add jokes.csv with 50 curated jokes from the internet
feat6f42927Claude Opus 4.5
  • Allow picklebot commands from personal chat (message to self)
  • Test chat gets admin access for full command testing
  • Configured via GitHub variable PICKLEBOT_TEST_CHAT_ID
feat09ccf72Claude Opus 4.5

WhatsApp doesn't auto-preview i.redd.it URLs. Switch to sending actual images via GREEN-API sendFileByUrl endpoint.

feat37ca789Claude Opus 4.5

Reddit blocks Cloud Function IPs. Instead:

  • Read memes from "Pickleball Memes" sheet
  • Sheet populated with 49 memes from Reddit (fetched locally)
  • Random meme selected on each request
fix13448dcClaude Opus 4.5

Reddit blocks cloud provider IPs. Try:

  • old.reddit.com instead of www.reddit.com
  • Browser-like User-Agent instead of bot UA
refactor6600468Claude Opus 4.5
  • Search r/memes, r/funny, r/Pickleball for pickleball content
  • Send caption + image URL as text (WhatsApp unfurls the preview)
  • Remove sendFileByUrl approach which had issues with Reddit URLs
fix44af5c3Claude Opus 4.5

Reddit requires User-Agent in format: python:app:version (by /u/username) Generic user agents get blocked with 403 Forbidden.

feata1eaa9aClaude Opus 4.5
  • Add fetch_reddit_memes() to pull from r/Pickleball via public JSON API
  • Fetches from /hot and /top endpoints for variety
  • Filters for actual images (i.redd.it, imgur, .jpg/.png)
  • Skips NSFW and video posts
  • Uses post title as caption instead of Claude-generated
fix2a91c6eClaude Opus 4.5
  • Add normalize_phone() to standardize phone number comparison
  • Update find_player() to handle:
  • @mentions (strips @ prefix)
  • Phone numbers (matches against mobile field)
  • Names with or without @ prefix
feat25b2d51Claude Opus 4.5
  • Add /pb remind payment <name> - send payment reminder to one player
  • Add /pb remind vote <name> - send vote reminder to one player
  • Add Claude-generated joke to payment reminder messages
  • Remove "family-friendly" directive from joke prompt
fixe01c216Claude Opus 4.5

Players who change their vote have multiple rows in the poll log. Now we keep only the most recent vote per player based on timestamp.

feat7dbc3c3Claude Opus 4.5

Confirmation flow: 1. User sends /pb book, /pb poll create, or /pb reminders 2. Bot stores action in GCS, returns message with confirm link 3. User clicks link -> action executed -> result sent to WhatsApp

  • Add GCS storage for pending actions with 24-hour expiration
  • Store pending action when user requests book/poll/reminders
  • Generate confirmation URLs with unique tokens
  • Add GET endpoint to handle confirmation link clicks
  • Execute action and notify WhatsApp on confirmation
  • User-friendly HTML response pages for confirm/error states
  • Preview handlers now return dicts with message and token
feat06e401bClaude Opus 4.5
  • /pb games: Show all scheduled games this week from poll votes
  • /pb next: Show next upcoming game with countdown and player list
  • Read poll votes from Pickle Poll Log sheet
  • Parse game dates from poll option strings (e.g. "Mon 1/26/26 7pm")
  • Filter out non-game options like "Cannot play this week"
  • Add Phase 2 stub code for confirmation flow (not yet active)
feat1ee6e78Claude Opus 4.5
  • Filter out rows where first name is 'Totals', 'Total', or 'Sum'
  • Prevents double-counting in deadbeats and balance reports
feat7d5b295Claude Opus 4.5

README.md: webhook/picklebot/README.md:

  • Add Picklebot Chatbot section under Features
  • Add Admin Dinkers group to system diagram
  • Add smad-picklebot Cloud Function to architecture
  • Add Picklebot command flow to diagram
  • Add Claude API cost estimate
  • Link to picklebot README
  • Create comprehensive documentation
  • Document access levels (Admin vs SMAD groups)
  • List all commands with descriptions
  • Explain smart scheduling feature
  • Document environment variables
feat3d86d84Claude Opus 4.5
  • Forward /pb commands from both Admin Dinkers and SMAD groups
  • Pass is_admin_group flag to picklebot
  • Block action commands (book, cancel, poll, reminders) in SMAD group
  • Show restricted help message in SMAD group (no Actions section)
  • Action commands in SMAD group return "command not available here"
fix8fbe036Claude Opus 4.5
  • Replace broken imgflip/reddit URLs with stable Unsplash CDN URLs
  • Add validate_image_url() to check URLs before sending
  • Shuffle and try multiple sources if first URL fails
  • Handle error case when no valid URLs are found
infra/CIe203e78Claude Opus 4.5

Cloud Scheduler library requires more memory than 256MB limit.

feata29b085Claude Opus 4.5
  • /pb joke - Generates pickleball jokes using Claude
  • /pb meme - Posts pickleball memes with captions
  • Add send_whatsapp_image function for image posting
  • Update help message with fun commands section
featd03f05cClaude Opus 4.5
  • Auto-schedule bookings >7 days out via Cloud Scheduler
  • Jobs run at 12:01 AM PST, 7 days before booking date
  • Add /pb jobs command to list scheduled bookings
  • Add /pb jobs cancel <job_id> to cancel scheduled bookings
  • Add date/time parsing for various formats
  • Update help message with new commands
feat1e6eaf6Claude Opus 4.5
Add emojis to SMAD Picklebot signature
fix700ba40Claude Opus 4.5

GREEN-API sends some messages as extendedTextMessage instead of textMessage. Now handles both types when detecting /pb commands.

featef4b70fClaude Opus 4.5
  • New picklebot Cloud Function for handling /pb commands in Admin Dinkers group
  • Commands: help, deadbeats, balance, status (read-only)
  • Preview handlers for destructive commands (book, poll, reminders)
  • Intent parsing with Claude Haiku API (with regex fallback)
  • Dry run support (--dry-run flag) for testing without sending messages
  • Route /pb commands from main webhook to picklebot
  • Update deploy-webhook.yml for picklebot deployment
refactor2e26f6eClaude Opus 4.5

Replace complex regex patterns with simple text search: find "until reservations open" and extract text before it.

feat359e811Claude Opus 4.5

Track elapsed time for each book_court() call and display in:

  • Individual booking log messages
  • Booking Performance section in summary
feat88d266aClaude Opus 4.5

Enhanced regex patterns to extract countdown text (e.g., "5 hours 44 minutes") even when format varies across different page layouts.

fix6e3c701Claude Opus 4.5

Normalized time format to handle "08:00 AM" vs "8:00 AM" mismatch when detecting BOOKED_BY_OTHERS failure reason.

fixef26f4eClaude Opus 4.5
  • Made countdown regex more flexible with multiple fallback patterns
  • Added check for "until reservations open" text
  • Added NoSlots class check to avoid false BOOKED_BY_OTHERS detection
  • Updated gha-error-monitor to extract COURT_NOT_RELEASED prefix
feata8ec958Claude Opus 4.5

All booking failure reasons are now extracted from logs: This eliminates Vision API costs and reduces latency. Claude Haiku text API remains as fallback for unknown failures.

  • Court not yet released (countdown)
  • Booked by someone else
  • Already reserved by you
feat1cfa2d7Claude Opus 4.5
  • Detect when court is reserved by someone else (white/gray block, no link)
  • Detect when user already has the court reserved (blue block with Edit)
  • Log extraction in gha-error-monitor for BOOKED_BY_OTHERS and ALREADY_RESERVED
  • Skips Claude Vision API call when failure reason is detected from logs
fix8962e9aClaude Opus 4.5
  • Look for "Countdown:" instead of "COURT_NOT_RELEASED:" in logs
  • Match "X days Y hours Z minutes until reservations open" pattern
feata7874b3Claude Opus 4.5
  • Store screenshots in GCS bucket (smad-pickleball-screenshots) with public URL
  • Include clickable screenshot URL in booking failure alert message
  • Add countdown detection in court-booking.py when courts not released
  • Extract failure reason from logs to skip Claude Vision API calls
  • Only use Claude Vision as fallback for unknown failure reasons
feat978b1c5Claude Opus 4.5

When a booking fails, the monitor now: 1. Fetches the booking calendar screenshot from workflow artifacts 2. Sends it to Claude Vision for visual analysis of the calendar state 3. Sends the screenshot image via WhatsApp alongside the text alert

  • Enable booking_no_slot_found.png screenshot capture in court-booking.py
  • Add fetch_screenshot_artifact() to download screenshots from GHA artifacts
  • Add upload_to_greenapi() to upload images for WhatsApp sending
  • Add send_whatsapp_image() to send screenshots via GREEN-API sendFileByUrl
  • Add diagnose_booking_failure_with_screenshot() using Claude Vision API
  • Update booking failure handler to fetch, analyze, and send screenshots
  • Update README with screenshot analysis feature documentation
fixd711e95Claude Opus 4.5

Prefer specific step log files (e.g., "Run booking script") over combined logs (0_*.txt) which are too large. The failure messages appear late in combined logs, getting truncated at 15KB.

fixc1a0f30Claude Opus 4.5

Logs were being truncated before reaching booking script output. Now prioritize files with 'booking' in the name to ensure failure patterns are captured within the 15KB limit.

featfe6deb7Claude Opus 4.5
  • Detect failed bookings even when workflow succeeds
  • Parse logs for "NO AVAILABLE SLOT FOUND" and extract booking details
  • Add booking-specific alert format with court/date/time info
  • Use Claude for diagnosis with simple fallback
feat4b0f535Claude Opus 4.5
  • New Cloud Function to monitor GitHub Actions workflow failures
  • Uses Claude API (Haiku) for intelligent error diagnosis
  • Falls back to simple pattern matching if API credits exhausted
  • Sends WhatsApp alerts to Admin Dinkers group + personal DM
  • Terraform config for secrets (ANTHROPIC_API_KEY, GITHUB_TOKEN, GITHUB_WEBHOOK_SECRET)
  • Auto-deploys via deploy-webhook.yml workflow
feat4d9b64eClaude Opus 4.5

Move page reload inside the courts-to-book loop to ensure fresh DOM elements for every court booking, not just the 2nd+ courts.

fix542bf05Claude Opus 4.5

Reload page before booking 2nd+ court to get fresh DOM elements. Fixes "Element is not attached to the DOM" error that occurred when trying to enter date for South Pickleball Court after booking North.

infra/CI4140634Claude Opus 4.5

Updated vote-payment-reminders Cloud Scheduler job from 7:45 AM to 8:00 AM.

infra/CI3a2c372Claude Opus 4.5

Updated vote-payment-reminders Cloud Scheduler job from 10:00 AM to 7:45 AM.

featb672a45Claude Opus 4.5
  • Add archive_poll_log() function to move entries to 'Pickle Poll Log Archive'
  • Creates archive sheet automatically if it doesn't exist
  • Clears poll log (keeps header) after archiving
  • Called at start of create_availability_poll for fresh log each week
feat9f3dd75Claude Opus 4.5

Shows players on vacation with their return dates in the admin summary message sent after vote reminders.

fixd69ab97Claude Opus 4.5

Previously only parsed MM/DD/YYYY (4-digit year), now also handles MM/DD/YY (2-digit year) format for vacation return dates.

feat6fed9bfClaude Opus 4.5
Add .claude/ to gitignore and remove from tracking
refactorf4c946cClaude Opus 4.5
  • Rename ath-booking.py to court-booking.py (more descriptive name)
  • Create COURT_BOOKING.md with extracted court booking documentation
  • Create PAYMENT_MANAGEMENT.md for payment tracking documentation
  • Rewrite README.md to be project-centric rather than court-booking-centric
  • Update all references to ath-booking.py across codebase:
  • .github/workflows/court-booking.yml
  • .github/copilot-instructions.md
  • GITHUB_ACTION_SETUP.md
  • email_service.py
  • .env.example
featd49bb26Claude Opus 4.5
  • Add Terraform notes to all setup guides (SMAD, Venmo, Gmail Watch, GitHub Actions)
  • Update README.md with Terraform in system architecture diagram
  • Add Infrastructure as Code subgraph with terraform.yml workflow
  • Update deployment instructions to reference CI/CD as primary method
  • Clarify which resources are managed by Terraform vs manual setup
  • Add links to infra/terraform/README.md across all documentation
infra/CI1f17e68Claude Opus 4.5
  • Create Terraform configuration for all GCP resources:
  • APIs (Sheets, Gmail, Functions, Pub/Sub, etc.)
  • Secret Manager secrets (structure only, not values)
  • Pub/Sub topic for Venmo email notifications
  • Cloud Functions (Gen2) for webhooks
  • IAM bindings and service accounts
  • Storage bucket for function source code
  • Add GitHub Actions workflow for Terraform CI/CD:
  • Runs plan on PRs with comment output
  • Applies on merge to main
  • Manual trigger option
  • Import existing resources into Terraform state
  • State stored in GCS bucket for team collaboration
feat5e51be2Claude Opus 4.5

When a Venmo payment is recorded, notify Admin Dinkers in addition to sending the thank you DM to the payer.

infra/CI63f494bClaude Opus 4.5

BREAKING: Remove input defaults that were overriding GHA variables. Before: workflow_dispatch input defaults (00:00:15 for target time, 'True' for safety mode) would always be used, even when Cloud Scheduler triggered the workflow - making vars.BOOKING_TARGET_TIME and vars.SAFETY_MODE completely ignored. After: Inputs have no defaults, so the || chain correctly falls through to GHA variables, then to hardcoded fallbacks. Precedence is now: explicit input → GHA variable → hardcoded fallback Also updated all documentation from 00:00:15 to 00:01:01 (courts open at 12:01 AM, not 12:00:15 AM).

feat7db131dClaude Opus 4.5

Shows when each player last played in the Admin Dinkers summary report, helping to identify inactive players with outstanding balances.

fixb4ddb6bClaude Opus 4.5

Use safe_print() for poll questions containing emojis to prevent UnicodeEncodeError on Windows console (charmap codec).

infra/CI8d17eccClaude Opus 4.5
  • Cloud Scheduler now triggers at 11:55 PM PST instead of 12:00 AM
  • Gives GHA ~5 minutes to spin up runner, checkout, pip install
  • Script waits until BOOKING_TARGET_TIME (00:01:00) before booking
  • Prevents missing the 12:01 AM booking window due to GHA startup latency
feat1551134Claude Opus 4.5
  • Parse vacation column as return date, skip vote reminders if current date < return date
  • Add is_on_vacation() helper function for vacation status checks
  • Add Vacation column to list-players report in smad-sheets.py
  • Players on vacation still receive payment reminders (only vote reminders skipped)
feat243c46bClaude Opus 4.5

When GREEN-API fails to deliver pollUpdateMessage webhooks (like for Karan's phone), this command allows manually recording votes: Features:

  • update-vote "Player Name" "option1, option2" - record vote
  • update-vote "Player Name" --list-options - show available options
  • --dry-run flag for preview
  • Updates Last Voted column
  • Sets 'y' for selected date options
  • Clears 'n' for other current poll options (replaces previous vote)
  • Records entry in Pickle Poll Log with manual flag
infra/CI7e3c656Claude Opus 4.5
  • Split daily-booking.yml into court-booking.yml and vote-payment-reminders.yml
  • Rename weekly-poll-creation.yml to poll-creation.yml
  • Remove all GHA cron schedules (unreliable for newer workflows)
  • Add Cloud Scheduler setup script with 4 jobs:
  • poll-creation: Sunday 10:00 AM PST
  • vote-payment-reminders: Daily 10:00 AM PST
  • gmail-watch-renewal: Days 1,7,13,19,25 at 6:00 PM PST
  • court-booking: Daily 12:00 AM PST
  • Update README with Cloud Scheduler in systems architecture diagram
  • Cloud Scheduler provides reliable timezone-aware scheduling (no DST issues)
docs483ef94
Add CLAUDE.md with project instructions for Claude Code
infra/CI58bab5c
Replace bash script with PowerShell for Windows compatibility
infra/CIb67764aClaude Opus 4.5

Cloud Scheduler jobs will trigger GitHub Actions via workflow_dispatch API:

  • Add gcp-scheduler/setup-scheduler.sh to create Cloud Scheduler jobs
  • Add gcp-scheduler/README.md with setup instructions
  • Remove payment reminders cron from daily-booking.yml (now via Cloud Scheduler)
  • Delete redundant daily-reminders.yml and test-schedule.yml workflows
  • weekly-poll-creation: Sunday 10am PST
  • daily-payment-reminders: Daily 10am PST
  • gmail-watch-renewal: Days 1,7,13,19,25 at 6pm PST
infra/CI8f382cd
Revert cron order, set payment reminders to 10am PST
fixd446bb2
Fix webhook to use existing poll creation date for subsequent votes
infra/CI824cbbb
Move payment reminders cron to first position, 11:32 AM PST
infra/CI145a866
Change payment reminders to 11:27 AM PST for debugging
infra/CIee34d25
Combine payment/voting reminders into daily-booking workflow (11:24 AM PST for debug)
infra/CI4025090
Test schedule 11:12 AM PST
infra/CI6bcf2a7
Add minimal test schedule workflow
refactor3cf758c
Rename workflow to force new registration
infra/CI70ca092
Test cron 11:09 and 11:10 AM PST
infra/CIfd9fe37
Add dual cron schedules like daily-booking
infra/CI07a5937
Simplify schedule syntax to match working daily-booking workflow
infra/CI1858bf5
Test scheduler: change to 11:03 AM PST
infra/CI082643e
Change daily reminders to 11:01 AM PST for scheduler debugging
infra/CI5b3c878Claude Opus 4.5

Adding timestamp comments to force GitHub to re-register schedules. GitHub Actions scheduler can stop firing without workflow file changes.

feat298a5b2Claude Opus 4.5
  • Post weekly poll to Admin Dinkers group (votes not tracked)
  • Add SMAD_WHATSAPP_GROUP_ID filter to webhook to ignore non-SMAD votes
  • Fix webhook to use PST timezone for all datetime operations
  • Add pytz dependency to webhook requirements
feat85f9ae3Claude Opus 4.5
Update PICKLEBOT_SIGNATURE in webhook modules
feat46e53c3Claude Opus 4.5
Change bot signature to SMAD Picklebot
fixb69a89dClaude Opus 4.5

GitHub Actions runs in UTC, so datetime.now() would return UTC time. After 4pm PST (midnight UTC), the GHA would see Monday as "today" and generate poll dates for the following week instead of current week.

fix60e9e16Claude Opus 4.5

The poll options show dates starting from tomorrow, so the question should reference the Monday of that week, not the current week.

infra/CIcece8faClaude Opus 4.5
Add comment to weekly-poll-creation to trigger scheduler
fixba69c79Claude Opus 4.5
Fix Windows console encoding error in create-poll dry run
feat2211b91Claude Opus 4.5
Add nul to gitignore (Windows artifact)
infra/CIb7f4200Claude Opus 4.5

Group IDs aren't sensitive - use vars instead of secrets for consistency with SMAD_WHATSAPP_GROUP_ID.

infra/CI9cebe14Claude Opus 4.5
  • Add comment to workflow file to wake up GitHub's scheduler
  • Add ADMIN_DINKERS_WHATSAPP_GROUP_ID for admin summaries
feat272d43cClaude Opus 4.5
  • Add ADMIN_DINKERS_WHATSAPP_GROUP_ID to .env.example
  • smad-whatsapp.py: Send summary to admin group after payment/vote reminders
  • ath-booking.py: Send booking results to admin group after court bookings
  • daily-booking.yml: Add WhatsApp secrets for booking notifications
fixb8d5db8Claude Opus 4.5

The 10-minute cap was truncating the normal ~10.6 minute wait (invoke at 23:50, target at 00:01:02), causing bookings to start 37 seconds early before courts were released.

docs9eec48bClaude Opus 4.5
Reorder architecture diagram bullet points
docs5c563b2Claude Opus 4.5

Adds Gmail Watch + Pub/Sub + venmo-sync-trigger pipeline, smad-whatsapp.py CLI, shared venmo_sync module, all 5 GitHub Actions workflows, and corrects Playwright labeling.

refactor1c5c458Claude Opus 4.5

Remove ~250 lines of duplicate sync logic and WhatsApp functions from payments-management.py. The sync-venmo command now calls the same sync_venmo_to_sheet() used by the Cloud Function.

feat4c9f6e0Claude Opus 4.5

record_payment() now does a fresh read of existing transaction IDs right before each write, preventing duplicates from sequential Gmail notifications. Also adds deduplicate_payment_log() as a safety net to clean up any that slip through truly concurrent writes.

fix809ca3fClaude Opus 4.5

After target time is reached, reload the page so newly-released court slots are visible. Also increase calendar wait from 1s to 2s for the schedule iframe to fully load after date entry.

featbdb1b48Claude Opus 4.5

Tells players they can vote on Sunday or request vacation mode to stop receiving daily vote reminders.

infra/CI330e9c3Claude Opus 4.5

Both Cloud Functions (WhatsApp poll/vote webhook and Venmo sync trigger) now auto-deploy on push to webhook/ files.

fixe13a1f4Claude Opus 4.5
  • Re-read sheet after recording payment to get formula-recalculated balance
  • Add DRY_RUN env var support for safe testing without side effects
featea91d02Claude Opus 4.5

Sends automatic thank you DM with payment amount and balance when the Cloud Function records a new Venmo payment.

feat9130e54Claude Opus 4.5

Re-adds push notification pipeline: Gmail watch → Pub/Sub → Cloud Function → venmo-sync. Includes GitHub Actions workflow for auto-renewal every 6 days.

featfcf80deClaude Sonnet 4.5

Changes to WhatsApp thank you DM:

  • Add "Your balance is now: $X.XX" after payment amount
  • Remove redundant "Your payment has been recorded." line
  • Extract balance from spreadsheet for each player
featdb281f6Claude Sonnet 4.5

Match the signature format used in other WhatsApp DM messages: "Picklebot🥒🏓🤖"

feat067e64dClaude Sonnet 4.5

When sync-venmo records new payments, automatically send WhatsApp DM to each payer: "Hi {first_name}! Thank you for your payment of ${amount}! Your payment has been recorded." Features:

  • Sends thank you to all newly recorded payments
  • Requires GREENAPI_INSTANCE_ID and GREENAPI_API_TOKEN env vars
  • Use --no-thank-you flag to disable
  • Gracefully skips if WhatsApp not configured
feat99cafe3Claude Sonnet 4.5

Reverting to manual Venmo sync approach (via CLI or cron). Deleted Cloud Function, Pub/Sub topic, Gmail watch setup, and GitHub Actions workflow.

feat17e8478Claude Sonnet 4.5

Gmail API watches expire after 7 days. This workflow automatically renews the watch every 6 days to maintain real-time Venmo payment notifications. New files: Changes: Workflow schedule: Requires GitHub Secrets: Setup instructions in GMAIL_WATCH_SETUP.md

  • .github/workflows/gmail-watch-renewal.yml: Runs every 6 days
  • GMAIL_WATCH_SETUP.md: Complete setup guide for OAuth and watch
  • Updated .gitignore to exclude Gmail OAuth files
  • Runs on days 1, 7, 13, 19, 25 of each month at 2:00 AM UTC
  • Can be triggered manually from GitHub Actions UI
  • Provides 1-day buffer before 7-day expiration
  • GMAIL_OAUTH_CLIENT_JSON: OAuth client credentials
  • GMAIL_OAUTH_TOKEN_JSON: OAuth refresh token
featab3a89aClaude Sonnet 4.5

Changed Cloud Function from HTTP trigger to Pub/Sub (Gmail API watch). Provides instant notifications when Venmo emails arrive (no polling). Changes: Architecture: Venmo email → Gmail → Gmail API watch → Cloud Pub/Sub → Cloud Function → venmo-sync Setup required: 1. Create OAuth credentials in Google Cloud Console 2. Run setup-gmail-watch.py to authorize Gmail access 3. Watch expires after 7 days, must be renewed Deployed:

  • Updated main.py to use @functions_framework.cloud_event
  • CloudEvent handles Pub/Sub messages from Gmail
  • Changed return behavior (Pub/Sub functions don't return HTTP responses)
  • Added cloudevents dependency to requirements.txt
  • Created setup-gmail-watch.py script for Gmail API configuration
  • Topic: projects/smad-pickleball/topics/venmo-payment-emails
  • Function: venmo-sync-trigger (Pub/Sub trigger)
  • Trigger: google.cloud.pubsub.topic.v1.messagePublished
feat002b221Claude Sonnet 4.5

Copied shared/ module into venmo-trigger/ directory for deployment. Cloud Functions needs all dependencies in the source directory. Changes: Deployment successful:

  • Copy webhook/shared/ to webhook/venmo-trigger/shared/
  • Remove parent directory path manipulation in main.py
  • Function now deploys successfully from venmo-trigger/
  • URL: https://us-west1-smad-pickleball.cloudfunctions.net/venmo-sync-trigger
  • Tested and working (0 recorded, 22 skipped)
featf56a027Claude Sonnet 4.5

Implements real-time Venmo payment sync via Gmail forwarding to Cloud Function. Emails trigger venmo-sync (no parsing) for robust username matching, handling duplicate names and format changes. Architecture: New files: Benefits: Setup: Deploy Cloud Function + Gmail Apps Script (every 5 min) Cost: $0/month

  • Venmo email → Gmail forward → Cloud Function → venmo-sync → Sheet
  • webhook/shared/venmo_sync.py: Shared sync logic module
  • webhook/venmo-trigger/main.py: Cloud Function entry point
  • webhook/venmo-trigger/requirements.txt: Dependencies
  • webhook/venmo-trigger/README.md: Deployment guide
  • VENMO_EMAIL_SYNC_SETUP.md: Complete setup instructions
  • Real-time sync (seconds vs hours)
  • Matches by @username (handles "Gabe vs Gabriel", duplicates)
  • No email parsing (robust to format changes)
  • Free (Cloud Functions free tier)
  • Reuses existing venmo-sync logic
featcb75289Claude Sonnet 4.5

Since polls will be manually pinned (GREEN-API doesn't support automatic pinning), updated reminder messages to reflect that the poll is pinned to the top of the group. Changes:

  • Individual DM: "Check this week's poll pinned to the top of the group"
  • Group message: "vote in this week's poll pinned to the top of the group"
featc3dcc3eClaude Sonnet 4.5

GREEN-API does not support the pinMessage() method. Removed the non-functional pinning code that was causing warnings and updated messages to reflect that polls are not automatically pinned. Changes:

  • Removed pinMessage() call attempt (GREEN-API doesn't support it)
  • Updated vote reminder: "Check the latest poll" (was "pinned")
  • Updated group reminder: "this week's poll" (was "pinned poll")
feat1875e73Claude Sonnet 4.5
  • Added .claude/settings.local.json to .gitignore
  • Removed file from git tracking (still exists locally)
  • Local settings should not be committed to repository
infra/CI2d2d0b1Claude Sonnet 4.5
  • Check workflow input parameter first (manual trigger)
  • Fall back to GitHub Actions variable (vars.POLL_CREATED_DATE)
  • Display which source is being used in workflow logs
  • Enables edge case handling via repository variable configuration
infra/CI55bf5f1Claude Sonnet 4.5
  • Added optional poll_created_date input parameter (M/D/YY format)
  • Sets POLL_CREATED_DATE env var if provided
  • Displays override value in workflow logs
  • Supports edge case where poll exists before webhook can detect votes
infra/CIf78c20bClaude Sonnet 4.5

Fixes argument parsing error: "unrecognized arguments: --dry-run"

  • Move --dry-run flag before subcommand in smad-whatsapp.py calls
  • Changes:
  • smad-whatsapp.py send-balance-dm all $DRY_RUN → smad-whatsapp.py $DRY_RUN send-balance-dm all
  • smad-whatsapp.py send-vote-reminders $DRY_RUN → smad-whatsapp.py $DRY_RUN send-vote-reminders
  • smad-whatsapp.py create-poll $DRY_RUN → smad-whatsapp.py $DRY_RUN create-poll
infra/CIf994bb2Claude Sonnet 4.5

Both workflows follow the same pattern as daily-booking.yml with:

  • daily-payment-voting-reminders.yml: Daily 10am automation for Venmo sync, payment reminders, and vote reminders
  • weekly-poll-creation.yml: Sunday 10am poll creation
  • Cron schedules (PST/PDT with manual DST updates)
  • Manual trigger via workflow_dispatch with dry-run mode
  • Secrets/variables configuration
  • Artifact upload for debugging
docsbcde832Claude Sonnet 4.5

Replace PNG image reference with inline Mermaid code for native GitHub rendering. Diagram shows complete system architecture including WhatsApp integration, Cloud Functions, Google Sheets, Venmo API, court booking automation, and CI/CD pipeline.

featecf3f15Claude Sonnet 4.5

Changes: The webhook now exclusively uses Google Sheets for poll vote tracking. Firestore was fully replaced in the previous migration.

  • Remove google-cloud-firestore dependency from requirements.txt
  • Rewrite webhook/README.md to document Google Sheets integration
  • Update .env.example to clarify GCP_PROJECT_ID usage
  • Update .github/copilot-instructions.md to reflect Sheets-based webhook
  • Remove Firestore database creation from deploy.bat and deploy.sh
  • Remove Firestore API enablement from deploy-webhook.yml workflow
feat73134e3Claude Sonnet 4.5

Main Changes: Technical Details:

  • Add Sunday cleanup feature to webhook that deletes poll log entries >7 days old
  • Migrate webhook from Firestore to Google Sheets (Pickle Poll Log)
  • Add date column matching by label (first match left-to-right)
  • Add poll age validation (7-day expiration for vote updates)
  • Add COUNTIF formulas to totals row for new date columns
  • Update poll question format to include Monday date of current week
  • Reverse date column order (newest on left)
  • Add duplicate date column checking to prevent overwriting old data
  • webhook/main.py: Added cleanup_old_poll_logs() function
  • webhook/main.py: Complete rewrite to use Google Sheets API instead of Firestore
  • webhook/main.py: Added timedelta import for date calculations
  • smad-whatsapp.py: Enhanced add_poll_date_columns() with totals formulas
  • smad-sheets.py: Added Pickle Poll Log sheet management functions
featd0a04ec
sheet balance and last paid formula/logic refactoring
featd6a885d
made some changes
feat67fa036
contains search for 60/120 dropdown selection
feat2480ecd
change BOOKING_TARGET_TIME to 12:01:01 AM PST
infra/CI59021df
change booking target time default to 12:01:01 am
feat03b10d0
create poll now use BOOKING_LIST for game date options for the upcoming week
feat6dd4074
fine tuned reminder to vote message
refactor9668a4d

Changes made: webhook/main.py - Simplified to only handle Firestore updates: Removed Google Sheets imports and configuration Removed get_sheets_service(), col_to_letter(), update_sheet_vote() functions Removed the call to update_sheet_vote() in handle_poll_update() Added note in docstring that Google Sheets updates are handled by smad-sheets.py webhook/requirements.txt - Reduced dependencies: Removed google-api-python-client and google-auth (no longer needed) Only needs functions-framework and google-cloud-firestore smad-sheets.py - Now the central location for all Google Sheets functions: Already had CANNOT_PLAY_PHRASES, is_cannot_play_option(), update_vote_in_sheet(), col_index_to_letter() Added new sync-votes command that: Connects to Firestore Gets the most recent poll Reads all votes from Firestore Updates Google Sheets with Last Voted dates and y/n values New architecture: Webhook (Cloud Function) → Writes votes to Firestore only (real-time) smad-sheets.py sync-votes → Reads Firestore, updates Google Sheets (on-demand) This separation is cleaner because Cloud Functions can't import local Python modules, and it gives you manual control over when to sync to sheets.

feat2619fd0
missed another midnight border condition with booking 7 days ahead of invocation time not target booking time
feat2fae4f2
whatsapp integration
feat06c70fc
SMAD Sheets - Pickle Registry!
feat5765e3f
change booking target time to 12:01 AM PST (from 12:00:15 AM PST)
feat9fafad5
change booking date logic to be exact day of week at least 7 days after execution time, escaped unicode in python
feat58ada28
change booking date logic to be exact day of week at least 7 days after execution time
feat7a2b0b9
missed another midnight border condition with booking 7 days ahead of invocation time not target booking time
infra/CI1db29c3
bump back the 2 cron jobs
feat22ea05d
proper logging
feat82492f2
move waiting to after booking page loads
feat44e38a8
✅ Safety cap = 10 minutes - Changed from 11 to 10 minutes in ath-booking.py:1193 ✅ Booking date calculation - Now uses target booking time (12:00:15 AM) instead of invoke time (11:54 PM) in ath-booking.py:1375-1394
feat2cc05ee
12 factor app refactoring
fixe2f4035
fix wait 23 hours by accounting for midnight wraparound boundry condition and having a wait cap of no more than the 10 minute grace period
feat33bf5dc
improve email subject, combine booking-date and booking-time to booking-date-time, and passing this in as commandline arg will override booking-list and do single date-time booking
featb4db28b
cron 6 UTC is wrong, should be 7 UTC for 11:50 PM PST run. dumb claude!
feat01fc403
more comprehensive .gitignore
feat10f5869
email booking summary report
feat169a13b
add a backup cron trigger to start at 12:01am and increased grace period for delayed booking from 5 minutes to 10 minutes
infra/CI5122bc4
change cron to run at 11:50pm PST give us 10 minute buffer for GHA warm u
feat2333777
Merge branch 'main' of https://github.com/genechuang/athpicklecourt
infra/CI4a56de7

booking_list (line 32-34): Override the repository's BOOKING_LIST variable Example: "Tuesday 7:00 PM,Friday 4:00 PM" When set, this overrides single booking parameters and activates Booking List Mode booking_target_time (line 35-38): Control when the script starts booking Default: '00:00:15' safety_mode (line 39-42): Control whether to complete the booking Default: 'True' (stops before booking for safety)

infra/CI65af1cd
Update daily-booking.yml back to 1155pm trigger
infra/CIe95b133
Update daily-booking.yml for cron debug
infra/CIfe112e9
cron testing only
refactora257514

Previously, GitHub Actions generated invoke_time in UTC and the script converted it to PST. This made debugging confusing with mixed timezones in logs.

feat14502b6
Major Features: - Weekly recurring bookings via BOOKING_LIST with human-readable day names (Monday-Sunday) - GitHub Actions integration with automated daily scheduling at 11:55 PM PST - Multi-court booking support (COURT_NAME=both books North + South simultaneously) - Configurable booking target time (BOOKING_TARGET_TIME environment variable) - 5-minute grace period to handle GitHub Actions midnight contention delays - 7-day advance booking with automatic date calculation - PST/PDT timezone handling with pytz
feat2477d7b
making daily midnight booking and BOOKING_LIST work
feat38387bd
Merge branch 'main' of https://github.com/genechuang/athpicklecourt
feat2a3c91a
optimize booking nav and added GHA
feat1d23749
optimized booking bot and added GHA
refactor62317cf
Delete .env
feat9eac36a
Update .env
feat78f18b0
Ath booking MVP