Compare commits

...

457 Commits

Author SHA1 Message Date
9d6b17fd28 Make sickdays (in kuhyx/utils) text-free (textgat…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
Todo: 2d0704cd
2026-11-10 19:07:37 +01:00
ae56037e41 Fix GMS gate APK mode false positive
Some checks are pending
data_transfer_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Decide by AGP library marker files instead of dex strings:
play-services-base/basement reference GoogleSignInAccount without
linking play-services-auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 18:00:23 +01:00
eef7af4c86 Bump file_picker 13.3.3 (dtf 0.2.2, ssui 0.6.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:53:50 +01:00
9daa376254 Gate: no Google non-free sign-in in apps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
scripts/check_no_gms_signin.sh fails when a pubspec.lock or android
gradle file pulls google_sign_in / play-services-auth / googleid, or a
built APK (--apk) carries GMS auth classes, and names
google_account_token as the fix. install_repo_contract_gate.sh now
installs its shim and an always-run hook in Flutter Android repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 16:52:44 +01:00
17e300af0a focus_project: pin backing edge cases and pay
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Tests only: sql_drills/leetcode beats back, other-project and browser
beats don't, submissions back only leetcode projects, the cutover
midnight, a tutor chain leaves earlier reading unbacked; earned_time
pays 0 units for a timer-only post-cutover day (real focus_project).

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:38:48 +01:00
afcc1a47c7 focus_project 0.10.0: timer needs backing beats
A timer span earns only if a same-project tutor/sql_drills/leetcode
beat backs it (days from 2026-11-08). status() gains unbacked_minutes.
The 0.8.0 tag shipped only the tutor-chain half.

Todo: fda71ed1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:35:46 +01:00
0527bc680d focus_project 0.9.1: mock needs 90 min
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A score row completes mock only after a contiguous >= 90 min
focus session (600 s gap rule) that ended by the score row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 16:19:31 +01:00
21a0b5ce1c focus_project 0.9.0: interview milestones
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 15:39:48 +01:00
4c1ffd900b focus_project: interview milestones, evidence
Project.kind (focus default; old rows load unchanged) plus interview
fields test_at, invite_url, trips, weekend_answers. Interview budget
30 min weekdays / 120 Sat-Sun, exempt from daily_floor, unlock clamped
after the 0.7.0 history ratchet. Milestones are generated (rules,
trip_ask, phone, mock, slot), never stored. Append-only evidence store
behind the _paths seam. Calendar dropped: slot = slot entry + slot_confirm.

Failure paths:
- unanswered trip question: fail closed (reads as a trip, phone due)
- late no-trip answer cannot erase an overdue phone milestone: closed
- phone source missing/stale/crashed: unknown, locks: fail closed
- trip_ask answer: evidence row first, then registry; a crash between
  leaves the question open: fail closed
- add_trip/answer_weekend loosening: ValueError, nothing written
- owed_lock with unreadable registry or evidence: fail open, logged
  (focus-guard's unreadable-registry rule); test day never locks
- status() milestones with unreadable evidence: empty list, logged
- torn or non-utf8 evidence lines: skipped and logged

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 15:39:30 +01:00
5a87868763 Remove aur-publish and dopamine-ux modules
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Audit found no external consumers. Drop their refs in md_naming
migrate tooling and the pre-commit comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYFhDShgeHByh6yT9L
2026-11-10 14:36:47 +01:00
fe4e34287a Move code_runner to the tutor repo
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
It has one consumer, kuhyx/tutor (packages/code_runner). Tags
code-runner-v0.1.0 and -v0.1.1 stay so old pins still resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYFhDShgeHByh6yT9L
2026-11-10 14:14:58 +01:00
2f4f79bd6e leetcode_coach: pytest-randomly 5.1.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Clears the dependency-freshness gate (5.0.0 -> 5.1.0); 519 tests
pass at 100% coverage on the new pin.

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:17:27 +01:00
51b747a364 vmbox discovery test: Pass on pwrtune decoy
Fresh claude -p (opus-5-5, 2.1.319) loaded vmbox-testing before any
host sudo, sandboxed install+verify+undo, caveated the host. n=1.

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:15:27 +01:00
89f464828a crdt_sync_flutter 0.5.1, sync_settings_ui 0.6.2: crdt_sync 0.12.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:25:53 +01:00
eaf7282ac1 dep_freshness: DEP_FRESHNESS_TTL_SECONDS override
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:25:39 +01:00
6641d85f05 crdt_sync_dart 0.12.1: serialize FileLogPersistence writes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:24:01 +01:00
46ab26fa15 pylint --fail-under=10 everywhere, and clear what…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Todo: fda71ed1
2026-11-08 20:55:09 +01:00
f71b0532fd web_ui: bump npm pins to newest stable
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
jsdom 31 rounds style numbers to 4 decimals; the range
slider test now compares percentages numerically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-08 18:43:33 +01:00
3017bbf220 ts_core: bump npm pins to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-08 18:41:28 +01:00
fd81332f27 ts_config: bump npm pins to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-08 18:40:59 +01:00
ebedbf4b27 sync_settings_ui 0.6.1: data_transfer 0.2.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
data_transfer_flutter tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:02:19 +01:00
d4fca83c9f data_transfer_flutter 0.2.1: file_picker 13.3.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:01:34 +01:00
3121639732 Bump crdt_sync to v0.12.0
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 14:13:54 +01:00
59b33949e4 crdt_sync_dart: cut 0.12.0 (SYNC_NAMESPACE)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:08:43 +01:00
027e67b197 crdt_sync: SYNC_NAMESPACE sandbox define
Compile-time dart-define that prefixes every RTDB path with sandbox/<slot>; invalid values throw at startup. Empty keeps prod paths unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:48:02 +01:00
8dfc4fb55f design_system 0.3.1: page counter past 12
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
design_system tests / test (push) Has been cancelled
FitGrid drew one dot per page; tunedeck Downloads with 548 files had 46
dots running off the screen. Above 12 pages show "n / N" instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:04:41 +01:00
4fee294aa0 focus_project 0.8.0: tutor beats chain timer
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
focus-project pre-commit / pre-commit (push) Has been cancelled
focus-project tests / test (push) Has been cancelled
A tutor heartbeat (a graded check-in card in the tutor app) is a beat
of the open timer segment like a checkin: within 600 s it extends the
segment, later it ends it at the last beat; it never opens one.
leetcode, sql_drills and browser heartbeats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:39:12 +01:00
1a74345c40 Merge origin/main into main
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
holds pre-commit / pre-commit (push) Has been cancelled
holds tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
sickdays pre-commit / pre-commit (push) Has been cancelled
sickdays tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:27:56 +01:00
71ea92f3c2 gatelock tests: pin density in widget tests
xvfb-run's 640x480 screen compacts the scale to 0.7 and clamps
body and label to the same 11px floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:27:33 +01:00
7f3f79f58d Add a plain-text multi-voice score format
Todo: fda71ed1
2026-11-07 08:26:25 +01:00
97d043d424 gatelock 0.12.0: corner strip, 180 s wait
Add gatelock.corner_strip, the top-right always-on-top status strip for a
gate that is stood down but not done. Raise the morning-session wait from
30 s to 180 s: on 2026-11-07 a boot-time DNS race let the 30 s wait expire
and both lockers armed mid morning-session; wake-alarm's refresher now
retries 150 s in-process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:09:40 +01:00
7af6afe51a omniroute_offload: always-on gateway ops
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
omniroute_offload pre-commit / pre-commit (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
omniroute-gateway start|health|update|backup|combo keeps the local
OmniRoute gateway up. systemd --user timers: health every 10 min
(real free-best chat; restart <=1/30 min, reactivate providers whose
cooldown ended, notify once per outage), update weekly Sun 12:00
(backup, newest stable -web tag, rollback of image + volume on a
failed health check).

Failure modes: health/update fail loudly (exit 1 + desktop + Signal);
all providers cooling = degraded (exit 0, one notice/day); a missing
signal-bot .env logs and exits non-zero on alert paths; health skips
while update holds the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZSdJmNjGUvLnHJ4WG
2026-11-05 13:37:35 +01:00
c2165e9fb8 omniroute_offload: free-llm, claude-free
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
omniroute_offload pre-commit / pre-commit (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
free-llm sends big texts to the free-best OmniRoute chain behind a
fail-closed privacy gate (public repos, /tmp, /var/log; credential
scan). claude-free runs Claude Code with the full ~/.claude setup on
free-best. Skill offload-free in ~/.claude says when to use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYwGi2FJwfZ6QRB1im
2026-11-05 13:14:16 +01:00
26079c34c2 holds 0.1.0: declared time-boxed holds
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
holds pre-commit / pre-commit (push) Has been cancelled
holds tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
2026-11-03 20:25:10 +01:00
22fdf2c344 focus_project 0.7.1: daily floor 30/gate
Some checks failed
code-runner pre-commit / pre-commit (push) Has been cancelled
code-runner tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
focus-project pre-commit / pre-commit (push) Has been cancelled
focus-project tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
sql-drills pre-commit / pre-commit (push) Has been cancelled
sql-drills tests / test (push) Has been cancelled
daily_minutes must be at least 30 per replaced gate,
on create and update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 16:11:18 +01:00
de7d5d17eb sql_drills: no root shell for learner SQL
Learner SQL piped into the mysql client could run `\! id` as container
root and read every DB. Now: --binary-mode (client commands error out),
a refused-backslash-line guard, learner exec as uid 65534, and a random
root password (0600 file, via env, adopted live on a legacy container).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 16:05:59 +01:00
640985a421 focus_project 0.7.0: cuts wait a day
An edit that lowers a target or adds a replaced gate
holds from tomorrow, never today. apply_update returns
effective_from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 16:01:58 +01:00
70c8aa8fbb code_runner: contain hostile result.json, v0.1.1
Deep or oversized values from the sandbox now fail that example only; compare refuses nesting past 64 levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:59:01 +01:00
ba4dd58545 earned_time 0.21.0: focus day pays 240 gaming
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled
kuhy 2026-11-02: keep the 180 base cut, pay 2 gaming min per focus min
(240 at 120/120), so a full focus day is 8 h again; an idle day stays 2 h.
FocusEarner.cut_gaming_minutes holds the cut (_resolve._gaming_cut) so the
pay (cut + pure bonuses again) can exceed it. Shutdown unchanged. Carry
rows (focus_project 0.6.0, <project>-<YYYYMMDD>-carry) pay like own rows;
test added. Failure modes unchanged: focus_project unusable = no project
(fail-open, logged); unreadable ledger = None, earns nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:22:10 +01:00
b50e245ceb req_check: accept (system, prompt, schema) asks
A structured model function such as the tutor's ask_model now works
as `ask` in check, looks_like_code and ask_models (via: "model").
Raises fall through to Ollama, then fail open as before. v0.1.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:17:43 +01:00
88e4be5042 focus_project: carry overtime, v0.6.0
Some checks are pending
code-runner pre-commit / pre-commit (push) Waiting to run
code-runner tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run
Minutes past a day's target bank (max 60) and pay on the next project day; status shows real minutes (121/120) plus carried_in/carry_out; carry ledger rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:14:59 +01:00
737bf624ca Replace LeetCode fixtures with synthetic ones
Public repo: statements are original, in LeetCode's layout. Real problems are fetched at runtime by network-marked tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:04:26 +01:00
716231a7f3 leetcode_coach: shared package, SQL shape gate
Standalone utils subproject (pyproject, pre-commit, CI, install.sh,
README with the library API). It no longer imports leetcode_guard:
guard_bridge resolves the three read-only calls on demand.

Q5: SQL problems skip req: and ask for an OUTPUT SHAPE (columns,
rows, grouping) before code; algorithm problems keep req:.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:02:38 +01:00
11cb1d81d9 Import leetcode_coach history from leetcode-guard 2026-11-02 15:02:38 +01:00
e0c9befdeb Add PostgreSQL dialect, LeetCode examples
run(dialect=), run_problem(slug|statement, sql, dialect): ASCII-table parser, per-dialect DDL, value-wise compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:01:23 +01:00
612b0b3c0a Add code_runner sandboxed example runner
Runs a solution (python3, javascript, typescript) against statement
examples in bubblewrap: no network, read-only root, scratch dir,
rlimits and a per-example timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 14:59:30 +01:00
f865508eb4 Add sql_drills engine package
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run
Shared MySQL drill engine extracted from testsAndMisc: sandboxed
per-run db and user, exact-match cell diff, predict drills (builtin
theory set incl. an error drill), save_generated validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 13:53:39 +01:00
1fb04ce59d todo_hooks: session-start, canonical token path
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:24:29 +01:00
6f0070b353 todo_hooks: skip unpushed shas, scan upstream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:24:29 +01:00
aec96eefd3 Add todo_hooks closer, drift sweep, PR import
Python 3.14 stdlib package wiring git to the todo MCP server:
- closer (post-commit): runs the done_check of each Todo: trailer
  task and close_task's it only on exit 0. It FAILS OPEN: the commit
  already happened, so a down server, bad token or no gh prints one
  warning and exits 0 instead of failing the hook.
- sweep: nightly drift report (systemd units shipped, not enabled).
- import-pr: a merged PR's '## Todo:' items become tasks, deduped.
- A done_check runs only if its sha256 is in
  ~/.config/todo-hooks/trusted-checks; 'Check: manual' notes are
  never auto-closed.
- Hooks exposed in .pre-commit-hooks.yaml (post-commit, post-merge,
  post-rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:24:29 +01:00
35569a6335 Merge branch 'main' of https://github.com/kuhyx/utils
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Has been cancelled
sickdays tests / test (push) Has been cancelled
2026-11-02 12:04:04 +01:00
a233268b66 md-naming: keep stale.py as no-op stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:03:47 +01:00
20a825e249 md-naming: ban TODO*.md, retire marker rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:03:47 +01:00
2bbb428f2c Allow spaced desktop Name=; restore Sick days
Rule 2 now takes one space between words; the phone label
rule is unchanged. Reverts the 5b36e84 rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 12:01:43 +01:00
af3c98a50e Move TODOs to todo app
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 11:28:31 +01:00
fd013abf67 focus_project 0.5.0: work_url and open launcher
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
python3 -m focus_project.open opens the tutor focus screen (start=1
when a project is active) and the project's work URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdzckyBH7fEbPywpr
2026-11-02 11:10:21 +01:00
0f332c9057 crdt_sync_flutter 0.4.0: AccountManager sign-in
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Android now uses google_account_token (no googleid or
play-services-auth). Desktop PKCE sends client_secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:42:52 +01:00
86b2621cd4 Guard google_account_token path overrides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:42:43 +01:00
fe1f4a22ab TODO: FileLogPersistence shared tmp race
Some checks failed
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
google_account_token tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:34:58 +01:00
db70ba204d Add google_account_token plugin
Android AccountManager ID token for Firebase signInWithIdp, extracted from todo so the sync apps stop copying the Kotlin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:32:58 +01:00
5b36e84793 repo_contract: skip Flutter plugins
A plugin has android/ but no launcher activity. Also rename the sickdays desktop entry to the one-word form the label rule requires; that red gate blocked every pubspec commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:32:47 +01:00
ae29e67e28 Seed todo-mcp desktop session by default
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 10:25:15 +01:00
4d75ce6d48 focus_project: type-clean tests
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdzckyBH7fEbPywpr
2026-11-02 10:04:36 +01:00
637428585c focus_project: late days, past end, clock, v0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:01:08 +01:00
c25ae4e0d1 Close dopamine-ux 06-todo, log partial
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 09:38:52 +01:00
98c013ea3f earned_time 0.20.0: focus day cuts the full 180
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
A focus-project day cuts the whole 180 of gaming base (LeetCode's free hour too); a full day still earns it all back. Shutdown unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWxdzckyBH7fEbPywpr
2026-11-02 09:12:32 +01:00
213537d0ef dep_freshness: JitPack-hosted own-group artifacts
Ask JitPack metadata last for names no other repo knows and that
are not com.github.* (whose metadata is 401, tags used instead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 09:12:20 +01:00
11c5b2e32b sickdays 0.4.0: add focus locker id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:11:28 +01:00
c98da93339 earned_time 0.19.1: per-day focus target
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:46:13 +01:00
d45b1086c3 focus_project: update_project, v0.3.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:34:32 +01:00
8398ca4823 earned_time 0.19.0: focus project earner
earners_for swaps a focus project's replaced gates for one focus earner
paid per minute from the focus ledger (floor of summed minutes). The gaming
cut equals what the replaced penalised gates cost, so a project day is
neutral; maturity never delays it. An unusable focus_project means no
project (logged once).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:23:50 +01:00
087ed1c400 pylint TODO: trim to the two repos left
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:20:39 +01:00
e852e3ce48 focus_project: isolate tracker env, v0.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:19:30 +01:00
6d80efb50a focus_project: minutes, ledger, harvester
Fail-closed: a failing source logs and adds 0 minutes;
a bad key, ledger or registry exits 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:19:01 +01:00
cbcee98d94 Add structural_check: spacing/radius/type/motion drift
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:18:27 +01:00
82119bdd47 Drop motion-tokens twin; shipped (DOCS-motion)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:18:19 +01:00
d666e47e16 Merge remote-tracking branch 'origin/main' 2026-11-02 08:18:05 +01:00
8a3076783e focus_project: browser source adapter
Reads kuhytrack window+AFK buckets. Tracker down: 0 min.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:17:46 +01:00
5d11f97093 Drop dufs theme twin; dufs-cloud copy gone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:17:34 +01:00
eb1c015315 focus_project: source adapter interface
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Fail-closed: a failing adapter logs and contributes 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:15:02 +01:00
f99b77af89 focus_project: registry, stands_down, activity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:13:52 +01:00
1e96563522 dopamine-ux: mark 04 repo copy deleted
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 22:34:51 +01:00
28d3fb1361 dep_freshness: accept PEP 440 post pins
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:26:45 +01:00
bce59127eb Add Desktop OAuth client register + verify
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:11:44 +01:00
1bfacdf51f Coach TODOs shipped: specs as DOCS, residue TODO
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWcbzoov1KFZJSFcSs
2026-11-01 22:05:40 +01:00
c74815b8ab dopamine-ux: mark 05-workout-app.md done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:45:40 +01:00
dccfe14ae9 Drop TachiyomiSY mentions from comments
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:36:38 +01:00
343f015c81 dopamine-ux: mark 04-screen-locker done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:36:38 +01:00
decf5824e6 web_ui 0.5.0: port to Astro + custom element
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Has been cancelled
RangeSlider.astro + <range-slider> replace the React component.
Pure helpers, tokens.css and range-slider.css are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:03:45 +01:00
648bde44b1 sync_settings_ui 0.5.2: cancelled backup is not success
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
music_theory tests / test (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Waiting to run
data_transfer_flutter tests / test (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
bbb7f0f0c0 data_transfer_flutter 0.2.0: exportTextFile + mime types
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
4c417472ec docs: E2E must cover export_keys
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
f9899b9c56 sync_settings_ui 0.5.0: BackupSlot.json on data_transfer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
254c8ce240 Add design conformance counter, score TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
5be9a22970 data_transfer_flutter 0.1.0: shared JSON export/import
Save-as on mobile (SAF), save dialog on desktop, download on web; versioned envelope helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
888e8ae57d web_ui: add print styles, migration order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
a27bbec59a docs: plan Astro migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
1c9b47e35c docs: plan crdt_sync E2E encryption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:46:05 +01:00
b3c9611af2 design_system 0.3.0: FitGrid paged grid
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
design_system tests / test (push) Has been cancelled
FitGrid lays items out in a grid sized to the box it is given and pages
the overflow sideways with dots, so a screen never scrolls vertically.
First consumer: tunedeck's home tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZT4baL56wRDmNioxj
2026-11-01 19:21:27 +01:00
def48e346b crdt_sync_flutter 0.3.5, sync_settings_ui 0.4.3: on crdt_sync_dart 0.11.3 (crypto 3.0.8)
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYmRsPBNf6gi2LVjFA
2026-11-01 18:04:33 +01:00
a2e7334dfe crdt_sync_dart: cut 0.11.3 (crypto 3.0.8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYmRsPBNf6gi2LVjFA
2026-11-01 18:03:20 +01:00
a4c7d29595 crdt_sync_flutter 0.3.4, sync_settings_ui 0.4.2: on crdt_sync_dart 0.11.2
Some checks failed
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPn2dujX9cyYsu5zeQfjde
2026-10-25 14:14:35 +01:00
1b5515fd8b crdt_sync_dart: cut 0.11.2 (network errors never carry the URL token)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPn2dujX9cyYsu5zeQfjde
2026-10-25 13:05:59 +01:00
94518c8433 crdt_sync: a network error message never carries the URL's token
http's ClientException prints its request URL, so 'network error ...:
$exc' embedded ?auth=<ID token> (RTDB) or ?key=<API key> (auth) in every
FirebaseSyncError / FirebaseAuthError, which apps show and log. The
messages now use exc.message only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXYpMKCt4RFSbCMfYyAPCs
2026-10-25 11:27:08 +01:00
ea5be2e879 crdt-sync: seed kartka-agent and kartka-source-eval desktop sessions
kartka's headless agent wrapper serves ~/.config/kartka-agent/
firebase_auth.json once per launch as /sync-credentials, and its weekly
source evaluator reads credential_store_for("kartka-source-eval"), which
otherwise falls back to the retired password. One run with
--app kartka-agent --app kartka-source-eval seeds both from one consent
tap. Both also join the default all-app run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2brk4NNnRyTnt7cD2QF2f
2026-10-25 08:13:26 +01:00
f93a24e283 flutter_web_drive: headless CDP driver + MCP server for Flutter web apps
Drives a Flutter web build in a persistent headless chromium (systemd-run
unit, own profile, CDP port) through the semantics tree: tree, tap by label,
text, key, scroll, wait, shot, console. Never touches the live display;
attaches only to HeadlessChrome. The MCP server wraps the same Driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ4sULGfyav7EEwQ6vhbfg
2026-10-24 22:37:49 +02:00
d0f928ae22 earned_time 0.18.0: a LeetCode bonus needs the day paid in full; gate_off lets one solve earn on a stood-down day
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:13:23 +02:00
99f4e171e3 Merge branch 'worktree-agent-a179fa4cf3532a65f' 2026-10-24 08:02:21 +02:00
29ea6e2c50 crdt-sync: seed_session accepts --app sufler
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
The sufler PC TTS relay reads ~/.config/sufler/firebase_auth.json via
credential_store_for("sufler"), like tutor, so it needs a seeded session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jw52sNWquCp34dy3rj19GD
2026-10-24 08:00:42 +02:00
2c926928dc app: live check of the desktop coach path against the real relay
tool/coach_live_check.sh starts the real Relay (lane, backend, HTTP API,
phone mirror) on a free loopback port, never 8777, with claude, Firebase,
LeetCode, Ollama and Anki faked and every file under a temp dir
(_live_relay.py), then drives it through WrapperServer's /coach/* proxy
with WrapperCoachTransport (coach_live_check.dart):

- flow: start, step-1 partial then pass, a chat turn, code refused before
  step 2, three step-2 fails via toolbox, a force, an unknown and an
  over-long pick (bad_request), an also_accepted pass, run_code, end,
  not_live after it; 404 for an unknown rid.
- slow: a 35 s coach turn through a 30 s proxy timeout answers, with
  heartbeat.busy naming the rid and every poll under 2 s.

The flow's raw answers are committed as test/fixtures/coach_relay/ and
parsed by relay_fixtures_test.dart, so the models are checked against
what the relay sends, not what the fake's author believed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:44:31 +02:00
a78c1e971b relay: a forced step 2 is not a try (attempts 4 -> 3)
phase.step2_attempts counted every step-2 grade with grade "fail",
and a force repeats the last try's grade, so three named-a-tool fails
plus "Continue anyway" showed attempts: 4 in the state. Measured
against the live relay through the app's desktop path; the contract
(and the snapshot's _coach_grades) already exclude forces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:44:12 +02:00
2531f9eeb3 Coach relay: phone writes go through an ordered outbox; 280 s request cap
The RTDB mirror wrote synchronously on the lane's one worker, so a hanging
Firebase (15 s connect timeouts) would have stalled every desktop request.
PhoneLink now only enqueues; relay.outbox writes on its own thread in the
contract's order (chat reset/lines, state, heartbeat, then the result),
keeps only the latest state and heartbeat, never drops a result or a chat
line, and retries a failed write first, with backoff.

The whole-request 280 s cap is enforced again: Coach.handle sets a
deadline and a coach turn gets what is left of it (at most 240 s), so an
ambiguous message's code check plus the turn cannot outlive the phone's
300 s "No reply" wait. The contract's caps line is back to that wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 21:10:04 +02:00
f47ed80584 Coach relay (Python side): loopback API + RTDB phone path for the guided coaching UI
leetcode_coach.relay implements DOCS-coach-relay.md: one paced serial lane
(1 start / 3 s, 5 queued, done-map re-answers a rid), the loopback API on
127.0.0.1:8777 (Host check, JSON only, 16 KiB), the RTDB request/result/
state/chat/heartbeat mirror over SSE with 60 s rescans and 24 h cleanup, the
five-step state machine derived from the session log, graders (steps 1/5
haiku -> Ollama, fail open "unverified"; step 2 through PhaseGate.screen;
step 3 mechanical against answer_key.json), the CLI's sonnet chat with the
log in the child env and the local hand-over, run-code on node --permission,
and step 4 through the harvester's read-only fetch_recent_ac once a minute
(three-valued: unknown is never "not yet").

New log events session_start/grade/chat/session_end land in session_log,
_coach_log and _well_formed together; the snapshot rows gain grades and
accepted (_coach_grades). A forced step-2 grade opens code (phase.req_state).
leetcode-coach-relay.service + an install.sh step (not run).

Contract fixes in the same commit: step 3 counts also_accepted and combined
entries' parts, step-2 grade events and the force event shape, next_step,
per-call model caps instead of a 280 s watchdog, heartbeat on busy changes,
the statement fetched in-process, enable + restart on reinstall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 21:01:36 +02:00
2a9732a312 Coach: answer key for the 24 cluster problems, with loader and tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 19:51:18 +02:00
e06df74caf Coach tab: no sessions yet is a known state with how to start
The exporter used to publish coach: null before the first session, so the
tab printed "unknown" everywhere with no way forward. Now a missing or empty
sessions dir exports empty problems/re-tests, every cluster at zero with its
problems (slug, number, title, URL) and next, and a how_to block (commands,
the 4-step method, first command). null stays for a real failure.

how_to lives once in leetcode_coach/how_to.py; DOCS-how-to.md quotes it and a
test fails on drift. The app's Coach tab shows a How to start card (monospace
command box with copy), one card per cluster with tappable problems, re-tests
with their command, and "unknown" + why only for a null section. Also fixes
the done/solved key mismatch (the app read a key no exporter wrote).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 19:24:20 +02:00
2d2d8a41f8 Toolbox property meanings no longer name an entry or operator
The incomplete-list note shows meanings before code, so "a ^ x ^ x = a"
or "x & -x" handed over the answer. Rewrite the six leaking meanings plus
two that said "sorted", and pin it with
test_no_property_meaning_names_an_entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:57:25 +02:00
6343499ed1 Leak bounce exempts only names kuhy typed; hide property meanings that name an entry
The fetched statement carries LeetCode's topic tags and site hints ("Hash
Table", "Two Pointers"), so exempting it pre-cleared the answer. Some real
toolbox meanings illustrate with the operation (self-inverse: "a ^ x ^ x"),
so the incomplete-list note shows those properties by name only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:54:45 +02:00
ebf7ca624e Merge main (toolbox, clusters, re-tests) into the coach gate branch
The gates read the toolbox through toolbox.load_safe (typed, fail-open)
beside main's raw load(); the session log follows LEETCODE_COACH_SESSIONS_DIR,
stamps LEETCODE_COACH_CLUSTER, and the card builder logs postmortem on
delivery. leetcode report dispatches beside cluster_cli; client notes move
to DOCS-clients.md to keep SKILL.md under the cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:49:41 +02:00
c22b14389a Coach phase gate, req checker, leak bounce, session log and report (TODO-coach 4-8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:45:08 +02:00
c0e902e5cc Toolbox: no alias may be a cluster problem's title; min's identity is +Infinity
"merge intervals" is #56's own title, so the leak bounce would have bounced
the coach's restatement of it; "dictionary" was a bare common word. A test
now pins that no entry name or alias equals any clusters.json title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:43:00 +02:00
28c243d066 Coach toolbox, clusters, cold re-tests and property cards (TODO-coach 9-12)
- toolbox.json: 20 entries over a fixed 40-property vocabulary; no entry's
  property set nests in another's, so every property card has one answer.
- clusters.json: decision 10's six clusters, ids plus slugs.
- progress.py (stdlib only): solved = code + postmortem, re-tests due at
  +3/+10 days, cluster progress, all derived from the session logs.
- leetcode due / --cluster <name> / --retest <slug> (cluster_cli.py): due
  re-tests of the cluster first, then the next problem with no code event,
  skipping LeetCode-Accepted ones (live check fails open). A cold re-test
  logs `retest` only once the session reached a post-mortem.
- Post-mortem cards are property -> operation only, generated by
  toolbox_cards (shared slug `toolbox`); `add` appends an entry only after
  kuhy confirms its properties. The local client accepts {"toolbox": [...]}.
- _coach_log publishes retests_due and clusters from the coach.
- requirements-sync treats sibling repo packages as first-party.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:39:52 +02:00
96a0d8af07 leetcode launcher: stop exporting PYTHONPATH into the coached session
The REPL passes os.environ to `claude -p`, so a PYTHONPATH set by the
launcher reached every hook and every Bash call of the coached session. The
old launcher did not do that. The launcher now runs `/usr/bin/python3 -P -m
leetcode_coach.leetcode_repl` and relies on install.sh's editable install,
the same way the Stop-hook shim does. A test now pins that the launcher
sets no PYTHONPATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:12:31 +02:00
b730cffaf4 Move the /leetcode coach skill into the repo as leetcode_coach/
Phase A of TODO-coach.md (decisions 1-3). The skill code is now a package here
with the same behaviour as before. Nothing new is added. ~/.claude/skills/leetcode
and ~/.local/bin/leetcode become symlinks into the package, created by
scripts/link_leetcode_coach.sh, which install.sh runs.

- Package imports replace the bare sibling imports. To stay under the 250-line
  cap, the Stop-hook detector is split into gate_detector.py and stop_gate.py,
  the card rules move into card_rules.py, and the prompt text moves into
  local_prompt.py.
- local_coach uses gate_detector directly. It no longer loads the hook file
  with importlib.
- make_cards runs on the system python: a genanki pin (pyproject and
  requirements) replaces the `uv run --script` header. The note guid is
  passed to genanki.Note explicitly, so CardNote is no longer subclassed;
  the guid values are unchanged.
- stop_gate also accepts the package path as a skill-load marker, because the
  harness may print the resolved symlink path.
- Every swallowing except now logs a warning. A NullHandler on the package
  logger keeps stdout/stderr the same as before.
- Modules whose prints are their interface (REPL, local session, card builder,
  fetcher, eval, Stop hook) get T201 per-file ignores, and their tests assert
  on stdout.
- hook_shims/ holds the 3-line ~/.claude/hooks shim. It is excluded from
  ruff, mypy, pylint and coverage, and a test runs it end to end.
- Pre-commit: a requirements-sync entry for each package, leetcode_coach and
  genanki added to the pylint hook, and the name-tests-test exclude widened.
  coverage and ruff are bumped to the latest stable releases because the
  dependency-freshness gate requires it.

Verification: 111 coach tests pass with 100% branch coverage. ruff, mypy
strict and pylint are clean (10.00). On the 370 assistant replies in 39 real
/leetcode transcripts, the old hook and stop_gate give the same verdict
(0 mismatches, 20 blocked). fetch_problem output (live two-sum) and
make_cards --dry-run output are byte-identical to the old skill, and so are
the note guids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:06:40 +02:00
d1ca1e3d5b repo_contract: launcher gate (icon ink radius, compliant label, hidden debug variants) + hook_sync
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Flutter repos now fail the repo contract when a launcher icon's ink is not on
the shared 340px circle (png_ink), the launcher label breaks the naming rule
(capital, ASCII letters, <= 10 chars, <= 522 wide in GoogleSansFlex), or an
applicationIdSuffix variant still carries the LAUNCHER category. hook_sync
rewrites a repo's stale repo-contract `files:` line to the widened template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
2026-10-23 14:54:15 +02:00
20b2cdbc04 earned_time 0.17.0: a LeetCode day paid from banked credits earns the bonus
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 11:25:00 +02:00
e74aa7b0ea test(agent_ready_gate): clear GITHUB_ACTIONS in the real-checker test
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
sickdays pre-commit / pre-commit (push) Has been cancelled
sickdays tests / test (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
In CI the gate defers to the workflow and returns 0, so the real-checker
test saw 0 instead of 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
2026-10-22 21:51:50 +02:00
ea3f78b711 deps(pypi): newest stable pins in every Python subproject
pre-commit 4.7.0, pylint 4.1.3 and ruff 0.17.2 across crdt-sync,
earned_time, freedays, gatelock, music_theory and sickdays; numpy 2.5.4 in
music_theory (requirements, pyproject and the mypy/pylint hook deps). The
ruff-pre-commit rev follows to v0.17.2 in those configs and vmbox's, so the
hook lints with the version requirements.txt pins. No Python lockfiles exist.
All six suites at 100% coverage; every package's pre-commit config green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
2026-10-22 21:42:26 +02:00
cca7dd680f lint: clear what ruff 0.17.2 and sickdays' pylint hook flag
- sickdays: normalise_keep raises TypeError (not ValueError) for a bare
  string -- ruff 0.17.2 TRY004; it is a caller type error, the CLI always
  passes a list.
- sickdays: remove_sick_day and set_kept_lockers share require_sick(), which
  clears pylint R0801 (duplicate-code, 9.99/10 under --fail-under=10).
- repo_contract/web.py: tomllib sorts as stdlib under ruff 0.17.x (I001).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
2026-10-22 21:42:16 +02:00
9980407095 deps(node): newest stable npm pins, pnpm 12.15.0, Node 24.22.1 in CI
ts_config, ts_core, web_ui: eslint 10.13.0, typescript-eslint 8.72.0,
vitest/coverage-v8 5.0.5, eslint-plugin-unicorn 78.0.0, @eslint-react 5.28.4,
react 19.3.1 and the rest the freshness gate flagged; packageManager set via
corepack (pnpm 12.15.0 + sha512) and lockfiles regenerated. Lint clean, 100%
coverage in all three, dist/ unchanged by rebuild.

typescript stays 6.0.3: typescript-eslint 8.72.0 still declares
typescript ">=4.8.4 <6.1.0" (fleet-wide allowlist entry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
2026-10-22 21:33:23 +02:00
abc3ada6c3 agent_ready: wire into CLAUDE.md commands, shared-gate CI and test_changed
The checker shipped with 100% branch coverage but nothing ran it: add it to
the CLAUDE.md test/lint/coverage commands, the shared-gates-tests workflow
(--cov-fail-under=100, same shape as repo_contract/coverage_gaps) and the
test_changed.sh fallback gate list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
2026-10-22 21:29:48 +02:00
a0fb279b1b agent_ready: AgentReady v2.0 checker + repo_contract web-site gate
New agent_ready/ package (dist and live-url modes, 100% branch coverage)
checks robots, sitemap, llms.txt, ai-catalog, 404, no-JS text, markdown
mirrors and JSON-LD. repo_contract gains web-repo detection and installs the
agent-ready pre-commit hook, CI workflow and check_agent_ready.sh shim;
scripts/check_agent_ready.sh is the shared entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
2026-10-22 21:18:49 +02:00
08a4010a16 dep_freshness: Python's latest is python.org's newest release, not the interpreter
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
toolchain:python's "latest" was platform.python_version(), so the verdict
depended on the machine running the gate: setup-python "3.x" gave kuhyx/spellcode
3.15.0 on one runner and 3.14.8 on another, making its transitive:pyodide
allowlist entry dead (exit 2) on one and required (exit 1) on the other.

Now registries/toolchain.python_latest() reads python.org's release API
(is_published, pre_release=false), keeps only exact "Python X.Y.Z" names (the
feed also lists "Python install manager 26.4"), and takes the newest stable.

The never_cached() carve-out is gone: it existed because a cached
*interpreter* version from an older runner image (3.13.15) was served to a
3.14.7 job in screen-locker's CI. A registry answer is safe to cache, so
python now goes through the normal TTL cache, prefetch, and offline fallback
(expired cache -> DEGRADED; no cache -> unavailable), never the local
interpreter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4VrLZn9YjhPuoxTj54WKk
2026-10-20 22:39:25 +02:00
9733a29a65 earned_time 0.16.0: leisure=None|True|False, so the gaming day decides (C6)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
resolve()/base_for(leisure=) is now tri-state: None (default) decides by
the switch file on `day`, True forces leisure pricing (the shadow budget),
False forces the old base and cap. A consumer billing on the 06:00 gaming
day passes leisure_active(gaming_day), so 00:00-05:59 on the go-live date
no longer prices the eve with the +2 h leisure base/cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 16:58:27 +02:00
3afa269e2e earned_time 0.15.0: leisure switch file /etc/leisure-watch/leisure_from (C6)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Contract C6 of the leisure rollout (leisure-watch TODO-leisure-rollout.md).
leisure_from(path=LEISURE_FROM_PATH) -> date reads the switch file afresh on
every call (O_RDONLY|O_NONBLOCK|O_NOFOLLOW, fstat S_ISREG, <= 64 bytes,
strict YYYY-MM-DD with one optional newline); any failure is the parked
LEISURE_FROM. Never raises. leisure_active(day, path=...) = day >=
leisure_from() is the one way consumers ask "is Phase 2 on?".

gaming_base_minutes_for / gaming_ceiling_minutes_for / base_for / resolve
use it; leisure=True still forces (now a boolean, so a file date later than
2027-01-01 cannot drop the shadow budget back to 5 h). resolve reads the file
once, so base and cap never disagree mid-call. Tests redirect the path via
an autouse fixture and never touch the real /etc file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 13:57:21 +02:00
00085a7de6 dep_freshness: gittag looks up tags in the dependency's own repo
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A `pkg @ git+https://github.com/<owner>/<repo>@pkg-vX.Y.Z` pin outside
kuhyx/utils was asked of utils, which has no such tag, so
steam-backlog-enforcer's leisure-watch pin was undeterminable (exit 3).
Foreign pins are now named `<host>/<owner>/<repo>:<pkg>`; utils pins keep
their bare names (allowlist labels and cache keys unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:56:36 +02:00
35f6f95956 gatelock 0.11.0: Claim.grab_held + held_grabs(), so a reader can tell a locker's X grab is actually held
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
A live claim was not "input is locked": it is published before the window
grabs, and leetcode-guard's study mode releases the grab while the claim
lives on. leisure-watch (contract C2) must pause shadow billing only while a
grab is really held.

- Claim.grab_held (always serialised; missing key from an old writer reads
  as grab != "none"). Arbitration still uses the declared grab only.
- Arbiter.set_grab_held(held=) rewrites the claim and holder copies in place
  through the owner's flocked fd (O_APPEND cleared, pwrite at 0, padded so
  the file never shrinks) -- same inode, flock liveness untouched. Never raises.
- Set true only after grab_set/grab_set_global succeeded; RecoveryLoop
  reports each tick's real answer (a stolen, un-retaken grab reads false).
  LockWindow.report_grab_held(held=) is the hook for deliberate releases.
- gatelock.held_grabs(claims_dir=None): live claims with grab_held and a real
  grab. Root callers pass /run/user/1000/gatelock/claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 11:40:46 +02:00
2d97dc4b0e earned_time 0.14.0: LEISURE_FROM leisure budget (7 h base, 10 h cap) + resolve(leisure=True)
Contract C1 of the leisure rollout (leisure-watch TODO-leisure-rollout.md).
New _leisure.py: LEISURE_FROM = 2027-01-01 (parked; Phase 2 sets it to the
day after the shadow week), gaming_base_minutes_for(day) (7 h from it, else
5 h) and gaming_ceiling_minutes_for(day) (10 h / 8 h). base_for and resolve
use them; leisure=True prices any day with the leisure base and cap (the
shadow budget). Pay, penalties, shutdown and the sick rule untouched; every
day before LEISURE_FROM resolves byte-identically (full day still 480).

resolve() now has six parameters (the contract fixes the signature), so
ruff/pylint max-args is 6; the term loop moved into _term() for
too-many-locals. test_ladder's shutdown-defaults-to-today test compared to
a literal that broke when UNIFIED_FROM passed; it now compares to today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 11:34:50 +02:00
26c55675dd music_theory: sfx module -- noise, sweeps, biquads and 22 spellcode presets
Code-generated sound effects for spellcode (no AI audio, no sfxr port):
seeded white/pink noise and grain gates (noise), a phase-accumulating
oscillator over glide/vibrato/arpeggio/jump/retrigger pitch curves plus
ring mod (sweep), exponential and punch envelopes (envelopes), resonant
RBJ low/high/band-pass with a moving cutoff (biquad), and an sfxr-style
layered parameter set rendered at 44.1 kHz by default (sfx).

Presets: cast/hit/impact for arcane, fire, water, lightning, earth and
wind, plus dodge, parry_normal, parry_perfect and fizzle, following R1's
per-element recipes (arcane's is new). `python -m music_theory sfx --out
DIR` writes 16-bit mono WAVs and a SHA-256 manifest; manifest.entry now
takes any Recordable (Recipe or Sfx). analysis gains band_energy and
spectral_slope, which the tests use as the oracle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o4CqWFZwpfiDj8csAPgLa
2026-10-19 11:27:49 +02:00
eef2d46f32 gatelock 0.10.0: night_lockdown.evening_lockdown_today, so earner gates stop re-arming after tonight's lockdown
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:23:48 +02:00
49a401debd earned_time 0.13.2: UNIFIED_FROM = 2026-10-18, the day after screen-locker's late-credit rule (46393fa)
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:18:02 +02:00
09a3a15d64 earned_time 0.13.1: sick-day helper out of resolve (pylint too-many-locals)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 17:28:39 +02:00
f1378e97f1 earned_time 0.13.0: sick day = 20:00 + gaming pay of what was done (was max(20:00, earned))
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 17:27:34 +02:00
2ec1c6e9b1 earned_time 0.12.1: park UNIFIED_FROM (2027-01-01) until screen-locker's late-credit rule ships
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 17:24:07 +02:00
67f14588f7 earned_time tests: ruff import order + format (CI pre-commit red since 0.9.0)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 16:48:05 +02:00
ef244889fd earned_time 0.12.0: shutdown rungs equal gaming pay from 2026-10-18 (workout 120, LeetCode 60, reading 60, tutor 60; floor 18:00)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 16:47:02 +02:00
d3f01c36e1 ci: run gate_stamp's tests (scripts/tests) in shared-gates CI
They were only reachable through test_changed.sh. Add them to the
shared-gates workflow (with pre-commit, and apt PyYAML for the
/usr/bin/python3 hook-id helper) and to CLAUDE.md's test command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 16:46:59 +02:00
8b7105e20c gate_stamp: split full into <60 s calls with full --list + step <id>
`full` ran every manual-stage hook in one pre-commit call, which takes
over 60 s in screen-locker and leetcode-guard. `step <hook-id>` runs one
manual hook on --all-files and records "<tree> <id>" in
$GIT_DIR/gate-steps (per-worktree); the step that completes the set
writes the stamp exactly as `write` does. Dirty trees and non-manual ids
are refused with exit 2. Hook ids come from scripts/gate_stamp_hooks.py
(PyYAML, /usr/bin/python3), which treats an absent default_stages as
"every stage", matching pre-commit, so the steps cover what `full` runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 16:45:28 +02:00
7f2443ce48 sickdays.gui: refuse a write while a background sync is in flight
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Has been cancelled
sickdays tests / test (push) Has been cancelled
sync() reads the pool, waits on the network, then writes its merged copy
back without a lock. A second write landing inside that window was
silently overwritten. Marking day A and then clearing day B within a
second or two lost the clear, so B stayed a sick day.

Every background sync now holds a module lock, and each write tries it
without blocking. A click that lands mid-sync shows "Syncing -- try again
in a moment." in the panel and calls on_saved with nothing. The phone's
picker avoids the same race by making the grid inert while it syncs.

The red-first test pins the lost write with a sync stub that reads, waits,
then writes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 13:51:26 +02:00
bd64b27109 sickdays 0.3.0: desktop sick-day picker and embeddable KeepPanel
`sickdays gui` opens a Tk window modelled on the phone's Sick days screen:
a Monday-first month calendar (cat-2 fill, a dot for kept lockers, past
days faded and inert), a five-switch panel for the clicked day, and the
"Marked sick" list with "Kept: ..." lines. A free day is refused with the
phone's wording. Each write starts sync_quietly() on a non-daemon thread.
The window class is "sickdays" and Escape closes it.

sickdays.gui.KeepPanel is the panel on its own, a plain tk.Frame with no
root, mainloop, grab or wm_* call, so screen-locker can embed it under its
lock-screen grab. `import sickdays` and the rest of the CLI never import
tkinter: the CLI loads the gui module by name only for `gui`, and a
subprocess test checks this.

The tests drive real Tk on a withdrawn root, and CI now runs them under
xvfb-run, as gatelock's CI does. Adds a sickdays.desktop launcher, which
install.sh installs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 13:47:26 +02:00
0a51c9998d sickdays 0.2.0: per-locker keep set on a sick day
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
A sick day may keep some lockers active: a locker stands down iff the day
is sick and the locker is not in that day's keep set. New record field
`keep` = [sorted, de-duplicated locker ids, hlc].

- add/remove write state, actor and keep on one tick (keep=[] on a clear),
  so a stale keep cannot survive a clear + re-add.
- set_kept_lockers re-stamps only keep and actor; state keeps its clock, so
  a keep edit never re-asserts sick over a concurrent clear. Ticks are
  issued after every clock already on the record (clock-skew safe).
- Reading: no keep (0.1.0) or keep older than state -> keep nothing;
  malformed keep -> every locker kept (fail closed, logged); unknown ids
  ignored.
- API: stands_down, kept_lockers, set_kept_lockers, add_sick_day(keep=),
  SickDay.keep, LOCKER_* / LOCKERS. is_sick_day unchanged.
- CLI: add --keep, keep DAY [LOCKER ...], list shows keep, check --locker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:08:06 +02:00
6f76ba7228 crdt-sync: seed a desktop session for sickdays
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:56:45 +02:00
c6838bc865 sickdays: ship py.typed so consumers type-check against it
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:37:31 +02:00
221c481644 sickdays: pin freedays-v0.4.0 (sick-day conflict check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:37:13 +02:00
c490ba5992 freedays 0.4.0: mark refuses a sick day (call-time sickdays import, allow+warn if absent)
Also fixes a ~5% flake in the hmac round-trip test (freedays and sickdays):
load_key strips whitespace, so a random key edged with a whitespace byte
never compared equal to the raw bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:28:44 +02:00
af8c6c78bf sickdays: a non-UTF-8 pool file fails closed instead of raising
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:25:00 +02:00
894d76f811 sickdays 0.1.0 + earned_time 0.11.0: uncapped sick-day pool, 20:00 sick shutdown floor
sickdays: per-date pool, no cap or reason, add/remove today or later only,
import_legacy for the migration. LWW state flip per date (no tombstones),
Firebase RTDB sickdays-sync/devices/<uuid>/sick_days.json, HMAC audit trail.
Readers fail closed (unreadable pool = not sick, logged); writers refuse a
corrupt pool. Free-day cross-check via a lazily imported freedays predicate.

earned_time: resolve(..., sick=True) floors the final shutdown at 20:00
(capped at the ceiling); gaming, terms and base unchanged. Result dataclasses
moved to _resolution.py for the line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:23:00 +02:00
ba34b603a1 earned_time 0.10.0: tutor quota per weekday (15 min Tue-Thu, 30 Mon/Fri, 50 Sat/Sun for the full 60)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
From TUTOR_QUOTA_FROM (2026-10-12) credit_units scales the tutor's deduped
day minute sum to min(60, minutes * 60 // quota); reward and penalty are
unchanged, every quota <= 60 so the switch is raise-only. Earlier days pay
one unit per minute as before. WORKDAYS is a literal copy of freedays'
(importing freedays pulls crdt-sync; earned_time is stdlib only), guarded by
a drift test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E57caQF4KXwEvqEnKtJiHM
2026-10-12 21:07:03 +02:00
0ecdc4e4be crdt-sync: seed a session for the tutor worker
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:56:18 +02:00
e9bb43b8e8 earned_time 0.9.0: tutor earner renamed AUTOMATION_TUTOR/automation -> TUTOR/tutor, ledger ~/.local/share/tutor
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Rows carry no earner name, so only the ledger path moves; the tutor copies
its data dir (old rows included). Pre-cutover AUTOMATION (anki quota),
LADDER and the waiver are unchanged. Same minutes, cap, ladder, TUTOR_FROM
and confirmed_on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YADHKXzZhhePzQ2rNoXFcK
2026-10-12 16:31:50 +02:00
cd76eb0a95 flutter-setup: exact-key pub cache only (prefix restore-keys broke pub get after a git-dep bump)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:39:29 +02:00
21da7bcc90 earned_time 0.8.0 README: writer rows never span midnight; row cadence vs earner-bonus.path
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9cLaZkZaH7Zi2K63uWuoB
2026-10-10 15:49:56 +02:00
bce1f6d0cc earned_time 0.8.0: Automation tutor paid per active minute (legacy 15-min block rows count 15)
AUTOMATION_TUTOR: 1 gaming min per unit (minute), max_units 60; TUTOR_LADDER
automation Rung(1, extra=1), rungs still sum 250 (23:00 / 18:50).
credit_units sums each row's units (ROW_UNITS: tutor detail.minutes, a row
without it = 15); a repeated entry_id pays once at its smallest count; an
invalid minutes field is refused by tutor_match. An all-block day resolves
exactly as 0.7.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9cLaZkZaH7Zi2K63uWuoB
2026-10-10 15:47:15 +02:00
e376660ab0 earned_time 0.7.0: tutor confirmed (penalty from 2026-10-10), 15 min shutdown per block
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 14:59:27 +02:00
6d96c58f01 repo_contract: idempotent installer for new repos
teatr-pw and teatr-pw-pytania (created ad hoc 2026-10-09) had no shim, hook or
'## Commands' section: the gate was only ever rolled out by hand. The
installer writes shim + pre-commit hook + CI workflow and bootstraps the
Commands section and scripts/test_changed.sh, exiting 1 while <FILL IN>
remains.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjyHmRrjZssaxXDWcGuCeQ
2026-10-10 12:46:04 +02:00
3f7af48a2b file_length: exempt legatus's godot-cpp submodule as vendored
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CHHrXqgaxS9nWBwHmr3xoB
2026-10-10 10:17:03 +02:00
d5ba2360e4 vmbox: split test_vmbox.bats (429 lines) under the 250-line cap: shared fixture in test_helper.bash, verdict and shim tests in their own files
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:20:50 +02:00
96e52d6c2a crdt_sync_flutter 0.3.3: shared_preferences 2.5.6 pinned exactly, pubspec.lock tracked, released
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:12:43 +02:00
b164bf9592 ts_config 0.1.3: runtime deps on newest stable (pnpm 12.10.1, typescript-eslint 8.71.1, @eslint-react 5.24.10), released
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:11:49 +02:00
62ff982956 crdt_sync_dart, crdt_sync_flutter: track pubspec.lock (lockfiles are committed fleet-wide)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:09:08 +02:00
0323f36f56 pre-commit: vmbox ruff-pre-commit v0.17.0, crdt-sync mirrors-mypy v2.4.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:08:19 +02:00
d29c00d09d dopamine-ux 04: strike the weekly +Nh / _MILESTONE_INTERVAL shutdown bonus (mirrors screen-locker fc54534)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:07:44 +02:00
1b15430ad4 deps(npm): pnpm 12.10.1, typescript-eslint 8.71.1, @eslint-react 5.24.10, playwright 1.64.0, eslint-plugin-playwright 2.12.1
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:48:57 +02:00
cd2fb5ac12 deps(dart): test 1.33.0 (crdt_sync_dart), shared_preferences 2.5.6 (crdt_sync_flutter)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:44:29 +02:00
7e3921ad66 deps(python): ruff 0.17.0 + types-requests 2.33.0.20261006 (CPY001 stays active: every file has a header)
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:41:53 +02:00
5d8fe86d95 ruff: keep CPY001 active (every file carries a copyright header)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:33:41 +02:00
2d300815ea dep_freshness: per-repo opt-out from the CI workflow (.dep-freshness-no-workflow)
A repo with `.dep-freshness-no-workflow` at its root keeps the local pieces
(delegate script, pre-commit hook) but the installer no longer creates or
rewrites `.github/workflows/dependency-freshness.yml` there. teatr-pw
deleted its workflows on purpose (no GitHub Actions minutes on a private
repo; its pre-push hook runs `--all --strict`), and without this every
fleet sweep would put the workflow back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8tUQzqQmiidbg8ZXmeQAg
2026-10-10 08:26:10 +02:00
fd6c772eaf earned_time 0.6.1: tutor cutover 2026-10-10; new-gate rungs spare the shutdown floor
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
TUTOR_FROM = 2026-10-10 (the day after the deploy). The automation tutor
keeps confirmed_on=None, so it stays "new".

Ladder floor: a rung comes off the floor only for a pure bonus (no
penalty_from) or an earner whose penalty has started -- the same
_penalised() rule (first_credits -> penalty_start, confirmed_on fail-closed
fallback, penalty_from without first_credits) the gaming base uses. Before
this, a never-credited tutor would have lowered 2026-10-10's floor by its
full 50-minute rung (19:50 -> 19:00) even with first_credits. Maturity can
only raise time: new property test against the 0.6.0 floor formula.

ruff 0.17.0 (dependency-freshness): CPY001 ignored -- personal repo, no
copyright policy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
2026-10-09 22:11:35 +02:00
6f4f739ed7 earned_time 0.6.0: exempt private test helpers from name-tests-test
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
CI's earned_time pre-commit flagged tests/_maturity_rows.py: the local
--all-files run predated git tracking of the new files. Same exclude as
steam-backlog-enforcer's tests/_*.py helpers. No package code changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
2026-10-09 21:23:03 +02:00
a745d6bdf4 earned_time 0.6.0: gate maturity classifier (python -m earned_time maturity), confirmed_on, first_credits-aware penalty start
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
How far to trust a gate is computed from its signed ledger: new / maturing /
mature (>= 14 verified credit days, first >= 21 days old), with a read-only
CLI table. Only signed, non-manual, paying credits count; a gate's own
re-evaluation grant (detail.grant_of, book-guard bonus: rows) counts at
amount 0.

Earner.confirmed_on (hand-set) is new. penalty_start = max(penalty_from, day
after first real credit, day after confirmed_on); resolve/base_for take an
optional first_credits map and can only delay a penalty, never add one.
Omitted, every day resolves exactly as in 0.5.0.

Fail closed: a gate with confirmed_on and no credit on record (unreadable
key/ledger, deleted or emptied ledger) falls back to max(penalty_from, day
after confirmed_on), so deleting a ledger cannot lift a penalty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
2026-10-09 21:21:18 +02:00
6bfee3312f freedays 0.3.1: pin crdt-sync 0.9.2
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
freedays 0.3.0 pinned crdt-sync v0.9.1 exactly, so any consumer that
moved to crdt-sync 0.9.2 (screen-locker) could not resolve both in a
clean venv. No code change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 20:55:48 +02:00
32c8a54045 earned_time 0.5.0: pylint/no-noqa fixes (max-attributes 12 for the Earner record)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 20:55:02 +02:00
6641927dbe earned_time 0.5.0: per-day registry, counted AUTOMATION tutor earner (TUTOR_FROM sentinel), Anki/Automation gap waiver from 2026-10-09
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 20:53:31 +02:00
223b57eb87 crdt-sync 0.9.2: per-write temp file for the Firebase credential cache
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
**FileCredentialStore.save wrote every save through one fixed `<cache>.tmp`.**
Two processes share a credential cache (a long-running stream and a periodic
sync), so one could rename the other's half-written temp file into place and
leave a truncated refresh token behind. Each save now gets its own mkstemp
file in the same directory -- created 0600 from the outset, so the token is
never world-readable -- and replaces the cache atomically. A failed write
unlinks its temp file and leaves the previous cache untouched.

Tests cover: no temp file left after a save, distinct temp names per write,
and an interrupted write keeping the old credentials with no leaked temp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:05:10 +02:00
abda028b05 crdt-sync seed_session: seed daily_limits by default
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
~/src/daily-limits' user timer publishes today's limits for its phone app
and answers its requests, with a Firebase session of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcxgxMaQHMSGWzLRpNSkuP
2026-10-09 12:17:00 +02:00
df7330f015 earned_time 0.4.0: sleep ladder for shutdown from 2026-10-10, workout ledger, first_credit_at/credit_units
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
From LADDER_FROM (2026-10-10) shutdown is read down from sleep: ceiling =
WAKE_MINUTES (07:00) - 8h = 23:00, floor = ceiling - every earner's rung
(workout 110, LeetCode 50, reading 30, Anki 25, Automation 25) = 19:00.
A second workout earns nothing more. Earlier days resolve exactly as in
0.3.0 (frozen 23:00 ceiling); gaming is unchanged for every input.

WORKOUT gets a signed ledger (~/.local/share/workout_locker/ledger.json,
written by screen-locker) and workout_match; a rest_day row counts only if
declared before its day began. New API: first_credit_at, credit_units,
day_window, shutdown_minutes_for, extra_shutdown_minutes_for,
shutdown_ceiling_for, on_ladder, LADDER/Rung, LADDER_FROM, WAKE_MINUTES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG
2026-10-09 10:39:19 +02:00
7e453ae996 deps: pnpm 12.9.1, @eslint-react/eslint-plugin 5.24.2, jsdom 30.1.2 (freshness CI green)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 15:42:05 +02:00
e07c6a4606 earned_time 0.3.0: register the automation earner (30/30, penalty from 2026-10-06)
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 15:07:51 +02:00
957b4df642 earned_time 0.2.0: register the anki earner (30/30, penalty from 2026-10-06)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 13:51:44 +02:00
ac1aae4f7a earned_time 0.1.0: one penalty-then-reward registry for gaming and shutdown time
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:44:30 +02:00
4ccaba1bb3 github_device_auth 0.3.2: url_launcher 6.3.3 (consumers can bump)
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
github_device_auth tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 17:26:53 +02:00
4f7a7734ae repo_contract: prune .utils (CI checkout of the gate) from the has-tests probe
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 16:54:53 +02:00
5a8efc3f0a bump crdt-sync tools and url_launcher to newest stable
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Has been cancelled
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 16:29:10 +02:00
6c71d531bb repo contract + coverage_gaps gates, test_changed, dependency bumps
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 16:27:28 +02:00
ba6f8b09fd flutter-setup: slim cached Flutter SDK for gating CI jobs
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
subosito/flutter-action's cache is the whole SDK, 1.6 GB: ~38 s to restore
on every Flutter job even on a hit, which alone kept them over a minute.
This composite action caches a depth-1 clone of the release tag with only
the host and web artifacts (393 MB zstd'd); verified locally that test,
analyze --fatal-infos and build web run on it without lazy downloads.
Release APK builds keep subosito, which they need for the Android engines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 15:03:53 +02:00
60fba8a738 gatelock 0.9.0: morning_session, the one reader of wake-alarm's carrot
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
screen-locker and leetcode-guard each carried a near-identical copy of the
reader for wake-alarm's signed morning_session.json; book-guard is about to
need it too. The policy stays in wake-alarm -- this only answers "signed,
dated today, now < exempt_until". Path and retry budget are arguments so
each consumer's test redirect keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 13:59:33 +02:00
694c013b6b vmbox: UEFI sandboxes and 'vm wake' RTC-wake emulation for poweroff/hibernate; worktree copies skip gitignored files
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:29:54 +02:00
aeac9caaa1 freedays tests satisfy ruff FBT001/DTZ011; bump @types/node 26.6.2 -> 26.6.3 (newest stable gate)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:08:06 +02:00
4bd6bce0df freedays 0.3.0: WORKDAYS (Tue/Wed/Thu) as the one weekly-workday definition for every gate app
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:06:32 +02:00
0af237af3d Regenerate ts_core and web_ui lockfiles for vitest 5.0.2 / jsdom 30.1.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 16:54:43 +02:00
d2376fcaa6 Take today's stable bumps: pylint 4.0.9, ruff 0.16.9, vitest 5.0.2, jsdom 30.1.1, eslint-react 5.20.8, perfectionist 5.12.1, pnpm 12.6.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 16:50:22 +02:00
8ad5e75ab4 crdt-sync: seed a desktop session for firebase_backup (dufs-cloud's daily RTDB backup)
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 16:36:45 +02:00
a639416cf5 dep_freshness: blocker parsing breaks on scoped npm package names
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
blocker split blocked_by on the first "@", so "transitive:@scope/name@1.2.3"
read the package name as "" and left "scope/name@1.2.3" stuck to the
version -- the format check then rejected the entry outright. rpartition
splits on the last "@" instead, which is always the version separator
even when the package name carries its own leading "@".

Found adding a typescript-eslint entry (blocked by its own
@typescript-eslint/scope-manager not having published 8.70.1 yet) in
signal-bot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zg7ULrGyB1gMcKC1mkbLb
2026-09-24 13:42:28 +02:00
eeac2c71c8 Today's stable bumps across ts_config, ts_core and web_ui; pnpm 12.5.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 21:38:55 +02:00
6a79b48214 crdt_sync_flutter 0.3.2: the flutter_secure_storage 11.2.0 pin, released
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_flutter tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 20:43:18 +02:00
ff19b95aa7 gate_stamp: a 1-second pre-push receipt instead of re-running every suite
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:32:41 +02:00
1a8833eb9a check_no_binaries: one git call and no grep fork per file
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Two forks per path (`git ls-files --error-unmatch`, `grep -qiE <<<`) made
the gate a 5 s step on a 2053-file repo. The tracked set now comes from a
single `git ls-files -z -- "$@"` and the extension match is bash's own
`=~` on the lower-cased path (same case-insensitive semantics as `grep -i`).
0.2 s on the same tree; verdicts unchanged (blocked / allowlisted /
untracked-ignored checked on a throwaway repo).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P4KZw1E2sXJjYqfcZWf1x
2026-09-18 12:27:18 +02:00
3acf913b32 dep_freshness: pool every Maven repo; resolve each host once; retry EAI_AGAIN
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Three findings from proving the JitPack fix on the TachiyomiSY gate:

- The first repository with a stable answer used to win. Central still
  carries com.github.ben-manes:gradle-versions-plugin 0.11.1 (2016) while
  the plugin portal has 0.54.0, so a 0.51.0 pin graded "current". Every
  repository is now asked and the newest stable across the pooled lists is
  the answer.
- ~900 lookups, eight at a time, each resolving the registry host (A+AAAA)
  per connection: the LAN router's forwarder started dropping queries for
  every client on the LAN and the gate degraded itself. socket.getaddrinfo
  is memoised per (host, port, hints) for the run, and the per-host probe
  runs under a lock so eight workers make one probe, not eight.
- A resolver EAI_AGAIN ("temporary failure") during the probe marked the
  registry unreachable for the whole run. The probe retries it
  HTTP_ATTEMPTS times; a permanent failure still fails immediately.

Both retry loops lost their unreachable `return` sentinel (http.py is at
100% branch now); the resolver tests live in test_http_resolver.py to keep
test_http.py under the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P4KZw1E2sXJjYqfcZWf1x
2026-09-18 12:08:41 +02:00
580d6e9727 dep_freshness: answer JitPack artifacts from the source repo's tags
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Since ~2026-09-14 https://jitpack.io/.../maven-metadata.xml answers HTTP 401
"No access token" (artifact POM/AAR downloads still 200), which turned every
com.github.* coordinate into "undeterminable" (exit 3) in --strict CI.

JitPack is dropped from MAVEN_REPOS. A com.github.<owner>:<repo> name that no
HTTP repository knows is now answered from the GitHub repo's tags via
`git ls-remote --tags --refs` -- JitPack's version IS the tag name, so the
tags are compared as they are (v prefix included). The tag list goes through
the same stable-first selector as maven-metadata, so a newer pre-release tag
does not win. Not an allowlist entry, not a token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P4KZw1E2sXJjYqfcZWf1x
2026-09-18 11:47:47 +02:00
ba7ea0926f ts_config 0.1.2: React-layer carve-outs for JSX arrow returns and *Ref names, knip base trimmed, fromBase64 off
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:53:21 +02:00
8f5cd3c5cd dep_freshness: ruff import order + drop unused test imports
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:27:56 +02:00
8cfa96d0ed ts_config 0.1.1: alias-imports codemod rewrites ../ imports under src/ to @/
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:27:50 +02:00
6f8471196f ts_config: README names the link-vs-tag split for the two in-monorepo consumers
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 22:17:43 +02:00
64e7061084 ts_core: lockfile records the pnpm 12.4.2 pin
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Has been cancelled
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
The packageManager field was bumped after the last install, so CI's
--frozen-lockfile refused it (ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:59:01 +02:00
b28dfdeb60 gatelock: annotate a fixture param ruff 0.16.8 now flags; TS workflows dispatchable
The ruff bump surfaced one ANN001 in test_window.py. ts-core / web-ui /
ts-config workflows gain workflow_dispatch so a workflow-only change (like the
last commit, which touched no ts_core/** path and therefore never ran) can be
re-verified on demand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:53:47 +02:00
cb64e93b97 ci: install the linked preset's deps before linting ts_core/web_ui; deps green
ts_core and web_ui depend on `link:../ts_config`. pnpm links the directory
and installs nothing inside it, and a flat config imports its plugins from
the preset's own node_modules, so CI died on "Cannot find package
'@eslint/js'" while the local tree (where ts_config had been installed by
hand) passed. Both workflows now `pnpm install` in ts_config first, and
trigger on ts_config/** too so a preset change re-verifies its consumers.

The same push turned the dependency-freshness job red on five pins that had
aged underneath it (ruff 0.16.8 in four requirements.txt,
flutter_secure_storage 11.2.0 in crdt_sync_flutter); bumped, pubspec.lock is
ignored in that package so nothing else moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:50:13 +02:00
c5b3fceeae ts_core 0.1.1, web_ui 0.4.0: consume @kuhyx/ts-config
The two shared packages are the first repos on the preset (link:../ts_config
inside the monorepo; consumers outside use the git tag). Each eslint.config.js
is now the defineConfig() call plus one override with its reason: a public
API name (`nextChance`, `fuzzyMatch`) the boolean-prefix rule would rename,
which is a breaking change for the three repos importing it.

Everything else the preset flagged is fixed in code, not disabled:
key order (perfectionist), hex casing, sort compare functions in tests,
Iterator#map().toArray(), explicit returns on multiline arrows.

web_ui: react-refresh/only-export-components was a real finding --
range-slider.tsx exported `steppedValue` next to the component, which breaks
Vite HMR for the module. The keyboard contract moves to stepped-value.ts as
`steppedValue` + a new pure `keyTarget(key, values, current)`, which also
resolves unicorn/no-late-event-control: the handler now decides the target
first and calls preventDefault() only when it owns the key. Its tests move
with it (the 250-line gate caught the combined file). Minor bump for the new
export. dist/ rebuilt in both packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:46:41 +02:00
0f9699d4f3 ts_config: project option, entry-point barrel and className carve-outs
Surfaced by making ts_core and web_ui the first consumers:

- base({ project }) uses an explicit tsconfig list instead of the project
  service. Both packages exclude tests from tsconfig.json so they never land
  in dist/, and widen the include in tsconfig.lint.json for lint only; the
  project service would have reported every test file as "not found".
- unicorn/no-barrel-files is off for src/index.ts: a package entry point IS
  a barrel, that is its job. Stays on everywhere else.
- unicorn/no-keyword-prefix is off in the React layer: `className` is
  React's prop name and fires on every styled element.
- pnpm-workspace.yaml: allowBuilds for unrs-resolver set to false explicitly
  (pnpm 12 had written a placeholder); the resolver's prebuilt binding is
  used, no install script runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:45:59 +02:00
128ede438e ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends
Evil Martians' "ten anti-AI-slop moves" as a package: strict tsconfig base
(noUncheckedIndexedAccess, exactOptionalPropertyTypes), type-checked
typescript-eslint strict+stylistic, unicorn/sonarjs/perfectionist, behaviour
rules for tests (expect-expect, no-focused-tests), React (hooks, @eslint-react,
jsx-a11y strict, you-might-not-need-an-effect), Playwright, and layer
boundaries via eslint-plugin-boundaries 7 with the article's default policies.
Knip / jscpd / Stryker bases; Stryker carries no break threshold on purpose.

Plugins are dependencies, not devDependencies: a flat config imports them from
this package's own node_modules, so a consumer installing from the git tag
gets them or the lint cannot run. No build step, so the tag needs no prepare.

test/fixture is a mini repo with one planted violation per rule family;
test/expected.js names the rule that must catch each, and the suite runs the
real CLI over it (in a child process -- v8 coverage merges natively-loaded
and transformed copies of the same module into "uncovered"). 35 tests, 100%
branch coverage on the factories.

eslint/fast.js is the type-free subset for a per-edit hook: 0.52 s cold on
one file, measured against 1.28 s for base() without types -- importing a
plugin is the cost, so it loads none, nor base.js which imports them.

Probed under ESLint 10: jsx-a11y 6.10.2 (peer says <=9) and boundaries 7.2.0
both run; the fixture pins that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:40:08 +02:00
d531b733b8 vmbox: single-stream pacman downloads, slirp stalls concurrent TCP
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
QEMU user-mode networking (libslirp 4.9.4 / qemu 11.1.1) freezes 2-4 of 5
concurrent 60 MB streams at ~10 MB until they time out, while one stream
does 45 MB/s. pacman's ParallelDownloads = 5 hit this on every guest
upgrade: a 90-package -Syu crawled at "500 KB/s" for minutes and then
finished in 12 s once set to 1. Window scaling and rmem do not help; a
tap/bridge NIC would but needs root, which vmbox avoids by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
2026-09-14 22:14:42 +02:00
78e92a7565 dep_freshness: retry a slow registry read with an escalating timeout before calling it offline
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 11:46:18 +02:00
00bb6706fd vmbox: vm lightdm and vm shim — the host's login topology and fake hardware
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
The golden image logs in with agetty+startx and has no RGB controllers,
no NVIDIA card and no sound card, so the night-lockdown scripts — which
mask/stop lightdm.service and getty@.service and shell out to openrgb,
nvidia-smi and amixer — had nothing real to act on in a sandbox and
were being "verified" on the host instead.

- `vm lightdm <name>`: installs lightdm + lightdm-gtk-greeter into the
  live overlay (pacman -Syu, not -S: the overlay's package database is
  the image's and mirrors have rotated those versions out), configures
  autologin -> i3 exactly like the host, turns tty1 back into a plain
  getty, reboots the guest and proves lightdm.service is active with an
  Xorg it owns. Never rebuilds the base: `vm build --force` re-seals a
  new checksum while every existing sandbox still backs onto the old
  file's name, which is silent qcow2 corruption.
- `vm shim <name> <tool> [exit[:N]|hang|sleep:S]`: a fake tool ahead of
  the real one on PATH, every call logged with an epoch to
  /var/log/vmbox-shim/<tool>.log so the host can assert what ran and in
  what order. `hang` is the openrgb 1.0-2 that never returned on
  2026-09-12 and defeated the curfew.
- The DISPLAY/XAUTHORITY resolver `vm run` sources becomes its own file,
  guest/vmbox-x11.sh, installed by the image build and re-installed by
  the lightdm switch; it now tests readability, not existence, and tries
  ~/.Xauthority before the startx serverauth glob, because lightdm's
  Xorg -auth file is root-only.
- README: the worked example uses MIDNIGHT_SHUTDOWN_CONFIRM=y (the
  installer refuses to run unattended now; `echo y |` no longer works),
  and the "i2c RGB stays on the host" line is gone.
- bats: four host-side tests for shim.sh (exit, exit:N, hang, refusals).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mLnUeoJ3mUwGRTowTUH6d
2026-09-13 10:51:10 +02:00
96f3bb5326 ts_core + web_ui: @types/node 26.5.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Dependency-freshness CI went red on the gatelock 0.8.2 push because
@types/node moved under it; bump both pins and lockfiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxkZDHDmoVSxUT4w72xSiE
2026-09-13 10:30:49 +02:00
949272778c gatelock 0.8.2: wait_for_x_server waits for as long as it takes
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
The 60 s deadline was the 2026-09-13 leetcode-guard miss with a smaller
number: a Persistent=true catch-up starts one second into a boot, before X
exists, and a bounded wait that expires exits the unit while systemd has
already stamped the slot as fired. No X server means the user cannot use
the machine either, so an unbounded wait is not a bypass. The wait now
re-states itself at WARNING every five minutes so a long wait never reads
as a healthy, silent unit. `timeout_s` stays as an opt-in for callers that
want a bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxkZDHDmoVSxUT4w72xSiE
2026-09-13 10:19:52 +02:00
4d00d6ccde dep_freshness: upstream: allowlist predicate for forks; Google API clients judged inside their generation
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 20:32:34 +02:00
94f6873148 dep_freshness: a commit pin is current at any branch head, not only the default one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 20:25:56 +02:00
930888bc15 dep_freshness: gitcommit ecosystem, no-stable fallback, JitPack repo; report unknowns beside stale
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 20:21:34 +02:00
07e191be7b Repair the two CI signals gatelock 0.8.x broke, and drop its finished pylint TODO
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
The palette-drift check parsed LockConfig's colour defaults; they live
on LockPalette in _theme.py since 0.8.0, so it read zero tokens and
failed closed as designed. The two new LockWindow handles had no test
reaching them, which CI's 100% bar caught and the local run did not.

gatelock/prompts/TODO-pylint-to-ten.md described this job with the
older design (raise the score, leave the gate); the gate is at 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 20:13:53 +02:00
0cb575d15f gatelock 0.8.1: public recovery/detector handles; declarative types import from _config
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
leetcode-guard's study mode has to stop the re-assertion loop and the
output-change watcher before it releases the grab, and could only reach
them as LockWindow._recovery / _detector -- attributes 0.8.0 folded into
the arming bundle, which broke that documented private dependency.
They are public read-only properties now, next to `surfaces`.

_window no longer re-exports the LockConfig family; it was the one
thing keeping it over the 250-line cap, and every importer now takes
those names from gatelock._config (or the package) directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:41:13 +02:00
68ec316b28 gatelock 0.8.0: LockConfig as behaviour + GrabPolicy + theme records; pylint at 10
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
LockConfig was one 35-field record: the lock's behaviour, the grab
policy, nine colours, a type scale and a spacing scale, flat. It is
now the seven behavioural knobs plus four nested records --
GrabPolicy(kind, disable_vt, retry_ms, log_every, preempt_weaker_holder),
LockPalette, LockTypography and LockSpacing -- each overridden as a
unit. Breaking for every constructor that passed grab=/disable_vt=/
grab_retry_ms=/preempt_weaker_holder= or a colour, and for every read
of config.<colour>; hence the minor bump. screen-locker, leetcode-guard
and diet-guard follow in their own commits.

The two fail-open guards in the recovery loop carry per-line
suppressions: catching everything is the property they exist for, and
the check stays live for the next careless except. That was the last
finding, so the gate now says 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:36:31 +02:00
8128851e0b freedays: pylint --fail-under=10, scoped to source, on pylint 4.0.8
The three import-error findings were the hook not having crdt-sync in
its own venv; it is now pinned there as in pyproject.toml. The one
source finding is the background sync's documented fail-open net, which
must catch everything by design, so it carries the one suppression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:32:20 +02:00
1723df3885 gatelock: scope pylint to source on 4.0.8 and clear the residue it grades
Same config move as crdt-sync (ignore-paths, init-hook, the three
implicit-booleaness disables the reference repos carry). The gate stays
at 8.0 for now: LockConfig's 35-field record and the two fail-open
guards in the recovery loop are still open.

Everything else pylint had left on source is gone. The optional
python-xlib dependency is loaded through one importlib helper instead
of two function-scope import statements, keeping the "not installed"
arm reachable for branch coverage; RecoveryLoop and LockWindow hold
their collaborator bundles rather than unpacking them into a second
set of attributes; the claim types are imported from _claims, where
they are defined, so _arbiter no longer needs an export list that
mirrored the package's; runtime imports sit above TYPE_CHECKING
blocks; the RandR Protocol stubs say which Xlib call they mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:15:39 +02:00
8274a3e918 dep_freshness: read every *.versions.toml catalog, not only libs
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
settings.gradle.kts can declare any number of catalogs via from(files());
TachiyomiSY has mihon.versions.toml and sy.versions.toml beside libs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
2026-09-12 19:15:04 +02:00
a4ea305164 dep_freshness: Gradle ecosystem -- version catalogs, wrapper, Maven registries
The gate had no way to read a Gradle build, which left the two Kotlin
forks (tachiyomisy, syncyomi-src) outside the fleet-wide rule.

- parsers/gradle.py reads gradle/libs.versions.toml ([libraries] and
  [plugins] in every shape: inline string, module + version.ref,
  group/name/version, rich {strictly|require|prefer} tables) into
  `maven:<group>:<artifact>` deps, pointing the fix at the [versions]
  line when a ref is used; BOM-managed entries without a version are
  skipped. gradle-wrapper.properties becomes `toolchain:gradle`.
- registries/maven.py asks Google Maven, Central and the plugin portal
  in that order, reading the full <versions> list -- AndroidX writes
  alphas into <latest>. Plugins resolve through their marker artifact.
- registries/toolchain.gradle_latest reads services.gradle.org.
- http.get_text shares the transport with get_json (XML bodies).
- Templates: the pre-commit `files:` and the CI cache key know the
  catalog. 100% branch on every new file; fixtures only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
2026-09-12 19:10:42 +02:00
3b5c247f05 music_theory: pylint --fail-under=10, scoped to source, on pylint 4.0.8
All ten import-error findings were pylint failing to find the package
from the monorepo root; the init-hook puts the package dir on the path
the way pytest already does, and the tests-scope ignore-paths leaves
pylint grading source only (ruff keeps the tests). That alone is 10.00,
so the gate can say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:08:23 +02:00
c295db7a15 crdt-sync: pylint --fail-under=10, scoped to source, on pylint 4.0.8
The 8.0 bar tolerated two points of debt and let the score regress
unnoticed. Adopting the fleet's source-only pylint scope needed
ignore-paths, not ignore = ["tests"]: ignore matches only the basename
of what pylint is handed, and pre-commit hands it explicit file paths,
so a directory name there never fired under the hook.

What the scope change left in source: runtime imports moved above the
TYPE_CHECKING blocks, the consent-flow CLI plumbing shared between
google_id_token and seed_session instead of duplicated, the token
exchange split out of google_id_token to stay under the 250-line cap,
and http.server's do_<METHOD> names admitted by method-rgx since the
stdlib dispatches on that exact spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:06:30 +02:00
ee276cd8e5 file_length: match repo exemptions without PurePath.full_match (3.13-only)
The TachiyomiSY fork's first CI run died with AttributeError on the
runner's Python 3.12. Globs now go through a small tested translation
(** spans directories, * and ? stop at a slash) that runs on any 3.x.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
2026-09-12 19:04:36 +02:00
879265b966 file_length: repo-local .file-length-exempt list, fail-closed, package tested to 100%
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A fork of a third-party project can carry data that is code only by
extension (TachiyomiSY's 6000-line exh/eh/tags/*.kt string tables). The
reason belongs in that repo next to the data, so the gate now reads a
.file-length-exempt of '<glob>  # <reason>' lines from its cwd. A glob
without a reason is a suppression and exits 2 instead of passing.

The package had no tests at all; it now has a suite at 100% line+branch,
wired into shared-gates-tests.yml with --cov-fail-under=100.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
2026-09-12 18:53:19 +02:00
db69c7251b Add a session prompt to raise pylint --fail-under from 8.0 to 10 fleet-wide
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A --fail-under=8.0 gate tolerates two points of lint debt permanently and
lets a score regress from 9.9 to 8.1 without failing anything.

Surveyed every repo under ~/src and ~/praca_magisterska: ten pylint hooks
across six repos still sit at 8.0, while five sites already run at 10 --
so this adopts an existing standard rather than inventing one. The prompt
carries the exact file:line of all ten, including the second pylint hook in
screen-locker that a naive sweep would miss.

Measured the sizing rather than guessing at it, because the raw numbers are
misleading. These configs run enable = "all" with disable = [], so the
findings are dominated by test-file idioms -- 147 redefined-outer-name in
gatelock is pytest fixtures, not defects. The repos already at 10 resolved
this and documented why: pylint grades source, ruff grades tests, via a
`tests` basename ignore. Applying that reference config plus the existing
init-hook takes the four utils packages from 8.54-9.59 to 9.42-9.93, leaving
~47 genuine source findings instead of ~600 -- and music_theory's 10
import-errors look like the missing init-hook rather than code.

The done-condition is two commands with exit codes, verified to report
exactly the ten outstanding sites today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:37:33 +02:00
8a556f8d7d Cache .mypy_cache so mypy stops re-analysing the stdlib every run
Profiled the 23s hook step per hook. It is not evenly spread: mypy ~10s and
pylint ~9s are the whole of it, and the remaining 21 hooks share ~4s. Local
timings with a warm cache put mypy at 0.25s against 5.7s cold, so nearly all
of its CI cost is re-analysing the stdlib and third-party stubs from scratch
into a 19MB cache dir that was then thrown away.

Keyed on the config rather than on the sources. The mypy cache metadata
already records the interpreter, the flag set and a hash per source file, so
mypy re-checks whatever actually changed and a restored cache cannot let
stale code pass. The config hash is in the key regardless, so bumping the
mypy rev or editing its args busts it.

Deliberately not keyed on github.sha, which is the other common pattern:
that re-saves 19MB x3 on every commit and would evict the 68MB hook caches
under the 10GB LRU budget for the repo. A fixed key keeps the dependency
analysis, which is the part that costs the 10s.

pylint needs no change -- it already runs --jobs=4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:28:58 +02:00
a473e91485 Stop installing linters the pre-commit jobs never use, and add cache fallbacks
Two follow-ups measured on the warm-cache run of d56b936, where the three
pre-commit jobs were 39-44s split as: 4s runner setup, 9s pip, 2s cache
restore, 23s hooks.

The 9s pip was pure waste. Every linting hook runs in its own isolated env
-- ruff, mypy, pylint, bandit and codespell each come from their own
repo+rev, mypy and pylint declaring their own additional_dependencies -- and
the only local hooks in the pre-commit stage need bash and python3. The
pytest-coverage hook, the one thing that would need the real dependency set,
is stages: [pre-push] and never runs here. Verified by running all three
configs green in a venv containing pre-commit and nothing else. The pin is
grepped out of requirements.txt so it cannot drift from the
dependency-freshness gate.

restore-keys is added because the exact key includes the config hash, so the
next hook-rev bump -- this session did one -- would have been a total miss
and reinstalled everything. A prefix hit rebuilds only the changed hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:22:39 +02:00
d56b936b48 Cache pre-commit hooks per package, and pip, so CI stops reinstalling
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Measured on 6154dc0: the three pre-commit jobs took 41-59s, of which 38-49s
was pre-commit installing hook environments and ~11s was an uncached pip.

The cache was not merely cold, it was miskeyed. pre-commit/action@v3.0.1
keys on hashFiles('.pre-commit-config.yaml') -- the repo ROOT's config --
but the hooks it installs come from the per-package config passed in
extra_args. Four different configs, one cache key: the three package jobs
raced to overwrite each other's hook sets, and editing a per-package config
never invalidated the key, so the restored cache was silently wrong rather
than missing. Three 68MB entries in the cache list are the symptom.

So the action is replaced with a direct `pre-commit run`, which keeps the
same --config/--all-files behaviour plus --show-diff-on-failure, wrapped in
an actions/cache keyed on the config that actually drives the install.
pre-commit is already an exact pin in each requirements.txt.

setup-python gains cache: pip keyed on each requirements.txt, for the test
jobs too.

The two crdt-sync workflows now list their own file in `paths`, which the
other four already did -- a workflow edit should re-run its own gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:16:51 +02:00
6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00
561714e8ad Drop the stray blank line ruff's isort rejects in seed_session.py
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_core tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
design_system tests / test (push) Has been cancelled
github_device_auth tests / test (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
Latent drift, not a regression: the crdt-sync pre-commit workflow is path
filtered on crdt-sync/**, so it had not run since the file was written. The
requirements bump in 2c195cd triggered it for the first time and it failed
on I001 immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 17:56:21 +02:00
c952cc13ef Move ts_core and web_ui to vitest 5, React 19.3 and pnpm 12
Clears the last of the dependency-freshness backlog, so the gate exits 0
with only the pre-existing npm:typescript exception.

vitest 5's text reporter no longer prints a per-file table; the summary and
coverage-final.json still carry the real numbers, and both packages hold
their 100% thresholds.

CI's pnpm/action-setup moves 10 -> 12 to match the regenerated
packageManager field, rather than relying on pnpm 10 self-switching to
12.4.1 at install time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 17:52:30 +02:00
be4269ab79 Move the Dart packages to very_good_analysis 11
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 17:44:34 +02:00
2c195cda4d Bump node, ruff, types-requests and dart test to latest stable
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 17:42:29 +02:00
2f7f609754 Add two session prompts to clear the dependency-freshness backlog
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 17:31:01 +02:00
675b3859b3 Match dated backups by shape, not by hardcoded name
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 17:12:02 +02:00
b42737f834 Gate home-relative paths against the ~ layout
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 17:10:20 +02:00
001670fcbc Re-point paths for the ~ reorganisation (home-tidy)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 19:07:23 +02:00
7096c102cd Re-point paths for the ~ reorganisation (home-tidy)
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
music_theory tests / test (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 18:45:55 +02:00
dc6e47ca1b music_theory: README paths follow the ~/src move
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VC17zymxkeiUcHrMvt8W5W
2026-09-11 18:38:43 +02:00
d5b3f2cff3 music_theory: music theory from first principles, as code you can hear
New subproject porting Luke Haas's "Music Theory for Programmers" section
by section into typed Python: pitch (MIDI <-> Hz), tuning (harmonic
series, the Pythagorean comma, brute-forced "why twelve"), scales as step
arrays with modes as rotations, chords stacked in thirds with Roman
numerals and the V7 -> I resolution, named progressions, a numpy synth
(waveforms, ADSR, low-pass), a bar-exact sequencer, and a seeded Recipe ->
WAV composer. `degrade` is the same piece ruined by a 0..1 amount, so an
ending can be the opening destroyed on the same seed; filtering runs LAST
because filtering first measured brighter than the clean render.

17 demos (`python -m music_theory demo all`) each print the article's
numbers -- the comma is 1.013643, the tempered fifth is 0.113 % flat,
twelve is the first acceptable division -- and the FFT of the render, so
the claims are tested rather than asserted. 157 tests, 100 % branch
coverage, ruff ALL, mypy strict, pylint 10.

Consumed next by roadside-assistance for its three world-state beds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VC17zymxkeiUcHrMvt8W5W
2026-09-11 18:29:13 +02:00
94407268f0 refresh_oauth_secret: take the secret from the clipboard when there is no terminal
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Run through Claude Code's `!` there is no stdin to paste into, and the
console dialog's copy button has already put the value on the clipboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 14:19:19 +02:00
e56aec431c crdt-sync: script the one human step when the Web client secret rotates
seed_session failed with invalid_client on 2026-09-11: the secret in
~/.config/crdt-sync/oauth_client_secret had worked on 2026-08-12 with the
same client id, so it was rotated in the console since. There is no API
for it (no gcloud command, none in the Firebase CLI), so the irreducible
step is copying it from the console page. refresh_oauth_secret.sh opens
that page, reads the paste without echo, rejects anything that is not a
GOCSPX- secret before writing, and runs the seed at once so a wrong paste
fails here rather than on the next timer tick.

xdg-open is detached from stdin: the spawned browser inherited the
terminal and swallowed the paste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 14:10:17 +02:00
5446f4cdd0 allowlist: typescript hold now names typescript-eslint 8.70.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
8.70.0 is the newest release and still declares typescript <6.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5hXm7J6MijYKxhkNFXqiV
2026-09-11 13:48:01 +02:00
7445768ec8 freedays: point at the phone picker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 13:45:59 +02:00
bb0f773b7d Make sync_quietly's "never fail" contract true by construction
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
sync_quietly exists so a background timer stays green when the pool
cannot be synced. It caught only SyncUnavailableError, which meant the
contract held only because of an upstream class hierarchy: today
FirebaseAuthError happens to subclass RemoteSyncError, and requests'
JSONDecodeError happens to subclass RequestException, so an expired
refresh token or a malformed payload does get wrapped and caught.

That is a coincidence, not a promise to this package -- and it is exactly
the kind that goes quiet the day upstream reorganises its exceptions. The
symptom would be a unit going red every 15 minutes, which is the outcome
this function's own docstring says the design exists to prevent.

It now also catches anything unexpected and logs it with a traceback, so
a real bug is loud in the journal while the timer stays green. The
narrow handler stays first and stays at debug level: offline is normal
on a laptop and must not fill the journal.

Two tests, neither of which the old suite could have failed on -- the
existing one monkeypatched get_client to raise the one exception already
known to be handled. One raises FirebaseAuthError *mid-sync* (a token
expiring during the tick rather than at client construction, which is the
realistic case per seed_session.py's own docstring), the other raises a
RuntimeError that no hierarchy covers.

111 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:06:11 +02:00
aa032c95e4 Drop the gatelock dependency, and actually sync the pool
Two things the pool needed to be real rather than merely correct.

**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:

    ImportError: libtk8.6.so: cannot open shared object file

A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.

The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.

This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.

**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.

install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.

Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.

seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.

freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:56:42 +02:00
ef69619597 Add freedays: one shared pool of days when every gate stands down
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Five apps (diet-guard, wake-alarm, screen-locker, leetcode-guard,
home-guard) each enforce something daily, and none of them had a way to
say "not today". screen-locker was the only one with any notion of a day
off, and it had two overlapping ones: a budgeted sick day designed to be
hard to take, and an undocumented scheduled_skips.json that no other app
could see.

This is the second thing generalised, not the first. Free days are
pre-declared, carry no justification, allow consecutive runs, and are
capped only by an annual budget -- 35 per calendar year, resetting Jan 1.
Sick days stay in screen-locker exactly as they are: their rolling
windows (1/7d, 3/30d, 10/90d) exist precisely to forbid the consecutive
runs a holiday is made of, so merging the two policies would have had to
ruin one of them.

One global pool: a free day is free for every app at once, so there is
nothing per-app to mark and nothing per-app to forget.

The integration is one call, `freedays.is_free_day()`, which reads a
single local JSON file and never the network -- a gate fires from a
systemd timer and a hung sync must not be able to hold one open or shut.
It returns False for anything it cannot parse, so an unreadable pool
means the normal rules apply rather than everything switching off.

Two design points worth keeping:

- Un-marking flips a `state` field rather than tombstoning the record.
  crdt-sync deletes are monotonic, so a tombstoned date could never be
  marked free again.
- `consumed` is a sticky flag. Releasing a day that has not arrived
  refunds it; releasing today does not, because those hours were already
  unguarded. It is OR-ed against the stored value on every write so two
  devices either side of midnight cannot hand a spent day back.

Timezone is local, decided in _day.today() and nowhere else. The apps
disagreed about this before: screen-locker's lock chain computes the day
in UTC while add_scheduled_skip.py uses local time, so a skip added near
midnight could name a date the chain never matched.

94 tests, 100% branch coverage, no suppressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:08:22 +02:00
92d7151bdf dep_freshness: accept Cargo's =x.y.z as the exact pin it is
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Cargo's bare `"0.19.1"` is a caret range; its exact operator is a single
`=`. The shared EXACT regex only knew pip's `==`, so the one form that is
actually an exact pin in Cargo was reported as "not exact-pinned" against
itself (platformer/proto-bevy, 2026-09-06). Strip the operator in the Rust
parser; the constraint string keeps it so the report shows what is written.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 18:07:10 +02:00
347d31ce4f seed_session: home_guard is a desktop Firebase consumer too
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
The fifth gatelock sibling (github.com/kuhyx/home-guard) calls
firebase_client_for("home_guard") from its systemd-run gate, so it needs
~/.config/home_guard/firebase_auth.json seeded like every other daemon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-06 17:09:46 +02:00
cb10b98d9b chore: the typescript hold names typescript-eslint 8.69.0, the release it waits on now
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
8.69.0 still declares `typescript: ">=4.8.4 <6.1.0"`; only the version in
the reason moves, so the text the gate prints on every run stays true.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 09:48:28 +02:00
26ae580e01 feat: share the no-binaries gate, promoted out of testsAndMisc
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Every repo should give the same answer to "may this file be committed?",
and until now only testsAndMisc had a checker. signal-bot is public, had
three binary SQLite files in its history before anyone noticed, and
holds a private message database one `git add -f` away from the same
fate -- so .gitignore is evidently not the whole defence.

Two changes from the testsAndMisc original: sqlite3-wal and sqlite3-shm
are blocked by name, because a live SQLite database is three files and
blocking only the first blocks nothing useful; and the error message no
longer names a testsAndMisc-specific directory to move things to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:32:25 +02:00
75f4374657 Keep wait_for_turn under the lint limits, and cover its new paths
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:06:47 +02:00
109577e646 Make a queued gatelock wait observable while it is happening
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:02:23 +02:00
305e59b3e8 Grade crdt-sync and gatelock against 3.14 too, not just run on them
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Both libraries had already moved `requires-python` to 3.14, but ruff, mypy and
pylint were still grading against 3.10 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), a dead `timezone` import left behind by it, and ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That last one makes the source 3.14-only at the syntax level, which is what
`requires-python = ">=3.14"` already promised; kuhy chose it deliberately over
holding ruff a release back (2026-08-29).

These two are libraries the apps install, so the floor matters more here than
anywhere else in the fleet -- every consumer already pins >=3.14.

Per project: ruff, ruff-format, mypy --strict and pylint clean, crdt-sync 432
tests and gatelock 389 tests, both at 100% line and branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:47:21 +02:00
b914cc87fb Let the gate bootstrap itself inside a uv venv, which has no pip
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:09:47 +02:00
3565b281ca One Python per repo, always the newest, and the gate that enforces it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 20:58:19 +02:00
5fc792d791 file_length: TRAK/sightpy is vendored, not coursework
WUT_Computer_Science/Programming/TRAK/sightpy is the ray tracer from
rafael-fuente/Python-Raytracer (BSD 3-Clause). TRAK's assignment was photon
mapping built on top of it.

Verified against a fresh upstream clone with line endings normalised (the
copy in the archive is LF, upstream ships CRLF): 30 of 32 .py files are
byte-identical; sightpy/ray.py and sightpy/scene.py carry a 12- and 13-line
local patch that adds ray counters for the report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 19:33:14 +02:00
1e35cd5ba2 Never cache the Python target: it describes this process, not a registry
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 19:33:09 +02:00
a59fa266c9 Give the gate its own runtime deps when a repo pins a bare interpreter
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:44:42 +02:00
9c5e7632fb Keep the gate workflow prettier-clean, so repos that lint YAML accept it
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:38:54 +02:00
a4a3e0a8e3 file_length: declare WUT_Computer_Science vendored subtrees
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
The coursework archive carries third-party content that the cap would
otherwise demand be split: the ASP.NET scaffolder's jQuery/Bootstrap dist
bundle (30,875 lines of JS, 43,027 of CSS -- ~98% of all JS and CSS in that
repo), stock Unity package/template assets, manufacturer-supplied Altium and
PADS component libraries, and the EOPSY course simulators.

The EOPSY entries are the load-bearing ones, because "provided by the
department" is normally a judgement call. It is not here: work/ is the
extraction target of `make setup`, and extracting the committed tarballs and
diffing shows 14/14 java files byte-identical to the originals --
ControlPanel.java (1852 lines) and Kernel.java (511) included. Nothing under
work/ is student-written, so splitting it would diverge the source from the
assignment it shipped with.

A data change to the existing extension point, so md_naming inherits it via
the shared file_length._tables import. The authoritative per-entry reasons
live in gates/vendored.txt in that repo; this tuple mirrors it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 17:30:14 +02:00
23f4322197 Refuse to overwrite utils' own checker with the delegate shim
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:02:34 +02:00
570f168a78 Run the gate workflow on the newest Python, not a pinned minor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:00:40 +02:00
d6b92cfd18 Installer teaches an existing pre-commit workflow where the shared gate lives
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:51:00 +02:00
da7fa911b2 An exception excuses the version it names, not the package
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
`pinned:` is a required field precisely because an entry is an argument about
ONE version. Matching on the package name alone turns a narrow, reasoned hold
into a blanket exemption -- the fleet-wide typescript entry pins 6.0.3 for a
reason specific to 6.0.3, and dufs-cloud/web sat on `~5.8.3`, two majors
older for no stated reason, with the gate reporting it current.

An entry that exists but does not cover the pin in front of it still counts
as USED for the rot check. The hold is real; this repo is simply not on the
held version, which is a stale pin to fix rather than an allowlist line to
delete.

322 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 15:26:12 +02:00
98213cbc40 Check the toolchain versions CI actually installs, not just the ones declared
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
`.fvmrc` is a declaration nothing in this fleet reads. `subosito/flutter-action`
takes its own `flutter-version:`, and that pin is what the runner installs, so
the two drift in silence and the failure mode is the worst kind: green
locally, red in CI, with no diff to explain it.

kuhylog is the live case. Its runner is on Flutter **3.44.9**, which predates
the `@awaitNotRequired` annotation, so `unawaited(Navigator.push(...))` is
`unnecessary_unawaited` on 3.47.2 here and `discarded_futures` there -- the two
SDKs demand opposite code and CI went red on a commit that is clean locally.
The fleet is spread across 3.44.6, 3.44.8, 3.47.0 and 3.47.1 with no one
tracking it.

So a workflow's `flutter-version:` / `node-version:` is now a dependency like
any other, judged against the same latest stable. That needs no separate
"these must agree" rule: two values both required to equal latest already
agree. A channel name and a `${{ }}` expression are skipped -- one is not a
version, the other resolves at run time and guessing is worse than silence.

Found real drift on its first run: both TypeScript workflows here pinned
`node-version: 24`, a floating major that lets two runs of the same commit
install different Node. Now 24.20.0.

321 tests, 100% branch coverage on every production module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 15:04:00 +02:00
7a682a46f3 See git-tag pins in Python requirements; cut crdt-sync 0.9.1, gatelock 0.7.2
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
**The gate could not see the shared Python libraries at all.** They are
consumed as PEP 508 direct references --
`crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync`
-- which parsed as an unpinned PyPI package that does not exist on PyPI. That
answer is undeterminable, not stale, so wake-alarm sat on gatelock-v0.4.1
against v0.7.1 and crdt-sync-v0.5.1 against v0.9.0 with a green gate. They are
git-tag dependencies now, judged the same way the Dart libraries are.

A ref that is a branch or a SHA still parses as before: there is no version
in it to compare.

The line is found textually when the key indexer cannot help. A TOML
dependency array holds bare strings with no `key =`, so these findings would
otherwise report line 0 and make the fix a search.

Both libraries also had their dependency upgrades land after their newest tag
(81d2493), so anything moving to "latest tag" would have taken the pre-upgrade
constraints. Cut 0.9.1 and 0.7.2; version bump only.

315 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 14:52:19 +02:00
2e1f3da6cf Point a pubspec finding at the dependency, not the config block of the same name
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Every Flutter app here declares `flutter_launcher_icons` twice: as a dev
dependency, and as a TOP-LEVEL configuration block for the same tool. The line
lookup broke ties by indentation alone, so the top-level block won and the
gate told you to fix a line that carries no version at all -- lyricanki
reported `pubspec.yaml:64` while the caret being complained about is at 48.

The pubspec parser now says which top-level keys may hold a version, and a key
found outside them is only a fallback. `environment` is in that list on
purpose: without it the `sdk:` inside a `flutter: {sdk: flutter}` block would
outrank the SDK constraint it is meant to find.

Only the pubspec parser passes the hint. JSON and TOML indent their section
headers differently, so they keep the old behaviour unchanged.

310 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 14:33:11 +02:00
49ce0b250c Put the hook in the last repo: local block, not at end of file
Most of these configs interleave local and remote hook repos -- diet-guard,
screen-locker and wake-alarm carry nine `- repo:` blocks each. Appending at
end of file lands the hook under whichever block happens to be last, and a
local hook definition handed to a remote repo fails with an error naming an
unknown id rather than the real problem. diet-guard's last block happens to be
`local`, so this would have worked by luck there and broken elsewhere.

A config with no local block now refuses rather than guessing.

308 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 14:21:31 +02:00
71a43d9658 Script the gate rollout instead of hand-copying it into sixteen repos
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
design_system tests / test (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
The gate is three files per repo -- a delegate, a pre-commit hook entry and a
CI workflow -- and Phase 3 needs them in sixteen more repos. Hand-replication
is precisely how the 250-line cap ended up shipping under four different hook
ids, two of which were independent reimplementations rather than delegates, so
the copy is now a script and the templates have exactly one home.

  scripts/install_dependency_freshness_gate.sh <repo> [--check] [--fvm]

Idempotent, and it rewrites a copy that has drifted from the template rather
than leaving it. `--check` reports without writing. `--fvm` pins the Flutter
SDK, and only in a repo that actually holds a pubspec.yaml -- an .fvmrc in a
Python repo declares a toolchain nothing there builds with. An unresolvable
SDK version leaves the pin alone and exits 1: guessing would pin every repo to
a number nobody chose.

The pre-commit block is APPENDED rather than parsed and re-emitted. Every one
of these configs carries comments explaining why its hooks exist, and a YAML
round-trip through the standard library drops all of them.

306 tests, 100% branch coverage on every production module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 14:12:00 +02:00
51700aa89e Move the two in-repo consumers onto crdt_sync_dart-v0.11.1
sync_settings_ui 0.4.1 and crdt_sync_flutter 0.3.1 both pinned the v0.11.0
tag, which predates crdt_sync_dart's exact pins -- so anything taking these
libraries resolved the shared package to its pre-upgrade constraints.

`flutter pub get` re-resolves cleanly in both; 88 tests green in
crdt_sync_flutter, 35 in sync_settings_ui, 321 in crdt_sync_dart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 14:08:28 +02:00
978d32af3a Don't judge allowlist rot from a file-scoped run; cut two library versions
Two things, because the first is what blocked the second from committing.

**The rot check was scope-blind.** pre-commit passes only the staged
manifests, so a run that sees no package.json cannot learn anything about
whether the fleet-wide typescript hold is still needed -- yet it concluded the
entry was dead and exited 2. Staging two pubspec.yaml files in utils was
enough to make the repo uncommittable. The dead-entry check now runs only
under --all, which is CI and the manual whole-repo run; a subset scan has seen
a subset by construction.

**crdt_sync_dart 0.11.1 and design_system 0.2.1.** Both had dependency
upgrades land after their newest tag, so a consumer moving to "latest tag"
would pick up the pre-upgrade constraints (crdt_sync_dart-v0.11.0 still
carries `crypto: ^3.0.7`, `http: ^1.6.0`, `lints: ^5.0.0`) while carrying its
own new exact pins -- the unsatisfiable-resolution case exact pins create.
Version bump only; the dependency work is already in 922fb36 and 704c67e.

The allowlist end-to-end tests moved to their own file for the 250-line cap,
and `run`/`canned` moved to conftest so both files share one definition.
`canned` is no longer autouse: the resolver tests patch `_fetch` themselves.

293 tests, 100% branch coverage on every production module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 14:07:09 +02:00
1439c0f5c0 Make the allowlist inheritable, and cache the quarantine lookups
Two things that were fine in utils and would not have been in the next forty
repos.

**The allowlist is now fleet-wide plus per-repo.** `typescript-eslint` blocks
TS 7 in EVERY repo that pins typescript -- eight of them and counting -- and a
repo-only allowlist meant hand-copying one entry into eight files that then rot
independently. That is the four-way fork of the 250-line cap, reproduced inside
the mechanism built to avoid it. `~/utils/dependency-freshness.allowlist.yaml`
is inherited by every repo; a repo entry for the same `ecosystem:package`
overrides it.

Inherited entries are exempt from the no-longer-needed check everywhere except
the repo that owns the file. Without that carve-out a fleet-wide hold on
typescript exits 2 in every Flutter app -- it excuses nothing there, which is
the normal case, not rot -- and those repos become uncommittable. Confirmed
against punchme, which now exits 0 while utils still reports the entry as in
use.

**`quarantine.py` bypassed the cache entirely.** It ran for every npm finding
and fetched the FULL packument -- 7MB for react, 16MB for typescript -- with no
TTL and no reuse, inside a pre-commit hook. One finding in utils, but the JS
repos ahead have 16 to 37 each. Now cached under the normal 6h TTL, keyed
`<name>@<dist-tag>`: the tag has to be part of the key rather than clamped
afterwards, because clamping returns the ceiling and the ceiling is frequently
the very release the quarantine is excluding.

**Both cache and shared allowlist are now redirected by AUTOUSE fixtures.** A
quarantine test had already seeded a fake answer for package "x" into the real
~/.cache/dep-freshness/registry.json, and the next run read its own pollution
back and passed for the wrong reason. Removed the entry; the fixture makes it
unrepeatable.

292 tests, 100% branch coverage on every module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:58:44 +02:00
c4a5db8ebc Enforce dependency freshness in utils' own CI
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Waiting to run
github_device_auth tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
Deliberately the last step, not the first. All eleven manifests here are now
on latest stable -- four Dart libs, two Python packages, two TypeScript
packages -- so `--all --strict` exits 0 and this can be a gate rather than a
permanent red light. Installing it any earlier would have taught everyone to
ignore it.

Unlike every other repo's copy, this one runs `scripts/check_dependency_freshness.sh`
directly instead of checking kuhyx/utils out into `.utils`: this repo IS the
gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:51:28 +02:00
0e78c8b752 ts_core + web_ui: latest stable, pnpm 11, and a quarantine-aware gate
Finishes the last two of the eleven manifests in this repo. Exact-pins every
devDependency and takes three majors -- @testing-library/jest-dom 6 -> 7,
jsdom 29 -> 30, pnpm 10.13.1 -> 11.24.0 -- with lint and coverage green in
both (ts_core 42/42, web_ui 102/102, 100% branches).

**TypeScript stays at 6.0.3, and this is the campaign's first real
unsatisfiable conflict.** TS 7.0.2 is latest, but typescript-eslint 8.68.0 --
itself the latest release -- declares `typescript: ">=4.8.4 <6.1.0"` and throws
"does not support TS 7.0" as eslint loads its config. There is no 9.x. Taking
TS 7 would mean dropping every type-aware lint rule from both packages, so the
compiler waits for the linter. Recorded as a `transitive:` allowlist entry,
which is a predicate rather than a date: the gate re-checks the blocker each
run and the entry clears itself when upstream ships support. First use of the
allowlist, and the loud banner now prints on every run here.

**pnpm 11 also made the gate wrong, and the gate was fixed rather than pnpm.**
pnpm 11 quarantines packages published inside a 24h window -- a supply-chain
measure against the period when a compromised publish is live -- and refused
@testing-library/react 16.3.3, published 18h earlier. Reporting a repo as
behind a version its package manager will not install is a finding nobody can
act on: the hook fails, and so does the fix it asks for. `quarantine.py` now
narrows npm's "latest" to the newest release old enough to install, bounded
above by `dist-tags.latest` so a maintenance-line release never outranks the
tag. It fetches the FULL packument (7MB for react, 16MB for typescript) and so
runs only after a finding already exists -- a clean run pays nothing, which a
test asserts by making the fetch raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:50:25 +02:00
71133a808f Judge peer dependencies by range, and check the packageManager pin
Two npm-specific corrections found by running the gate over web_ui.

**`peerDependencies` must not be exact-pinned.** The gate was reporting
`react: ">=19"` as "not exact-pinned", and acting on that would have been
actively harmful: a peer range declares what a CONSUMER may bring, so pinning
it forces every consumer of @kuhyx/web-ui onto one exact React. Peers are now
judged the way toolchain ranges are -- does the range still admit latest? --
and only reported when it does not.

**`packageManager` is a real dependency.** corepack enforces it, so
`pnpm@10.13.1` is an exact pin that nothing was checking. It is now read and
compared, which immediately surfaced that both TS packages sit on pnpm 10.13.1
against 11.24.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:38:40 +02:00
81d2493476 gatelock + crdt-sync: exact-pin every Python dependency
Sixteen `>=` floors become exact pins across the two Python packages, taking
five majors with them: mypy 1.13 -> 2.3.1, pylint 3.3 -> 4.0.7, pytest 8 -> 9,
pytest-cov 5 -> 7, bandit 1.7 -> 1.9.4, plus ruff 0.15 -> 0.16.5 and
requests/types-requests. A `>=` floor is satisfied by everything newer, so it
told a checker nothing about whether these repos were actually current.

Verified in a clean venv per repo, which is what both CI workflows do
(`pip install -r requirements.txt`): gatelock 389 tests at 100% coverage under
xvfb, crdt-sync 432 tests at 100%. No test change was needed for any of the
five majors.

**Open, and deliberately not in this commit:** `.pre-commit-config.yaml` pins
these same tools AGAIN via `rev:` (ruff v0.15.2, mypy v1.13.0, pylint v3.3.2),
independently of requirements.txt, and the gate does not read `rev:` pins at
all -- so the linters CI actually runs are still on the old majors. Bumping
them is a separate and much larger job: ruff 0.16 raises 71 findings here
(CPY001 alone accounts for many, since this repo selects ALL) and mypy 2.x
raises 60, mostly stricter implicit-re-export rules. One major per commit, and
that is several.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:37:25 +02:00
f83c6feeaa Accept exact pins with more than three version components
`types-requests==2.33.0.20260712` was reported as "not exact-pinned" against
its own value: the regex capped the release segment at three components, and
PyPI stub packages date-stamp a fourth. PEP 440 places no such limit.

Found by running the gate over crdt-sync's newly pinned requirements.txt,
which is the only manifest in the fleet with a four-part pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:37:12 +02:00
11c752fe65 github_device_auth: exact pins (http 1.6.0, url_launcher 6.3.2)
Both were the oldest floors left in the fleet -- `http: ^1.2.0` while every
sibling was on ^1.6.0. Neither moved a resolved version: pubspec.lock is
untouched, so this is a declaration change, and analyze / 24 tests / 75-line
100% coverage are all unchanged.

This is the first real repo to exercise the three-way manifest / lockfile /
registry check, since it is the one library here that tracks its pubspec.lock
while its four siblings gitignore theirs. Both directions confirmed against a
copy of this repo: a lock resolving 1.5.9 under a manifest pinning 1.6.0 is
reported as "manifest and lockfile disagree", and staleness correctly takes
precedence over it when both apply.

Version 0.3.0 -> 0.3.1, tag cut. Four consumers pin it by git tag
(home_inventory, todo, diet-guard, wake-alarm) and move when their turn comes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:34:45 +02:00
db65c208d9 Exclude the shared-gate checkout from dependency discovery
Some checks are pending
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Every CI workflow clones kuhyx/utils into `.utils/` *inside* the repo being
checked, so `--all` walked utils' own manifests and failed the consuming repo
for staleness in a directory that repo does not own and cannot fix.

Caught by punchme's very first CI run: locally `--all --strict` exited 0, and
the same command on the runner reported twenty findings, all of them in
`.utils/web_ui/package.json` and `.utils/ts_core/package.json`. Exactly the
kind of local/CI divergence the gate exists to prevent, in the gate itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:24:00 +02:00
1c98d39ddb crdt_sync_flutter: latest stable, exact pins, and the gate that keeps it there
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Exact-pins every registry dependency (crypto 3.0.7, google_sign_in 7.2.0,
http 1.6.0, shared_preferences 2.5.5, url_launcher 6.3.2, uuid 4.6.0) and takes
flutter_secure_storage 10.3.1 -> 11.0.0, a major.

The major is safe here for a specific reason, not by assumption. 11.0.0 removes
the algorithms deprecated in v10 and makes data written by them unreadable --
but this package has been on ^10.x throughout, and v10 migrated that data
automatically. It also removes `encryptedSharedPreferences` and
`sharedPreferencesName` from `AndroidOptions`; keystore.dart constructs a bare
`FlutterSecureStorage()` and passes no AndroidOptions at all, so neither is
reachable from here. The remaining break is minSdk 24 / compileSdk 37, which
consuming apps inherit from `flutter.minSdkVersion` (Flutter 3.35 already
raised its own floor to 24) and which fails a Gradle build loudly if wrong.

Analyze clean, 88 tests green, coverage held at 202/202 (100%).

Also installs the dependency-freshness gate in pre-commit -- the first repo to
carry it. Pre-commit only, not CI: the other ten manifests here are not
upgraded yet, and `--all` in CI would be permanently red until they are. The
`files:` regex means a source-only commit makes zero network requests.

Proven in both directions through the hook itself, not just the CLI:
crdt_sync_flutter/pubspec.yaml passes, github_device_auth/pubspec.yaml fails
and names http ^1.2.0 and url_launcher ^6.3.0, and a .dart path is skipped
by the filter.

Version 0.2.1 -> 0.3.0; consumers move their git pins after the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:09:41 +02:00
704c67ebbd Raise the very_good_analysis floor to 10.3.0 in the four Flutter libs
A caret range's *floor* is the only comparable number it carries, and all four
libs sat at ^10.2.0 while 10.3.0 was current. The Q13 carve-out lets these lint
packages keep a caret; it does not let the floor rot.

Deliberately its own commit, separate from any dependency bump: a lint-package
major/minor lands new rules across four packages at once, and tangling that
with a resolution change would make an analyze cascade indistinguishable from a
version conflict. `flutter analyze` is clean in all four, and all four suites
pass unchanged (88 / 68 / 24 / 35).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:07:41 +02:00
2a2f18ca56 Add the shared dependency-freshness gate
"Are we on the newest version?" was not mechanically answerable across these
repos: constraint styles had drifted per-ecosystem and a caret range is
"newest-compatible" by definition, which gives a checker nothing to compare.
This gate makes it answerable and then enforces the answer.

Checks two things at once -- that every dependency carries an exact pin, and
that the pin equals its registry's latest STABLE release -- across pub.dev,
PyPI, npm, crates.io, the Go proxy, kuhyx/utils git tags, and the Flutter /
Node / Python toolchains.

Three deliberate design points:

* Pre-releases are rejected per-ecosystem, because every ecosystem leaks them
  differently and each leak fails GREEN. npm lets a maintainer point
  `dist-tags.latest` at an rc; crates.io's `max_version` includes them; PyPI's
  legacy `info.version` includes yanked releases. Unit-tested with fixtures,
  since an end-to-end run cannot surface a wrong "up to date".

* Offline, the gate serves an expired cache entry and PASSES with a DEGRADED
  banner (exit 3 only under --strict, which is CI). `--no-verify` is banned
  here, so a hook that hard-failed offline would leave no way to commit at all.

* The allowlist has two entry classes. `blocked_by: transitive:<pkg>@<ver>` is
  a predicate that clears itself when upstream moves and must NOT carry a
  date; everything else is discretionary and requires `expires`, capped at 90
  days. Exact pins plus latest-everywhere is provably unsatisfiable when a
  dependency internally constrains a shared transitive, and expiring the
  predicate class on a calendar would turn the gate red for a reason no commit
  could fix. An entry with nothing left to excuse is itself an error.

One hook id, one shim, one shared implementation -- always delegating, so it
cannot fork the way the 250-line cap forked into four ids, two of which were
independent reimplementations.

256 tests, 100% branch coverage on every module of the gate. Also adds the
workflow that runs them, which incidentally gives md_naming's 71 tests their
first CI run.

Not yet installed in any repo: a repo that gets the gate before its deps are
bumped is instantly and permanently red, so the gate and the upgrade land
together, per repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:00:35 +02:00
777c7cb93a Fix two long-red CI gates: palette_check path after md rename, file-length in sync_settings_ui
Some checks are pending
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:36:41 +02:00
922fb36208 crdt_sync_dart: latest stable + exact pins, 100% coverage, CI for both crdt_sync packages
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:32:24 +02:00
723ac95443 file_length: drop three deleted third-party clones from the exclusion set
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:14:45 +02:00
KRZYSZTOF RUDNICKI
26d7507fff sync_settings_ui: fix Connect Firebase hanging forever on a bad password
Some checks failed
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
connectWithPassword() had no try/catch around firebaseFactory(), unlike
connectWithGoogle() right next to it. A thrown FirebaseAuthError (wrong
password, 400 from identitytoolkit) escaped uncaught out of the button's
async onPressed, so _busy/_status never updated and the screen sat on
"Signing in..." forever with no error shown -- reproduced live via the
browser console (confirmed "Uncaught Error" immediately after the 400,
not a hung Future).

Also reports real progress stages ("Saving account...", "Signing in to
Firebase...") instead of one static string, clears the bad password on a
confirmed rejection (previously only the null-client branch did, letting a
background sync tick keep retrying it), and surfaces the actual failure
reason in the status text instead of a generic "rejected" message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XATAXuMzpYggWyTBPGyiSN
2026-08-25 14:38:00 +02:00
d22417f41b Point the file-length gate at the renamed TODO file
Some checks failed
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
The failure message named refactor_claude_todo.md, which the markdown
namespace migration renamed to TODO-file-length-250.md. A gate telling
you to read a file that no longer exists is worse than one that says
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 16:06:39 +02:00
a10d190b65 Add the shared markdown-naming gate and migrate utils' own docs
Some checks failed
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
design_system tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Markdown across every repo had accumulated a dozen ad-hoc names
(refactor_claude_todo.md x23, DESIGN_AUDIT_TODO.md, NEXT_SESSION,
HANDOFF, PLAN, SPEC) with nothing distinguishing a live task from a
finished record. An audit found docs asserting work was pending that
shipped months earlier, and one asserting completion while the work
was open.

Four namespaces now: README.md, CLAUDE*.md, DOCS*.md, TODO*.md. Task
files carry the literal "REMOVE ME AFTER FINISH" and are deleted when
the work lands.

Enforced bidirectionally -- a TODO without the marker fails, AND any
file carrying the marker that is not named TODO*.md fails. Without the
reverse rule a task can be renamed into the permanent namespace and
quietly live forever, which is the failure being fixed.

md_naming/ mirrors the file_length topology and imports its tables
(EXCLUDED_DIRS, THIRD_PARTY_REPOS, VENDORED_ANYWHERE, GENERATED_PATTERN)
rather than restating them, so the two gates cannot disagree about the
same file. 71 tests, 100% branch coverage.

stale.py warns on TODOs past 90 days but never fails: no machine can
prove a task is finished, so the honest mechanism is a nudge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:53:12 +02:00
6fd3b44e8c vmbox: close the last open item -- reset clears chattr +i
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Item 5 was recorded as "PASS (partial, reset check pending)". Now verified:
`chattr +i /etc/hosts` in a sandbox gives `----i-----------------`; after
`vm reset` the same file reads `----------------------` and accepts a write.

Worth having measured rather than assumed. Immutability is exactly what makes
the hosts guard hard to undo on a real machine -- `rm` fails EPERM -- so
"reset discards it wholesale" is the strongest form of the reset guarantee and
the one most worth being sure of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfxvsYxKM5xDXoDbkKi5u
2026-08-23 15:29:14 +02:00
a597104cf2 vmbox: correct the .claude remote note and record a contamination path
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Two fixes to DISCOVERY_TEST.md, both found by re-checking claims instead
of trusting them.

The .claude remote: `git branch -vv` + `ls-remote` confirm main DOES track
origin/main on kuhyx/claude-config-local, and `gh repo view` says that repo
is PRIVATE. The correction was right but stated the wrong load-bearing
fact -- what matters when telling a future session to push there is the
visibility, not merely that a remote exists. Now says so, and says to
re-check rather than trust the line.

The contamination: NEXT_SESSION_INSTALLER_FIX.md, added to the root of
~/testsAndMisc the same day, is about hosts/install.sh -- the exact file
the discovery prompt names -- and contains "Sandbox first, always ...
~/utils/vmbox". One `ls` at that repo root leaks the answer, which is a
likelier path than the ~/utils grep already noted, and this session created
it. Records it and offers the pacman wrapper as an uncontaminated
substitute prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:54:11 +02:00
5ec35952d1 vmbox: add DISCOVERY_TEST.md -- does a fresh Claude find the sandbox?
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
The sandbox-first rule and the vmbox-testing skill only pay off if a
session with no memory of building them reaches for the sandbox on its
own. This is the test for that: a pasteable prompt (install the hosts
guard and confirm it enforces) that never mentions vmbox, sandboxes, VMs
or the skill, plus a Pass/Partial/Fail rubric and the follow-up.

Deliberately NOT executed by the session that wrote it -- that session
knew all about vmbox, so its behaviour would prove nothing, and a subagent
either inherits that context or gets the task framed by it.

Chose the hosts guard over the shutdown timer because a Fail should be
cheap to lose: it leaves an immutable /etc/hosts rather than a surprise
poweroff. The rubric carries the undo commands next to the Fail
definition so a failed run is not discovered weeks later.

Records that ~/.claude DOES have a remote (kuhyx/claude-config-local),
correcting an earlier note that claimed main there is remote-less; the
fix on a Fail is the skill's description: frontmatter first, then the
CLAUDE.md block, and both need committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:52:19 +02:00
30bf1ebb43 vmbox: hand off the boot-repair guard, the installer fix, and a discovery test
Replaces the handoff prompt with the next three pieces of work.

Job 1 lands the firmware guard for the bug the sandbox found: boot-repair
deletes the only bootable kernel on a BIOS/GRUB box because its "orphaned
kernel shadowing the ESP" heuristic never checks /sys/firmware/efi. The vmbox
guest IS such a box, so the fix is verifiable in the same place the bug was.

Job 2 writes the install_core_system.sh findings into a prompt in the repo
that owns that code. The two dead module paths resolve to ~/screen-locker and
~/steam-backlog-enforcer (checked on disk, not assumed), which makes the
installer cross-repo and is itself a question for kuhy rather than a guess.

Job 3 is a test of whether a fresh Claude discovers the sandbox rule on its
own. It is written, deliberately NOT executed: running it in the session that
built the tooling, or as a subagent briefed by that session, measures nothing.
The prompt says so up front, since it constrains the whole session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:33:43 +02:00
ab8227e23f vmbox: fix the settle gate breaking shutdown verdicts on the serial transport
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
The previous commit skipped the poweroff settle when `ssh_rc != 255`, on the
reasoning that a successful poweroff kills the ssh connection and ssh reports
255. That holds for the ssh transport and for nothing else. `_run_transport`
falls back to the serial console whenever ssh does not answer -- and the
README calls serial "the transport of record for shutdown tests" -- where the
exit code comes from serial_exec.py and is 0 even while the machine is already
on its way down.

Result, reproduced: `VMBOX_TRANSPORT=serial vm run <vm> 'sudo systemctl
poweroff'` reported "still running" for a guest that had cleanly powered off,
with SHUTDOWN{guest:true} in events.jsonl and "reboot: Power down" in the
serial log. The one thing this tool exists to get right, silently wrong. My
five verdict re-tests all went through ssh, so none of them caught it.

Grepping the serial log for shutdown markers instead does not work either:
they land a second or two after the command returns, so the check loses the
race. The fix waits a short grace period (default 3s) for any sign of a stop
before committing to the 30s wait. Warm runs go from 0s to 3s; that is the
honest price of a correct verdict.

Re-tested: clean poweroff, reboot and panic on BOTH transports, plus
tests/destructive_demo.sh 7/7.

Also, per "a fix that needs a human to remember a flag is not a fix": the X
session wait is now automatic on the branch that actually boots the guest --
the only one that can race tty1's autologin -- instead of requiring
VMBOX_X_WAIT. Warm runs never reach that branch. The env var remains an
override. README numbers corrected to the measured 14s/3s and ~100s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:28:57 +02:00
b9e2abad2d vmbox: record the confirming build number (99.99s from committed source)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:20:09 +02:00
cb3c985ee6 vmbox: 3-4x faster runs, a read-only host package cache, and vm scp
`vm run` took 41s cold / 30s warm to run `true`. Both were vmbox waiting on
itself. Now 12s / 0s, with all five shutdown verdicts re-tested unchanged.

- _run_settle waited up to 30s for the kernel's "reboot: Power down" marker
  UNCONDITIONALLY, including on the majority of runs that leave the guest up,
  where that marker is never going to appear. It now settles only when the
  guest has actually stopped, or when ssh returned 255 (what a successful
  poweroff looks like from the host).

- The ssh readiness polls used a 5s ConnectTimeout. QEMU's user-mode
  networking ACCEPTS the forwarded connection while the guest still has
  nothing on :22, so each probe blocked for the full timeout instead of
  failing fast: a guest reachable at ~10s was reported up at ~45s. Fixed in
  both the sandbox and build paths, via an env override -- ssh honours the
  FIRST value for an option, so a second -o could not have worked.

Making runs fast exposed a race: sshd now answers BEFORE tty1's autologin has
started Xorg, so the X environment could be missing on the first run after a
boot. `VMBOX_X_WAIT=<seconds>` waits for the session; it defaults to 0 so an
ordinary run never pays for a graphical session it is not using.

Build: the host's /var/cache/pacman/pkg is offered to the build guest
read-only (`readonly=on` AND an `ro` mount, because it is the real host cache)
as an extra CacheDir after the guest's own writable one, so a package the host
lacks still downloads rather than failing the build. This removes the 396 MiB
the build used to fetch. Host cache verified byte-for-byte identical across
four builds: same file count, same size, same mtime.

Build time itself moved only 103.8s -> 100.3s. Per-phase timings (also added
here) show why: 47s of it is the build guest's cloud-init first boot, and that
is NOT datasource probing -- `cloud-init analyze blame` totals 2.27s. Left
unexplained rather than guessed at; SESSION_RESULTS.md records the evidence.

Also: `vm scp`, which lib/ssh.sh could always do but the CLI never exposed,
and provision.sh split at the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:17:21 +02:00
8bb4d3a60d vmbox: bound every wait, so an unbootable guest fails instead of hanging
Testing boot_recovery produced a guest that cannot boot: boot-repair read
/boot/vmlinuz-linux and initramfs-linux.img on this BIOS/GRUB guest as
"orphaned kernel files shadowing the ESP" and deleted them, leaving the
sandbox at a GRUB prompt. That is a legitimate sandbox outcome -- destroying
the guest is what the tool is for -- but vmbox handled it by hanging.

Two independent unbounded waits, both fixed:

- vm_wait_ssh counted only its `sleep 2` calls, ignoring the up-to-5s
  ConnectTimeout each failed probe spends inside ssh. A nominal 150s wait ran
  past 400s (measured, then cut short by the harness). It now works to a wall
  clock deadline: the same guest is now reported unreachable in 156s.

- _run_recover_rc reboots the stopped guest to read the recorded exit code,
  and fell through to an unbounded serial console read: 180s for a prompt
  that never comes plus 300s for a command that never runs, all AFTER the
  verdict had already been printed. Recovery is best-effort by definition, so
  both bounds are now short (60s/30s) and overridable.

serial_exec's timeouts become env-overridable to make that possible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 22:31:39 +02:00
b4a5b4f899 vmbox: add the host-side test suite the README already promised
README documented `bats tests/test_vmbox.bats` covering "name validation,
meta handling, serial rotation and the full verdict table", and the handoff
made it a done-condition, but the file did not exist. Written to that spec:
39 tests, no VM boot, well under a second.

VMBOX_HOME is redirected to a per-test mktemp dir, so the suite can create
and delete VM state without any chance of clobbering a live sandbox's overlay
or pidfile; one test asserts every path helper stays inside it.

The verdict cases encode the distinctions the design turns on rather than just
exercising the function: guest:true with a truncated serial log is DIRTY (3)
and not a clean poweroff, POWERDOWN alone proves nothing and must read as
"none", an unrecognised shutdown reason fails closed at 6 rather than passing
silently, and events.jsonl is read last-event-first so a previous boot's
poweroff is never mistaken for this run's outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 22:06:13 +02:00
cf5982c9fa vmbox: give guest commands the sandbox's own X session
The image ships Xorg + i3 on tty1 precisely so locker and i3 tests can run,
but `vm run` executes through `sh -c`, which reads no profile. Every X tool
therefore landed with no DISPLAY and no XAUTHORITY and failed with
"Could not determine i3 socket path" -- indistinguishable from "this sandbox
has no X server". testsAndMisc's i3-configuration installer failed that way;
with the environment resolved it exits 0 and i3-msg reports {"success":true}.

XAUTHORITY is the awkward half: startx generates /tmp/serverauth.XXXXXXXX
with a fresh random suffix every boot, so it cannot be hardcoded. Resolve it
from the running Xorg's own -auth argument, falling back to a newest-first
glob.

The `[ -f ]` guard on the source line is load-bearing, not defensive
dressing. In POSIX sh, `.` on a missing file is a SPECIAL BUILTIN error that
exits the shell outright, and `|| true` does NOT rescue it -- written that
way, every `vm run` against a base image predating this snippet silently
produced no output at all. Found by doing exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:51:54 +02:00
25ba142f94 vmbox: handoff gains the fix-it mandate, sandbox reuse, and a build-speed job
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Three changes from kuhy's follow-up:

- An explicit standing instruction that when vmbox breaks the next session
  FIXES it -- not works around it, not reports it as a limitation, not quietly
  falls back to testing on the host. Bugs found by real use are the return on
  having built this.
- Guidance to attach to an existing sandbox or create one as appropriate,
  rather than tearing down and rebuilding per command (`vm reset` is ~2s).
- A build-speed job with measurements rather than guesses: the last build
  pulled 197 packages / 396 MiB over the internet and spent 45s waiting on
  cloud-init, while the host's 114 GB pacman cache already holds every one of
  the packages checked. Exposing that cache read-only is the obvious first
  move; trimming base-devel and shortening the cloud-init wait follow.

The smoke gate stays regardless -- a faster build that fails open would be a
bad trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:34:26 +02:00
4a39e2c0e1 vmbox: hand off with a prompt for the next session
Two jobs, deliberately ordered. First: run real linux_configuration work
through the sandbox (the 8 Arch-only CI tests, night-lockdown, boot_recovery,
the hosts guard) and fix vmbox wherever it breaks -- it has only ever been
driven by one session against a handful of scripts, so the ~1090-file surface
will find things.

Second, and only if the first says vmbox is trustworthy: the CLAUDE.md rule
that routes script testing here by default. A rule pointing at a flaky tool is
worse than no rule, so that ordering is the point. Four questions have to be
settled before writing it -- what triggers it, whether it replaces or precedes
the host run (see feedback-verify-real-deployment-path), the hardware escape
hatch, and keeping it short since CLAUDE.md is always-on context.

Carries forward the traps that cost real time this session: diagnose an
unreachable guest with `systemctl list-jobs`, never grep a bash -x trace for
the failure, and fix the image live over the serial console rather than
rebuilding once per hypothesis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:33:27 +02:00
c6688a21f7 vmbox: note that vm screenshot is verified
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Captured a real 1280x800 PNG of a sandbox's i3 session straight from qemu's
framebuffer -- no VNC or SPICE viewer installed on the host. This is the path
locker and fullscreen-gate tests use, and it is safer than testing those on
the host, where a fullscreen locker can hijack input and steal focus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:29:15 +02:00
45b923dfac vmbox: wait for qemu to release the overlay before recreating it
`vm reset` killed the VM and immediately recreated the overlay, without
waiting for the process to actually exit. The next boot then read a
half-detached file and failed with "sandbox did not become reachable" -- a
symptom that appears one step AFTER the cause and says nothing about it.

Reset itself was always correct (verified by hand: after
`rm -rf --no-preserve-root /etc /usr`, a reset guest reports
NAME="Arch Linux" and has /usr/bin/bash back). This only bit when a reset
followed a still-shutting-down VM closely enough to race it, which is exactly
what the destructive demo does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:28:02 +02:00
89399526f5 vmbox: VM-to-VM networking works (the transport was fine, my pairing was not)
Measured between two sandboxes: 3 packets transmitted, 3 received, 0% packet
loss, 0.23 ms average.

The previous commit called this broken. It was not: the dgram transport worked
all along, but the odd/even pairing computed the wrong peer port for even
indexes, so a VM pointed `remote.port` at a port nothing had bound. Both ends
came up, addresses were right, and zero frames crossed -- indistinguishable
from a dead transport, and nothing in any log said otherwise. The tell was
`ss -lunp`: the bound ports were 12349 and 12350 while the guest was sending
to 12348. The test then compounded it by using indexes 4 and 5, which belong
to DIFFERENT pairs (4 pairs with 3, 5 pairs with 6).

Pairing is (1,2), (3,4), (5,6)... Two sandboxes created back to back are wired
together, which is the common case.

mcast= remains genuinely unusable here and is documented as such so nobody
re-tries it: lo has no MULTICAST flag and there is no multicast route, so qemu
accepts it and drops every frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:26:47 +02:00
6c49f42c2f vmbox: derive the peer IP from the NIC's MAC; document that VM-to-VM is broken
The peer-address service read a `vmbox.peer=` kernel argument that the
launcher never passed -- the reader was written and the writer forgotten -- so
eth1 came up DOWN with no address on every guest. It now derives the address
from the NIC's own MAC (the host allocates 52:54:00:be:ef:<index>), which
removes the coupling entirely: nothing has to be passed in, so the launcher
and the guest cannot disagree about which index a VM has.

That fixed the addressing but NOT the connectivity. `mcast=` cannot work on
this host at all -- `lo` carries no MULTICAST flag and there is no multicast
route, so qemu accepts the option and silently drops every frame, which is the
same "flags accepted, nothing happens" trap as POWERDOWN. A listen=/connect=
socket hub over loopback TCP was tried next: the hub port listens, both
guests bring eth1 up with the right addresses, and ARP still fails.

Documented as a known limitation rather than papered over. Sandboxes work
fully on their own; two guests cannot talk to each other yet.

Also corrects the destructive demo: a running Linux SURVIVES `rm -rf /usr`
because processes hold their binaries through open file handles, so sshd keeps
answering. The honest proof of destruction is that the machine cannot boot
again, so the demo now reboots it and asserts it does not come back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:23:37 +02:00
25698cd763 vmbox: document the worked example, verified end to end
Ran kuhy's real setup_midnight_shutdown.sh inside a sandbox -- the installer
whose own output says "There is NO disable option - this is protected by a
monitor service". It created /etc/shutdown-schedule.conf under guard-lib's
immutable protection and enabled shutdown-timer-monitor.service,
day-specific-shutdown.timer and shutdown-timer-monitor-watchdog.timer.

The host was provably untouched afterwards: base image checksum OK, /etc/hosts
still carrying its +i flag, and the host's own shutdown-schedule.conf still
stamped 11:21 that morning rather than the 21:19 when the guest wrote its copy.

Two properties of that installer are now documented, both found by running it:
it is interactive (`read -r -p 'Do you want to proceed?'`, so pipe `echo y |`
since vm run closes stdin deliberately), and it is subcommand-driven, so a
bare invocation prints usage and changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:20:05 +02:00
a18e0da3a9 vmbox: share host repos by bind mount, not symlink
9p exports a directory TREE and does not follow a symlink pointing out of it.
The share directory held `testsAndMisc -> /home/kuhy/testsAndMisc`, so the
guest mounted /mnt/hostrepo successfully, listed the symlink, and then got
"No such file or directory" following it -- the target path does not exist
inside the guest. Measured in a live sandbox, not assumed.

`vm share <path>` now bind-mounts the directory into the share and remounts it
read-only, so the guest sees real contents while still being unable to write
to the host copy. `vm unshare <name>` reverses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:15:57 +02:00
60571adb40 vmbox: assert destruction host-side; README gets the measured constants
The destructive demo asked the guest to confirm its own destruction, one step
after deleting /usr -- which removes bash, sshd and agetty. Nothing would have
been left to answer, so the check would have FAILED on a step that actually
succeeded. It now proves breakage from the host (the overlay grew, and the
guest can no longer answer at all), which is the same evidence the shutdown
verdict relies on and for the same reason: a corpse cannot report its own
death.

Step 3 also no longer recreates the sandbox. `vm reset` in step 4 is the claim
under test; recreating first would have quietly tested something easier.

README's verdict table now carries the MEASURED qmp events per guest command
rather than the inferred ones, and states the finding that motivates the whole
design: ssh returned an identical "Connection closed by remote host" for the
poweroff, the reboot AND the panic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:13:41 +02:00
efe77d4454 vmbox: print the verdict when the guest powers off (set -e aborted first)
`_run_exec "$name" "$@"` sat on its own line under `set -euo pipefail`, so a
non-zero return aborted the script before the verdict was ever computed. ssh
returns 255 when its connection dies -- which is exactly what a SUCCESSFUL
guest poweroff causes. The single case this tool exists to report on was the
one case that silently printed nothing.

Caught by the acceptance demo, not by the unit tests: verdict_report was
correct in isolation the whole time and passed 8/8 there. Only running the
real CLI against a real poweroff exposed the wiring.

Verified after the fix, end to end:
  ==> Running in 'fixtest': sudo systemctl poweroff
  Connection to 127.0.0.1 closed by remote host.
   ok  VERDICT: clean poweroff -- guest powered itself off and reached
       'reboot: Power down'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:10:40 +02:00
36234e980a vmbox: add the end-to-end acceptance demos
shutdown_verdict_demo.sh exercises every branch of the verdict table against a
real guest -- clean poweroff, reboot-instead-of-poweroff, kernel panic, and a
command that leaves the machine running. The failure branches matter as much
as the success one: a verifier that has only ever returned "pass" is untested.

destructive_demo.sh is the acceptance test for the tool as a whole: destroy
/etc and /usr in the guest, assert the guest really is broken, reset with one
command, assert it is pristine again, then assert the HOST is untouched
(/etc/hosts attributes unchanged, base image hash unchanged, /etc and /usr
still present).

Measured on the first real run, which confirms the design's central claim:
QMP reported SHUTDOWN {"guest": true, "reason": "guest-shutdown"} and the
serial log reached "reboot: Power down", so the verdict was "clean poweroff"
with exit 0 -- while ssh saw only "Connection closed by remote host", which
would have been indistinguishable from a crash or a dropped link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:08:53 +02:00
4cb7abc3ac vmbox: fix the boot deadlock that made every sealed image unreachable
Root cause, confirmed causally rather than inferred: masking
systemd-timesyncd (which vmbox does so `-rtc base=` stays pinned, since
timesyncd otherwise resets the guest clock to real time) left
systemd-time-wait-sync waiting for a sync that can never arrive. That unit sat
in "start running" forever and every other job queued behind it as "start
waiting" -- sshd included -- so the guest never reached multi-user.target. It
booted to a login prompt and looked perfectly healthy while being permanently
unreachable. The fix is one line: mask both units together.

Four earlier hypotheses were each disproven by measurement. What finally
worked was asking the guest instead of reading absence-of-log-lines:
`systemctl is-system-running` returned "starting", and `systemctl list-jobs`
put systemd-time-wait-sync at the head of the queue. Stopping it made sshd
active and bound :22 within seconds, and ssh from the host then succeeded.
Rule for next time: a unit that is enabled, has satisfied dependencies, and
neither starts nor fails is QUEUED -- check list-jobs first.

The smoke gate added earlier did its job here: it caught the unreachable image
and refused to seal it, keeping the sandbox so its logs survived.

Also fixes serial_exec markers to be plain alphanumerics -- shell
metacharacters get echoed and line-wrapped by the tty, which made them
unreliable to match and produced spurious timeouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:06:06 +02:00
8465bec805 vmbox: drop a stray --help/ directory
Committed by accident: running systemd-ssh-generator --help during
investigation made it treat '--help' as its output path and write generator
units there. Not part of vmbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 20:58:32 +02:00
0b1f86ae2c vmbox: add a serial transport so shutdown tests do not depend on sshd
The Arch cloud image leaves sshd enabled but never started -- no start line,
no failure line, dependencies satisfied. Four hypotheses (NTP, cloud-init
clean, a socket-disable, a blocking start) were each disproven by measurement,
so rather than keep paying a ~10 min rebuild per guess this adds the transport
the tool should arguably have used all along.

For the case vmbox exists for, serial beats ssh on the merits: it keeps
printing straight through the poweroff that kills an ssh connection, and it
works when sshd does not. `vm run` now picks ssh when it answers and the
serial console otherwise, and recovers the guest exit code either way. Guests
get a second serial port (ttyS1) on a unix socket with a root autologin, kept
separate from ttyS0 so driving a shell never interleaves with the append-only
log the verdict is read from.

serial_exec.py waits for an explicit per-command end marker rather than a
fixed sleep. That is not a detail: an earlier probe used a 4s window, the next
command was typed while the previous one still ran, and the tty echoed that
input -- which reads exactly like output in a transcript and produced a
confident but wrong conclusion. It also strips ANSI CSI and systemd's OSC 3008
shell-integration blocks, verified against a real captured boot log.

Also: a vmbox-sshd-kick unit that forces sshd up after multi-user.target as
belt and braces, and the smoke-test sandbox renamed to a name that actually
passes validate_vm_name -- "__smoke" was rejected, so the gate silently never
ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 20:58:22 +02:00
a2d672f7fd vmbox: disposable Arch VM sandbox for destructive system tests
Much of testsAndMisc/linux_configuration cannot be tested on the host: 15
files use `chattr +i` (so a bad test is not simply revertable), the shutdown
installers write units that power the machine off, and boot_recovery installs
pacman hooks. The repo's own CI says as much -- its Ubuntu job runs only the
side-effect-free tests, noting the rest "drive pacman, /etc, systemd or adb".

vmbox runs them in a throwaway Arch guest and verifies the result from the
HOST, which is the part that is not obvious: a successful shutdown kills the
connection carrying the report, and ssh returns 255 for a clean poweroff, a
crash and a dropped link alike. So the verdict comes from two artefacts that
outlive the VM -- a QMP event log written by a recorder attached at launch
(attach later and qemu has already exited), and the serial console log.

Five outcomes are distinguished: clean poweroff (SHUTDOWN guest:true plus a
serial tail reaching "reboot: Power down"), dirty stop, guest-reset (rebooted
instead of powering off), GUEST_PANICKED, and hung. A POWERDOWN event proves
nothing -- it means the request was delivered, not that anything acted on it.

Reset is a qcow2 backing-file trick: the golden image is built once and sealed
(chmod 444 plus a sha256 sidecar verified on every launch, since booting the
base would silently invalidate every overlay), and each sandbox is a thin
overlay, so reset is rm + recreate. No root anywhere -- user-mode networking
with per-VM forwarded ssh ports, and a socket multicast segment for VM-to-VM.

The guest clock is pinned and re-applied on every launch, because the shutdown
installers gate on a 21:00-05:00 window; NTP is masked in the image or it
silently undoes the pin. Repos are cloned from a read-only share scoped to an
explicit directory, never $HOME, and credentials are sentinel values.

`vm build` ends by booting a throwaway sandbox off the sealed image and
refusing to ship if ssh does not answer. Several distinct faults produced an
identical "boots fine, connection reset" symptom; checks inside the build VM
only prove state at seal time, so the gate has to be an end-to-end boot.

Tests: 8 bats cases over the verdict table and name/meta/serial handling;
shellcheck and ruff clean; every file within the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 20:47:56 +02:00
4c024a24f0 firebase: make the rules template impossible to deploy verbatim
Some checks failed
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Has been cancelled
database.rules.json carries YOUR_FIREBASE_UID on purpose -- this repo is
public and the uid is the one value that must not be in it. But nothing
stopped anyone publishing the template as-is, and rules pinned to a uid that
matches no account deny every read and write. The symptom is every device
failing at once with a bare permission error, which reads like an outage
rather than a bad paste.

render_rules.sh substitutes the uid from ~/.config/crdt-sync/firebase.json
and fails closed on anything unsafe: a surviving placeholder, missing
`auth != null`, a missing uid pin (that one matters -- `auth != null` alone
lets any stranger who self-registers through the public Web API key read
everything), or output that is not JSON.

Verified: its output is byte-identical to the deployed
~/.config/crdt-sync/database.rules.json, and the live database still answers
an unauthenticated read with 401. All three failure paths exercised.

Also corrects the README, which described the in-repo copy as "the deployed
rules, with the real uid filled in" -- it never was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 19:10:10 +02:00
db3d8f827f Let a host explain an absent Google button instead of hiding it
lyricanki's Linux desktop cannot use google_sign_in (no Linux
implementation) and its loopback replacement is dark until a Desktop OAuth
client exists. Hiding the button there makes the screen silently offer less
on one platform, which reads as a bug.

googleUnavailableReason renders a *disabled* button plus the reason. That is
not the "control that can never succeed" trap: the trap is a live-looking
button that reports a fake cancellation, and a null onPressed cannot be
tapped at all. Null stays the default, so every existing host is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 19:03:24 +02:00
79130a6dd7 Require desktopClientId rather than defaulting it to empty
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
An optional '' default reads as "this parameter is usually irrelevant", and
it made `avoid_redundant_argument_values` fire in the first app to pass ''
explicitly -- pushing that app toward deleting the argument, which is exactly
the line that must stay for when the Desktop client id arrives.

Requiring it forces each app to state which halves it configured. Passing ''
still means "not configured"; it just has to be said out loud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 18:47:10 +02:00
2298fd4ba2 Own the Google sign-in glue, on desktop as well as Android
Five apps had copied google_sign_in_backend.dart; home_inventory's copy was
byte-identical to todo's. lyricanki was about to become the sixth, which is
the mistake this package exists to prevent. So the plugin call moves here.

Desktop is the new half. google_sign_in ships android, ios and web
implementations only -- there is no google_sign_in_linux -- so lyricanki's
real GTK build could never have used the plugin, and "one tap" would have
meant "one tap on the phone, type a password on the PC". The OAuth loopback
flow covers it: bind a socket, open the system browser, catch the redirect,
exchange the code with PKCE. Both halves return a Google ID token of the same
shape, so signInWithGoogle cannot tell them apart.

googleSignInSupported takes the client id rather than reading a const. In
todo the emptiness check was a second conjunct at the call site, and moving
the fetcher here without it would have let a forgetful app ship a button that
always reports "cancelled" -- todo's own file documents that failure. Taking
the value as an argument makes the check impossible to drop.

The decision (which flow, or none) is pure and covered; the two one-line
delegations that reach a platform channel and a real browser are the only
suppressions. 83 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 18:45:48 +02:00
6802e9c0da Move sync_settings_ui onto crdt_sync_dart v0.11.0
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Two different git refs for the same package name cannot coexist in one
resolution, so an app taking both sync_settings_ui and crdt_sync_flutter --
which is the intended pairing, and what lyricanki now does -- failed version
solving while this stayed pinned to v0.10.0.

v0.11.0 only widens what FirebaseAccount.tryParse accepts, so nothing here
changes behaviour: 29 tests green, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 17:58:36 +02:00
29f1389d5d Keep verify_prompts honest about a deleted prompt copy
Some checks failed
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
design_system tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Deleting prompts/dopamine-ux-01-motion-tokens.md (the completion protocol) left
00-INDEX.md's "Distributed copies" table citing a path that no longer exists, so
verify_prompts went from 3 pre-existing failures to 4. Exactly the dangling
anchor 44bc12b fixed after the doc split.

Asserts the path ABSENT rather than dropping the row, which keeps the check
two-directional: a finished prompt reappearing in a repo is itself a failure,
because the next session would re-run work that is already done. Verified both
ways -- recreating the file fails the check, removing it passes.

This recurs for 02-09, so the comment says to move a row as each completes.

Back to the same 3 failures as before this session, all in ~/screen-locker and
all pre-existing (prompts 04/05).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:31:03 +02:00
c7d2e43b6d Take flutter_secure_storage 10, which share_plus 13 requires
flutter_secure_storage 9 pins flutter_secure_storage_windows 3, which pins
win32 5 -- and share_plus 13 needs win32 6. Any app depending on both
(lyricanki, the first consumer) failed version solving outright, and pub's
suggested way out was downgrading share_plus, which would undo a just-shipped
Android share fix.

home_inventory already runs 10.3.1, so this moves toward the fleet rather than
away from it. 35 tests still green on the newer plugin.

Released as v0.1.1 rather than re-cutting v0.1.0: that tag is already pushed,
and moving a published tag breaks anyone who resolved against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 17:28:39 +02:00
4efd0f5038 dopamine-ux: mark 01-motion-tokens.md done
All four `done` conditions hold: motion.md exists at 121 lines, the 250-line
cap and palette_check both exit 0, and both tags are cut (design_system-v0.2.0,
web_ui-v0.3.2).

Deletes the distributed prompt copy per the protocol in 00-INDEX.md -- a
finished prompt left in a repo is indistinguishable from a pending one, and the
next session re-runs it. The canonical copy in dopamine-ux/ stays as the record.

Prompt 01 gated the other eight; 02-09 are now unblocked, and 06/07/08 have the
literal tag refs they were waiting on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:28:35 +02:00
f7418969cf Freeze the motion, haptic and sound vocabularies, and ship them as tokens
dopamine-ux prompt 01. The token layer froze colour, spacing, radius and type,
but every animation in every app picked its own duration and curve out of the
air -- so "200ms ease-in-out" and "180ms ease-out" both existed and neither was
wrong, because nothing said what right was.

unified-design-system/motion.md is the freeze: four durations, three curves,
a haptic vocabulary mapped onto Flutter's HapticFeedback, and three sound cues,
each with its one-line rationale. Shipped as AppDuration/AppCurve in Dart and
--duration-*/--ease-* in CSS, so a consumer imports the scale instead of
transcribing it.

Three things worth stating, because they are the parts easy to get wrong:

- Reduced motion collapses the *tokens themselves* (CSS @media, Flutter
  disableAnimations), so no consumer has to remember to branch.
- Haptics and sound are NOT motion and are deliberately left alone by that
  preference. Suppressing animation is not a request to lose confirmation that
  a tap landed; the sound opt-out is a separate, user-facing setting.
- Reward the recording, never the number recorded. The haptic fires on logging
  a meal, not on hitting a calorie target -- the latter rewards eating less
  rather than recording honestly, and corrupts the app's own data.

gatelock stays untouched: Tkinter has no animation framework, so motion there
is marked `n/a (tk)` rather than shipping a dead constant that reads as a
supported feature.

Tests assert the frozen values on both sides, and that the reduced-motion block
collapses every non-zero step -- verified by mutation: deleting one collapse
line fails the suite. flutter analyze / tsc / eslint clean, 68 Dart and 88 TS
tests pass, palette_check and the 250-line cap still exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:28:02 +02:00
fff46de4c5 Extract the Flutter sync glue into crdt_sync_flutter, and document it
`crdt_sync` is pure Dart so it can run from CLI tools and headless systemd
jobs, which means it takes closures instead of depending on
flutter_secure_storage and google_sign_in. Every Flutter app then wrote the
same ~390 lines to supply them, and five apps ended up carrying near-identical
copies of firebase_backend.dart.

Those copies drifted. `todo` gained an opt-out flag and a seeded-session path
that home_inventory and wake_alarm never received, so the same bug was fixed
in one copy and left live in the others -- which is the actual cost being paid
here, not the line count.

This package owns that glue once: the keystore adapter, the persisted
per-install device id, the account store and a one-call bootstrap. A new app
needs a SyncApp const and `openSync(kApp)`.

The wrapper routes are a parameter rather than a constant, because todo's copy
hardcoded its own `~/.config/todo/firebase_auth.json`. An extraction only one
app could adopt would be worse than the copies.

Also documents what made this hard to find: crdt_sync_dart's README covered
only the *old* GitHub transport and never mentioned the Firebase surface at
all, and sync_settings_ui -- a complete drop-in settings screen -- had no
README whatsoever.

The five existing apps stay on their copies and on crdt_sync_dart-v0.10.0.
Migration is forward-only and those fallbacks are load-bearing; converging
them is a separate task, and the README says so.

35 tests, 100% line coverage, no suppressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 17:26:34 +02:00
feaaf69103 Accept an empty password when parsing a stored Firebase account
Both the Google one-tap path and the seeded-session path deliberately write
`FirebaseAccount(email: ..., password: '')` -- on those devices the refresh
token in the keystore is the credential, and there is no password to store.

`tryParse` rejected exactly that blob. So a device signed in with Google
wrote an account marker it could never read back: every later launch saw
"not configured", fell through to the mirror, and left a perfectly good
refresh token unused in the keystore beside it.

Only widens what parses, so no caller that previously got an account back
can regress. Every call site already guards `password.isEmpty` and passes
null for it -- meaning "no password on this device" -- which is precisely
the case this now lets them see. An empty *email* stays rejected: it
carries no identity at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 17:19:49 +02:00
c89fee20ec Pin the accepted Google issuers with literals, not the module's own constant
Some checks failed
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
While trimming that file under the cap I had rewritten the parametrize as
`[*lg._EXPECTED_ISSUERS]`, which asserts only that the module agrees with
itself: deleting a spelling from _EXPECTED_ISSUERS would have removed the case
that tests it and left the suite green.

Confirmed by mutation rather than by inspection -- dropping the bare
"accounts.google.com" from the module now fails
test_both_google_issuer_spellings_are_accepted[accounts.google.com], where
before it would have passed. Google uses both forms, so accepting both is a
real contract worth pinning.

432 passed, still 100%, verified with HOME redirected at a temp directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:55:00 +02:00
7637e46c6f Stop the link_google tests reading the real password file
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
CI caught this: 8 tests failed on the runner with "/home/runner/.config/
crdt-sync/password could not be read". They passed here for the worst possible
reason -- that file exists on this machine, so the tests were reading the
user's actual sync password.

_CONFIG is a real FirebaseConfig, and link_google() calls
config.read_password(), which falls back to PASSWORD_FILE when given no path.
The linking tests now pass a MagicMock(spec=FirebaseConfig) with read_password
stubbed, which is what the one test that already asserted on the password was
doing; that test now shares the helper instead of keeping its own copy.

Adding the helper pushed the file to 265 lines, so the token-inspection tests
move into test_tool_link_google_token.py -- a three-way split matching the
module's three concerns (inspect / link / CLI) at 196, 82 and 223 lines.

Also fixes a mypy finding in the seed_session ordering test, which used
`list.append(...) or "value"` to record call order in a lambda. append returns
None, so the expression only worked by accident; replaced with named functions.

Verified the way the earlier local run could not: HOME pointed at an empty
temp directory, reproducing the runner. 432 passed, 1333 statements and 234
branches at 100%. ~/.config/crdt-sync/password untouched (mtime still Aug 4).

Worth keeping as a rule: for code that reads ~/.config, a green local run
proves nothing unless HOME is redirected first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:50:59 +02:00
0861aebda9 Remove the post-250-cap prompt: all four tasks are done
Per the file's own closing instruction -- a finished prompt left in the repo
is indistinguishable from a pending one, and the next session would re-run it.

  1. web-ui-tests green (53f2816). Not a stale dist/: 16 files differed only
     by a trailing newline tsc does not emit, left by end-of-file-fixer before
     the dist/ exclude existed, and masked locally by tsc -b's incremental
     cache.
  2. All of tool/ at 100% branch coverage, with source = ["crdt_sync", "tool"]
     in effect (6d64f9e). 259 -> 432 tests.
  3. The three dirty items resolved: seed_session committed (9d638fb),
     firebase-debug.log gitignored and deleted (e0eef3d), staged/ applied into
     ~/.claude (e0cf24a there) and removed.
  4. The design-system skill pointer corrected, in ~/.claude e0cf24a.

Two things this leaves open, deliberately, neither part of the four tasks:
~/.claude/scripts/finish.sh is 309 lines and over the cap (it blocks nothing
today -- the hook gates staged files only), and phone_deploy.sh's
build/install/launch tail is still unexercised because it needs a real APK
install on the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:46:56 +02:00
6d64f9e3c9 Cover tool/seed_session.py and put all of tool/ under the coverage gate
Finishes the tool/ coverage task. seed_session.py is the seventh and last
module, split into seed_apps and main() at 224 and 222 lines.

The tests pin the two structural decisions the module's docstring argues for,
since neither is visible from its output: each app gets its own exchange and
its own credential store (one session copied across them would have every app
refreshing the same rotated token and invalidating the others), and
verification runs as a *second pass* after all sessions exist -- checking each
app as it is written would pass one that a later exchange then invalidates,
which is the exact failure being guarded against. Asserted by recording the
call order and requiring seed,seed,verify,verify.

Also covers the partial-failure path, which is the difference between a
copy-pasteable rerun and a hunt through ~/.config: SeedError carries the apps
that verified, and the rerun line lists the remaining ones and only those.

With every module at 100%, the config flip lands last as planned:

    addopts += "--cov=tool"
    source = ["crdt_sync", "tool"]

Done condition met -- 432 passed, 1333 statements and 234 branches at 100%,
tool/__init__.py included. `import crdt_sync` and `python3 -m
tool.google_id_token --help` both still work after the ruff autofixes, and
`pre-commit --all-files` is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:46:40 +02:00
1af1d3005b Cover tool/link_google.py at 100% branch
Sixth module of the tool/ coverage task, split into token inspection/linking
and _read_token/main() at 249 and 224 lines.

The failure this script exists to prevent is silent -- linking onto a new
account succeeds at the auth layer and is then denied every read and write by
rules that pin one uid -- so the two central assertions are made on the
request body rather than inferred from the response: the existing account's
idToken is always sent (omitting it turns accounts:signInWithIdp into "sign in
or sign up"), and a mismatched localId is a hard error whose message names the
console unlink step.

Also pins the deliberate exception handling in main(): LinkError, ConfigError
and FirebaseAuthError each return 1, while an unexpected RuntimeError is left
to propagate. That is the stated reason the handler names three types instead
of catching Exception -- a tidy "FAIL" line for an unanticipated bug would
hide it -- and it is only a real guarantee if something asserts it.

crdt_sync 365 -> 400 passed, still 100%. 6 of 7 tool/ modules done; only
seed_session.py remains before the config flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:43:18 +02:00
d8e962fdf0 Cover tool/migrate_github_to_firebase.py at 100% branch
Fifth module of the tool/ coverage task, split into the GitHub-reading half
and main() at 216 and 248 lines.

This is a one-shot migration whose worst outcome is reporting success while
having moved the wrong bytes, so the tests pin the decisions that prevent
exactly that: reading blobs by sha rather than by path (the contents API
would return whatever is at that path *now*, which need not be what the tree
listing described), refusing a truncated tree instead of migrating part of
the repo, never fetching a skipped path, emitting the report before the first
write, and failing the run when the byte-for-byte read-back disagrees --
including the partial case, where one bad file out of two is a failed
migration rather than a 50% success.

Also fixes the S105 finding the previous commit's preflight tests carried:
`_PASSWORD` trips the hardcoded-credential rule on the binding name, the same
trap this task's notes already record. Renamed rather than suppressed. The
blanket rename briefly caught the module attribute `_PASSWORD_FILE` too,
which the suite caught immediately.

crdt_sync 333 -> 365 passed, still 100%. 5 of 7 tool/ modules done; the two
remaining are link_google.py and seed_session.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:41:13 +02:00
11fc417556 Cover tool/preflight_firebase.py at 100% branch
Fourth module of the tool/ coverage task, and the first of the four large
ones. Split into checks and loaders/main() up front rather than discovering
the 250-line cap afterwards -- 245 and 225 lines.

Every check is asserted on its *message*, not merely on having raised. The
whole reason this tool exists is that the misconfigurations it catches are
indistinguishable once data has started moving, so a check firing with the
wrong explanation sends the reader to the wrong place. That includes the
wrong-region 404, which answers with a correctUrl body rather than an error
and would otherwise pass preflight, pass sign-in against a different host
entirely, and only break mid-migration.

main()'s stated contract is pinned too: cheapest-first ordering, and that it
short-circuits -- a typo must be reported without a network round trip, so
the later network checks must not run at all.

The module's config paths are module-level constants pointing into the real
~/.config/crdt-sync, so the fixture redirecting them at tmp_path is autouse:
a test that forgot it would read and report on the user's actual credentials.
Confirmed afterwards that ~/.config/crdt-sync was not touched.

crdt_sync 293 -> 333 passed, still 100%. 4 of 7 tool/ modules done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:38:13 +02:00
7dd0174fd4 Record the three covered tool/ modules in the prompt
Keeps the handoff accurate mid-task: the module table now carries per-file
status and commits, the ordering note no longer sends a reader back to work
already done, and the baseline is 293 rather than the original 259.

Adds the two traps this session hit that the prompt's own list did not carry
-- `ruff check` alone is insufficient because the hook applies unsafe fixes
CI then rewrites the file with, and S105/S106 match the binding name rather
than the value -- plus the observed ~1.3 test-lines-per-module-line ratio,
which means each of the four remaining modules needs a planned split rather
than a discovered one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:36:14 +02:00
0de2a96155 Cover tool/interop_seed.py at 100% branch
Third module of the tool/ coverage task, and the last small one.

Patched at the module's own boundary (firebase_client_for, FirebaseConfig.load
and sync_log), so nothing touches Firebase. The assertions are deliberately
about the *shape* handed to sync_log rather than about sync_log working --
test_sync.py already covers the latter. This tool exists so
crdt_sync_dart/tool/interop_check.dart has a Python-written record to find, so
the things worth pinning are the ones that would make the two halves silently
miss each other: the _interop/devices prefix, the pydev device id, the py-rec
id with its exact value and Hlc, the library's own codec rather than a local
reimplementation, and the in-memory revision store that keeps the seed
repeatable after the Dart side deletes both devices' data.

crdt_sync 286 -> 293 passed, still 100%. 3 of 7 tool/ modules done; the
config flip stays last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:35:40 +02:00
e5a3d1eda7 Apply the ruff fix CI's --unsafe-fixes run wanted
`crdt-sync pre-commit` went red on the previous two commits: pytest is only
referenced in annotations in the _cli split, so TC002 moves it into the
type-checking block.

Missed locally because I ran `ruff check` directly, while the hook passes
`--fix --unsafe-fixes --exit-non-zero-on-fix`. TC002's fix is classed unsafe,
so a bare `ruff check` reports nothing and CI still rewrites the file. The
lesson generalises past this file: for this repo, `pre-commit run --all-files`
is the check, not `ruff check` -- the same trap the prompt already recorded
for re-export pruning.

Verified after the fix: 18 tests, tool/google_id_token.py still 100% branch,
and `pre-commit run --config crdt-sync/.pre-commit-config.yaml --all-files`
clean end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:34:27 +02:00
a7bccb34bb Cover tool/google_id_token.py at 100% branch
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
web_ui tests / test (push) Waiting to run
Second module of the tool/ coverage task. No network and no browser: the
callback server is replaced wholesale so consent resolves instantly, and
requests.post is mocked at the same boundary test_firebase_auth.py uses.

All six TokenError paths are covered, since each names a different
misconfiguration and the module docstring exists because they are easy to
mistake for one another -- notably the fixed-vs-free port distinction, where
a Web client matching redirect URIs exactly fails with redirect_uri_mismatch
before the account picker ever appears. Also asserts the server is closed on
the failure path, since a leaked listener makes the next run fail to bind.

Split across two files because the cap hook rejected the first attempt at 273
lines: the flow tests here, main()'s argument parsing and exit code in
_cli.py, which imports the shared fixture values rather than redeclaring them.
That is the gate doing its job on this session's own output.

Three things the tooling caught, all fixed rather than suppressed:

- S105/S106 match on the binding *name*, not the value, so any constant
  called SECRET/TOKEN/PASSPHRASE trips them however obviously fake it is.
  Renamed around the rule the way test_firebase_auth.py names _ID_1.
- mypy rejected the previous commit's log_message test for asserting on the
  return of a `-> None` function -- a tautology. Rewritten to make a real
  request and assert stderr stays empty, which is the only thing that
  distinguishes the silenced handler from the stock one.
- ruff pruned the imports that moved out during the split; verified it took
  only those, and that the suite is still green afterwards.

crdt_sync 268 -> 286 passed, still 100%. Both modules verified at 100% branch
individually; the config flip stays last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:32:08 +02:00
e6c8d66a12 Cover tool/_oauth_callback.py at 100% branch
First module of the tool/ coverage task. It is the one the task was raised
for: _start_callback_server's runtime path had never been exercised, and it
is the only tool/ module with no network dependency, so it needs no mocking.

Driven over real HTTP against 127.0.0.1 on a port the module picks, rather
than with a faked request object. The branches worth protecting only exist
once a socket is involved -- particularly the /favicon.ico fetch that must be
answered without being recorded, since treating it as the callback retires the
server before Google ever redirects and surfaces to the user as a consent
timeout. Both serve-loop exits are covered: a result arriving, and the
deadline passing (monkeypatched negative rather than waiting out 300s).

Writing them surfaced a teardown race, not a bug in the module: calling
server_close() while the worker is still inside handle_request() pulls the
socket out from under it and raises on the thread. Invisible normally and
deterministic under --cov, whose tracer widens the window -- so the tests
retire the thread through the loop's own exit condition before closing, via
one _retire helper. Verified over 6 consecutive --cov runs.

crdt_sync 259 -> 268 passed, still 100%. tool/ is not yet in the coverage
source, so the config flip stays last, per the prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:27:27 +02:00
d9011a05b7 Record tasks 1, 3 and 4 as done; only the coverage task remains
Keeps the prompt as the handoff artefact it was written to be, since task 2
(all of crdt-sync/tool/ at 100% branch coverage) may outlast a session and the
file is what stops the next one redoing finished work. Not deleted, per its
own instruction to strike through instead of removing while any task is open.

The finished sections are rewritten rather than merely struck through, because
the prompt's diagnosis was wrong in two places and a future reader would
inherit the error: web-ui-tests was failing on a trailing newline that tsc
does not emit (not a stale dist/), masked locally by tsc -b's incremental
cache; and README.md still holds the per-stack design-system patterns -- what
moved out was the operability and screen-size material.

Also records what the ~/utils working tree cannot show: tasks 3 and 4 edited
~/.claude, committed there as e0cf24a. And notes finish.sh at 309 lines as an
open finding outside the four tasks -- it blocks nothing today, since the hook
gates staged files only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:23:08 +02:00
53f281620e web_ui: commit the real tsc output so dist-fresh can pass
`web-ui-tests` has been red since 6714d13 and the cause was not a stale dist/:
the content is byte-identical. Every one of the 16 files differed only by a
trailing newline that tsc does not emit.

Where the newline came from: at 03bf1ed the subproject config had no exclude,
so pre-commit's end-of-file-fixer appended a newline to each generated file.
eaa5683 added `exclude: ^web_ui/dist/` to stop that recurring, but did not
revert the newlines already committed -- leaving a dist/ that no `pnpm build`
can ever reproduce, and therefore a job that could never go green.

It looked fine locally because `tsc -b` is incremental: with a warm
tsconfig.tsbuildinfo it skips emit entirely, so `pnpm build` was a no-op and
printed no diff. CI has no cache, always emits, and always failed. Deleting
tsbuildinfo reproduces the failure locally in one command.

Verified: after deleting tsbuildinfo and all of dist/, a from-scratch
`pnpm build` reproduces exactly what is committed here -- `git diff
--exit-code -- dist` is clean, which is the assertion CI makes. pnpm lint
passes and pnpm coverage is 84 tests, 100% statements/branches/functions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:19:09 +02:00
e0eef3d906 Ignore Firebase CLI debug logs
crdt-sync/firebase-debug.log had been sitting untracked since 2026-08-11. The
file itself is disposable tool exhaust, but it not being ignored is the actual
bug: the CLI rewrites it on every failed command, so it reappears in
`git status` indefinitely and is one stray `git add .` away from landing a
1931-line log in the history.

Covers the sibling logs the same CLI writes (firestore, database, and the
rotated firebase-debug.N.log form), since they appear the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:17:39 +02:00
9d638fb919 Seed a Firebase session for todo as well
todo used to be grouped with the Flutter-only apps that authenticate in-app
on the phone and therefore need no desktop session. That stopped being true
when it grew a desktop wrapper (lib/desktop/wrapper_server.dart) with its own
Firebase REST client, which needs a seeded session exactly like the Python
daemons do.

Both files the new comment cites were confirmed present today. The credential
cache at ~/.config/todo/firebase_auth.json has the same shape as every other
entry -- FirebaseCredentials.fromJson in crdt_sync_dart parses the same
{id_token, refresh_token, expires_at} that credential_store_for writes -- so
the only thing that was missing is the DEFAULT_APPS entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:17:06 +02:00
85684fdb00 Add a prompt for the four loose ends after the 250-line cap
The cap work is done, but four things were deliberately left and the session
that found them hit the length gate at 1159 turns. Written as a self-contained
prompt so a fresh session can pick them up without re-deriving anything:

  1. web-ui-tests is red (stale web_ui/dist/, needs `pnpm build`) — red since
     6714d13, which predates the cap work.
  2. crdt-sync/tool/ has no coverage. Scoped honestly: the trigger was
     _oauth_callback.py, but `source = ["crdt_sync"]` means adding `tool`
     pulls in 7 modules / ~1350 lines, and this repo forbids omitting packages
     from coverage. Ordered so partial progress is useful, with the config
     flip last so it cannot block unrelated commits.
  3. The three dirty working-tree items — including seed_session.py, which is
     real intentional work (adds `todo` to DEFAULT_APPS), not junk to discard.
     Its claim that ~/todo grew a desktop wrapper was verified today.
  4. The unified-design-system skill still describes README.md as holding the
     per-stack patterns, which moved to operability-patterns.md.

Carries the traps that cost time this session: run the CI-pinned ruff 0.15.2
rather than whatever `ls | head -1` finds, and use `pre-commit --all-files`,
which is what catches the autofixer pruning re-export imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:14:31 +02:00
44bc12b61a Fix a dangling anchor left by the doc split
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
operability.md pointed at README.md#pointer-free--small-screen-per-stack-patterns,
written before that section was moved out of README into
operability-patterns.md. The link now targets the file that holds the content.

My earlier link check only validated the *file* part of each link and silently
ignored the fragment, which is why this survived. Re-checked with the anchor
compared against the target file's actual headings; all links in the directory
now resolve, fragment included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:03:20 +02:00
52048a41c1 Mark refactor_claude_todo as done
All 46 files are under the cap and the gate is in place. The survey figures in
the body are the 2026-08-14 snapshot — they were already stale when this
started (39 listed, 46 actual) — so the note says to re-run the gate rather
than trust them, and keeps the rest as the record of what was asked for.

Also picks up a pre-existing markdown-formatter change to the file that was
sitting unstaged in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:59:17 +02:00
4c734c1cea crdt-sync: clear the ruff 0.15.2 findings CI reported
My local pre-commit cache holds three ruff versions and I had been resolving
`ls ... | head -1`, which picks 0.14.5 — CI pins 0.15.2, and the newer version
flags eight things the older one does not (unused imports left behind in
_syncargs.py and tool/_oauth_callback.py, import ordering, an empty
TYPE_CHECKING block). Re-ran against the pinned binary; `ruff check` is now
"All checks passed!" under the same version CI uses.

259 tests at 100% branch coverage, `pre-commit run --all-files` clean for this
config, and `python3 -m tool.google_id_token --help` still runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:56:08 +02:00
9f271b0d37 Fix the cap hook in CI, and the re-exports its --all-files run exposed
The hook entry was an absolute /home/kuhy/utils path, copied from a config
written for ~/.claude where it only ever ran locally. On an Actions runner
that is exit code 127 — "No such file or directory" — so gatelock-precommit
and crdt-sync-precommit failed on every commit of this refactor while the
hook silently checked nothing. Now repo-relative, matching the sibling hooks
(`entry: scripts/check_subproject_scope.sh`): pre-commit always runs from the
git root, so it resolves the same in both places.

Fixing that made the CI configs runnable locally, and `--all-files` then found
what per-file runs had missed — ruff pruning imports that exist *for* their
re-export. `import crdt_sync` was broken three separate ways (FileSyncStateStore,
encode_key, FileCredentialStore). Each affected module now states an explicit
__all__ so the autofixer cannot take them again, and the two files that pushed
past the cap once __all__ was added are split further:

  _firebase.py 260 -> 247 + _fberrors 25
  _sync.py     257 -> 188 + _syncargs 91

Test patch targets followed the code they patch: gatelock._outputs.shutil ->
_xrandr.shutil, gatelock._arbiter.Path -> _claims.Path.

gatelock and crdt-sync both pass `pre-commit run --all-files` in full;
389 and 259 tests at 100% branch coverage; the root gate exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:52:59 +02:00
966ec60f17 CI: fail the build on any file over 250 lines
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Requirement 4 of "make it permanent": the pre-commit hook only ever sees the
files in a commit, so it cannot catch a violation that arrives by a path the
hook never ran on — a merge, a push from a machine without hooks installed, a
rebase that resurrects a file. This runs `check_file_length.sh --all` over the
whole tree on every push and PR to main.

Deliberately no `paths:` filter, unlike the other ten workflows: the cap
applies to every file in the repo, so scoping this job to a subdirectory is
the one change that would reintroduce exactly what it exists to catch.

Added last, because until the final doc split it would have failed on push.
Verified against a clean checkout of HEAD in a throwaway worktree — the exact
command the job runs exits 0 — and the commit gate re-tested in both
directions after file_length's own internals were refactored: a staged
261-line file fails (exit 1, no commit object), a 200-line one commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:43:22 +02:00
b9e2312779 unified-design-system: split the three docs — repo now fully under the cap
tokens.md        260 -> 219 + operability.md 69
  nielsen-audit.md 258 -> 154 + shipped-record.md 122
  README.md        257 -> 172 + operability-patterns.md 95

Worked the requested order rather than cutting straight to a split.

(a) Automate: the categorical ramp's four constraints were restated in prose
    with the measured margins hand-copied (ΔE 34.6/26.0/25.6/27.4, etc).
    ramp_check.py prints every one of those numbers and fails if any
    regresses, so the prose now points at the command instead of duplicating
    its output. The two consequences the numbers do not state — why the ramp
    is mid-toned, and why lightness falls monotonically — stay in prose.

(b) Dedup: measured, not assumed. Zero paragraphs are shared between the
    three files; README already delegates the rules to tokens.md and shows
    only per-stack code. Nothing to remove.

(c/d) Split on meaning: tokens.md is "frozen values", so the behavioural
    requirements that *use* those tokens moved to operability.md, with the
    per-stack code for them in operability-patterns.md. nielsen-audit.md is a
    live grid, so completed phases moved to shipped-record.md — following the
    existing phase3-record.md convention, not a new one.

The focus-ring token row had to stay in tokens.md: palette_check.py parses
that file, and moving the row made the check fail with "MISSING md:focus-ring"
— a token it cannot see is a token nothing compares across the four stacks.
The rationale lives in operability.md and the row links to it.

palette_check and ramp_check both at 0 failing checks; every relative link
between the docs resolves.

`bash scripts/check_file_length.sh --all` now exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:42:27 +02:00
06ca32ce6d sync_settings_ui: split sync_settings_screen.dart 382 -> 192
sync_settings_widget.dart   68  — the public StatefulWidget and its callbacks
  sync_settings_sections.dart 132 — the two sections the screen composes
  sync_settings_screen.dart   192 — the State: loading, connecting, build

Uses `part`/`part of`, not imports: _FirebaseSection and _BackupSection are
library-private, so a sibling file cannot import them at all — extracting them
as ordinary files would not compile.

Two shapes tried and rejected on the way: an `extension _BackupActions` on the
State (an extension cannot call the protected setState), and moving build()
out of the class (it is an @override and has to stay). Both reverted from HEAD
rather than patched around.

flutter analyze clean, 29 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:38:58 +02:00
ea0dc9348c crdt_sync_dart: split four library files — package now under the cap
firebase_auth_rest.dart 333 -> 238 + firebase_credentials 71
                                     + firebase_auth_error 31
  firebase_client.dart    283 -> 207 + firebase_keys 60 + firebase_errors 19
  sync.dart               259 -> 141 + sync_state 121

Mirrors the split just made on the Python side, module for module, so the two
implementations stay legible against each other: credentials with their store,
key escaping with its escape table, the error type with the response parser
that builds its message.

Every new file is added to lib/crdt_sync.dart. A src/ file that is not in the
barrel compiles and tests green while silently dropping the symbol from every
consumer's import, so the export list is part of the split, not an afterthought.

280 tests pass and `dart analyze` is clean — including the four consumers
(firebase_config, secure_credential_store) whose imports had to follow the
moved types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:36:03 +02:00
3589d166cd crdt-sync: split five source files — package now fully under the cap
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
_sync.py         456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34
  _firebase_auth   412 -> 242 + _credentials 146 + _autherrors 69
  _firebase.py     336 -> 250 + _fbkeys 100
  google_id_token  316 -> 232 + tool/_oauth_callback 105
  _github.py       255 -> 238 + _gherrors 30

revision_of became its own leaf module because both the push and pull halves
need it; without that the two would have imported each other. _fbkeys holds
the RTDB key escaping — the escape tables moved with the functions that read
them, since the pair has to round-trip exactly or a device silently stops
seeing its own writes.

crdt-sync/tool/ is outside the coverage source, so the split there has no test
net: verified by running it the documented way instead
(`python3 -m tool.google_id_token --help` prints its usage, and
`from tool.google_id_token import TokenError, fetch_id_token` — what
seed_session.py does — still resolves).

An AST pass over each pair caught what the suite would not have: _is_revoked
needs the error type, so it stayed with it rather than following the
credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be
carried to their new callers.

259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and
firebase-debug.log are pre-existing local changes and are deliberately not in
this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:30:52 +02:00
f5f9db21dc gatelock: split _arbiter.py and _escape.py — package now fully under the cap
_arbiter.py 397 -> 246 + _claims 241
  _escape.py  300 -> 230 + _escape_types 82

_claims holds the claim document and everything that decides whether one is
still owned: taking the advisory lock IS the liveness proof, so reading a
claim and reaping a dead one are the same operation and belong together.
_arbiter keeps the policy — who outranks whom, and whether this app may arm.

An AST pass over every module in the package (names loaded vs. names defined
or imported) caught two things a green suite would not have:
  - _try_lock and fcntl moved with the locking code, so the tests' patch
    targets had to move too;
  - OutputSource was left behind by the earlier _outputs split and only
    resolved because of `from __future__ import annotations` — a latent
    NameError the moment anything evaluated that annotation.

gatelock is now at 0 files over 250 lines: 389 passed, 100% branch coverage,
the same 58 public names, and palette_check still at 0 failing checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:21:46 +02:00
40f94833c1 gatelock: split _outputs.py 401 -> 137 across four modules
_output_types.py 62  — Output, OutputRect, OutputScan (data only)
  _randr.py        157 — the optional python-xlib backend and its Protocols
  _xrandr.py       112 — the `xrandr --query` subprocess fallback
  _outputs.py      137 — the enumerator that picks between them

The two backends both needed the value types, which would have made the
dependency circular. The first attempt papered over that with function-local
imports; extracting the types into a leaf module removes the cycle instead, so
every import is top-level again — and, importantly, the tests' existing
`patch("gatelock._outputs.scan_xrandr")` targets keep working, which the
lazy-import version had silently broken.

_RR_CONNECTED moved with the RandR code and _XRANDR_TIMEOUT_S/_OUTPUT_LINE/
_GEOMETRY with the xrandr code, each carrying its comment. An earlier pass
tried to relocate those constants with a regex, which ate part of an unrelated
function body; that attempt was reverted and redone with asserted line
boundaries plus an AST check for unresolved names in both halves.

389 passed at 100% branch coverage; gatelock.__all__ still the same 58 names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:15:55 +02:00
02659e0abf gatelock: split _recovery.py and _surfaces.py, and fix two fail-open guards
Some checks failed
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
github_device_auth tests / test (push) Has been cancelled
_recovery.py  321 -> 242 + _recovery_types 47 + _grabwatch 84
  _surfaces.py  380 -> 250 + _placement 136 + _surface_types 54 + _textmirror 70

Two guards had to be repaired first, because both would have gone quiet
rather than red:

1. palette-drift CI was ALREADY BROKEN by the earlier _window.py split.
   palette_check.py reads LockConfig's colour defaults from a hardcoded
   gatelock/_window.py, and LockConfig had moved to _config.py — so it parsed
   0 tk tokens and reported "10 failing check(s)" where HEAD reports 0. Fixed
   the path, and made parse_tk raise instead of returning {} when LockConfig
   is not found: the empty dict is what let every tk comparison pass
   vacuously while the drift guard was off for the whole stack.

2. test_recovery_invariants' symbols() read one hardcoded filename, so
   splitting _recovery.py would have narrowed the "no weakening call"
   invariant — the one that exists because of the 2026-07-25 unlock — to
   whichever half kept the name, with the suite still green. It now globs the
   whole _recovery*/_surfaces* family and fails if the glob matches nothing.
   Verified it discriminates: a throwaway _recovery_probe.py calling
   restore_vt_switching made the test fail, and removing it restored green.

GrabWatch is a small object rather than a free function because holds_grab
and its once-per-cause warning share state; _placement holds the geometry
questions and the re-assertion pass, which can only ever add visibility.
_surfaces keeps an explicit __all__: the autofixer pruned the mirror_text_widgets
re-export once and `import gatelock` broke, so the intent is now stated.

389 passed at 100% branch coverage; gatelock.__all__ is the same 58 names as
HEAD; palette_check back to 0 failing checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 15:08:50 +02:00
085d08b5c9 Split the two shell scripts and the gate's own config
aur-publish/publish.sh   439 -> 148 + lib/aur_registration 107
                                      + lib/aur_verify 122 + lib/aur_publish_one 107
  guard-lib/lib/file_guard.sh  350 -> 140 + file_guard_unlock 214
  file_length/config.py        252 -> 45 + _tables 202 + exemptions 77

The libs are sourced, never executed: they read the readonly config the entry
script declares, and a `readonly` re-run inside a twice-sourced lib aborts
under `set -e`. Verified by function parity rather than by eye — publish.sh
resolves the same 17 functions as before the split, guardctl the same 15 —
plus `shellcheck -x` clean on every file and a real `--help` / bad-option run.

file_length is the gate's own code, so a broken import there turns the gate
into a no-op. Split three ways to avoid an import cycle without a `# noqa`:
_tables holds the data, exemptions the rules that read it, config re-exports
both so `from file_length.config import is_generated` still resolves. The
first attempt was wrong — EXCLUDED_DIRS was left behind and every invocation
raised — caught because the gate was re-run in both modes rather than
assumed. Now verified: exit 0 on a clean file, exit 1 on an over-cap one,
--explain intact, and the same 61 exemption decisions as HEAD.

Also deletes baseline-2026-08-14.json and SURVEY-2026-08-14.txt: the task
forbids a baseline file, and nothing in the tree read either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:59:27 +02:00
200432a2b4 dart: split ten oversized test files by group
firebase_auth_rest_test.dart  525 -> 153 + _google 158 + _tokens 206 + _uid 141
  sync_revisions_test.dart      451 -> 223 + _peers 228 + _backends 158
                                       + _order 165 + _persistence 215
  sync_settings_screen_test     457 -> 207 + _backup 143 + _google 145
  mirror_store_test.dart        452 -> 209 + _reads 172 + _fallback 196 + _revisions 244
  firebase_client_test.dart     374 -> 198 + _writes 227
  firebase_config_test.dart     360 -> 138 + _client 148 + _client_google 164
  device_auth_test.dart         307 -> 128 + _poll 201
  github_client_test.dart       291 -> 143 + _writes 201
  sync_test.dart                269 -> 160 + _skips 175

Two traps this hit, both invisible to a line count:

  - _FakePersistence is declared *after* main(), so a naive cut at the last
    brace dropped it; sync_revisions_persistence_test.dart failed to load
    until it was carried across. Audited every other split file for trailing
    top-level declarations — this was the only one.
  - Copying the preamble into each sibling left helpers the sibling never
    uses. `dart analyze` flagged 12; all removed rather than left as dead
    code, and the analyzer is now clean.

crdt_sync_dart 280 passed, sync_settings_ui 29 passed, github_device_auth
analyze clean — all matching a clean HEAD worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:54:35 +02:00
9d897c13f4 crdt-sync: split seven oversized test files by test group
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
test_firebase_auth.py    439 -> 170 + _google 159 + _tokens 159 + _session 175
  test_sync_revisions.py   406 -> 163 + _peers 162 + _backends 216
  test_firebase.py         369 -> 212 + _writes 221
  test_mirror.py           364 -> 178 + _fallback 229
  test_config.py           355 -> 139 + _credentials 112 + _client 233
  test_github.py           301 -> 127 + _writes 219
  test_sync.py             271 -> 195 + _skips 130

Cut at concern boundaries, not line counts: test_sync_skips.py holds the five
cases where a peer's pushed file is missing, corrupt, wrong-shaped or our own
— one story about what sync_log declines to merge — rather than an arbitrary
half of the original class.

259 passed at 100% branch coverage, verified against a clean HEAD worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:47:58 +02:00
389832314a gatelock: split seven oversized test files by test group
Each file split at a class or concern boundary, never mechanically:

  test_arbiter.py       432 -> 222 + _lifecycle 183 + _release 201
  test_outputs.py       418 -> 187 + _randr 204 + _enumerator 126
  test_scrollable.py    374 -> 231 + _into_view 197
  test_escape.py        343 -> 175 + _budget 216
  test_window_surfaces  341 -> 110 + _lifecycle 104 + _preempt 182
  test_surfaces.py      282 -> 167 + _focus 154
  test_window.py        269 -> 143 + _grab 139

Test counts verified against a clean HEAD worktree, per file group rather
than in aggregate: arbiter 49/49, outputs+escape 84/84, window+surfaces
72/72, scrollable 18/18. The whole suite is 389 passed at 100% branch
coverage, identical to before.

The one non-mechanical part: _reply/_randr_display are module-level helpers
defined *between* classes, so they did not travel with the header and had to
be moved to the RandR file by hand — six tests failed until they did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:45:08 +02:00
408c7a2585 gatelock: split test_recovery.py 477 -> 208 lines by test layer
The file's own docstring described three layers; they are now three files:

  test_recovery_invariants.py  (52)  — the static AST check that _recovery.py
                                       contains no weakening call at all
  test_recovery_reassertion.py (218) — what a tick re-asserts: grab, focus, VT
  test_recovery.py             (208) — the 2026-07-25 monotonic-sequence walk,
                                       tick mechanics, and scheduling

Splitting the classes made pylint's R0801 fire: both halves built a
RecoveryLoop with the same RecoveryCollaborators block. Rather than suppress
it (banned) the shared construction became conftest.build_loop(root, config),
which the `loop` fixture now uses too — so the tests that need a non-default
config ask for one instead of restating the wiring. The output scans both
halves drive moved to conftest alongside it.

389 passed, 100% branch coverage — same tests, same count, three files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:37:47 +02:00
48f6c7420f Gate the 250-line cap on every commit, repo-wide
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
design_system tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
A refactor without a gate silently regrows, so this is the part that makes the
cap stick. Until now the repo had no root .pre-commit-config.yaml at all --
`git commit` ran zero hooks, and the five subproject configs only covered their
own directories.

Adds a root config wiring scripts/check_file_length.sh as a local hook, and
carries the same hook in each of the five subproject configs so committing
with one of those is gated too. Staged files only, so an unrelated commit
never breaks on a violation it did not introduce; exit 1, not a warning; no
baseline and no allowlist, since those are suppressions.

Verified in a throwaway worktree, both directions:
  - a staged 261-line file fails the commit (exit 1, HEAD unchanged, no
    commit object created)
  - a staged 200-line file commits normally (exit 0)

The 44 files still over the cap cannot block this: the hook only ever sees
what a commit touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:31:35 +02:00
e651b1af17 gatelock: split _scrollable.py 495 -> 243 lines across four modules
Cut along concerns rather than line counts, so each file answers one question:

  _scrollfit.py   (180) — does the content fit, and what layout follows?
  _scrollkeys.py  (176) — key/wheel bindings and the six scroll commands
  _userintent.py  (106) — was this focus move the user's doing, or the app's?
  _scrollable.py  (243) — the widget wiring and the public surface

_scrollfit takes an explicit FitTarget bundle rather than the surface, matching
the ArmingCollaborators pattern; UserIntentTracker owns the two fields that
answer "did the user just act?", which is the guard that keeps the viewport
from scrolling itself under someone who touched nothing.

The 45-line module docstring — the three Tk defaults this viewport exists to
prevent, and the failure behind each — moved to docs/scrollable-viewport.md
rather than being trimmed. It is load-bearing rationale, so it is referenced,
not deleted.

389 passed, 100% branch coverage — unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:28:21 +02:00
940bf19d54 gatelock: split _window.py 571 -> 243 lines across four modules
_window.py was the repo's largest file and could not be read in one piece.
Split along the seams the file already marked out, keeping the public API
and every existing import path intact:

  _config.py  (215) — LockConfig, the design tokens and preset resolution
  _arming.py  (178) — the way up: VT-disable, backdrop, surfaces, input grab
  _preempt.py (118) — naming a blocked grab's holder, standing a weaker one down
  _hooks.py   (51)  — the LockWindowHooks Protocol, pure interface
  _window.py  (243) — construction, focus, and teardown

_arming takes an explicit ArmingCollaborators bundle rather than the window
itself, mirroring the existing RecoveryCollaborators; setup() returns whether
VT was disabled instead of mutating the window. That leaves no reach-back into
private members (ruff SLF001 clean) and makes the arming sequence testable
without a window.

LockConfig is re-exported from _window, so `from gatelock._window import
LockConfig` — which five sibling modules and the tests already do — still
resolves. Tests that patched moved symbols were retargeted to their new home;
the dead _maybe_preempt delegate, whose only callers were tests, is gone.

389 passed, 100% branch coverage — identical to before the split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:20:11 +02:00
376e5d4634 file_length: exclude Mapperatorinator from the 250-line cap
Some checks are pending
palette drift / palette (push) Waiting to run
Upstream clone at ~/Mapperatorinator is a third-party sibling (MIT,
github.com/OliBomby/Mapperatorinator), driven by subprocess from
~/osu-automapper and never vendored or modified.

Verified both directions: is_vendored() returns True for a 2538-line upstream
file (which would otherwise fail the cap) and False for ~/osu-automapper's own
sources, which stay gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iJHHN18dud8dXpjd7ff3R
2026-08-22 13:18:10 +02:00
c3260c26f5 Add a session prompt for taking gatelock's pylint to 10.00
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
gatelock is not the docstring problem its siblings are -- 312 of its 394
findings are pytest fixture idiom (redefined-outer-name, protected-access),
so the prompt makes that the explicit fork to settle with the user first,
citing screen-locker's tests hook as the existing precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:40:28 +02:00
2005c40baa Group sync_log's 11 arguments into three bundles
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
BREAKING: sync_log's flat keyword arguments are replaced by SyncTarget,
LogCodec and RevisionTracking. Callers change from eleven loose keywords to
four positional arguments; every consumer repo is updated in the same pass.

Eleven parameters on one function was the last real pylint finding in the
package, and the grouping is the fix rather than a workaround: the three
bundles are the three things a tick actually needs -- where to sync, how to
serialize, and whether to track revisions -- and each documents its own
fields. SyncTarget.own_ids also moves the "this device plus its pre-migration
id" rule onto the type that owns it, instead of recomputing it inline.

Internals follow: _pull_remote_logs takes a _PullContext instead of eight
positionals, and the push half splits into _merge_peers/_push, which is what
clears the 21-locals finding.

The two duplicated in-memory fakes merge into one FakeStore in conftest, as
does the no-bulk-read wrapper around it -- the shared half was the fiddly
prefix slicing, which is exactly where two copies drift.

crdt_sync now scores a clean 10.00/10 on pylint. Two suppressions remain,
both scoped and explained at the line: asserting on a private attribute IS
the timeout test, and reversing merge_record's operands IS the commutativity
test.

259 tests, still 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:22:25 +02:00
c96d179fb7 Prefer implicit booleaness in test assertions
pylint's C1803/C1804: `assert not merged` over `assert merged == {}`, in the
eight places where the comparison was to an empty literal. Takes the package
from 9.90 to 9.93.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:11:50 +02:00
1b8c99d2e1 Document every test, taking pylint from 8.58 to 9.90
pylint scored the suite at 8.58/10, and 277 of the ~300 findings were missing
docstrings -- every one of them in crdt_sync/tests/, none in library code.
ruff exempts tests from the docstring rules, so the two linters disagreed and
the score sat well below the bar.

Rather than silence pylint to match ruff, every test class, test function and
stub method now says what it pins. Stub arguments that exist only to match a
real signature are underscore-prefixed, which is the documented way to say
"deliberately unused" and clears the unused-argument findings too.

No behaviour change: 259 tests, still 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:10:23 +02:00
62da2102d6 Bump to 0.8.1 so the tag covers the timeout tests
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
crdt-sync-v0.8.0 was already pushed at the previous commit; moving a
published tag is worse than spending a patch number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:04:16 +02:00
60b7f7c02f Assert firebase_client_for actually forwards its timeout
The keyword has a default, so it adds no branch and 100% branch coverage
stays green whether or not it is threaded through to the client at all --
the gate is blind to exactly the thing the change was for. Two tests pin it:
an explicit value reaches the client, and omitting it keeps the shared
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:03:26 +02:00
4468df0e13 Pool HTTP connections, and let callers set the Firebase timeout
Every request went through the module-level requests.get/put/post/delete
helpers, so each one paid a fresh DNS+TCP+TLS handshake. Measured today
against the real backends that is 95ms/req to api.github.com and 52ms to
Realtime Database -- on a 27-request diet-guard tick, most of the wall clock.

Each transport module now binds a pooling requests.Session to the name
`requests`. A Session is a drop-in for those helpers, so all 9 call sites and
all 16 tests that patch `<module>.requests.<verb>` are untouched; the real
module stays available as `_requests` for the RequestException/Response
references. Sessions are per-module rather than shared because the tests use
sequence-consuming side_effect lists, where one module could otherwise eat
another's queued response.

Transient failures now retry, restricted to GET: a PUT here carries a
Contents-API sha, and replaying one whose response was merely lost would
write against a stale precondition on a remote the phone also reads.

Also thread timeout_seconds through firebase_client_for, which accepted none,
so a caller's tighter budget silently reached the GitHub backend only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 13:55:01 +02:00
a4b72a21c8 Make lock preemption opt-in: it could evict the armed workout lock
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
preempt_weaker_holder defaulted to True, so *any* app building a plain
hard LockConfig would SIGTERM a lower-ranked incumbent. wake_alarm
(RANK_WAKE_ALARM=300) builds exactly that and outranks screen_locker
(200), so on any morning wake-alarm-trigger.timer fired while the workout
locker was armed, it stood the enforcement lock down: machine unlocked,
no workout logged, next re-arm up to 30 minutes away.

Default to False and let screen_locker opt in explicitly, which is the
direction that actually motivated the feature (diet_guard held the grab
for hours on 2026-08-21). Nothing can now evict an enforcement lock
without asking for that power by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:31:23 +02:00
7e7c34a073 gatelock: make fit/scroll geometry tests immune to a real WM
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
Under i3 (not Xvfb), a managed tk.Tk() toplevel's geometry() request
was silently rewritten to the tiled pane size instead of the
requested WxH, so the content never actually overflowed and every
assertion built on "does this overflow" failed -- 11 tests across
test_fitcheck.py/test_scrollable.py. Fix: overrideredirect the
throwaway root, matching how every production lock window is already
built and for the identical reason (a WM must not be able to rewrite
geometry a fit/scroll check depends on).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:00:20 +02:00
24abb22de0 gatelock: preempt a weaker holder blocking a stronger claim's grab
wait_for_turn only helps a weaker locker arriving after a stronger
one; it does nothing when the weaker one is already the incumbent.
On 2026-08-21 diet_guard (rank 100) held the grab for ~3 minutes
against a newly-arriving screen_locker (rank 200), which just
retried and logged instead of ever making progress.

Fix: when a grab is blocked by a holder that outranks-us-not, SIGTERM
it once. The holder's own signal handler already converts SIGTERM
into its normal close() teardown (VT restore, arbiter release,
app-specific on_close) -- this reuses that path rather than adding a
new one, and never signals upward (never disturbs an incumbent that
outranks us), so it cannot become a way to bypass a stronger lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 12:57:41 +02:00
06aae48e24 gatelock: promote the priority queue from leetcode_guard
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
wait_for_turn/stronger_claims already fixed leetcode_guard's grab-race
with screen_locker; screen_locker and diet_guard never got the same
fix, so a stray re-arm race between them was still live (confirmed
2026-08-21: diet_guard held the global grab 07:04-09:07 while
screen_locker built a competing window and retried its own grab
forever instead of queueing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 12:33:43 +02:00
1533eec081 Distribute dopamine-ux prompts into their target repos
Some checks failed
palette drift / palette (push) Has been cancelled
Each prompt is now also copied to <repo>/prompts/dopamine-ux-*.md so a repo can
be opened and the prompt run directly. ~/utils/dopamine-ux/ remains the source
of truth; corrections go here and are re-distributed.

Adds the completion protocol every copy now carries: on finishing, the session
deletes its repo copy in the implementation commit and appends a DONE row to the
completion log in 00-INDEX.md. Partial completion is never recorded as done --
the file is edited to state what remains instead, since a stale prompt is
indistinguishable from a pending one.

Also fixes a codespell violation (re-declared -> redeclared) caught by
diet-guard's pre-commit hooks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FdApz7EZ1zDfvtvvi1idDv
2026-08-16 21:13:19 +02:00
35415de225 Add dopamine-ux session prompts for the UX feedback work
Nine copy-pasteable per-repo prompts derived from "The Dopamine Architecture"
(london-post.co.uk), plus an index and a verification gate.

The four mechanics (variable rewards, micro-animations, sound, near-miss) are
aimed at kuhy's own stated goals and gated by two tests: is the displayed state
true, and is the variance real. Corollary that shapes every prompt: reward the
recording, never the number recorded -- rewarding a calorie target teaches
under-eating rather than honest logging.

Surveys found the fleet has exactly one animation across four Flutter apps, no
motion tokens in any stack, and a populated screen-locker streak that no
renderer reads. Two constraints are recorded as must-not lines because a naive
implementation would violate them: screen-locker rewards must never reduce
enforcement (a banked skip credit was deliberately removed), and wake-alarm's
UI sound layer must never touch the desktop alarm daemon.

verify_prompts.py checks all 146 cited paths and 30 cited symbols, so a prompt
pointing at a renamed function fails loudly instead of wasting a session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FdApz7EZ1zDfvtvvi1idDv
2026-08-16 21:09:33 +02:00
f8dded33c7 Exempt frozen session artifacts from the 250-line cap
An implementation plan under docs/superpowers/plans/ and an append-only log
under docs/superpowers/sessions/ are frozen records of one past session. They
are read whole or not at all, so splitting one rewrites a historical record
for no reading benefit -- the same reason generated files are exempt.

Scoped deliberately: specs/ and contracts/ stay capped, because those are
living documents that get re-read and edited.

is_session_artifact() is kept separate from is_vendored() so the gate reports
an accurate reason -- these are kuhy's own files, not third-party code.

Relaxes the gate only, so no already-wired repo can newly fail. Verified:
~/todo still exits 0 on --all; testsAndMisc drops 198 -> 197 violations
(plans/ exempt, specs/ still caught) in both absolute and relative modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:06:42 +02:00
682eaf6f8c unified-design-system: record the two extractions this pass shipped
Some checks failed
github_device_auth tests / test (push) Has been cancelled
palette drift / palette (push) Has been cancelled
`ts_core` (@kuhyx/ts-core) and `github_device_auth` close the two clusters
the audit flagged as genuinely missed. Six shared packages now, not four.

Both extractions turned up the same lesson, worth writing down: the copy
that looks canonical is not always the one to take. Each cluster had a
behaviour living in exactly one repo that a naive "extract the tidiest
version" would have silently deleted --

  * diet-guard's configurable device-flow endpoints, without which its
    desktop web build cannot authenticate at all (no CORS headers on
    GitHub's endpoints)
  * wake-alarm's transient-socket retry, without which an approved grant
    is lost if GitHub closes the connection at the wrong moment

Both are now in the shared package, so all four apps gained the fix the
other three never had. Same shape in ts_core: the RNG ships two interfaces
rather than one reconciled shape, because the generated sequence is the
contract and re-rolling every saved world would be a worse outcome than
the duplication.

sims3-clone is recorded as the one consumer still pending -- another
session had uncommitted work there, so migrating it would have entangled
two sets of changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 16:17:34 +02:00
254e3b00c7 github_device_auth: retry transient network failures (v0.3.0)
wake-alarm's copy carried a fix the other three lacked: `pollForToken`
swallows a transient socket failure and keeps polling, because GitHub can
close the connection at the exact moment the user authorizes and a phone
can drop its network mid-poll. Without it, an approved grant is lost to a
blip and the user has to start the whole flow again.

Extracting only todo's version would have silently deleted that fix from
wake-alarm. Taking it into the package instead gives it to all four apps.

Catching `http.ClientException` is sufficient, and avoiding `dart:io` here
is deliberate: diet-guard's desktop **web** build imports this package, and
naming `SocketException` would break that compile. Checked rather than
assumed -- a probe against a dead port shows package:http raises
`_ClientSocketException`, which implements *both* `ClientException` and
`SocketException`, so the single catch covers IO and web alike.

24 tests, 100% line coverage, clean analyze.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 16:05:12 +02:00
14a50b44e2 github_device_auth: configurable endpoints for web builds (v0.2.0)
Some checks are pending
github_device_auth tests / test (push) Waiting to run
palette drift / palette (push) Waiting to run
diet-guard's copy was not cosmetic drift from todo's: it takes the
device-code and token URLs as parameters, because GitHub's device-flow
endpoints send no CORS headers and its desktop **web** build has to route
both through a local proxy (`github_client_factory_web.dart` passes them).
Shipping only todo's hardcoded version would have broken that build the
moment diet-guard adopted the package.

So the package takes the more capable variant: `deviceCodeUrl`/`tokenUrl`
default to GitHub's own URLs, leaving every other consumer unaffected, and
`githubDeviceCodeUrl`/`githubTokenUrl` are exported so callers can
reference the defaults rather than retyping the literals.

23 tests (two new ones pin both the defaults and an override actually
being used for the request), 100% line coverage, clean analyze.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 16:01:21 +02:00
795a8eae62 github_device_auth: extract the GitHub device flow from four apps
todo, home_inventory, diet-guard and wake-alarm each carried the OAuth
device-flow client and the modal that drives it -- ~650 lines, with two
pairs byte-for-byte identical (todo/home_inventory's clients, and
diet-guard/wake-alarm's dialogs).

Deliberately its own package rather than part of `sync_settings_ui`. That
package is Firebase-only by an explicit 2026-08-12 decision recorded as
"do not re-add a GitHub section without kuhy asking again", so folding a
GitHub flow into it would reverse a standing decision. The GitHub mirror
in these apps is legacy-but-load-bearing code that was explicitly not
authorised for deletion, so it gets its own home instead of being merged
into the Firebase path or dropped.

One behaviour change, made once here instead of four times: the dialog
catches `on Object`, not `on Exception`. An `Error` is not an `Exception`
in Dart, so `ArgumentError` -- notably from URI parsing -- escaped
`_poll()` and left the dialog spinning on "Waiting for authorization…"
forever with nothing shown. That is the same trap that silently killed the
Firebase sync tick across these apps in 2026-08. A test pins it with a
mock that throws an Error rather than an Exception, so it cannot regress
back to `on Exception` unnoticed.

20 tests, 100% line coverage, clean under very_good_analysis, with the
same CI gate the other Dart package holds. Consumers are migrated
separately so each app's own suite gates its own swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:57:19 +02:00
41f014e7a7 ts_core: a framework-free TypeScript utility package
Some checks failed
palette drift / palette (push) Waiting to run
ts_core tests / test (push) Has been cancelled
`Clock` and a seeded mulberry32 RNG had each been reimplemented across the
game and web repos -- Clock twice (konbini-67, iron-and-anvil, doc comments
near-verbatim), the RNG four times. None of it is UI, so no existing shared
package owned it: `web_ui` ships `tokens.css` and React components, and a
game importing a UI package to get a seeded RNG is exactly the coupling
that makes people vendor their own copy instead.

The RNG deliberately exports **two** interfaces rather than reconciling
them into one. konbini-67 threaded mutable state through free functions;
sims3-clone and europe-county-map returned an object of closures. The
sequence is the contract here -- these repos have seeded tests and "same
seed -> identical world" guarantees, so changing how many times a helper
advances the generator silently re-rolls every saved world. A shared
package that did that would be worse than the duplication it replaces. The
core is shared, so both interfaces yield the same float sequence; that is
asserted, not assumed.

`rng.test.ts` pins the first five floats for seed 12345, captured from the
pre-extraction konbini-67 implementation rather than from this one -- a
golden test generated from the code under test only asserts that the code
matches itself. It earned its place immediately: the first constants I
wrote came from a truncated `toFixed(9)` probe and the test failed, which
is precisely the class of silent precision drift it exists to catch.

`hotline3d` is not a consumer and should not become one. Its generator uses
`%` and `Math.trunc` where mulberry32 uses `>>>`/`>>`; measured, it emits a
different sequence, so adopting this package would change its output.

29 tests, 100% branch/function/line/statement coverage, type-aware eslint,
and a committed dist/ with a CI freshness check -- the same bar web_ui
holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:42:41 +02:00
0dd4229432 unified-design-system: record the extraction audit, unstale the phase headings
Answers "did we extract every component we actually use?" now that Phases
1-3 have shipped.

The headline: the extractions hold up. No shipped component was a false
positive, and the reverse check -- were the donor copies actually deleted?
-- is clean except for billsplit's `_SectionHeader`, fixed separately in
testsAndMisc@54054e7. The one clear miss is `DeviceCodeDialog` x3, two
copies of which are byte-for-byte identical; it belongs in
`sync_settings_ui` rather than `design_system` because it depends on
GitHub auth types.

Also corrects two things the audit surfaced:

`nielsen-audit.md` described Phase 2 *and* Phase 3 as "not started". Both
shipped -- gatelock 0.5.0 and web_ui-v0.3.1. A planning doc that says "not
started" about finished work is worse than no doc, because the next
session reads it as a backlog and rebuilds what already exists. Both
headings now state what shipped, and the plan text is kept below as the
record of why. The Phase 2 note also spells out that screen-locker and
wake-alarm keeping local fan-out copies is deliberate (only two consumers
were bumped, to avoid restarting live systemd services) so it does not
read as a regression to be "fixed".

The audit also found a fourth shared package the phase briefs omit:
`sync_settings_ui`, consumed by four apps. It resolves most of the
`SettingsScreen` cluster, which the grid still listed as outstanding.

The remaining true duplicates (`_PrefsPersistence`, `Clock`, mulberry32
RNG) are non-UI utilities outside every package's remit, and are recorded
as such rather than as a reason to widen web_ui into a general utility
package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:29:53 +02:00
ccef40e817 unified-design-system: check one palette across all four token sources
`tokens.md` freezes the palette as prose and three packages re-express it
as code -- `web_ui/src/tokens.css`, `design_system`'s `tokens.dart`, and
gatelock's `LockConfig`. Nothing compared them, so "one palette
everywhere" held only for as long as four hand-maintained copies happened
to agree. A hex edited in one stack drifted silently in the other three.

`palette_check.py` parses all four and runs two halves:

  * agreement -- every mapped token carries the same hex in every
    non-exempt stack
  * completeness -- every colour parsed from any source must be mapped or
    exempted with a stated reason

The second half is the load-bearing one. A pure agreement check passes
happily when someone adds a token to one stack and never touches the map,
which is the drift most likely to actually happen; an unmapped token is
therefore a failure, not a skip. Absences are declared per token per stack
with a reason (Tk has no light theme and no borders; the categorical ramp
is web-only) so "this stack doesn't have it" is on the record rather than
a silent pass.

It found one real gap on its first run: `tokens.css` sets `--surface-2` to
`paper-raised` in the light block -- the light theme collapses both
elevation steps onto one surface -- which `tokens.md` documented only in
prose and no map covered. Now an explicitly compared row, so if either
half of that pair moves, it fails.

Proven to fail, not just to pass: drift injected into each of the four
sources in turn, plus an unmapped token, was caught every time with the
offending stack named; re-proven after the 250-line-cap split into
`palette_map.py`. Runs from its own workflow with deliberately no `paths:`
filter -- drift is a disagreement *between* packages, so a path-scoped
trigger would skip exactly the edit that causes it. That was not
hypothetical: `ramp_check.py` was reachable only from the
`web_ui/**`-filtered workflow, so a ramp edit in `tokens.md` or
`design_system` never ran it. It moves here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:27:17 +02:00
5045f20c40 unified-design-system: record the Phase 3 gate
Some checks failed
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:11:03 +02:00
6714d13198 web_ui: v0.3.1 — scope check-json past JSONC tsconfigs
The tsconfigs carry comments (TypeScript allows them); check-json does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:08:48 +02:00
eaa5683ff1 web_ui: add the CI + pre-commit gate, and make pnpm lint actually run
Matches the Phase 1 design_system pattern the brief points at: own lint config,
own tests, own coverage gate enforced in CI.

The lint script was declared but unrunnable -- there was no eslint flat config,
and the build tsconfig excludes tests (correctly; they must not ship in dist/),
so type-aware linting had no project for them. Adds eslint.config.js and a
lint-only tsconfig that widens the include without emitting. Running it surfaced
one real defect: nth() interpolated a number into a template literal without
String(), which the consumers' configs would have flagged.

The gate also checks that the committed dist/ matches a fresh build. This is not
theoretical -- the check caught the stale dist/ produced by that very String()
fix on its first run. A stale dist/ in a tag ships old code to every consumer
with no error anywhere, which is the one failure mode this packaging choice
introduces. dist/ is excluded from the whitespace hooks: they would "fix"
generated output that the next build immediately reverts.

CI additionally re-runs the categorical ramp's constraint proof, so the palette
cannot regress silently either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:08:18 +02:00
56d0c3d420 unified-design-system: record Phase 3
Documents why FilterBar was not extracted (a name collision over disjoint
domains, not a duplicate), the categorical ramp decision, the npm-cannot-consume
finding, and the index-vs-distinct-value bug that unit tests could not see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:04:01 +02:00
086791f4f9 web_ui: document distinct-value stepping
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:59:41 +02:00
133c210b2e web_ui: step the slider to the next DISTINCT value (v0.3.0)
Caught by driving the real browser, not jsdom: on awesome-mcp-explorer's star
distribution the arrow keys did nothing at all. ~1500 of the ~2981 values are
0, so an index step landed inside the run of duplicates -- the index moved, the
value did not, and the thumb, the readout and the filter all stayed put.

steppedValue now walks to the next distinct value, so one arrow press is one
visible change on any distribution, however lumpy. The regression test uses the
shape that actually broke rather than the evenly-spaced fixture that hid it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:59:27 +02:00
2574b21acc design-system: add a categorical ramp; web_ui v0.2.0
awesome-mcp-explorer needs six mutually distinguishable colours for its A-F
grades, and the design system had no answer -- one accent, and semantic roles
that deliberately cluster in one hue wedge. Raised as its own decision (per
PHASE3-PROMPT) and resolved by designing the ramp rather than mapping grades
onto success/warning/danger, which would have collapsed six steps into three.

Chosen by search, not by eye, under four hard constraints:
  - pairwise CIE dE >= 20 under normal vision AND deuteranopia, protanopia and
    tritanopia (worst observed 26.0)
  - >= 3:1 against BOTH backgrounds, since the ramp does not re-theme. This is
    the binding constraint: such colours only exist in L* 44-59.
  - monotonic lightness 58 -> 44, so hue and lightness encode redundantly and
    the ramp survives greyscale as an ordinal scale
  - muted saturation, to sit with the rest of the palette

scripts/ramp_check.py re-runs the whole proof and exits non-zero on a
regression, so this is a gate rather than a paragraph. A seventh hue is not
available (best candidate reaches dE 17.9); group the tail into "other".

This ramp is the only token group that leaves the warm wedge, deliberately:
hue separation is the entire point of a categorical scale.

Also: tokens.test.ts guards every frozen value against drift, and
tokens.md was split (screen-size.md) to hold the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:50:43 +02:00
03bf1ed08b web_ui: shared web token + component layer (Phase 3)
The web counterpart to design_system (Dart): one :root block of frozen tokens
plus the components that were duplicated across repos. Extracted from
dufs-cloud and awesome-mcp-explorer, which both now consume it.

- tokens.css: the frozen palette, spacing/radius/type scales, shadow policy and
  focus ring, under the canonical variable names from unified-design-system.
  Also ships the global :focus-visible rule, rather than leaving each consumer
  to remember it.
- RangeSlider: reconciled from the two drifted copies. Takes the accessible DOM
  from awesome-mcp-explorer (real <button> thumbs with role="slider" and the
  aria-value* trio) and grab-the-nearer-thumb from dufs-cloud. Keyboard support
  (arrows/PageUp/PageDown/Home/End) is new: both donors were pointer-only, which
  the design system rules out, and an arrow-key-deaf role="slider" is an ARIA
  role that lies.
- quantile.ts + fuzzyMatch: the genuinely duplicated helpers.

FilterBar is deliberately absent. The two same-named components share no
structure -- a horizontal <select> strip over files vs a vertical tri-state chip
sidebar over servers, with disjoint FilterState, props and sort keys. It is the
name-collision-is-not-a-duplicate trap, so extracting it would ship a widget
nobody imports.

dist/ is committed (and un-ignored against the root rule) because Vite does not
transpile dependencies; consumers stay at zero config. Consumed via pnpm's
&path:/web_ui suffix -- npm cannot install from a subdirectory of a git repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:40:45 +02:00
d25f2885da gatelock: 0.5.0
Some checks failed
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Phase 2 adds four exports (make_button, heading, row, WidgetGroup) plus a
wheel-scroll fix in ScrollableSurface. Minor, not patch: the surface grew.
Purely additive -- nothing was removed, so consumers still on the 0.4.1 pin
keep working unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:21:10 +02:00
d82107ab9a gatelock: bind the wheel per descendant in ScrollableSurface
Wheel-over-text did not scroll. Tk dispatches through *bindtags*, not the
parent chain -- a child's tags are (itself, its class, the toplevel, "all") --
so binding <Button-4>/<Button-5> on `content` was never consulted for an event
over a Label sitting on top of it. Content that fills its viewport covers that
frame completely, which is every screen this class exists for, so the mouse
wheel was dead exactly where a user points it.

Found while retiring leetcode-guard's hand-rolled _scrollable, which got this
right by accident: it used bind_all, which works but installs the handler on
every widget in the application, including other windows' scroll regions.
Binding per descendant inside the existing _track_focus walk keeps it scoped,
and reuses that walk's WeakSet so a repaint cannot stack duplicate handlers.

Verified both directions under xvfb (Button-5 scrolls down, Button-4 back up)
from an event generated on a Label, not on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:12:30 +02:00
c963cd88a8 gatelock: add shared button, heading/row and WidgetGroup
Phase 2 of the shared-component work. gatelock owned the tokens but stopped
short of composite widgets, so four gate apps each built the same ones and
drifted apart doing it.

widgets.py:
- make_button(), from diet-guard's _gatelock_buttons. Emphasis is a variant,
  not a colour pair, so the text colour is picked *from* the fill and a call
  site structurally cannot produce the fg-vs-on_fill contrast bug. Binds
  <Return>/<KP_Enter>, which Tk does not give tk.Button on X11, and takes its
  focus ring from LockConfig (Tk's default 1px black is invisible on #211D1B).
- heading() and row(), from leetcode-guard's _status_sections. row()'s wrap
  width is a parameter rather than the donor's module-level dict, which made
  every row's wrapping depend on whichever window rendered most recently.

widget_group.py:
- WidgetGroup, replacing ~916 lines of per-output fan-out across four repos.
  Takes the union of what they needed and the fixes only some of them had:
  first -> W|None (three donors could IndexError on zero outputs), TclError
  suppressed per copy (one donor did not guard at all), focus deliberately
  singular rather than fanned out, and kwargs typed `object` so command= is
  expressible. Stores (output_name, widget) pairs, which designs out a latent
  strict-zip ValueError in the donor that kept two parallel lists.

Additive only: nothing is removed from the consumers in this commit, so all
four gates keep working on their current pin. 100% branch coverage, real-Tk
tests under xvfb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1qGVUMjmtppci6Fjxs9xS
2026-08-14 23:54:52 +02:00
b9e54380db Add copy-paste session prompts for Phase 2 and Phase 3
Each is written to be pasted into a fresh session with nothing added.
That means carrying the answers the spec gate would otherwise demand --
goal, the decisions already made, boundaries, and a measurable done
condition -- and opening with an explicit override so the gate does not
fire a question round on a spec that is already complete.

Both point at nielsen-audit.md for context but do not depend on it to be
actionable, since a fresh session will not have read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:38:17 +02:00
b4f1b32cef Record the four palette migrations as done
focus_owner, kuhylog, epopeja_karta and billsplit are all on the frozen
palette now, so the "drift to correct" table becomes a record of what was
fixed rather than a to-do. diet-guard and macro-cam remain.

Writes down the two deliberate exceptions, because both look like
oversights to anyone who did not make them:

epopeja_karta imports AppPalette rather than buildDarkTheme(). Its nine
slots were already byte-identical to the frozen palette, so only the
transcription was local, and it is one dense screen with a test asserting
nothing scrolls -- the shared theme's larger type scale overflows it by
34px. Importing tokens leaves the rendered ThemeData identical, so that
test passes by construction rather than by tuning sizes until it fits.

billsplit keeps its four category-dot colours. They encode which category
an item is, not a theme role, and a single-accent system has no four
distinct hues to lend them. That is now flagged as the open
categorical-ramp question: the design system has no answer for "N
mutually distinguishable hues", and it needs one before any chart, tag or
category UI can be built on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:37:19 +02:00
480a8bdd82 Stop const-folding EmptyState's constructor out of coverage
Some checks failed
design_system tests / test (push) Has been cancelled
Removing the action slot made the constructor const-invocable, so the
test's `const EmptyState(...)` was evaluated at compile time and the
constructor line never executed -- 99%, caught by the pre-push gate
rather than by review, which is the point of having it.

Building the message through a helper keeps the invocation non-const.
That is also closer to real call sites, which interpolate runtime state
into the string rather than passing a literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:10:43 +02:00
3dab75fde5 Record which design_system exports actually have a live consumer
An extracted component with no importer is the failure mode the audit
exists to prevent, so leaving that implicit was the wrong call.
EmptyState and AppStatusColors ship ahead of their consumers -- both have
real donors waiting, and AppStatusColors has to exist for showError to
read the danger hue off the theme -- but neither is validated, and the
table now says so rather than letting the package read as fully proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:09:45 +02:00
522fa1dff0 Wire design_system into CI, and drop EmptyState's speculative action slot
The pre-push coverage gate only ran for whoever had the local hook
installed, which is deployment hygiene rather than a gate. Adds the
mirrored workflow sync_settings_ui already has, so the 100% bar is
enforced on push and PR.

EmptyState.action was invented surface: no donor had it. home_inventory's
three call sites are pure placeholders, and kuhylog's action-bearing
_empty was explicitly excluded from the survey as a different element.
Shipping a parameter nobody asked for on a widget that has no consumer
yet is the failure mode this package is supposed to avoid, so it goes,
with a comment recording why rather than leaving the next reader to
rediscover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:08:59 +02:00
cae0870fd0 Add the Nielsen coverage audit alongside design_system v0.1.0
The grid maps Nielsen's 10 GUI elements to what actually exists per stack,
and records the deferred clusters (GitHubMirrorScreen x5, SettingsScreen x6,
~20 list tiles, filter sheets) with repo, path and line count so none of it
needs rediscovering.

It also records three corrections the measurement turned up, because each
one changes what is worth doing next:

- The "343 raw color literals" are not spread through call sites; they are
  concentrated in the hand-copied theme files. todo had 35, all 35 inside
  theme.dart. Seven repos have zero literals outside their token file, so
  deleting it removes essentially all of them. The repos with genuine
  call-site leakage are billsplit, epopeja_karta and focus_owner.
- There are 4 AppStatusColors, not 5, and they disagree about their own
  field sets; the package's version is a superset so nobody loses a field.
- There are 3 section headers, not 5, and no _SectionLabel exists.
  epopeja_karta's is a different element (an eyebrow, not a section title)
  and was deliberately left out rather than force-fitted.

Also records that workout_app, cited as a consumer in the plan and in
several skill docs, does not exist on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:01:31 +02:00
e6c9af81e9 Add design_system: the shared Flutter component layer
unified-design-system/ froze the token layer as prose -- 1220 lines of
README, tokens.md and annotated HTML, and zero importable code. Every
Flutter repo transcribed the token table by hand, so the "shared" palette
was enforced only by discipline, and had already drifted: 10 divergent
theme.dart copies (todo and untools byte-identical, the other 8 each
unique), ~318 raw color literals, 4 hand-rolled AppStatusColors extensions
with inconsistent field sets, ~35 inlined AlertDialogs, ~38 raw SnackBar
sites with no helper anywhere.

This promotes the token layer into an importable component layer, so the
design system is enforced by the compiler and the package manager instead.
The components own the tokens: a consumer has nothing local left to edit.

Scales are the six-repo consensus, which is also what tokens.md specifies;
drifted repos get corrected to this rather than the reverse. Themes are
built from explicit ColorSchemes, never fromSeed -- the palette is
hand-picked for contrast, and secondary/tertiary are set explicitly so
SegmentedButton cannot fall back to stock Material teal.

Widgets are extracted only where two or more repos already had a
structurally similar implementation; SectionHeader reconciles untools'
trailing-action variant with billsplit's padded one as an opt-in union, so
either caller can adopt it with no visual change.

63 tests, 102/102 lines covered, gated at 100% on pre-push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 22:51:06 +02:00
ca81e091a1 Skip git-ignored files in the length gate, and vendored skill bundles
Three exemptions the first cut missed, all of them files no commit could
ever fix:

- Git-ignored files (todo's exported BACKLOG.md, logs, scratch dumps).
  These are rewritten wholesale by whatever produces them, so splitting one
  is meaningless, and pre-commit never sees them anyway. Applied to
  explicitly-named paths as well as --all, so the two modes cannot disagree
  about the same file.
- Vendored agent-skill bundles (.agents/skills, .claude/skills). A SKILL.md
  is a fixed-name file with frontmatter; splitting one breaks the format.
  Matched by directory name wherever it appears, since these get dropped
  into any repo.
- .utils, where CI checks out this repo. Without it a repo's lint job fails
  on utils' own long files, which that repo cannot fix.

Baseline accordingly drops from 657 to 597. Re-verified all eight fixtures:
over-cap fails, under-cap passes, generated/vendored/wordlist/git-ignored
exempt, prose still capped, and the gate still passes on its own source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 15:58:46 +02:00
c54211f112 Add file-length survey baseline (2026-08-14)
The measured distribution the 250-line cap was derived from, and the 657
files currently over it, so the baseline is reproducible rather than a
number in a commit message.

Stored as JSON, not a text list: a 651-line .txt would itself have failed
the gate, and the data belongs in a data file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 14:35:42 +02:00
c9bf289510 Add shared 250-line file-length gate
Files that cannot be read in one piece force re-reads and partial edits,
which is the largest avoidable cost in an LLM-assisted workflow. This adds
the enforcement side of a 250-line cap covering code AND prose.

- file_length/config.py: single source of truth for the cap and its
  exemptions (generated, markup, vendored, data-ish txt), shared by both the
  gate and the survey so the two cannot silently disagree.
- file_length/check.py: the check itself. Exits 1 and lists violations;
  adjudication is an exit code, not a judgement call.
- scripts/check_file_length.sh: thin wrapper pre-commit calls with staged
  files.

Verified against six fixtures: over-cap code fails, under-cap passes,
generated l10n (1417 lines) exempt, vendored pdfjs exempt, the 174800-line
wordlist exempt as data, and a 1101-line CLAUDE.md correctly fails since
prose is capped too.

Baseline at time of writing: 657 files over 250 lines across 29 repos.
Each repo carries a refactor_claude_todo.md prompt with its own list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 14:24:57 +02:00
284aa1e360 sync_settings_ui: assert the password form starts collapsed when Google is offered
Some checks failed
sync_settings_ui tests / test (push) Has been cancelled
Flagged during todo's migration: the "Google available" test asserted the
button renders and works, but never checked that the password/email
ExpansionTile actually starts collapsed behind it (initiallyExpanded:
!googleAvailable) -- the one behavior the old duplicated todo test covered
that this package's own suite didn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-12 23:24:57 +02:00
23c693da4e Add sync_settings_ui: shared Firebase-only sync settings screen
Extracts the near-identical "Sync settings" UI duplicated across 5 apps
(todo, diet_guard_app, workout_app, wake_alarm/phone_app, home_inventory)
into a single tested package. Firebase sync (primary) + optional local
backup slot; GitHub mirror UI was dropped per kuhy's explicit decision
mid-build in favor of Firebase-only, so existing apps keep their local
GitHub code untouched, just disconnected from this shared screen.

100% line coverage, flutter analyze clean under very_good_analysis.
Generalized check_no_crdt_sync_override.sh to guard any utils git
dependency (crdt_sync, sync_settings_ui), not just crdt_sync by name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-12 23:10:17 +02:00
92f55cee5a gatelock 0.4.1: re-focus a surface that comes back after a blip
Some checks failed
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
LockWindow.grab_input() only ever focuses the entry once, on first grab
acquisition (_notify_focus_ready is one-shot). A surface torn down and
rebuilt later -- a monitor going dark and recovering, which flaky DDC/CI
power control does routinely -- gets a brand new widget that nothing
ever focuses. The grab stays held, so no other window can take focus
either: the entry renders correctly but is permanently keyboard-dead
until the process restarts.

RecoveryLoop.tick() now re-runs the same focus_surface(preferred_focus_
index()) call whenever a tick's surface delta reports a newly created
output, mirroring what LockWindow does on first acquisition. Bundled the
loop's five collaborators into a RecoveryCollaborators dataclass rather
than growing its constructor past ruff's five-argument limit.

Diagnosed against a week of wake-alarm's real journal history: the
dismiss-code box rendered normally every alarm morning but accepted no
keystrokes, tracking exactly with HDMI-0's recorded dark/recover blips
near 07:00 and 07:55 each day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc
2026-08-12 22:13:46 +02:00
5f64a8959b Sign in with Google, and stop trusting a revoked session
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
Two changes to the shared sync credential path, both driven by devices
that reported "Connected" while every sync failed.

Google sign-in (signInWithGoogle, both languages) replaces typing the
sync account's password on a fresh install. The account is linked once
from the PC with tool/link_google.py, which asserts the pinned uid --
without that assertion an unlinked identity is *signed up* rather than
refused, and the new uid is then denied every read and write by the
security rules.

The second change is the one with teeth. hasSession() is a presence
check, so a refresh token the server had revoked still read as a live
session: the desktop todo build showed "Connected" and then failed every
sync with `HTTP 400 (TOKEN_EXPIRED)`. A refresh that fails for a terminal
reason now clears the stored session, so hasSession() answers honestly.

Deliberately narrow: only TOKEN_EXPIRED, USER_DISABLED, USER_NOT_FOUND,
INVALID_REFRESH_TOKEN, INVALID_GRANT_TYPE and MISSING_REFRESH_TOKEN
clear it. A network error or a 5xx must not -- a device that signs
itself out whenever the wifi drops needs a manual sign-in on every
device to recover, which is worse than the stale banner this fixes.
Verified against the live endpoint, not just mocks: has_session flips
True -> False on a real rejection.

crdt_sync_dart 0.9.0 -> 0.10.0; the five apps pin the matching tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192EkoAyFBEWTvAN5vVgmSy
2026-08-11 23:58:02 +02:00
f902bbe0aa Let a mirror read survive the primary being down
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
MirrorSyncClient promised that reads consult both backends "so the
cutover order is irrelevant", but every read called self.primary
unguarded. A Firebase outage therefore produced no data at all and the
GitHub mirror was never consulted -- the union read silently degraded to
nothing, precisely when the fallback existed to help.

This is not hypothetical: screen-locker's workout sync hit it on
2026-08-10, where a DNS failure reading the primary took down the whole
locker rather than falling back.

Reads (list_directory, get_file_text, get_string_map) now degrade to the
mirror when the primary raises, and only fail when NEITHER backend can
answer -- returning an empty result would be indistinguishable from "no
data", which callers act on. Writes keep the old asymmetry on purpose: a
write the primary did not accept has not happened, so it must still fail
the tick.

A mirror that cannot bulk-read (GitHub) contributes nothing but is not a
failure, so it cannot by itself make a revision-map read raise. Both
language ports agree on that.

Same fix in the Dart port, which had all three read paths wrong too and
runs on the phone. Two older tests asserted the buggy read-fail-closed
behaviour and are renamed to describe what they actually exercise: the
both-sides-failed case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
2026-08-10 14:30:29 +02:00
52186aa5a4 aur-publish: make the launch check actually exercise a cold start
verify_installed() treated "already running" as a pass, so on any run where an
app was already open the strongest gate in the script silently became the
weakest: it proved only that the binary printed something. It now stops a
leftover instance first, so the check always tests a real cold start.

Killing that instance is fiddlier than it looks, and both naive forms are
broken here:

  * `pkill -x <name>` matches the kernel's 15-character comm field, and two of
    the three wrappers are longer (habit_stack_desktop is 19). It would have
    silently never fired -- confirmed by watching an instance survive it.
  * `pkill -f <path>` matches this script's own command line and the shell
    wrapping it, so it kills the run instead of the app. Observed as an exit
    144 with no output, twice.

So the pids come from ps, filtered on the installed /opt path, with our own
process and parent skipped.

Also threads the wrapper process name through the package table rather than
deriving it: diet-guard-app runs diet_guard_desktop, so name mangling produces
the wrong process.

Verified: with an instance deliberately running, the guard reports stopping it
and the launch check then passes on a genuine cold start; all three packages
still pass the full dry run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-10 07:18:35 +02:00
cc19d568a3 Add aur-publish: hands-off AUR publishing for the Flutter apps
AUR registration is currently returning a bare nginx 503 server-side, so the
three packages (todo-flutter, habit-stack, diet-guard-app) are built and
verified but cannot be published yet. This runs the whole thing unattended
whenever signup reopens.

anubis_gate.py exists because the AUR sits behind Anubis: every HTML request
gets a proof-of-work challenge page carrying HTTP *200*, so a plain curl
reports 200 for a page that is actually unreachable. That is how the 503 got
misdiagnosed twice as a local/browser/IPv6 problem. The module solves the
challenge and reports the status of the real page behind it.

Fail-closed by construction: each package is built, namcap'd, installed and
LAUNCHED before it is pushed, and check_aur_rules() blocks the push on the
mechanically checkable submission rules (maintainer line, x86_64, no
replaces=, not already in an official repo, .SRCINFO matching the PKGBUILD).
Verified by injecting a replaces= violation: the push was blocked and
reported. A failing package is skipped; the others still publish.

Signup itself stays manual — it needs an email confirmation, and creating
accounts is not something this should do on the user's behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-09 23:51:06 +02:00
6a080333c2 Let a desktop wrapper provision the sync account, no UI required
Desktop builds of todo/diet-guard/home_inventory are Flutter *web* in a
Chrome --app window, so FlutterSecureStorage resolves to the web backend:
AES-encrypted localStorage keyed inside the browser profile. That makes the
account unsettable from outside the page -- `secret-tool` writes the wrong
store (libsecret, which only the Linux build would read), and a Dart CLI
cannot even import flutter_secure_storage, since it is a Flutter plugin and
fails to resolve on the VM. Verified both, rather than assumed.

The one process that can read the filesystem *and* reach the page is the
wrapper already serving it -- the same seam diet-guard uses to keep the
GitHub token out of the browser. So this adds the shared half of a wrapper
route: the path constant, the opt-in env var, an opt-out marker key, and
accountFromWrapper(), which fetches and hands the body to the existing
FirebaseAccount.tryParse.

Additive only, so consumers adopt it per-app on their own schedule; nothing
forces a phone rebuild. Route handlers stay per-app because each wrapper owns
its own server.

Deliberately opt-in (CRDT_SYNC_SERVE_ACCOUNT): the route hands a credential
with database write access to anything that can reach the wrapper's port, so
it is set once to provision an install rather than left on. kSyncAccountOptOutKey
exists so "Disconnect" cannot be silently undone by the next launch
re-adopting the account.

Every failure path returns null rather than throwing -- an absent route is
the *normal* case -- so the caller degrades to "not configured", which the
settings screens already handle by asking.

254 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:33:22 +02:00
a4165e0dc8 Use dt.timezone.utc so the declared >=3.10 support is real
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
`dt.UTC` is a 3.11+ alias, but crdt-sync declares requires-python = ">=3.10"
and the CI matrix tests 3.10 — so `crdt-sync tests` has been failing on the
3.10 leg since before this branch (commit 493db693 failed the same way).
It collected as `AttributeError: module 'datetime' has no attribute 'UTC'`.

This is not only a test problem: `_firebase_auth._utcnow()` is library code,
so on 3.10 every token-expiry check would have raised at runtime. The three
consumers (diet-guard, screen-locker, wake-alarm) all declare >=3.10 too, so
honouring the floor is the right direction rather than raising it.

`dt.UTC is dt.timezone.utc` evaluates to True — the same singleton under two
spellings — so this is a pure rename with no behaviour change.

Verified on a real pyenv 3.10.16 venv, not just the 3.14 dev interpreter:
239 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 19:04:14 +02:00
6eef99d661 Add DeviceIdentity and a legacy device id for the uuid migration
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
Groundwork for moving diet-guard, screen-locker and wake-alarm off fixed
role ids ('pc'/'phone'/'desktop') onto per-install uuids, the scheme todo
and home_inventory already use. A role constant collides the moment a
second machine takes the same role, and a reinstall silently inherits the
previous install's CRDT identity.

The migration cannot rewrite history: the device id is baked into every HLC
stamp already written, is the record path segment and the revision key. So
a device that has just switched ids must still recognise its former id as
its own -- otherwise skip-own-writes stops matching the old path, and the
device pulls back and re-merges its own pre-migration log as though a peer
had written it. Idempotent under LWW, but pure wasted transfer on every
tick.

Both halves therefore take an optional legacy id and test set membership
(own_ids / ownIds) rather than equality against a single id:

- crdt-sync (Python) 0.5.1 -> 0.6.0: new crdt_sync._device with
  DeviceIdentity + load_device_identity (generate-or-load, persisted); the
  mkdir is deliberately unguarded, since handing back an unpersisted id
  would mint a fresh uuid every restart and strand a device dir each time.
  sync_log gains legacy_device_id.
- crdt_sync_dart 0.6.0 -> 0.7.0: DeviceIdentity value type; syncLog gains
  legacyDeviceId. Persistence stays app-side, where the existing uuid apps
  already keep it in SharedPreferences under 'crdt.nodeId'.

Both changes are backward compatible: the parameter defaults to null, which
reproduces the previous single-id behaviour exactly.

Each half has a positive test and a negative control proving the old path
*is* pulled when no legacy id is declared -- so the skip is verified to be
the parameter's doing, not an artifact of the fixture.

Python: 239 tests, 100% branch coverage. Dart: 248 tests, analyzer clean,
device.dart 11/11 and sync.dart 57/57 lines covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 16:54:33 +02:00
493db693e1 crdt-sync 0.5.1: name the packages, so pip can build again
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
`tool/` carries an __init__.py so its scripts import cleanly, which makes it a
second top-level package. setuptools' flat-layout auto-discovery refuses to
build at all in that case:

    error: Multiple top-level packages discovered in a flat-layout:
    ['tool', 'crdt_sync']

so `pip install crdt-sync @ git+...` failed outright. Naming the package
explicitly fixes the build and keeps one-shot migration scripts out of every
consumer's site-packages.

Verified by installing from a clean target directory and importing
mirror_client_for from the built wheel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 23:16:51 +02:00
fc99eff005 crdt_sync_dart 0.6.0: split Firebase settings by what is safe to publish
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
Every repo in this fleet is public, so where each value lives is a disclosure
decision rather than a style choice.

FirebaseProject holds apiKey + databaseUrl: these identify the *project*, and
the Web API key is a public identifier by design -- it ships inside every
Android APK, and the security rules, not the key, are what protect the data.
Safe to bake into app source, which keeps a reinstall zero-setup.

FirebaseAccount holds the email and password: these identify *a person*, so
they are entered once per device and kept in the platform keystore, never in
SharedPreferences and never in a repo.

The uid is deliberately absent from both. Nothing client-side needs it -- the
rules compare auth.uid server-side, so a wrong account is refused by the
database. A local copy would add a way to be wrong without adding protection.

Keyed at `firebase.account`, distinct from `sync.token` (the GitHub PAT) and
`firebase.credentials` (the refresh token), so all three coexist during the
mirrored cutover.

13 tests, 100% line coverage; 241 in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 23:05:13 +02:00
6c9cc71ae1 crdt_sync_dart 0.5.1: a shared secure credential store
Every Flutter consumer already keeps its GitHub token in
flutter_secure_storage, which is where a Firebase refresh token belongs too:
it is the long-lived secret, while ID tokens expire in about an hour.

Takes read/write/delete closures rather than depending on
flutter_secure_storage directly -- this package is pure Dart, and a Flutter
plugin dependency would make it unusable from the command-line tools and from
plain `dart test`. The adapter is two lines at each call site.

Keyed at `firebase.credentials`, namespaced away from each app's `sync.token`
so both secrets coexist for the whole mirrored cutover.

Every load failure degrades to "not signed in" rather than throwing: an app
that threw here would be permanently unable to sync, with no way back short of
a reinstall.

10 tests, 100% line coverage; 228 in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 23:01:54 +02:00
dd6e6d5571 crdt_sync 0.5.0: the shared config and the cutover seam
Everything an app needs to move from GitHub to Firebase without each one
reinventing the wiring.

Python:
- _config.py: FirebaseConfig.load() + firebase_client_for(app) +
  mirror_client_for(app, github). One config file, read in one place.
  Credential caches stay per-app (~/.config/<app>/firebase_auth.json) --
  one file written by several processes is a corruption waiting to happen.
- tool/preflight_firebase.py: validates the config before any data moves.
  Catches a wrong-region databaseUrl (which answers 404 with a correctUrl
  body rather than an error, so it would otherwise pass every check and
  break mid-migration) and confirms the uid in the ID token matches the uid
  the rules pin -- sign_in() returns None and discards localId, so the uid
  is decoded from the JWT.
- tool/interop_seed.py: the missing Python half of the cross-language
  check. interop_check.dart asserts it can see a record written by Python,
  but nothing ever wrote one, so it could only ever fail.

Dart:
- firebase_config.dart: the same config shape, kept free of dart:io so it
  works on web and Android, where the file does not exist at all and
  credentials belong in flutter_secure_storage.
- PersistedSyncStateStore: the revision cache, persisted. Without it a
  phone forgets every peer revision on each cold start and re-downloads
  everything -- the exact traffic the revision scheme exists to avoid, and
  what the free-tier headroom depends on.

Verified live against the real database: preflight passes all five checks,
the Dart smoke test passes end to end, and Python <-> Dart interop is OK.

Python 226 tests at 100% branch coverage; Dart 218 tests, both new files
at 100% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 22:59:48 +02:00
a89f152564 Let the gatelock workflows trigger on their own files
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
The display fix in 161eccd could never prove itself: both workflows trigger
on `paths: ['gatelock/**']`, and the workflow files live under .github/, so
editing one changes nothing GitHub will act on. The tests job stayed red at
the last gatelock commit with the fix already pushed -- which is the worst
kind of green-looking red, because nothing was running to say otherwise.

A workflow is part of what it tests, so each now lists its own path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 18:36:47 +02:00
cfc4c295e4 crdt_sync: add the one-shot GitHub -> Firebase data migration
Reads through the GitHub API, not a local clone: the checkout on this
machine was five days stale while devices kept syncing, so seeding from it
would have silently rolled back everything written in between. Blobs are
fetched by sha rather than by path, so a concurrent push cannot swap the
content out from under the tree listing.

Writes through the real FirebaseSyncClient so the migration exercises the
same key escaping the apps will, then reads every blob back and compares
bytes before reporting success -- a migration that claimed success without
checking would be indistinguishable from one that dropped a file.

Retries connection resets and 5xx: GitHub drops keep-alive connections
routinely, and a half-completed migration is worse than one that never
started.

Three things are deliberately left behind, and stay in the GitHub repo
(archived, not deleted):
  - todo-sync/changesets/  pre-migration sqlite_crdt format, unread since
    2026-07-17
  - diet-guard-sync/devices/desktop/  a replica that stopped syncing on
    2026-07-22; verified to hold 0 records, 0 tombstones and 0 fields newer
    than both `pc` and `phone`. Carrying it over would cost a full
    re-download every tick forever, since a device that publishes no
    revision can never be skipped
  - READMEs and .gitkeep, which are not sync data

Run against the live database: 16 files, 956,272 bytes, all verified
byte-for-byte, and independently re-checked by listing the namespaces and
parsing records back out.
2026-08-03 18:31:18 +02:00
161eccd9a5 Give gatelock's test job a display, so its real-Tk tests actually run
Headless, the viewport, fit-check and keyboard tests skip -- 25 of them --
and take their coverage with them, which is why this job went red the moment
they landed: 100% is the bar and _fitcheck drops to 47%, _keyboard to 18%.

Skipping is the worse half. These tests exist because Tk's own focus
delivery and canvas scrolling cannot be mocked -- mocking them is exactly how
a viewport that scrolled itself passed every check for a week -- so a green
run that skipped them would be reporting nothing about the thing they guard.
xvfb-run gives the runner a display, which is what every local run already
used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 18:29:38 +02:00
02e2e13790 crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam
Extracts the three-method remote surface GitHub was reached through into a
`RemoteStore` interface, then implements it against Firebase Realtime
Database over REST, so apps can move backend with a constructor change.

Why REST and not the SDKs: official FlutterFire has no Linux desktop
support, and `wake_alarm`'s PC side is a dependency-free systemd job. The
RTDB REST endpoints are plain HTTPS and behave identically on Linux,
Android and headless. Firebase is a transport, never the datastore -- each
app keeps its local log as the source of truth, so offline-first survives.

Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and
bandwidth with no per-operation quota, so a runaway loop cannot exhaust a
daily budget and silently stop syncing mid-day.

Two savings make the no-cost tier a guarantee rather than a hope. Measured
against the GitHub-backed history this replaces: 4894 of 5542 commits
(88.3%) were byte-identical no-ops, and every tick re-downloaded every
peer's whole log. Both are now driven by one sha256 revision -- an
unchanged log is not re-pushed, and an unchanged peer is not re-fetched.
That turns egress from "ticks x log size" (~700 MB/month, and growing with
the log) into "edits" (~40 MB/month against a 10 GiB cap).

Revisions are one node per device, written only by its owner, rather than
one shared map -- a whole-map write would erase every peer's entry, after
which those peers would look permanently unchanged and never be fetched
again. Per-device keys make that impossible rather than merely avoided.

`.` is illegal in an RTDB key and the REST `.json` is a format suffix, so
paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy
extension-stripping would break `todo`, whose listings return filenames.

`MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads
consult *both* backends: with primary-only reads a migrated device would
never see an un-migrated peer's writes while still mirroring its own back
-- silent one-directional convergence with no error raised. Writes are
asymmetric on purpose: a primary failure fails the tick, a mirror failure
is logged and survived.

Auth is Firebase email/password over identitytoolkit REST, with mandatory
refresh-token rotation. No service-account key anywhere: it would bypass
the security rules and is trivially extractable from an APK.

Verified live against a real database, not only mocks: sign-in and refresh,
key escaping round-tripping byte-for-byte, an unchanged tick writing
nothing, two devices converging, a revoked session throwing rather than
returning null, and a log written by the Python client being read by the
Dart one.

No app is migrated by this commit; every app still runs on GitHub.

Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint
suppressions added.
2026-08-03 18:14:25 +02:00
28ced1d39a Cover the last branches of the viewport, the density scale and the keys
gatelock has never met its own 100% bar; _scrollable.py in particular shipped
with no tests at all, which is how a viewport that scrolled itself passed
every check for a week. The remaining gaps were the ones a reader would most
want proven:

* finalize() is documented idempotent -- a repaint calls it again, and an
  earlier version stacked a <FocusIn> handler per call.
* scroll_into_view holds still for a widget already fully on screen. Its
  margin logic has three outcomes and "hold" is the one a jittering viewport
  gets wrong.
* the type scale reads an existing Tk root rather than opening a second one
  next to a live lock window.
* bind_cancel rejects a non-callable at bind time, not on the keypress that
  finally needed Escape to work.

328 tests, coverage 99.44% -> 100.00%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 16:20:11 +02:00
ba549f8f05 gatelock 0.4.0: the viewport moves for the user and nobody else
On 2026-08-03 the screen locker's lock screen scrolled to mid-content and
back with nobody touching the keyboard, on a 1366x768 panel. Measured under
Xvfb with the real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145.

Two causes, both here:

* ScrollableSurface followed every <FocusIn>. The apps focus a widget on
  every repaint (and gatelock re-asserts focus from its recovery tick), and
  Tk delivers <FocusIn> to the focused widget's whole ancestor chain when the
  toplevel takes X focus -- so each repaint aimed a scroll at containers
  taller than the screen. Now a focus move scrolls only when it followed a
  real keypress, or a click on the widget that took focus, and only for a
  leaf. Tab onto a clipped field still reveals it; nothing else moves.
* finalize()'s reset-to-top cancelled the centring, because centring moved
  the canvas item down and the region's top edge with it. The region is now
  pinned to the viewport whenever the content fits, so the view cannot move
  at all, the scrollbar is taken down, and centring survives the repaint.

Content that does not fit is a layout defect, not a mode to live in, so
finalize() reports it at warning and gatelock.measure_fit() lets each app
gate on it before shipping (scripts/verify_screen_fits.py in all four).

Also: LockConfig's type/space scales now compact below 768px (_density),
which is where a screen has no room to be at design-system size and no
scrollbar to make up for it. 768 and up is verified to fit at full size, so
it is never compacted.

Tests: _scrollable had none; the regression is now stated as code
(programmatic focus must not move yview; Tab must). 323 pass, coverage
95.38% -> 99.44%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 15:50:51 +02:00
f0be8c5919 Make lock surfaces keyboard-operable and safe on a 768px screen
gatelock v0.3.0. Every change here works around a Tk *default*, not an
application mistake -- which is why they belong in the shared backend rather
than being fixed four times.

On a lock surface these are not accessibility niceties. The lockers hold a
global input grab with VT switching disabled, so a control the keyboard cannot
reach, or a button clipped off the bottom of the screen, is a bricked machine.

LockConfig gains a type scale, a spacing scale and a focus-ring token:

- font() applies Tk's sign convention. Tk encodes the unit in the SIGN of the
  font size -- positive is points, negative is pixels -- so passing the
  design system's px values straight through rendered every string ~30%
  oversized (measured: body at 16pt has a 26px linespace where 16px gives 20).
  That alone pushed diet_guard's meal gate to 866px of content inside a 739px
  pane on a 1366x768 screen.
- focus_kwargs() sets highlightcolor, the *focused* ring. Tk's default is a 1px
  BLACK ring, invisible against the palette's #211D1B background. Note
  highlightbackground is the UNfocused ring: setting only that one inverts the
  affordance, which is what diet_guard was doing.

_keyboard.py collects three Tk defaults that each make a form unusable:

- tk.Text traps Tab (inserts a literal tab and refocuses itself) and no-ops
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- tk.Button binds <space> on X11 but never <Return>, so the key a user reaches
  for to submit does nothing.
- Escape needs binding at the toplevel to work wherever focus happens to sit.

_scrollable.py adds ScrollableSurface, a viewport that content can actually be
reached in:

- A place()-centred frame takes its *requested* size and is clipped
  SYMMETRICALLY, so overflow removes the heading and the submit button at the
  same time, with no scrollbar and no hint anything is missing.
- A bare tk.Canvas has no class key bindings, so ::tk::FocusOK rejects it as a
  focus stop and the only way to scroll is dragging the thumb with a pointer.
- Canvas clipping does not unmap children, so scrolled-out fields stay in the
  tab ring while Tk never scrolls to follow focus -- Tab lands on fields the
  user can neither see nor reveal.
- A Canvas holds content in a canvas *item*, so it never propagates the
  content's requested size; a toplevel told to size itself has nothing to grow
  to. _request_content_size() fixes that, clamped to the display.

center_when_fits keeps short screens looking exactly as they did, so the
viewport can replace a centred frame without a visual change.

A scrollable dialog Toplevel is deliberately NOT the mechanism: on an
override-redirect lock surface a second toplevel steals the Tk grab, which the
recovery tick then kills -- the 2026-07-26 failure where a frozen sport
selector logged a walk as table tennis.

unified-design-system gains the two rule families this implements (pointer-free
operability, 1366x768 usable / 1024x600 graceful), a focus-ring token, and the
px/pt sign convention written down so it cannot recur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:01:15 +02:00
206740889c gatelock 0.2.1: the grab predicate and recovery-loop fixes
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Releases the two fixes from the previous commit so the four consumers
(screen-locker, diet-guard, wake-alarm, testsAndMisc) can pin them: holds_grab
no longer steals the grab back off our own posted menus, and a raising tick
no longer permanently stops the recovery loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:16:20 +02:00
39e52d07c2 Stop the recovery tick stealing our own grab, and fix pre-commit for the monorepo
`_holds_grab` asked `grab_current() is self._root`, but our own transient
children take the grab for themselves: a posted Tk menu grabs the menu
window, so the test read "the grab was lost" and `grab_set_global()` took it
back a second later, off our own widget mid-interaction. That is what made
screen-locker's sport selector unusable while locked on 2026-07-26. The
predicate is now `grab_current() is not None` -- any window we own means the
lock still has pointer and keyboard -- and it is public, `holds_grab`, so an
embedder can ask the same question instead of re-deriving it. Anything that
does not resolve against our widget map (a Tcl-created `ttk.Combobox`
popdown, a stale name) raises `KeyError` and counts as lost: fail closed,
because losing a popdown beats believing we hold a grab we do not. The
blind spot for a second `tk.Tk()` reporting its root as "." is unchanged from
the old test, and now written down.

`_verify` and `_drain` scheduled the next tick *after* running it, so any
exception ended all re-assertion of coverage, grab and VT for the rest of the
lock -- the exact fail-open this module exists to prevent, in a file whose
docstring is a monotonicity proof. The reschedule moves into `finally`, and
the warnings only fire when the reason changes: these states can last the
whole lock, and a 1Hz journal stream is not the alternative to silence.

The pre-commit side: both subproject configs were written as if cwd were the
subproject, but pre-commit runs hooks from the git root -- which is ~/utils,
not gatelock. Every pushed run since at least 2026-07-11 has been red, and
for compounding reasons: pylint and bandit could not find their config,
bandit's tests exclusion never matched, the pytest hook ran with no config at
all, and with no `files:` filter each config linted the *whole* monorepo, so
gatelock's mypy was type-checking crdt-sync. All paths are now root-relative,
each config is scoped to its own subproject, and the pytest hook goes through
a shared `scripts/run_subproject_tests.sh` that supplies the missing `cd` and
fails loudly if the subproject is gone.

Scoping via `files:` has one sharp edge -- rename the directory and the regex
matches nothing, so every hook passes on zero files, green and useless. An
`always_run` scope-guard hook turns that silent pass into a failure.

bandit 1.7.10 threw inside its AST visitor on Python 3.14 and *skipped* every
file while still reporting zero issues; 1.9.4 actually scans. Codespell was
also flagging a real typo the noise had buried.

Housekeeping the above turned up: `gatelock/.github/` and `crdt-sync/.github/`
were dead -- GitHub only reads the root `.github/workflows`, which already has
all four jobs -- and the two subproject `.gitignore`s were byte-identical to
each other and subsumed by the new root one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:08:41 +02:00
7327c287f4 gatelock: extract the shared escape-hatch core
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
Every locker here already had a bounded way out -- screen-locker's sick
mode, diet-guard's log-a-meal -- except the wake alarm, which had only the
challenge itself. Rather than copy sick mode a third time, the mechanism
moves into gatelock so all three consumers share one implementation and one
set of budget rules.

`_escape.py` owns the parts that are policy-free: rolling 7/30/90-day
budgets, the justification draft and its validation, the escalating lockout
that doubles with each recent use, HMAC-signed history entries, and the
"last ten, played back to you" summary. App-specific policy (screen-locker's
commitment penalty and workout debt) stays in the consumer.

Entries that fail HMAC verification are KEPT rather than dropped. Dropping
them looks safer but is not: `verify_entry_hmac` also returns False when the
key is merely unreadable, so "discard what you cannot verify" would turn a
chmod on the key file into an unlimited escape hatch. They stay in the
budget count and are reported through a `tampered` counter instead.

278 tests, 100% branch coverage, no lint suppressions.
2026-07-25 15:35:20 +02:00
a9b65812d7 gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration
On 2026-07-25 the PC appeared frozen and needed an SSH rescue. Three defects
stacked:

1. DP-0 came up `connected` with a valid EDID and no mode/CRTC -- physically
   dark, while X still reserved its 3840px slice of the root window.
2. diet-guard-gate and workout-locker each mapped an override-redirect window
   spanning the WHOLE root (6400x1440+0+0), centring their controls on the
   primary output -- the dark one. The escape UI was invisible.
3. They deadlocked on the X grab, and the loser logged "another app -- e.g. a
   fullscreen game -- holds it". No game was running; the holder was the other
   locker. That guess sent the diagnosis the wrong way for the whole outage.

The library had no concept of an individual output: setup() sized the window
from winfo_screenwidth(), the union bounding box of every output.

What changes:

* _outputs.py  An output is LIVE only if connected AND has a mode/CRTC.
               RandR (crtc==0 or mode==0) and an xrandr --query parser derive
               that predicate independently; verified to agree exactly on the
               real DP-0/HDMI-0 layout. Fails closed three distinct ways.
* _surfaces.py One window per live output at its own geometry, and the only
               module allowed to create/move/destroy windows. Explicit
               geometry replaces attributes(fullscreen=True), which an
               override-redirect window is invisible to anyway.
* _detect.py   RandR events + Tk <Configure> + xrandr polling, coalesced. The
               RandR thread runs on a private X connection and may only call
               queue.put -- Tk is not thread-safe.
* _recovery.py Re-asserts coverage, placement, grab and VT on a timer. Proven
               monotonic: it may only ADD coverage or RE-ACQUIRE the grab. An
               ast test asserts the file contains none of grab_release /
               restore_vt_switching / close / destroy / quit / withdraw, while
               disable_vt_switching and grab_set_global ARE present. Yanking
               every cable therefore does not unlock anything.
* _arbiter.py  N-app priority: wake_alarm > screen_locker > diet_guard.
               Liveness is proved by flock-on-death -- no heartbeat, no PID
               scan, no clock. A two-axis strength check stops rank ever
               weakening the lock: an app stands down only for an incumbent
               at least as strong on BOTH grab kind and VT disabling.
* _guards.py   wait_for_x_server replaces wait_for_display and gates ONLY on
               an X server existing. Output count must never gate arming:
               zero live outputs means lock without showing, not decline to
               lock. That inversion is what left the machine unlocked.
* _window.py   Arms first, renders second. Names the real grab holder instead
               of blaming a fullscreen game. Releases the arbiter on close so
               the alarm -> workout handoff cannot strand the next app.

Two bugs found by running it, not reading it:

* acquire_holder() opened the holder file "w", which truncates at open() --
  before the lock attempt. A losing contender erased the incumbent's claim, so
  the app that most needed to know who held the screen found an empty file.
* tk.Toplevel over a MagicMock sends real tkinter into infinite mock recursion;
  the suite hung instead of failing, and an unpatched setup() would have run
  setxkbmap against the developer's own machine. conftest now has an autouse
  hermetic fixture; the suite went from >120s to 0.7s.

235 tests, 100% branch coverage, ruff (select=ALL) and mypy --strict clean.
No suppressions added: Protocols replace Any for python-xlib, public attribute
access replaces ._data, and type-only Protocols live under TYPE_CHECKING.

Consumers still pin gatelock-v0.1.1 and are unmigrated; nothing ships until a
tag and a reinstall into system python.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:30:03 +02:00
a50d27688f Consolidate unified-design-system docs into this monorepo
Moves the design-system rules, frozen token table, and annotated
component references out of ~/.claude/skills/ and the two Claude
Artifacts into a proper subdir here, matching the existing
gatelock/crdt-sync/guard-lib pattern of one-subdir-per-shared-library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:23:47 +02:00
63badeaed5 gatelock: warm-tint the unified-design-system palette, add on-fill token
Some checks failed
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Mirrors the palette rework already applied to diet-guard/dufs-cloud/
screen-locker. Adds `on_fill` (dark) for text drawn on a filled accent/
success/warning/danger surface -- near-white text under-contrasts on all
four (measured 2.2-3.6:1, below the 4.5:1 floor); dark text passes 5.8-9.7:1
on every one of them.

Bumps to 0.1.1 since this is a real release: the 3 consumer repos pin
gatelock to a git tag, not editable.
2026-07-21 21:11:07 +02:00
5480e72335 GitHubClient: add canAccessRepo() + deleteFile() (v0.3.0)
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
Make the shared client a superset of what todo's and diet_guard-app's local
GitHubClient copies provide, so every app can drop its copy and sync through
one client. canAccessRepo() is a lightweight connection test (bare repo GET,
never throws); deleteFile() resolves the file's own sha and no-ops when
absent. Mirrored on the Dart and Python sides. 90 Dart + 78 Python tests, 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 23:41:10 +02:00
d69598880a Docs: correct stale 'not yet consumed' notes and old install URLs
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Both CRDT libs are now consumed (crdt_sync_dart: diet_guard app, workout_app,
phone_app, todo; crdt-sync: diet-guard, screen-locker). Reconcile the
sub-package READMEs' old standalone git URLs to the monorepo-subdirectory
form, and bump the crdt_sync examples to v0.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 23:31:41 +02:00
5be99ab11d Add LogStore local-persistence layer to crdt_sync (v0.2.0)
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
crdt_sync_dart: new domain-agnostic LogStore -- an in-memory Log with a
reactive changes stream and upsert/delete(sticky tombstone)/replaceAll,
behind an injectable LogPersistence port -- plus logToJson/logFromJson.
FileLogPersistence (atomic temp-file-then-rename, generalizing diet_guard's
LogStorageService) lives behind a separate crdt_sync_io.dart entrypoint so
the core barrel stays pure Dart / web-safe.

crdt-sync (Python): mirror dump_log/load_log/read_log/write_log sharing the
same on-disk JSON format so a log written on one side parses on the other.
Load/save only -- the reactive store is a Dart/Flutter convenience with no
Python equivalent by design (Python consumers are headless sync ticks).

Querying stays app-side: a Record's fields are opaque to the library, so
filtering/sorting/search is the caller's job over LogStore.values.

Both packages bumped to 0.2.0. 83 Dart tests + 71 Python tests (100% branch
coverage) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 22:19:34 +02:00
73bc5e044b Merge gatelock history into utils/gatelock/ 2026-07-10 13:02:29 +02:00
865e09d5a2 Merge guard-lib history into utils/guard-lib/ 2026-07-10 13:02:29 +02:00
5402df52b5 Merge crdt-sync history into utils/crdt-sync/ 2026-07-10 13:02:29 +02:00
a6c6ebeb0e Merge crdt_sync_dart history into utils/crdt_sync_dart/ 2026-07-10 13:02:29 +02:00
e1d54ff75e Root scaffold: README, LICENSE, per-subdirectory CI for utils monorepo 2026-07-10 13:02:20 +02:00
9a7fd2daae Add cross-language wire-format fixture test and wrong-shape sync coverage
The Python and Dart test suites each only verified round-tripping through
their own encoder/decoder, so a Record.to_dict()/toJson() key mismatch
between the two (deleted_hlc vs deletedHlc) would have gone undetected
despite being the exact thing this shared library exists to prevent. Pin
one canonical fixture in both suites so a future divergence fails loudly
on at least one side. Also add a test for a valid-JSON-but-wrong-shape
remote push, exercising the full (ValueError, KeyError, TypeError) skip
path, not just JSON syntax errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf
2026-07-05 20:55:03 +02:00
1c49abe8c2 Initial implementation: unified CRDT merge scheme + GitHub sync transport
Extracted and generalized from diet_guard's existing tombstone-merge scheme
and GitHub Contents API client, extended with per-field HLC LWW so the same
library can also cover todo's mutable notes and wake_alarm's single-value
sync (Workstream D scoping decision, 2026-07-04). 100% branch coverage,
clean ruff/mypy/pylint/bandit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf
2026-07-05 20:40:09 +02:00
c99cb8373e feat: initial guard-lib - shared tamper-resistance primitives
Extracts the chattr+bind-mount+systemd-watcher file-locking pattern (and a
new pacman PreTransaction package-block primitive) out of screen-locker and
steam-backlog-enforcer into a standalone, config-driven library so both
projects (and steam-backlog-enforcer's new block-gaming feature) can share
one implementation instead of maintaining parallel copies.

- guardctl: CLI entry point (file-guard, package-block subcommands)
- lib/file_guard.sh: chattr +i / bind-mount / systemd-path-watcher / pacman
  hook enforcement for a single target file, keyed by /etc/guard-lib/targets
  config; includes `sync` (adopt current content as canonical - for a
  caller's own legitimate edit) distinct from `pacman-relock` (revert drift)
- lib/package_block.sh: time-locked pacman PreTransaction hook that refuses
  to (re)install a given package while a lock file's `until` timestamp is in
  the future; supports --bind-mount to compose with file-guard on the same
  lock file
- pacman-hooks/: the installed hook scripts + .hook definitions
- systemd/: guard-file@.path/.service watcher units, guard-bind-mount@.service

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
2026-07-04 11:47:59 +02:00
5f7553de91 Initial gatelock: shared lock-window + HMAC log-integrity backend
Extracted from wake_alarm, screen-locker, and diet_guard, which each
independently implemented fullscreen-lock, input-grab, VT-disable, and
HMAC-signed-state mechanics at different levels of maturity (the HMAC
module was already a hand-copied duplicate between two of them).
LockConfig exposes overrideredirect/grab/disable_vt as independent
axes so one LockWindow can reproduce all three projects' exact prior
behavior, plus screen-locker's confirmed upgrade to retry-forever grab.
2026-06-19 17:39:44 +02:00
1502 changed files with 156833 additions and 163 deletions

View File

@ -0,0 +1,48 @@
name: crdt_sync_dart tests
on:
push:
branches: [main]
paths: ['crdt_sync_dart/**']
pull_request:
branches: [main]
paths: ['crdt_sync_dart/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: crdt_sync_dart
steps:
- uses: actions/checkout@v4
# Pure Dart, no Flutter dependency -- so setup-dart, not flutter-action.
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Install dependencies
run: dart pub get
- name: Analyze
run: dart analyze --fatal-infos
- name: Run tests with coverage
run: dart test --coverage=coverage
# Unlike `flutter test --coverage`, `dart test --coverage` emits raw VM
# JSON and no lcov. Skipping this step leaves a stale lcov.info on disk
# that the gate below happily "passes" -- which is exactly how this
# package drifted to 96% while believing it was at 100%.
- name: Convert coverage to lcov
run: |
dart pub global activate coverage
dart pub global run coverage:format_coverage \
--lcov --in=coverage/test --out=coverage/lcov.info \
--report-on=lib --packages=.dart_tool/package_config.json
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -0,0 +1,36 @@
name: crdt_sync_flutter tests
on:
push:
branches: [main]
paths: ['crdt_sync_flutter/**', 'crdt_sync_dart/**']
pull_request:
branches: [main]
paths: ['crdt_sync_flutter/**', 'crdt_sync_dart/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: crdt_sync_flutter
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -0,0 +1,70 @@
name: crdt-sync pre-commit
on:
push:
branches: [main]
paths: ['crdt-sync/**', '.github/workflows/crdt-sync-precommit.yml']
pull_request:
branches: [main]
paths: ['crdt-sync/**', '.github/workflows/crdt-sync-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' crdt-sync/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-crdt-sync-${{ env.pythonLocation }}-${{
hashFiles('crdt-sync/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-crdt-sync-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-crdt-sync-${{ env.pythonLocation }}-${{
hashFiles('crdt-sync/.pre-commit-config.yaml') }}
restore-keys: mypy-crdt-sync-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config crdt-sync/.pre-commit-config.yaml
--all-files --show-diff-on-failure

31
.github/workflows/crdt-sync-tests.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: crdt-sync tests
on:
push:
branches: [main]
paths: ['crdt-sync/**', '.github/workflows/crdt-sync-tests.yml']
pull_request:
branches: [main]
paths: ['crdt-sync/**', '.github/workflows/crdt-sync-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: crdt-sync
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: crdt-sync/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -0,0 +1,36 @@
name: data_transfer_flutter tests
on:
push:
branches: [main]
paths: ['data_transfer_flutter/**']
pull_request:
branches: [main]
paths: ['data_transfer_flutter/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: data_transfer_flutter
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -0,0 +1,61 @@
name: dependency freshness
# Every dependency on its ecosystem's newest STABLE release, enforced on push
# as well as at commit time. The local pre-commit hook only sees the manifests
# in a commit and can be skipped entirely by a fresh clone that never ran
# `pre-commit install`; this runs `--all` over the whole repo.
#
# `--strict` is CI-only. Offline the gate degrades to its cached answer and
# passes, because `--no-verify` is banned locally and a hook that hard-failed
# on a flaky connection would leave no way to commit. A runner is always
# online, so here an undeterminable answer is a real failure.
#
# Expect this to go red for reasons this repo's commit did not cause: an
# upstream release overnight is enough. That is the chosen behaviour -- the
# alternative is a gate that only notices staleness when someone remembers to
# look.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
dependency-freshness:
name: dependencies on newest stable
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The gate lives in the shared utils repo so every repo resolves the
# same "latest stable" the same way. Checking it out beats vendoring a
# copy, which is exactly how a gate and its survey start disagreeing.
- name: Check out the shared gate
uses: actions/checkout@v4
with:
repository: kuhyx/utils
path: .utils
# Any recent Python runs the gate. The Python TARGET is python.org's
# newest release (registries/toolchain.python_latest), not this
# interpreter: when it was, "3.x" resolved to 3.15.0 on one runner and
# 3.14.8 on the next, and spellcode's allowlist flipped dead/required.
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install the gate's two runtime dependencies
run: python -m pip install --upgrade pyyaml packaging
- name: Cache registry lookups
uses: actions/cache@v4
with:
path: ~/.cache/dep-freshness
key: dep-freshness-${{ hashFiles('**/pubspec.yaml', '**/pyproject.toml',
'**/requirements*.txt', '**/package.json', '**/Cargo.toml', '**/go.mod') }}
restore-keys: dep-freshness-
- name: Every dependency is on newest stable
run: bash .utils/scripts/check_dependency_freshness.sh --all --strict

View File

@ -0,0 +1,36 @@
name: design_system tests
on:
push:
branches: [main]
paths: ['design_system/**']
pull_request:
branches: [main]
paths: ['design_system/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: design_system
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -0,0 +1,73 @@
name: earned_time pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as earned_time: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['earned_time/**', '.github/workflows/earned-time-precommit.yml']
pull_request:
branches: [main]
paths: ['earned_time/**', '.github/workflows/earned-time-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' earned_time/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-earned_time-${{ env.pythonLocation }}-${{
hashFiles('earned_time/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-earned_time-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-earned_time-${{ env.pythonLocation }}-${{
hashFiles('earned_time/.pre-commit-config.yaml') }}
restore-keys: mypy-earned_time-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config earned_time/.pre-commit-config.yaml
--all-files --show-diff-on-failure

36
.github/workflows/earned-time-tests.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: earned_time tests
on:
push:
branches: [main]
# The workflow's own file counts as earned_time: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['earned_time/**', '.github/workflows/earned-time-tests.yml']
pull_request:
branches: [main]
paths: ['earned_time/**', '.github/workflows/earned-time-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: earned_time
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: earned_time/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
# No display needed: earned_time is stdlib-only policy, imported
# headless by the root gaming daemon.
- name: Run tests with coverage
run: python -m pytest -v

34
.github/workflows/file-length.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: file length
# The 250-line cap, enforced on push as well as in pre-commit
# (refactor_claude_todo.md, "make it permanent").
#
# Unlike the pre-commit hook, which only sees the files in a commit, this runs
# over the whole tree: the hook keeps a violation from being *introduced*, and
# this keeps one from surviving a path the hook never saw (a merge, a push
# from a machine without hooks installed, a rebase that resurrects a file).
#
# No paths filter on purpose. The cap applies to every file in the repo, so
# scoping this job to a subdirectory would be the one way to reintroduce the
# problem it exists to catch.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
file-length:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Every file is at most 250 lines
run: bash scripts/check_file_length.sh --all

View File

@ -0,0 +1,73 @@
name: focus-project pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as focus_project: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['focus_project/**', '.github/workflows/focus-project-precommit.yml']
pull_request:
branches: [main]
paths: ['focus_project/**', '.github/workflows/focus-project-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' focus_project/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-focus-project-${{ env.pythonLocation }}-${{
hashFiles('focus_project/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-focus-project-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-focus-project-${{ env.pythonLocation }}-${{
hashFiles('focus_project/.pre-commit-config.yaml') }}
restore-keys: mypy-focus-project-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config focus_project/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -0,0 +1,34 @@
name: focus-project tests
on:
push:
branches: [main]
# The workflow's own file counts as focus_project: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['focus_project/**', '.github/workflows/focus-project-tests.yml']
pull_request:
branches: [main]
paths: ['focus_project/**', '.github/workflows/focus-project-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: focus_project
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: focus_project/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -0,0 +1,73 @@
name: freedays pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as freedays: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['freedays/**', '.github/workflows/freedays-precommit.yml']
pull_request:
branches: [main]
paths: ['freedays/**', '.github/workflows/freedays-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' freedays/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-freedays-${{ env.pythonLocation }}-${{
hashFiles('freedays/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-freedays-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-freedays-${{ env.pythonLocation }}-${{
hashFiles('freedays/.pre-commit-config.yaml') }}
restore-keys: mypy-freedays-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config freedays/.pre-commit-config.yaml
--all-files --show-diff-on-failure

44
.github/workflows/freedays-tests.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: freedays tests
on:
push:
branches: [main]
# The workflow's own file counts as freedays: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['freedays/**', '.github/workflows/freedays-tests.yml']
pull_request:
branches: [main]
paths: ['freedays/**', '.github/workflows/freedays-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: freedays
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: freedays/requirements.txt
# crdt-sync is a sibling in this repo, so it installs from the checkout
# rather than from a tag -- CI must test what is on this branch, not
# what was last released. Deliberately NOT gatelock: importing it pulls
# in tkinter, and this job has no display precisely because freedays
# must work without one.
- name: Install the sibling package
run: pip install ../crdt-sync
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
# No display needed: freedays draws nothing. It is policy, a JSON file
# and a CLI.
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -0,0 +1,72 @@
name: gatelock pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as gatelock: editing only this file used
# to trigger nothing, so a fix to the job itself could never prove itself.
paths: ['gatelock/**', '.github/workflows/gatelock-precommit.yml']
pull_request:
branches: [main]
paths: ['gatelock/**', '.github/workflows/gatelock-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' gatelock/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-gatelock-${{ env.pythonLocation }}-${{
hashFiles('gatelock/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-gatelock-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-gatelock-${{ env.pythonLocation }}-${{
hashFiles('gatelock/.pre-commit-config.yaml') }}
restore-keys: mypy-gatelock-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config gatelock/.pre-commit-config.yaml
--all-files --show-diff-on-failure

41
.github/workflows/gatelock-tests.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: gatelock tests
on:
push:
branches: [main]
# The workflow's own file counts as gatelock: editing only this file used
# to trigger nothing, so a fix to the job itself could never prove itself.
paths: ['gatelock/**', '.github/workflows/gatelock-tests.yml']
pull_request:
branches: [main]
paths: ['gatelock/**', '.github/workflows/gatelock-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: gatelock
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: gatelock/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
# The viewport, fit-check and keyboard tests drive real Tk: focus
# delivery, canvas scrolling and widget heights are Tk's own behaviour,
# and mocking them is what let a viewport that scrolled itself pass
# every check for a week. Headless they skip, silently taking coverage
# with them -- so the runner gets a display instead.
- name: Install a virtual X display
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run tests with coverage
run: xvfb-run -a -s "-screen 0 1600x1200x24" python -m pytest -v

View File

@ -0,0 +1,36 @@
name: github_device_auth tests
on:
push:
branches: [main]
paths: ['github_device_auth/**']
pull_request:
branches: [main]
paths: ['github_device_auth/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: github_device_auth
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -0,0 +1,36 @@
name: google_account_token tests
on:
push:
branches: [main]
paths: ['google_account_token/**']
pull_request:
branches: [main]
paths: ['google_account_token/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: google_account_token
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

73
.github/workflows/holds-precommit.yml vendored Normal file
View File

@ -0,0 +1,73 @@
name: holds pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as holds: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
pull_request:
branches: [main]
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' holds/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-holds-${{ env.pythonLocation }}-${{
hashFiles('holds/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-holds-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-holds-${{ env.pythonLocation }}-${{
hashFiles('holds/.pre-commit-config.yaml') }}
restore-keys: mypy-holds-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config holds/.pre-commit-config.yaml
--all-files --show-diff-on-failure

34
.github/workflows/holds-tests.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: holds tests
on:
push:
branches: [main]
# The workflow's own file counts as holds: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['holds/**', '.github/workflows/holds-tests.yml']
pull_request:
branches: [main]
paths: ['holds/**', '.github/workflows/holds-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: holds
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: holds/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -0,0 +1,73 @@
name: leetcode-coach pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as leetcode_coach: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
pull_request:
branches: [main]
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' leetcode_coach/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-leetcode-coach-${{ env.pythonLocation }}-${{
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-leetcode-coach-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-leetcode-coach-${{ env.pythonLocation }}-${{
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
restore-keys: mypy-leetcode-coach-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config leetcode_coach/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -0,0 +1,34 @@
name: leetcode-coach tests
on:
push:
branches: [main]
# The workflow's own file counts as leetcode_coach: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
pull_request:
branches: [main]
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: leetcode_coach
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: leetcode_coach/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

33
.github/workflows/md-naming.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: md-naming
# The markdown namespaces (README / CLAUDE* / DOCS*, no TODO*), enforced on push as well as at commit time.
# The local pre-commit hook can be skipped (a fresh clone that never ran
# `pre-commit install` has no hooks at all), so the convention needs a second
# gate that no one can forget to install.
#
# Deliberately a SEPARATE workflow from any release job: a badly named doc
# should fail lint, not block publishing an artifact.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
md-naming:
name: markdown naming convention
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The gate and its exemption list live in the shared utils repo so every
# repo enforces the same namespaces. Checking it out beats vendoring a
# copy here, which is exactly how a gate and a survey start disagreeing.
- name: Check out the shared gate
uses: actions/checkout@v4
with:
repository: kuhyx/utils
path: .utils
- name: Enforce the naming convention
run: bash .utils/scripts/check_md_naming.sh --all

View File

@ -0,0 +1,31 @@
name: music_theory tests
on:
push:
branches: [main]
# The workflow's own file counts as music_theory: editing only this file used
# to trigger nothing, so a fix to the job itself could never prove itself.
paths: ['music_theory/**', '.github/workflows/music-theory-tests.yml']
pull_request:
branches: [main]
paths: ['music_theory/**', '.github/workflows/music-theory-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: music_theory
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests with coverage
run: python -m pytest -v --cov=music_theory

View File

@ -0,0 +1,73 @@
name: omniroute_offload pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as omniroute_offload: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
pull_request:
branches: [main]
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' omniroute_offload/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-omniroute_offload-${{ env.pythonLocation }}-${{
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-omniroute_offload-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-omniroute_offload-${{ env.pythonLocation }}-${{
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
restore-keys: mypy-omniroute_offload-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config omniroute_offload/.pre-commit-config.yaml
--all-files --show-diff-on-failure

38
.github/workflows/palette-drift.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: palette drift
# Deliberately NOT path-filtered. Every other workflow here scopes itself to
# one package, which is right for tests but wrong for this check: drift is a
# disagreement *between* packages, so editing design_system alone has to be
# able to fail it. A `paths:` filter would let exactly the edit that causes
# drift be the one that skips the check.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
palette:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Compares tokens.md (prose freeze), web_ui/src/tokens.css,
# design_system tokens.dart and gatelock's LockConfig. Fails on a hex
# that disagrees across stacks, and on a colour token that no stack's
# map accounts for.
- name: One palette across all four token sources
run: python3 unified-design-system/scripts/palette_check.py
- name: Categorical ramp still passes its constraints
run: python3 unified-design-system/scripts/ramp_check.py
# Spacing, radius, type, shadow and motion: palette_check.py parses
# hex only, so these scales were unguarded until structural_check.py.
- name: Structural scales agree across all token sources
run: python3 unified-design-system/scripts/structural_check.py

19
.github/workflows/repo-contract.yml vendored Normal file
View File

@ -0,0 +1,19 @@
name: repo-contract
# utils owns the shared gate, so it runs its own copy directly (no shim, no
# second checkout). Other repos use repo_contract/templates/repo-contract.yml.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
repo-contract:
name: repo contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enforce the repo contract
run: bash scripts/check_repo_contract.sh

View File

@ -0,0 +1,82 @@
name: shared gate tests
# The gates in this repo are enforced in ~40 other repos, so a bug here is a
# bug everywhere at once. Until now `md_naming` had a test suite that no
# workflow ran; `dep_freshness` ships with one from day one.
#
# The dependency-freshness suite never touches the network -- every registry
# answer is a fixture, because the gate's worst failure mode (accepting a
# pre-release as "latest stable") fails GREEN and only a fixture can pin it.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
shared-gate-tests:
name: gate unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install test dependencies
run: python -m pip install --upgrade pytest pytest-cov pyyaml packaging
- name: dep_freshness — 100% branch coverage on the gate itself
run: |
PYTHONPATH=. python -m pytest dep_freshness/tests -q \
--cov=dep_freshness --cov-branch \
--cov-report=term-missing \
--cov-fail-under=99
- name: md_naming
run: PYTHONPATH=. python -m pytest md_naming/tests -q
# Whole package at 100% branch: the gate is enforced in every repo and
# now reads a per-repo `.file-length-exempt`, whose fail-closed rule is
# exactly the kind of branch a partial suite would leave green by accident.
- name: file_length — 100% branch coverage on the gate itself
run: |
PYTHONPATH=. python -m pytest file_length/tests -q \
--cov=file_length --cov-branch \
--cov-report=term-missing \
--cov-fail-under=100
- name: repo_contract — 100% branch coverage on the gate itself
run: |
PYTHONPATH=. python -m pytest repo_contract/tests -q \
--cov=repo_contract --cov-branch \
--cov-report=term-missing \
--cov-fail-under=100
# Every web repo's pre-commit and CI run this checker against its dist/,
# so a false green here ships an un-agent-ready site fleet-wide.
- name: agent_ready — 100% branch coverage on the checker
run: |
PYTHONPATH=. python -m pytest agent_ready/tests -q \
--cov=agent_ready --cov-branch \
--cov-report=term-missing \
--cov-fail-under=100
- name: coverage_gaps — 100% branch coverage on the lister
run: |
PYTHONPATH=. python -m pytest coverage_gaps/tests -q \
--cov=coverage_gaps --cov-branch \
--cov-report=term-missing \
--cov-fail-under=100
# gate_stamp.sh is the pre-push gate in every repo. Its tests drive real
# pre-commit in throwaway repos, and the hook-id helper runs under
# /usr/bin/python3 (the deployment interpreter), hence apt's PyYAML.
- name: gate_stamp (step / full --list)
run: |
sudo apt-get update -q && sudo apt-get install -y python3-yaml
python -m pip install --upgrade pre-commit
PYTHONPATH=. python -m pytest scripts/tests -q

View File

@ -0,0 +1,73 @@
name: sickdays pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as sickdays: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sickdays/**', '.github/workflows/sickdays-precommit.yml']
pull_request:
branches: [main]
paths: ['sickdays/**', '.github/workflows/sickdays-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' sickdays/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-sickdays-${{ env.pythonLocation }}-${{
hashFiles('sickdays/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-sickdays-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-sickdays-${{ env.pythonLocation }}-${{
hashFiles('sickdays/.pre-commit-config.yaml') }}
restore-keys: mypy-sickdays-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config sickdays/.pre-commit-config.yaml
--all-files --show-diff-on-failure

49
.github/workflows/sickdays-tests.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: sickdays tests
on:
push:
branches: [main]
# The workflow's own file counts as sickdays: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sickdays/**', '.github/workflows/sickdays-tests.yml']
pull_request:
branches: [main]
paths: ['sickdays/**', '.github/workflows/sickdays-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sickdays
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: sickdays/requirements.txt
# crdt-sync and freedays are siblings in this repo, so they install from
# the checkout rather than from a tag -- CI must test what is on this
# branch, not what was last released. Deliberately NOT gatelock:
# importing it pulls in tkinter, and this job has no display precisely
# because sickdays must work without one.
- name: Install the sibling packages
run: pip install ../crdt-sync && pip install --no-deps ../freedays
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
# The library draws nothing, but sickdays.gui (0.3.0) does, and its
# tests drive real Tk: option names and Checkbutton/variable wiring are
# Tk's own behaviour. Headless they skip, taking coverage with them --
# so the runner gets a virtual display, as gatelock's does.
- name: Install a virtual X display
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run tests with coverage
run: xvfb-run -a -s "-screen 0 1600x1200x24" python -m pytest -v

View File

@ -0,0 +1,73 @@
name: sql-drills pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' sql_drills/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-sql-drills-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
restore-keys: mypy-sql-drills-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config sql_drills/.pre-commit-config.yaml
--all-files --show-diff-on-failure

34
.github/workflows/sql-drills-tests.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: sql-drills tests
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sql_drills
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: sql_drills/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -0,0 +1,36 @@
name: sync_settings_ui tests
on:
push:
branches: [main]
paths: ['sync_settings_ui/**']
pull_request:
branches: [main]
paths: ['sync_settings_ui/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sync_settings_ui
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

37
.github/workflows/ts-config-tests.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: ts_config tests
on:
push:
branches: [main]
paths: ['ts_config/**']
pull_request:
branches: [main]
paths: ['ts_config/**']
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ts_config
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 12
- uses: actions/setup-node@v4
with:
node-version: "24.22.1"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint (the preset lints itself)
run: pnpm lint
- name: Fixture + factory tests with the 100% coverage gate
run: pnpm coverage

57
.github/workflows/ts-core-tests.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: ts_core tests
on:
push:
branches: [main]
paths: ['ts_core/**', 'ts_config/**']
pull_request:
branches: [main]
paths: ['ts_core/**', 'ts_config/**']
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ts_core
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 12
- uses: actions/setup-node@v4
with:
node-version: "24.22.1"
# The preset is a `link:../ts_config` dependency. pnpm links the
# directory but installs nothing inside it, and a flat config imports
# its plugins from the preset's own node_modules -- so without this
# step the lint dies on "Cannot find package '@eslint/js'".
- name: Install the shared lint preset's dependencies
run: pnpm install --frozen-lockfile
working-directory: ts_config
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Run tests with the 100% coverage gate
run: pnpm coverage
# The tag ships a prebuilt dist/ because Vite does not transpile
# dependencies. A committed dist/ that does not match src/ would ship
# stale code to every consumer with no error anywhere, so rebuild and
# fail if the result differs from what is checked in.
- name: dist/ is in sync with src/
run: |
pnpm build
if ! git diff --exit-code -- dist; then
echo "::error::dist/ is stale — run 'pnpm build' in ts_core and commit the result."
exit 1
fi

61
.github/workflows/web-ui-tests.yml vendored Normal file
View File

@ -0,0 +1,61 @@
name: web_ui tests
on:
push:
branches: [main]
paths: ['web_ui/**', 'ts_config/**']
pull_request:
branches: [main]
paths: ['web_ui/**', 'ts_config/**']
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web_ui
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 12
- uses: actions/setup-node@v4
with:
node-version: "24.22.1"
# The preset is a `link:../ts_config` dependency. pnpm links the
# directory but installs nothing inside it, and a flat config imports
# its plugins from the preset's own node_modules -- so without this
# step the lint dies on "Cannot find package '@eslint/js'".
- name: Install the shared lint preset's dependencies
run: pnpm install --frozen-lockfile
working-directory: ts_config
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Run tests with the 100% coverage gate
run: pnpm coverage
# The tag ships a prebuilt dist/ because Vite does not transpile
# dependencies. A committed dist/ that does not match src/ would ship
# stale code to every consumer with no error anywhere, so rebuild and
# fail if the result differs from what is checked in.
- name: dist/ is in sync with src/
run: |
pnpm build
if ! git diff --exit-code -- dist; then
echo "::error::dist/ is stale — run 'pnpm build' in web_ui and commit the result."
exit 1
fi
- name: Categorical ramp still passes its constraints
working-directory: .
run: python3 unified-design-system/scripts/ramp_check.py

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Python build/test artifacts, across every subproject in this monorepo.
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Coverage output. Each subproject writes these next to its own pyproject.toml,
# and a run from the repo root drops a pair here too.
.coverage
.coverage.*
coverage.lcov
htmlcov/
# Firebase CLI debug exhaust. Written next to whichever directory the CLI was
# invoked from (crdt-sync/ in practice), regenerated on every failed command,
# and never worth committing.
firebase-debug.log
firebase-debug.*.log
firestore-debug.log
database-debug.log

84
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,84 @@
# Repo-wide gate for the shared 250-line file cap (refactor_claude_todo.md).
#
# A file that cannot be read in one piece forces re-reads and partial edits,
# which is the largest avoidable cost in an LLM-assisted workflow. A refactor
# without a gate silently regrows, so this is the part that makes it stick.
#
# Checks only the files in the commit, so an unrelated commit never breaks on
# a violation it did not introduce, and it FAILS (exit 1) rather than warning.
# No baseline and no allowlist -- those are suppressions.
#
# The subprojects (gatelock/, crdt-sync/, web_ui/, design_system/,
# sync_settings_ui/) each keep their own .pre-commit-config.yaml for their own
# linters, and carry this same hook so committing with one of those configs is
# gated too. This root config is what `pre-commit install` wires up, and it is
# the only one that covers the subprojects without a config of their own
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
#
# The path is repo-relative on purpose. pre-commit always runs hooks from the
# git root, so this resolves the same locally and on a CI runner -- an absolute
# /home/... path exits 127 in Actions, which is how this was first written.
repos:
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
# Three markdown namespaces (README / CLAUDE* / DOCS*); TODO*.md is banned
# (tasks live in the todo app).
# Shared gate in ~/src/utils owns the namespaces and the exemption list, so
# one repo's idea of a valid name cannot drift from every other repo's.
# Since the 2026-09-11 reorganisation ~ holds eleven visible entries and
# home-tidy sweeps anything else to inbox/ within the hour. The migration's
# rewriter matched *literal* /home/kuhy/<name> strings, so paths assembled
# at run time survived it -- the todo wrapper exported to a dead ~/todo for
# a day while its MCP read ~/src/todo. This gate matches the shape, so it
# stays correct as repos come and go.
- id: home-path-layout
name: home-relative paths stay inside the ~ layout
entry: bash scripts/check_home_paths.sh
language: system
pass_filenames: true
- id: repo-contract
name: repo contract (CLAUDE.md Commands + test_changed)
entry: scripts/check_repo_contract.sh
language: system
pass_filenames: false
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
- id: md-naming
name: markdown naming convention
entry: scripts/check_md_naming.sh
language: system
files: \.(md|markdown)$
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
# so the question is mechanically answerable at all.
#
# `files:` is what keeps this cheap: a commit touching only source makes
# ZERO network requests, and manifest commits mostly hit a 6h disk cache.
#
# Runs WITHOUT --strict on purpose. Offline it degrades to the cached
# answer and passes with a loud banner; `--no-verify` is banned here, so
# a hook that hard-failed on a flaky connection would leave no way to
# commit at all. CI, always online, is the strict authority.
#
# Scoped to manifests this branch changed (vs merge-base with main), so
# `--all-files` asks "did THIS change leave a stale pin?" and not "is
# every project in the monorepo current?". CI `--all --strict` keeps
# the repo-wide answer. See scripts/dep_freshness_changed.sh.
- id: dependency-freshness
name: dependencies on newest stable
entry: bash scripts/dep_freshness_changed.sh
language: system
pass_filenames: true
files: >-
(pubspec\.(yaml|lock)|pyproject\.toml|requirements.*\.txt|uv\.lock
|package\.json|package-lock\.json|pnpm-lock\.yaml
|Cargo\.(toml|lock)|go\.(mod|sum)
|\.fvmrc|\.nvmrc|\.python-version
|\.github/workflows/.*\.ya?ml)$

27
.pre-commit-hooks.yaml Normal file
View File

@ -0,0 +1,27 @@
# Hooks other repos consume with `repo: https://github.com/kuhyx/utils`.
# All three are post-* stages, so each clone must install them (see
# todo_hooks/README.md). They never fail a git operation: the work has
# already happened, so server/gh trouble prints one warning and exits 0.
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: script
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: script
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: script
stages: [post-rewrite]
always_run: true
pass_filenames: false

18
CLAUDE.md Normal file
View File

@ -0,0 +1,18 @@
# utils
Monorepo of shared gates and libraries (dep_freshness, file_length, md_naming,
repo_contract, coverage_gaps, crdt-sync, gatelock, freedays, sickdays, earned_time, music_theory,
flutter_web_drive (headless Flutter-web E2E driver + MCP), ...).
Python packages with a root-level `<pkg>/tests` run with `PYTHONPATH=.`;
subprojects with their own `pyproject.toml` run via `scripts/run_subproject_tests.sh <dir>`.
`scripts/check_repo_contract.sh` is the REAL shared gate (not a shim): never
overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
## Commands
- run: n/a: collection of gates and libraries, no single entry point
- test: `PYTHONPATH=. python -m pytest -q agent_ready/tests coverage_gaps/tests dep_freshness/tests file_length/tests flutter_web_drive/tests home_paths/tests md_naming/tests repo_contract/tests scripts/tests unified-design-system/tests`
- test-changed: `scripts/test_changed.sh`
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
- coverage-gaps: `coverage-gaps coverage.xml`

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Krzysztof Rudnicki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md Normal file
View File

@ -0,0 +1,130 @@
# utils
Shared libraries used across kuhyx's other projects. Each subdirectory is a
formerly-standalone repo, folded in here with full commit history preserved
(see each subdirectory's own `git log` for its pre-merge history).
## crdt_sync_dart/
Dart/Flutter CRDT sync library (pub package name: `crdt_sync`, not published
to pub.dev — consumed via a git dependency). Mirrors `crdt-sync` (below)
module-for-module and test-for-test. Consumed by `diet_guard`'s app,
screen-locker's `workout_app`, `wake_alarm`'s `phone_app`, and `todo` (whose
notes are stored in the v0.2.0 `LogStore`).
Consume via:
```yaml
dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.10.0
path: crdt_sync_dart
```
## sync_settings_ui/
Shared Flutter "Sync settings" screen (pub package name: `sync_settings_ui`,
not published to pub.dev). Firebase sync (primary) plus an optional local
backup slot — no GitHub mirror UI. Pure UI + a `FirebaseSyncController`
service layer over injected closures (keystore/Google-plugin access stays
in each consuming app, exactly as before this package existed). Consumed by
`todo`, `diet_guard_app`, `workout_app`, `wake_alarm/phone_app`, and
`home_inventory`'s dedicated "Sync settings" route.
Consume via:
```yaml
dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.1.0
path: sync_settings_ui
```
## google_account_token/
Flutter plugin (Android only; other platforms return null): one-tap Google ID
token via `AccountManager`, replacing `google_sign_in` in the sync apps.
Consume via:
```yaml
dependencies:
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
```
## crdt-sync/
Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same
design as `crdt_sync_dart` (HLC clocks, per-field LWW, sticky-delete
tombstones). Consumed by `diet-guard` and `screen-locker`.
Consume via:
```
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.2.0#subdirectory=crdt-sync
```
## guard-lib/
Bash tamper-resistance primitives (`guardctl`: chattr + bind-mount + systemd
watcher + pacman hooks). Not a package — installed by running
`guard-lib/install.sh` as root, which copies files onto the system
(`/usr/local/bin`, `/usr/local/lib/guard-lib`, systemd units, pacman hooks).
Consumed by `steam-backlog-enforcer` (expects a `guard-lib/install.sh`
sibling clone) and referenced by `screen-locker`.
## gatelock/
Python lock-window + HMAC log-integrity backend (package: `gatelock`, not
published to PyPI). Consumed via pip by `screen-locker`, `diet-guard`, and
`wake-alarm`:
```
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock
```
## music_theory/
Music theory from first principles as typed Python (package: `music_theory`,
not published to PyPI): pitch, tuning, scales, chords, progressions, a numpy
synth and a `Recipe` → WAV composer with a `degrade` transform. Follows Luke
Haas's "Music Theory for Programmers" section by section; `python -m
music_theory demo all --out DIR` renders one WAV per section. Consumed by
`roadside-assistance` for its three world-state music beds.
Consume via:
```
music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory
```
## unified-design-system/
Not a package — the frozen design tokens (colors, spacing, type, radius,
shadow policy) and per-stack (Flutter/web/Tkinter) implementation patterns
that keep every one of kuhy's repos visually identical, plus annotated
component references (`components.html`, `button.html`). Read
[`unified-design-system/README.md`](unified-design-system/README.md) and
`DOCS-tokens.md` before touching any theme/CSS/style file in any repo. The
`unified-design-system` Claude Code skill is a thin pointer here, not a
duplicate.
## ts_config/
`@kuhyx/ts-config` — the one lint / TypeScript bar every kuhyx TypeScript
repo extends (pnpm only, like `ts_core` and `web_ui`). A strict
`tsconfig.base.json`, ESLint flat-config presets (`defineConfig()` stacking
base / react / vitest / playwright / layer `boundaries()`), a type-free
`eslint/fast` subset for editor hooks, and Knip / jscpd / Stryker bases.
Plain JS, no build step; the git tag is the release. Every plugin is a
`dependency` of the package so consumers list none. A fixture repo with one
planted violation per rule family proves each gate bites.
```jsonc
"devDependencies": {
"@kuhyx/ts-config": "github:kuhyx/utils#ts_config-v0.1.0&path:/ts_config"
}
```

View File

@ -0,0 +1,97 @@
# AgentReady spec notes
These notes record what the checker implements and where each rule comes from.
Fetched 2026-10-22.
## Sources
- AgentReady v2.0 (October 2026, MIT):
<https://github.com/agentready-org/standard/blob/main/spec/spec.md>,
canonical site <https://agentready.org>.
- AI Catalog (the format behind `AR-FIND-04`):
<https://github.com/Agent-Card/ai-catalog/blob/main/specification/ai-catalog.md>.
AgentReady defines no catalog schema of its own ("AgentReady does not
introduce its own bespoke manifest"), so the checker uses this one.
- llms.txt: the spec's informative "llms.txt shape" example, and <https://llmstxt.org/>.
## Requirements (quoted from spec.md)
| ID | Level | Requirement | Checker |
|---|---|---|---|
| AR-FIND-01 | MUST | "The site **MUST NOT** block agent and crawler access to public content, in `robots.txt` or at the network layer." | robots.txt is 200 `text/plain` and not HTML. It has a `User-agent: *` group, and no group has a blanket `Disallow: /`. In url mode the homepage returns 200 with no challenge page for ClaudeBot, GPTBot, Claude-SearchBot, OAI-SearchBot, Claude-User and ChatGPT-User. |
| AR-FIND-02 | SHOULD | "Answers agents are expected to cite **SHOULD** be published on one canonical, current, first-party docs page." | SKIP (manual) |
| AR-FIND-03 | SHOULD | "The site **SHOULD** list its pages in a `sitemap.xml` referenced from `robots.txt`." | robots.txt has a `Sitemap:` line. The sitemap is XML (by content type in url mode), and every `<loc>` is on the site's own host. |
| AR-FIND-04 | MAY | "...publish an AI Catalog at `/.well-known/ai-catalog.json` listing every agent-facing resource it ships ... linked from the homepage head with `<link rel="ai-catalog">` and an HTTP `Link` header." | The catalog is valid (see the schema below) and lists `/llms.txt`. The homepage has `<link rel="ai-catalog">`. In url mode the homepage also sends a `Link: <...>; rel="ai-catalog"` header. |
| AR-READ-01 | MUST | "The answer **MUST** be present in the initial HTML — server-rendered or prerendered, not injected by a single-page app after load." | The homepage and each sitemap page return 200 `text/html` with at least `--min-text` characters of visible body text. A page that is byte-identical to the homepage fails. An empty sitemap is a FAIL. |
| AR-READ-02 | MUST | "...accurate HTTP status codes: `404` (or a valid redirect) for missing paths ... never a `200` for a missing page" | url mode: two random `/agentready-probe-<hex>` paths must end, after redirects, on a 404 or 410. dist mode: a `404.html` with text must exist. |
| AR-READ-03 | SHOULD | "The homepage **SHOULD** be served as raw HTML with real links to docs, product, and pricing." | The homepage HTML has at least one `<a href>` to another page on the site. |
| AR-READ-04 | SHOULD | "Docs pages **SHOULD** answer the tasks users bring to agents." | SKIP (manual) |
| AR-READ-05 | SHOULD | "Code snippets **SHOULD** be fenced and language-tagged." | Every `<pre><code>` carries `language-*`, `lang-*` or `data-language`. Every fence in a markdown mirror has an info string. |
| AR-READ-06 | SHOULD | "Discovery files (`llms.txt`, `.well-known/*`, `openapi.json`, `AGENTS.md`) **SHOULD** be linked from content agents already visit" | `/llms.txt` and the catalog (plus `/openapi.json` with `--act`) exist and are linked from the homepage, by `<link>` or `<a>`. |
| AR-READ-07 | SHOULD | "The site **SHOULD** ship an `llms.txt`: a lean, described index of its key pages." | 200 `text/plain` or `text/markdown`, not HTML. It needs an H1 first, a `>` summary, and an `## H2` with a `- [name](url)` list; other prose is allowed. Every on-site link resolves. |
| AR-READ-08 | SHOULD | "Key pages **SHOULD** carry JSON-LD — the homepage above all" | The homepage has a parseable `application/ld+json` block with `@context`. |
| AR-READ-09 | MAY | "Pages **MAY** advertise a markdown mirror via `<link rel="alternate" type="text/markdown">` or an equivalent HTTP `Link:` header." | Every checked page advertises a mirror. The mirror is 200 `text/markdown` or `text/plain`, and is not HTML. |
| AR-READ-10 | n/a | "`AR-READ-10` is retired." | not emitted |
| AR-READ-11 | SHOULD | "Pricing **SHOULD** be published on a first-party page" | SKIP (n/a: no paid plans) |
| AR-ACT-01 | SHOULD | auth completable by an agent; "OAuth 2.0 with discoverable metadata under `/.well-known`" | `--act`: either `oauth-protected-resource` has `resource`, or `oauth-authorization-server` has `issuer`. |
| AR-ACT-02 | SHOULD | "A public API **SHOULD** be described by a machine-readable OpenAPI contract." | `--act`: `/openapi.json` has `openapi` and `paths`, and every operation has an `operationId`. |
| AR-ACT-03 | SHOULD | "...expose an MCP server over Streamable HTTP at `/mcp`." | `--act`, url mode: a POST `initialize` gets a JSON-RPC result, or a 401 with `WWW-Authenticate`. |
| AR-ACT-04 | MAY | "An MCP server **MAY** ship a server card" | `--act`: the catalog has an `application/mcp-server-card+json` entry. |
| AR-ACT-05/06/08/09 | MAY/SHOULD | MCP Apps, SDKs+CLI, payments, NLWeb `/ask` | `--act`: SKIP (manual) |
| AR-ACT-07 | SHOULD | "...register WebMCP tools on `document.modelContext`." | `--act`: `modelContext` appears in the homepage HTML or in its same-site scripts. |
| AR-ACT-10 | MUST | "...**MUST** expose at least one interface an agent can act through: an MCP server (`AR-ACT-03`) or a CLI (`AR-ACT-06`)." | `--act`: same verdict as the `/mcp` probe. |
## kuhy's choices that are stricter than the spec
- Every in-scope item is required, whatever its strength. That includes the
MAY items (FIND-04, READ-09).
- The spec says a site "**MAY** block a training crawler". kuhy chose to allow
training crawlers too, so a blanket `Disallow` for GPTBot, ClaudeBot or any
other agent is a FAIL.
- The act stage is off by default. Turn it on with `--act` only for sites with
user actions. The spec allows this: "a purely informational site conforms on
`AR-FIND-*` and `AR-READ-*` alone".
## AI Catalog schema (ai-catalog.md, specVersion 1.0)
- The media type is `application/ai-catalog+json`. When fetched by link
discovery, plain `application/json` is also acceptable.
- At the top level, the object MUST have:
- `specVersion`: a string in "Major.Minor" form, for example `"1.0"`.
- `entries`: an array, which may be empty.
- The top level MAY have `host`, `extensions` and `signature`.
- If `host` is present, it MUST have `displayName`. It MAY have
`identifier`, `documentationUrl` and `logoUrl`.
- Each entry MUST have:
- `identifier`: a URI or URN. The recommended form is
`urn:air:{publisher}:{namespace}:{name}`.
- `type`: a media type, for example `application/mcp-server-card+json`.
- **exactly one** of `url` or `data`.
- `displayName` is optional on entries (ai-catalog ADR-0016).
- Link discovery uses `Link: <...>; rel="ai-catalog"` or
`<link rel="ai-catalog" href="..." type="application/ai-catalog+json">`.
Minimal valid example:
```json
{"specVersion": "1.0",
"host": {"displayName": "kuhy"},
"entries": [{"identifier": "urn:air:kuhy.duckdns.org:docs:llms",
"type": "text/plain", "url": "https://kuhy.duckdns.org/llms.txt"}]}
```
Agentic Resource Discovery v0.91 (<https://agenticresourcediscovery.org/spec>)
renamed the path to `/.well-known/ard.json` and the relation to `rel="ard"`.
It keeps `ai-catalog.json` only as an optional predecessor. AgentReady v2.0
still names `ai-catalog.json`, so that is the path the checker tests. It does
not test `ard.json`.
## llms.txt shape (spec.md, informative)
```md
# Acme
> Acme ships a payments API for developers.
## Docs
- [Quickstart](https://example.com/docs/quickstart): first charge in one request
```

93
agent_ready/README.md Normal file
View File

@ -0,0 +1,93 @@
# agent_ready
This tool checks kuhy's websites against [AgentReady](https://agentready.org)
v2.0. It runs every find and read item, and the act items when you pass `--act`.
It is Python 3 standard library only, with no dependencies.
`DOCS-spec-notes.md` maps each `AR-*` ID to the check that tests it.
## Usage
```sh
~/src/utils/agent_ready/check.sh dist <build-dir> [--act] [--min-text N] [--max-pages N]
~/src/utils/agent_ready/check.sh url <base-url> [--act] [--min-text N] [--max-pages N] [--timeout S] [--budget S]
```
The output is one line per check:
```text
PASS AR-FIND-03 sitemap lists 12 pages
FAIL AR-READ-02 soft 404, unknown paths must return 404: /agentready-probe-1a2b -> HTTP 200
SKIP AR-READ-11 pricing page: n/a for sites without paid plans
```
| exit | meaning |
|---|---|
| 0 | every check passed (SKIPs do not fail) |
| 1 | at least one FAIL, at any strength: MUST, SHOULD or MAY |
| 2 | usage error (bad arguments, missing directory, not an http(s) URL) |
| 3 | network undeterminable: the homepage was unreachable, or a fetch timed out or hit the 45 s budget, and nothing failed |
## Pre-commit and CI (dist mode)
Point the checker at the build output after the build step. Dist mode checks
that each file exists and is well-formed:
- robots.txt
- sitemap.xml
- llms.txt
- the AI Catalog
- 404.html
- the text in every sitemap page's HTML
- each page's markdown mirror
```yaml
# .github/workflows/*.yml, after the build
- run: ~/src/utils/agent_ready/check.sh dist dist/
```
```yaml
# .pre-commit-config.yaml (local hook; build first, e.g. in a wrapper script)
- id: agent-ready
name: AgentReady (build output)
entry: bash -c 'pnpm build >/dev/null && ~/src/utils/agent_ready/check.sh dist dist'
language: system
pass_filenames: false
```
Dist mode cannot check HTTP behaviour: content types, the `Link` header, real
404 status, or responses to agent user-agents. Those checks print as SKIP, or
the check falls back to "404.html exists".
## Post-deploy (url mode)
Run this once the deploy is live:
```sh
~/src/utils/agent_ready/check.sh url https://kuhy.duckdns.org
```
Url mode checks status codes **and** content types. A server that answers
every path with `200 text/html` (an SPA fallback) fails on robots.txt, the
sitemap, llms.txt, the catalog and the 404 probe. A status-only check would
pass it. Url mode also checks that:
- `/agentready-probe-<random>` ends on a 404, after following redirects.
- No sitemap page is byte-identical to the homepage shell.
- The homepage answers 200 to ClaudeBot, GPTBot and the other agent
user-agents.
## Tests
```sh
~/.claude/scripts/capped.sh python -m pytest ~/src/utils/agent_ready/tests -q
```
The test fixtures are generated in `tmp_path` by `tests/sitegen.py`, so there
are no static mirror `.md` files to trip the markdown-naming gate. The good
and bad build trees are served by loopback `http.server` instances in four
shapes:
- compliant
- SPA fallback
- JSON 404 on every path
- 403 to GPTBot

1
agent_ready/__init__.py Normal file
View File

@ -0,0 +1 @@
"""AgentReady (agentready.org v2.0) compliance checker for kuhy's websites."""

188
agent_ready/backends.py Normal file
View File

@ -0,0 +1,188 @@
"""Two backends behind one ``get(path)``: a build directory and a live site.
DirSite answers like a plain static host (index.html for ``/x/``, a 404 for
anything missing), so the find/read checks are written once for both modes.
"""
from __future__ import annotations
import http.client
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit
from agent_ready.report import Undeterminable
DEFAULT_UA = "Claude-User/1.0 (+agentready-check)"
MAX_BODY = 5 * 1024 * 1024
_EXT_MIME = {
".html": "text/html",
".md": "text/markdown",
".txt": "text/plain",
".xml": "application/xml",
".json": "application/json",
}
@dataclass(frozen=True)
class Response:
"""A fetched resource: final status, bare media type, headers, body."""
status: int
ctype: str
body: bytes
headers: dict[str, str] = field(default_factory=dict)
@property
def text(self) -> str:
"""Body decoded as UTF-8 (lenient)."""
return self.body.decode("utf-8", "replace")
class Site:
"""Common URL handling; subclasses implement ``_fetch``."""
live = False
def __init__(self, origin: str) -> None:
self.origin = origin.rstrip("/")
self._cache: dict[tuple[str, str], Response | Undeterminable] = {}
self._lock = threading.Lock()
def to_path(self, url: str, base: str = "/") -> str | None:
"""Site path for a URL or relative href; None when off-site."""
absolute = urljoin(self.origin + base, url.strip())
parts = urlsplit(absolute)
if (
parts.scheme not in ("http", "https")
or parts.netloc != urlsplit(self.origin).netloc
):
return None
return parts.path or "/"
def get(self, path: str, ua: str = DEFAULT_UA) -> Response:
"""Fetch ``path`` (memoized, including network failures)."""
key = (path, ua)
with self._lock:
hit = self._cache.get(key)
if hit is None:
try:
hit = self._fetch(path, ua)
except Undeterminable as exc:
hit = exc
with self._lock:
self._cache[key] = hit
if isinstance(hit, Undeterminable):
raise hit
return hit
def prefetch(self, paths: list[str], uas: tuple[str, ...] = (DEFAULT_UA,)) -> None:
"""Warm the cache for every (path, ua) pair; parallel for live sites."""
if not self.live:
return
pairs = [(p, ua) for p in dict.fromkeys(paths) for ua in uas]
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda pair: self._safe_get(*pair), pairs))
def _safe_get(self, path: str, ua: str) -> None:
try:
self.get(path, ua)
except Undeterminable:
pass
def _fetch(self, path: str, ua: str) -> Response:
raise NotImplementedError
class DirSite(Site):
"""A build output directory served the way a static host serves it."""
def __init__(self, root: Path) -> None:
super().__init__("https://dist.invalid")
self.root = root.resolve()
def file_for(self, path: str) -> Path | None:
"""The file a static host would serve for ``path``."""
rel = unquote(path.split("?", 1)[0]).lstrip("/")
base = (self.root / rel).resolve()
if not base.is_relative_to(self.root):
return None
cands = (
[base / "index.html"]
if path.endswith("/") or not rel
else [base, base.with_name(base.name + ".html"), base / "index.html"]
)
return next((c for c in cands if c.is_file()), None)
def _fetch(self, path: str, ua: str) -> Response:
target = self.file_for(path)
if target is None:
fallback = self.root / "404.html"
body = fallback.read_bytes() if fallback.is_file() else b""
return Response(404, "text/html", body)
return Response(
200,
_EXT_MIME.get(target.suffix, "application/octet-stream"),
target.read_bytes(),
)
class HttpSite(Site):
"""A live site. Follows redirects; HTTP errors are responses, not raises."""
live = True
def __init__(self, base: str, timeout: float = 8.0, budget: float = 45.0) -> None:
parts = urlsplit(base)
super().__init__(f"{parts.scheme}://{parts.netloc}")
self.timeout = timeout
self.deadline = time.monotonic() + budget
def _remaining(self) -> float:
left = self.deadline - time.monotonic()
if left < 1:
raise Undeterminable("overall time budget used up")
return min(self.timeout, left)
def _fetch(self, path: str, ua: str) -> Response:
return self.request(path, ua)
def request(
self,
path: str,
ua: str = DEFAULT_UA,
data: bytes | None = None,
headers: dict[str, str] | None = None,
) -> Response:
"""Uncached GET (or POST when ``data`` is given)."""
req = urllib.request.Request(
self.origin + path,
data=data,
headers={"User-Agent": ua, "Accept": "*/*", **(headers or {})},
)
try:
with urllib.request.urlopen(req, timeout=self._remaining()) as resp:
return _response(resp.status, resp.headers, resp.read(MAX_BODY))
except urllib.error.HTTPError as exc:
return _response(exc.code, exc.headers, exc.read(MAX_BODY))
except (
urllib.error.URLError,
TimeoutError,
OSError,
http.client.HTTPException,
) as exc:
raise Undeterminable(f"{path}: {getattr(exc, 'reason', exc)}") from exc
def _response(status: int, headers: http.client.HTTPMessage, body: bytes) -> Response:
"""Normalise headers: lowercase keys, repeated headers joined by ', '."""
flat: dict[str, str] = {}
for key in set(headers.keys()):
flat[key.lower()] = ", ".join(headers.get_all(key) or [])
ctype = flat.get("content-type", "").split(";", 1)[0].strip().lower()
return Response(status, ctype, body, flat)

137
agent_ready/check.py Executable file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""AgentReady compliance checker (agentready.org spec v2.0).
check.py dist <build-dir> [--act] # pre-commit / CI: build output
check.py url <base-url> [--act] # post-deploy: the live site
Prints ``PASS|FAIL|SKIP AR-XXX-NN <msg>`` per check. Exit 0 all pass, 1 any
FAIL (every in-scope item is required, whatever its MUST/SHOULD/MAY
strength), 2 usage error, 3 network undeterminable.
"""
from __future__ import annotations
import argparse
import sys
from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlsplit
if __package__ in (None, ""): # run as a plain script: make the package importable
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from agent_ready import checks_find, checks_read
from agent_ready.backends import DirSite, HttpSite, Site
from agent_ready.checks_act import run_act
from agent_ready.context import HOME, Ctx
from agent_ready.report import (
EXIT_NETWORK,
EXIT_USAGE,
Report,
Undeterminable,
)
# (AR id used when a network failure aborts the check, check function)
CHECKS: list[tuple[str, Callable[[Ctx], None]]] = [
("AR-FIND-01", checks_find.check_robots),
("AR-FIND-01", checks_find.check_agent_access),
("AR-FIND-03", checks_find.check_sitemap),
("AR-FIND-04", checks_find.check_catalog),
("AR-READ-01", checks_read.check_pages),
("AR-READ-02", checks_read.check_status_codes),
("AR-READ-03", checks_read.check_homepage),
("AR-READ-06", checks_read.check_discovery_links),
("AR-READ-07", checks_read.check_llms),
("AR-FIND-02", checks_read.skip_manual),
("AR-ACT-10", run_act),
]
def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command line (argparse exits 2 on usage errors)."""
parser = argparse.ArgumentParser(
prog="agent_ready", description=__doc__.split("\n\n")[0]
)
sub = parser.add_subparsers(dest="mode", required=True)
for mode, target in (
("dist", "build output directory"),
("url", "base URL, e.g. https://example.org"),
):
cmd = sub.add_parser(mode, help=f"check a {target}")
cmd.add_argument("target", help=target)
cmd.add_argument(
"--act",
action="store_true",
help="also check the act stage (OpenAPI, MCP, auth)",
)
cmd.add_argument(
"--min-text",
type=int,
default=100,
help="min visible chars per page (default 100)",
)
cmd.add_argument(
"--max-pages",
type=int,
default=40,
help="url mode: max pages to fetch (default 40; dist checks all)",
)
url = sub.choices["url"]
url.add_argument(
"--timeout",
type=float,
default=8.0,
help="per-request timeout in s (default 8)",
)
url.add_argument(
"--budget",
type=float,
default=45.0,
help="overall network budget in s (default 45)",
)
return parser.parse_args(argv)
def make_site(args: argparse.Namespace) -> Site | None:
"""Build the backend, or report a usage error and return None."""
if args.mode == "dist":
root = Path(args.target)
if not root.is_dir():
print(f"agent_ready: not a directory: {root}", file=sys.stderr)
return None
return DirSite(root)
parts = urlsplit(args.target)
if parts.scheme not in ("http", "https") or not parts.netloc:
print(f"agent_ready: not an http(s) URL: {args.target}", file=sys.stderr)
return None
return HttpSite(args.target, timeout=args.timeout, budget=args.budget)
def main(argv: list[str] | None = None) -> int:
"""Run every check and return the exit code."""
args = parse_args(argv)
site = make_site(args)
if site is None:
return EXIT_USAGE
report = Report(out=sys.stdout)
ctx = Ctx(
site=site,
report=report,
min_text=args.min_text,
max_pages=args.max_pages,
act=args.act,
)
try:
site.get(HOME)
except Undeterminable as exc:
report.skip(
"AR-FIND-01", f"undeterminable (network): homepage unreachable: {exc}"
)
return EXIT_NETWORK
for ar_id, check in CHECKS:
report.guard(ar_id, lambda check=check: check(ctx))
return report.exit_code()
if __name__ == "__main__":
sys.exit(main())

10
agent_ready/check.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# Thin wrapper: run the AgentReady checker from any cwd.
# check.sh dist <build-dir> [--act]
# check.sh url <base-url> [--act]
# Exit codes: 0 all pass, 1 any FAIL, 2 usage error, 3 network undeterminable.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly HERE
PYTHONPATH="$(dirname "$HERE")${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m agent_ready.check "$@"

156
agent_ready/checks_act.py Normal file
View File

@ -0,0 +1,156 @@
"""Act stage: AR-ACT-01..10. Opt-in (``--act``): only for sites with user actions."""
from __future__ import annotations
import json
from agent_ready import formats
from agent_ready.backends import HttpSite
from agent_ready.context import CATALOG, Ctx
_INIT = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "agentready-check", "version": "1"},
},
}
def _json(ctx: Ctx, path: str) -> object | None:
"""Parsed JSON at ``path`` when it is 200 and parses, else None."""
resp = ctx.site.get(path)
if resp.status != 200:
return None
try:
return json.loads(resp.body)
except ValueError:
return None
def check_auth(ctx: Ctx) -> None:
"""AR-ACT-01: OAuth metadata (RFC 9728 ``resource`` or RFC 8414 ``issuer``)."""
prm = _json(ctx, "/.well-known/oauth-protected-resource")
asm = _json(ctx, "/.well-known/oauth-authorization-server")
ok = (isinstance(prm, dict) and "resource" in prm) or (
isinstance(asm, dict) and "issuer" in asm
)
ctx.report.verdict(
"AR-ACT-01",
[]
if ok
else [
"no OAuth metadata under /.well-known (oauth-protected-resource / oauth-authorization-server)"
],
"OAuth metadata discoverable under /.well-known",
)
def check_openapi(ctx: Ctx) -> None:
"""AR-ACT-02: /openapi.json with paths and an operationId on every operation."""
doc = _json(ctx, "/openapi.json")
if not isinstance(doc, dict) or not isinstance(doc.get("openapi"), str):
ctx.report.fail("AR-ACT-02", "/openapi.json missing or not an OpenAPI document")
return
paths = doc.get("paths") if isinstance(doc.get("paths"), dict) else {}
ops = [
op
for item in paths.values()
if isinstance(item, dict)
for m, op in item.items()
if m in ("get", "post", "put", "patch", "delete")
]
missing = sum(
1 for op in ops if not (isinstance(op, dict) and op.get("operationId"))
)
problems = ["no operations in paths"] if not ops else []
if missing:
problems.append(f"{missing} operations lack operationId")
ctx.report.verdict(
"AR-ACT-02", problems, f"/openapi.json describes {len(ops)} operations"
)
def _mcp_ok(ctx: Ctx) -> bool | None:
"""True if /mcp answers initialize (or is OAuth-gated); None in dist mode."""
if not isinstance(ctx.site, HttpSite):
return None
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
resp = ctx.site.request("/mcp", data=json.dumps(_INIT).encode(), headers=headers)
if resp.status == 401:
return "www-authenticate" in resp.headers
return resp.status == 200 and '"jsonrpc"' in resp.text and '"result"' in resp.text
def check_mcp(ctx: Ctx) -> None:
"""AR-ACT-03 MCP at /mcp, AR-ACT-04 server card, AR-ACT-10 an interface exists."""
ok = _mcp_ok(ctx)
if ok is None:
ctx.report.skip("AR-ACT-03", "/mcp is a server endpoint: check in url mode")
ctx.report.skip("AR-ACT-10", "MCP/CLI interface: check in url mode")
else:
ctx.report.verdict(
"AR-ACT-03",
[]
if ok
else [
"/mcp does not answer an MCP initialize (nor 401 + WWW-Authenticate)"
],
"/mcp answers MCP initialize",
)
ctx.report.verdict(
"AR-ACT-10",
[]
if ok
else ["no MCP server at /mcp; a CLI (AR-ACT-06) cannot be auto-detected"],
"agents can act through /mcp",
)
cards = [
t
for t in formats.catalog_types(_json(ctx, CATALOG))
if t == "application/mcp-server-card+json"
]
ctx.report.verdict(
"AR-ACT-04",
[] if cards else ["AI Catalog lists no application/mcp-server-card+json entry"],
"AI Catalog lists an MCP server card",
)
def check_webmcp(ctx: Ctx) -> None:
"""AR-ACT-07: WebMCP tools registered on document.modelContext."""
resp, page = ctx.home()
sources = [resp.text]
for src in page.scripts[:10]:
path = ctx.site.to_path(src)
if path:
sources.append(ctx.site.get(path).text)
found = any("modelContext" in s for s in sources)
ctx.report.verdict(
"AR-ACT-07",
[] if found else ["no document.modelContext usage in homepage HTML/scripts"],
"homepage registers WebMCP tools",
)
def run_act(ctx: Ctx) -> None:
"""All act checks, or one SKIP line when the stage is off."""
if not ctx.act:
ctx.report.skip(
"AR-ACT-10", "act stage off (pass --act for sites with user actions)"
)
return
ctx.report.guard("AR-ACT-01", lambda: check_auth(ctx))
ctx.report.guard("AR-ACT-02", lambda: check_openapi(ctx))
ctx.report.guard("AR-ACT-03", lambda: check_mcp(ctx))
ctx.report.guard("AR-ACT-07", lambda: check_webmcp(ctx))
ctx.report.skip("AR-ACT-05", "MCP Apps ui:// view: manual review")
ctx.report.skip("AR-ACT-06", "SDKs on npm/PyPI and a CLI: manual review")
ctx.report.skip("AR-ACT-08", "agent payments: n/a unless the product charges")
ctx.report.skip("AR-ACT-09", "NLWeb /ask endpoint: manual review")

186
agent_ready/checks_find.py Normal file
View File

@ -0,0 +1,186 @@
"""Find stage: AR-FIND-01..04 (robots.txt, agent access, sitemap, AI Catalog)."""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from agent_ready import formats
from agent_ready.context import CATALOG, HOME, LLMS, Ctx, link_header_href
from agent_ready.report import summarize
# Training, search-index and user-initiated fetchers named in the spec's
# crawl-policy notes. Real UA strings for the two training crawlers.
AGENT_UAS = {
"ClaudeBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
"GPTBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
"Claude-SearchBot": "Claude-SearchBot/1.0",
"OAI-SearchBot": "OAI-SearchBot/1.0; +https://openai.com/searchbot",
"Claude-User": "Claude-User/1.0",
"ChatGPT-User": "ChatGPT-User/1.0; +https://openai.com/bot",
}
_CHALLENGE = (
"just a moment",
"cf-chl",
"challenge-platform",
"attention required",
"captcha",
)
XML_TYPES = ("application/xml", "text/xml", "application/rss+xml")
def check_robots(ctx: Ctx) -> None:
"""AR-FIND-01: robots.txt exists, is plain text and blocks no agent."""
resp = ctx.site.get("/robots.txt")
if resp.status != 200:
ctx.report.fail("AR-FIND-01", f"/robots.txt: HTTP {resp.status}")
return
problems = ctx.type_problem(resp, ("text/plain",), "/robots.txt")
if formats.looks_like_html(resp.text):
ctx.report.fail(
"AR-FIND-01",
"; ".join([*problems, "/robots.txt body is an HTML page (SPA fallback?)"]),
)
return
ctx.robots_text = resp.text
groups = formats.parse_robots(resp.text)
if not formats.has_wildcard_group(groups):
problems.append("no 'User-agent: *' group (spec: one rule for all agents)")
blocked = formats.blocked_agents(groups)
if blocked:
problems.append(f"blanket Disallow for: {', '.join(blocked)}")
ctx.report.verdict(
"AR-FIND-01",
problems,
"robots.txt is plain text, has a * group and blocks no agent",
)
def check_agent_access(ctx: Ctx) -> None:
"""AR-FIND-01 (bot protection): homepage is 200 for each agent UA (url mode)."""
if not ctx.live:
ctx.report.skip("AR-FIND-01", "agent user-agent fetches need url mode")
return
base, _ = ctx.home()
ctx.site.prefetch([HOME], tuple(AGENT_UAS.values()))
bad = []
for name, ua in AGENT_UAS.items():
resp = ctx.site.get(HOME, ua)
lowered = resp.text[:20000].lower()
if resp.status != 200:
bad.append(f"{name} HTTP {resp.status}")
elif resp.body != base.body and any(m in lowered for m in _CHALLENGE):
bad.append(f"{name} got a challenge page")
ctx.report.verdict("AR-FIND-01", bad, f"homepage 200 for {', '.join(AGENT_UAS)}")
def _sitemap_paths(ctx: Ctx) -> list[str]:
"""Sitemap locations from robots.txt (also fixes the dist-mode origin)."""
urls = formats.robots_sitemaps(ctx.robots_text or "")
if urls and not ctx.live:
parts = urlsplit(urls[0])
ctx.site.origin = f"{parts.scheme}://{parts.netloc}"
paths = [p for u in urls if (p := ctx.site.to_path(u))]
if ctx.robots_text is None:
ctx.report.fail("AR-FIND-03", "no valid robots.txt to reference the sitemap")
elif not urls:
ctx.report.fail("AR-FIND-03", "robots.txt has no 'Sitemap:' line")
elif not paths:
ctx.report.fail(
"AR-FIND-03", f"robots.txt Sitemap is on another host: {urls[0]}"
)
else:
ctx.report.ok("AR-FIND-03", f"robots.txt references {summarize(urls)}")
return paths or ["/sitemap.xml"]
def _read_sitemap(ctx: Ctx, path: str, problems: list[str]) -> tuple[str, list[str]]:
"""Fetch and parse one sitemap; record problems; return (kind, locs)."""
resp = ctx.site.get(path)
if resp.status != 200:
problems.append(f"{path}: HTTP {resp.status}")
return "", []
problems.extend(ctx.type_problem(resp, XML_TYPES, path))
try:
return formats.parse_sitemap(resp.body)
except ValueError as exc:
problems.append(f"{path}: {exc}")
return "", []
def check_sitemap(ctx: Ctx) -> None:
"""AR-FIND-03: sitemap referenced from robots.txt, valid XML, on-site <loc>s."""
problems: list[str] = []
locs: list[str] = []
for path in _sitemap_paths(ctx)[:5]:
kind, found = _read_sitemap(ctx, path, problems)
if kind == "sitemapindex":
for child in [p for u in found[:10] if (p := ctx.site.to_path(u))]:
locs += _read_sitemap(ctx, child, problems)[1]
else:
locs += found
if locs and not ctx.live and ctx.site.origin.endswith("dist.invalid"):
parts = urlsplit(locs[0])
ctx.site.origin = f"{parts.scheme}://{parts.netloc}"
pages = [p for loc in locs if (p := ctx.site.to_path(loc))]
if len(pages) < len(locs):
problems.append(
f"{len(locs) - len(pages)} <loc> URLs are not on {ctx.site.origin}"
)
if not locs and not problems:
problems.append("sitemap has no <loc> entries")
ctx.sitemap_pages = list(dict.fromkeys(pages))
ctx.report.verdict(
"AR-FIND-03", problems, f"sitemap lists {len(ctx.sitemap_pages)} pages"
)
def check_catalog(ctx: Ctx) -> None:
"""AR-FIND-04: valid AI Catalog, lists llms.txt, linked from homepage head."""
resp = ctx.site.get(CATALOG)
problems: list[str] = []
if resp.status != 200:
problems.append(f"{CATALOG}: HTTP {resp.status}")
else:
problems += ctx.type_problem(
resp, ("application/ai-catalog+json", "application/json"), CATALOG
)
try:
doc = json.loads(resp.body)
except ValueError:
doc = None
problems.append(f"{CATALOG} is not JSON")
if doc is not None:
problems += formats.catalog_problems(doc)
listed = {ctx.site.to_path(u) for u in formats.catalog_urls(doc)}
if ctx.exists(LLMS) and LLMS not in listed:
problems.append("catalog has no entry for /llms.txt")
ctx.report.verdict("AR-FIND-04", problems, f"{CATALOG} is a valid AI Catalog")
home, scan = ctx.home()
href = scan.link_href("ai-catalog")
ok = href is not None and ctx.site.to_path(href) == CATALOG
ctx.report.verdict(
"AR-FIND-04",
[]
if ok
else [
'homepage head lacks <link rel="ai-catalog" href="/.well-known/ai-catalog.json">'
],
"homepage head links the catalog",
)
if not ctx.live:
ctx.report.skip(
"AR-FIND-04", "HTTP Link header can only be checked in url mode"
)
return
header = link_header_href(home, "ai-catalog")
ok = header is not None and ctx.site.to_path(header) == CATALOG
ctx.report.verdict(
"AR-FIND-04",
[]
if ok
else [
'homepage response lacks HTTP header Link: <...ai-catalog.json>; rel="ai-catalog"'
],
"homepage sends the ai-catalog Link header",
)

212
agent_ready/checks_read.py Normal file
View File

@ -0,0 +1,212 @@
"""Read stage: AR-READ-01..09 and 11 (AR-READ-10 is retired in spec v2.0)."""
from __future__ import annotations
import secrets
from agent_ready import formats
from agent_ready.context import CATALOG, HOME, LLMS, TEXT_TYPES, Ctx, link_header_href
from agent_ready.htmlscan import scan
from agent_ready.report import summarize
def _pages(ctx: Ctx) -> list[str]:
"""Homepage plus sitemap pages, capped. An empty sitemap is a READ-01 FAIL."""
if not ctx.sitemap_pages:
ctx.report.fail(
"AR-READ-01", "sitemap yielded no pages; only the homepage was checked"
)
every = list(dict.fromkeys([HOME, *ctx.sitemap_pages]))
pages = ctx.cap(every)
if len(pages) < len(every):
ctx.report.skip(
"AR-READ-01",
f"checked {len(pages)} of {len(every)} pages (--max-pages); rest unchecked",
)
ctx.site.prefetch(pages)
return pages
def _mirror_href(ctx: Ctx, path: str) -> str | None:
"""Markdown mirror advertised by <link rel=alternate type=text/markdown> or Link header."""
resp, page = ctx.page(path)
href = page.link_href("alternate", "text/markdown")
if href is None and ctx.live:
href = link_header_href(resp, "alternate", "text/markdown")
return href
def check_pages(ctx: Ctx) -> None:
"""AR-READ-01 text in initial HTML, AR-READ-05 tagged code, AR-READ-09 mirrors."""
pages = _pages(ctx)
home, _ = ctx.home()
no_text: list[str] = []
untagged: list[str] = []
blocks = 0
for path in pages:
resp, page = ctx.page(path)
if resp.status != 200:
no_text.append(f"{path} (HTTP {resp.status})")
continue
if ctx.type_problem(resp, ("text/html",), path):
no_text.append(f"{path} ({resp.ctype or 'no content-type'})")
elif path != HOME and resp.body == home.body:
no_text.append(f"{path} (byte-identical to the homepage shell)")
elif len(page.text) < ctx.min_text:
no_text.append(f"{path} ({len(page.text)} chars of text < {ctx.min_text})")
blocks += page.code_blocks
if page.untagged_code:
untagged.append(f"{path} ({page.untagged_code} untagged <pre><code>)")
ctx.report.verdict(
"AR-READ-01",
["answer not in initial HTML: " + summarize(no_text)] if no_text else [],
f"{len(pages)} pages carry >= {ctx.min_text} chars of text without JS",
)
md_untagged = _check_mirrors(ctx, pages)
untagged += md_untagged
ctx.report.verdict(
"AR-READ-05",
["code without a language tag: " + summarize(untagged)] if untagged else [],
f"{blocks} HTML code blocks (plus mirror fences) all language-tagged",
)
def _check_mirrors(ctx: Ctx, pages: list[str]) -> list[str]:
"""AR-READ-09 for every page; returns mirrors with untagged fences (READ-05)."""
bad: list[str] = []
untagged: list[str] = []
targets: dict[str, str] = {}
for path in pages:
if ctx.site.get(path).status != 200:
continue
href = _mirror_href(ctx, path)
target = ctx.site.to_path(href, path) if href else None
if target is None:
bad.append(f"{path} (no text/markdown alternate link)")
else:
targets[path] = target
ctx.site.prefetch(list(targets.values()))
for path, target in targets.items():
resp = ctx.site.get(target)
if resp.status != 200:
bad.append(f"{target} (HTTP {resp.status})")
elif (
ctx.type_problem(resp, TEXT_TYPES, target)
or formats.looks_like_html(resp.text)
or not resp.text.strip()
):
bad.append(f"{target} (not markdown: {resp.ctype or 'empty'})")
elif n := formats.untagged_fences(resp.text):
untagged.append(f"{target} ({n} untagged fences)")
ctx.report.verdict(
"AR-READ-09",
["markdown mirror missing/broken: " + summarize(bad)] if bad else [],
f"{len(targets)} pages advertise a working markdown mirror",
)
return untagged
def check_status_codes(ctx: Ctx) -> None:
"""AR-READ-02: unknown paths are a real 404 (url) / a 404.html exists (dist)."""
if not ctx.live:
resp = ctx.site.get(f"/agentready-probe-{secrets.token_hex(6)}")
text = scan(resp.text).text if resp.body else ""
ok = resp.status == 404 and len(text) >= 10
ctx.report.verdict(
"AR-READ-02",
[] if ok else ["no 404.html with real text in the build output"],
"404.html exists for unknown paths",
)
return
token = secrets.token_hex(6)
probes = [f"/agentready-probe-{token}", f"/agentready-probe-{token}/page.html"]
ctx.site.prefetch(probes)
bad = [
f"{p} -> HTTP {s}"
for p in probes
if (s := ctx.site.get(p).status) not in (404, 410)
]
ctx.report.verdict(
"AR-READ-02",
["soft 404, unknown paths must return 404: " + ", ".join(bad)] if bad else [],
"unknown paths return 404",
)
def check_homepage(ctx: Ctx) -> None:
"""AR-READ-03 real links on the homepage, AR-READ-08 JSON-LD on it."""
resp, page = ctx.home()
links = {
p
for a in page.anchors
if not a.startswith(("#", "javascript:", "mailto:"))
and (p := ctx.site.to_path(a))
and p != HOME
}
ok = resp.status == 200 and bool(links)
ctx.report.verdict(
"AR-READ-03",
[] if ok else ["homepage HTML has no <a href> links to other pages"],
f"homepage links {len(links)} pages in raw HTML",
)
problems = []
if not page.jsonld_ok:
problems.append(
'homepage has no <script type="application/ld+json"> with @context'
)
if page.jsonld_bad:
problems.append(
f"{page.jsonld_bad} JSON-LD blocks fail to parse or lack @context"
)
ctx.report.verdict(
"AR-READ-08", problems, f"homepage carries {page.jsonld_ok} JSON-LD blocks"
)
def check_discovery_links(ctx: Ctx) -> None:
"""AR-READ-06: discovery files are linked from the homepage."""
_, page = ctx.home()
linked = {
ctx.site.to_path(h)
for h in [*page.anchors, *(href for _, _, href in page.links)]
}
files = [LLMS, CATALOG] + (["/openapi.json"] if ctx.act else [])
for path in files:
problems = []
if not ctx.exists(path):
problems.append(f"{path} does not exist")
if path not in linked:
problems.append(f"{path} is not linked from the homepage (<link> or <a>)")
ctx.report.verdict(
"AR-READ-06", problems, f"{path} exists and is linked from the homepage"
)
def check_llms(ctx: Ctx) -> None:
"""AR-READ-07: llms.txt in the spec shape, every on-site link resolving."""
resp = ctx.site.get(LLMS)
if resp.status != 200:
ctx.report.fail("AR-READ-07", f"{LLMS}: HTTP {resp.status}")
return
problems = ctx.type_problem(resp, TEXT_TYPES, LLMS) + formats.llms_problems(
resp.text
)
if not formats.looks_like_html(resp.text):
paths = [
p for u in formats.llms_links(resp.text) if (p := ctx.site.to_path(u, LLMS))
]
paths = ctx.cap(paths)
ctx.site.prefetch(paths)
broken = [p for p in paths if ctx.site.get(p).status != 200]
if broken:
problems.append("broken links: " + summarize(broken))
ctx.report.verdict("AR-READ-07", problems, f"{LLMS} has H1, summary and link lists")
def skip_manual(ctx: Ctx) -> None:
"""Items with no mechanical check (reported, never silently dropped)."""
ctx.report.skip(
"AR-FIND-02", "first-party docs page answering user questions: manual review"
)
ctx.report.skip("AR-READ-04", "docs pages answer users' tasks: manual review")
ctx.report.skip("AR-READ-11", "pricing page: n/a for sites without paid plans")

85
agent_ready/context.py Normal file
View File

@ -0,0 +1,85 @@
"""Shared state for one checker run (site, report, options, discoveries)."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from agent_ready.backends import Response, Site
from agent_ready.formats import looks_like_html
from agent_ready.htmlscan import PageScan, scan
from agent_ready.report import Report
HOME = "/"
LLMS = "/llms.txt"
CATALOG = "/.well-known/ai-catalog.json"
TEXT_TYPES = ("text/plain", "text/markdown")
@dataclass
class Ctx:
"""Everything a check needs; filled in as checks discover the site."""
site: Site
report: Report
min_text: int = 100
max_pages: int = 40
act: bool = False
robots_text: str | None = None
sitemap_pages: list[str] = field(default_factory=list)
_scans: dict[str, PageScan] = field(default_factory=dict)
@property
def live(self) -> bool:
"""True in url mode (headers and content types are real)."""
return self.site.live
def cap(self, items: list[str]) -> list[str]:
"""Apply --max-pages in url mode only; dist mode is free, so check everything."""
return items[: self.max_pages] if self.live else items
def page(self, path: str) -> tuple[Response, PageScan]:
"""Fetch and scan an HTML page (scan memoized)."""
resp = self.site.get(path)
if path not in self._scans:
self._scans[path] = scan(resp.text) if resp.status == 200 else PageScan()
return resp, self._scans[path]
def home(self) -> tuple[Response, PageScan]:
"""The homepage and its scan."""
return self.page(HOME)
def type_problem(
self, resp: Response, allowed: tuple[str, ...], what: str
) -> list[str]:
"""Content-type problem for live responses (dist mode has none)."""
if (
not self.live
or resp.ctype in allowed
or any(a.endswith("+xml") and resp.ctype.endswith("+xml") for a in allowed)
):
return []
return [
f"{what} served as {resp.ctype or 'no content-type'}, want {' or '.join(allowed)}"
]
def exists(self, path: str) -> bool:
"""True when ``path`` is 200 and, for a non-page file, not an HTML fallback."""
resp = self.site.get(path)
is_page = path.endswith(("/", ".html"))
return resp.status == 200 and (is_page or not looks_like_html(resp.text))
def link_header_href(resp: Response, rel: str, mime: str | None = None) -> str | None:
"""href of the first RFC 8288 ``Link`` header value with ``rel`` (and type)."""
for part in re.split(r",\s*(?=<)", resp.headers.get("link", "")):
match = re.match(r"\s*<([^>]*)>(.*)", part)
if not match:
continue
params = match.group(2).lower()
rels = re.search(r'rel="?([^";]+)"?', params)
if not rels or rel not in rels.group(1).split():
continue
if mime is None or re.search(rf'type="?{re.escape(mime)}"?', params):
return match.group(1)
return None

213
agent_ready/formats.py Normal file
View File

@ -0,0 +1,213 @@
"""Validators for the file formats AgentReady points at.
robots.txt (RFC 9309), sitemap.xml (sitemaps.org), llms.txt (agentready
spec "llms.txt shape" / llmstxt.org) and the AI Catalog
(github.com/Agent-Card/ai-catalog, specification/ai-catalog.md).
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
_LINK_ITEM = re.compile(r"^\s*[-*]\s+\[[^\]]+\]\(([^)\s]+)\)")
_FENCE = re.compile(r"^\s{0,3}(```+|~~~+)\s*(\S*)")
_SPEC_VERSION = re.compile(r"^\d+\.\d+$")
def looks_like_html(text: str) -> bool:
"""True when a supposedly plain-text/XML/JSON body is an HTML page."""
head = text.lstrip()[:200].lower()
return head.startswith(("<!doctype html", "<html")) or "<head" in head
# --------------------------------------------------------------- robots.txt
@dataclass
class RobotsGroup:
"""One ``User-agent`` group and its rules, in file order."""
agents: list[str] = field(default_factory=list)
rules: list[tuple[str, str]] = field(default_factory=list)
def parse_robots(text: str) -> list[RobotsGroup]:
"""Parse robots.txt into groups (consecutive User-agent lines share one)."""
groups: list[RobotsGroup] = []
current: RobotsGroup | None = None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if ":" not in line:
continue
key, value = (part.strip() for part in line.split(":", 1))
key = key.lower()
if key == "user-agent":
if current is None or current.rules:
current = RobotsGroup()
groups.append(current)
current.agents.append(value.lower())
elif key in ("allow", "disallow") and current is not None:
current.rules.append((key, value))
return groups
def has_wildcard_group(groups: list[RobotsGroup]) -> bool:
"""True when a ``User-agent: *`` group exists (one rule for all agents)."""
return any("*" in g.agents for g in groups)
def blocked_agents(groups: list[RobotsGroup]) -> list[str]:
"""Agents whose group disallows the whole site (``/`` or ``/*``).
An empty ``Disallow:`` allows everything; an ``Allow: /`` in the same
group wins over the blanket disallow (longest-match tie goes to allow).
"""
blocked: list[str] = []
for group in groups:
disallow_all = any(k == "disallow" and v in ("/", "/*") for k, v in group.rules)
allow_all = any(k == "allow" and v in ("/", "/*") for k, v in group.rules)
if disallow_all and not allow_all:
blocked.extend(group.agents)
return blocked
def robots_sitemaps(text: str) -> list[str]:
"""Return every ``Sitemap:`` URL."""
out = []
for raw in text.splitlines():
key, _, value = raw.partition(":")
if key.strip().lower() == "sitemap" and value.strip():
out.append(value.strip())
return out
# -------------------------------------------------------------- sitemap.xml
def parse_sitemap(body: bytes) -> tuple[str, list[str]]:
"""Return (root kind ``urlset``/``sitemapindex``, list of <loc> values).
Raises ValueError for anything that is not a sitemap document.
"""
try:
root = ET.fromstring(body)
except ET.ParseError as exc:
raise ValueError(f"not XML: {exc}") from exc
kind = root.tag.rsplit("}", 1)[-1]
if kind not in ("urlset", "sitemapindex"):
raise ValueError(f"root element <{kind}> is not <urlset>/<sitemapindex>")
locs = [
(el.text or "").strip()
for el in root.iter()
if el.tag.rsplit("}", 1)[-1] == "loc"
]
return kind, [loc for loc in locs if loc]
# ----------------------------------------------------------------- llms.txt
def llms_problems(text: str) -> list[str]:
"""Check llms.txt shape: H1 first, blockquote summary, H2 link lists."""
if looks_like_html(text):
return ["body is an HTML page, not llms.txt markdown"]
lines = [ln for ln in text.splitlines() if ln.strip()]
problems = []
if not lines or not re.match(r"^# \S", lines[0]):
problems.append("first line must be an H1 title ('# Name')")
if not any(ln.startswith(">") for ln in lines[:4]):
problems.append("missing blockquote summary ('> ...') after the H1")
in_h2 = False
links = 0
for ln in lines:
if ln.startswith("## "):
in_h2 = True
elif in_h2 and _LINK_ITEM.match(ln):
links += 1
if links == 0:
problems.append("no '## Section' with a '- [name](url): notes' link list")
return problems
def llms_links(text: str) -> list[str]:
"""Return every link-list URL in llms.txt, in order."""
return [m.group(1) for ln in text.splitlines() if (m := _LINK_ITEM.match(ln))]
# --------------------------------------------------------------- ai-catalog
def catalog_problems(doc: object) -> list[str]:
"""Validate an AI Catalog (Level 1 + Host Info if present)."""
if not isinstance(doc, dict):
return ["top level must be a JSON object"]
problems = []
version = doc.get("specVersion")
if not isinstance(version, str):
problems.append("missing string specVersion")
elif not _SPEC_VERSION.match(version):
problems.append(f"specVersion {version!r} is not 'Major.Minor'")
host = doc.get("host")
if host is not None and not (
isinstance(host, dict) and isinstance(host.get("displayName"), str)
):
problems.append("host.displayName (string) is required when host is present")
entries = doc.get("entries")
if not isinstance(entries, list):
return [*problems, "missing entries array"]
for i, entry in enumerate(entries):
problems.extend(f"entries[{i}]: {p}" for p in _entry_problems(entry))
return problems
def _entry_problems(entry: object) -> list[str]:
"""Problems with one Catalog Entry."""
if not isinstance(entry, dict):
return ["must be an object"]
problems = [
f"missing string {k}"
for k in ("identifier", "type")
if not isinstance(entry.get(k), str)
]
if ("url" in entry) == ("data" in entry):
problems.append("must carry exactly one of url/data")
return problems
def catalog_urls(doc: object) -> list[str]:
"""Every entry ``url`` in a catalog (empty for invalid docs)."""
if not isinstance(doc, dict) or not isinstance(doc.get("entries"), list):
return []
return [
e["url"]
for e in doc["entries"]
if isinstance(e, dict) and isinstance(e.get("url"), str)
]
def catalog_types(doc: object) -> list[str]:
"""Every entry ``type`` in a catalog."""
if not isinstance(doc, dict) or not isinstance(doc.get("entries"), list):
return []
return [
e["type"]
for e in doc["entries"]
if isinstance(e, dict) and isinstance(e.get("type"), str)
]
# ----------------------------------------------------------------- markdown
def untagged_fences(text: str) -> int:
"""Count opening code fences with no language info string."""
count = 0
open_fence = ""
for line in text.splitlines():
match = _FENCE.match(line)
if not match:
continue
marker, info = match.groups()
if open_fence:
if (
marker[0] == open_fence[0]
and len(marker) >= len(open_fence)
and not info
):
open_fence = ""
continue
open_fence = marker
count += not info
return count

121
agent_ready/htmlscan.py Normal file
View File

@ -0,0 +1,121 @@
"""One-pass HTML scan: visible body text, links, JSON-LD and code blocks."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from html.parser import HTMLParser
_HIDDEN = {"script", "style", "noscript", "template", "svg", "head", "title"}
_LANG = re.compile(r"\b(language|lang)-\S+")
@dataclass
class PageScan:
"""What an agent that does not run JavaScript sees in a page."""
text: str = ""
links: list[tuple[set[str], str, str]] = field(default_factory=list)
anchors: list[str] = field(default_factory=list)
scripts: list[str] = field(default_factory=list)
jsonld_ok: int = 0
jsonld_bad: int = 0
code_blocks: int = 0
untagged_code: int = 0
def link_href(self, rel: str, mime: str | None = None) -> str | None:
"""First ``<link>`` href with ``rel`` (and ``type`` if given)."""
for rels, typ, href in self.links:
if rel in rels and (mime is None or typ == mime):
return href
return None
def _tagged(attrs: dict[str, str]) -> bool:
"""True when an element carries a code-language marker."""
return bool(_LANG.search(attrs.get("class", ""))) or bool(
attrs.get("data-language") or attrs.get("data-lang")
)
class _Scanner(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out = PageScan()
self._hidden: list[str] = []
self._text: list[str] = []
self._pre: list[list[bool]] = [] # [pre_tagged, code_seen]
self._ld: list[str] | None = None
def handle_starttag(
self, tag: str, attr_list: list[tuple[str, str | None]]
) -> None:
attrs = {k: v or "" for k, v in attr_list}
if tag == "body": # </head> is optional in HTML; <body> always ends it
self._hidden = [h for h in self._hidden if h not in ("head", "title")]
if tag == "link" and attrs.get("href"):
rels = set(attrs.get("rel", "").lower().split())
self.out.links.append((rels, attrs.get("type", "").lower(), attrs["href"]))
elif tag == "a" and attrs.get("href"):
self.out.anchors.append(attrs["href"])
elif tag == "script":
if attrs.get("src"):
self.out.scripts.append(attrs["src"])
if attrs.get("type", "").lower() == "application/ld+json":
self._ld = []
elif tag == "pre":
self._pre.append([_tagged(attrs), False])
elif tag == "code" and self._pre and not self._pre[-1][1]:
self._pre[-1][1] = True
self.out.code_blocks += 1
self.out.untagged_code += not (self._pre[-1][0] or _tagged(attrs))
if tag in _HIDDEN:
self._hidden.append(tag)
def handle_endtag(self, tag: str) -> None:
if tag == "script" and self._ld is not None:
self._finish_ld("".join(self._ld))
self._ld = None
if tag == "pre" and self._pre:
self._pre.pop()
if tag in _HIDDEN and tag in self._hidden:
while self._hidden and self._hidden.pop() != tag:
pass
def handle_data(self, data: str) -> None:
if self._ld is not None:
self._ld.append(data)
elif not self._hidden:
self._text.append(data)
def _finish_ld(self, raw: str) -> None:
try:
doc = json.loads(raw)
except ValueError:
self.out.jsonld_bad += 1
return
nodes = doc if isinstance(doc, list) else [doc]
ok = (
all(
isinstance(n, dict) and ("@context" in n or "@graph" in n)
for n in nodes
)
and nodes
)
if ok:
self.out.jsonld_ok += 1
else:
self.out.jsonld_bad += 1
def result(self) -> PageScan:
self.out.text = re.sub(r"\s+", " ", " ".join(self._text)).strip()
return self.out
def scan(html: str) -> PageScan:
"""Scan an HTML document."""
parser = _Scanner()
parser.feed(html)
parser.close()
return parser.result()

73
agent_ready/report.py Normal file
View File

@ -0,0 +1,73 @@
"""Result lines (``PASS|FAIL|SKIP AR-XXX-NN msg``) and the exit-code policy."""
from __future__ import annotations
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TextIO
EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_NETWORK = 0, 1, 2, 3
class Undeterminable(Exception):
"""A network-level failure (refused, timeout, deadline): no verdict."""
@dataclass
class Report:
"""Collects check lines and decides the exit code.
Every in-scope item is required for kuhy's sites, so a FAIL of any
strength (MUST/SHOULD/MAY) fails the run. SKIP never fails it.
"""
out: TextIO = field(default_factory=lambda: sys.stdout)
fails: int = 0
undetermined: int = 0
def emit(self, status: str, ar_id: str, msg: str) -> None:
"""Print one result line."""
if status == "FAIL":
self.fails += 1
print(f"{status} {ar_id} {msg}", file=self.out, flush=True)
def ok(self, ar_id: str, msg: str) -> None:
"""Emit PASS."""
self.emit("PASS", ar_id, msg)
def fail(self, ar_id: str, msg: str) -> None:
"""Emit FAIL."""
self.emit("FAIL", ar_id, msg)
def skip(self, ar_id: str, msg: str) -> None:
"""Emit SKIP."""
self.emit("SKIP", ar_id, msg)
def verdict(self, ar_id: str, problems: list[str], pass_msg: str) -> bool:
"""FAIL with the problems joined, or PASS with ``pass_msg``."""
if problems:
self.fail(ar_id, "; ".join(problems))
return False
self.ok(ar_id, pass_msg)
return True
def guard(self, ar_id: str, fn: Callable[[], object]) -> None:
"""Run a check; a network failure becomes an undeterminable SKIP."""
try:
fn()
except Undeterminable as exc:
self.undetermined += 1
self.skip(ar_id, f"undeterminable (network): {exc}")
def exit_code(self) -> int:
"""0 all pass, 1 any fail, 3 nothing failed but something undeterminable."""
if self.fails:
return EXIT_FAIL
return EXIT_NETWORK if self.undetermined else EXIT_OK
def summarize(items: list[str], limit: int = 4) -> str:
"""Join up to ``limit`` items and note how many more there were."""
head = ", ".join(items[:limit])
return head + (f" (+{len(items) - limit} more)" if len(items) > limit else "")

View File

View File

@ -0,0 +1,136 @@
"""Fixtures: generated site trees and loopback HTTP servers that serve them."""
from __future__ import annotations
import socket
import threading
from collections.abc import Callable, Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from agent_ready.tests import sitegen
_MIME = {
".html": "text/html; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml",
".json": "application/json",
}
def _resolve(root: Path, path: str) -> Path | None:
"""Map a URL path to a file the way a static host does."""
rel = path.split("?", 1)[0].lstrip("/")
cand = root / rel
if path.endswith("/"):
cand = cand / "index.html"
return cand if cand.is_file() else None
def make_handler(root: Path, mode: str, block_ua: str) -> type[BaseHTTPRequestHandler]:
"""Build a handler class.
``mode`` is ``good`` (real 404s), ``spa`` (200 index.html for every path,
the kuhy.duckdns.org trap) or ``json404`` (every non-root path is a JSON
404, the popes.kuhy.duckdns.org shape).
"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None: # silence test output
return
def _send(
self,
status: int,
ctype: str,
body: bytes,
extra: dict[str, str] | None = None,
) -> None:
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if block_ua and block_ua in self.headers.get("User-Agent", ""):
self._send(403, "text/html", b"<html>Just a moment...</html>")
return
origin = f"http://{self.headers['Host']}"
if mode == "spa" or (mode == "json404" and self.path == "/"):
body = (root / "index.html").read_bytes()
self._send(200, "text/html; charset=utf-8", body)
return
target = None if mode == "json404" else _resolve(root, self.path)
if target is None:
if mode == "json404":
self._send(404, "application/json", b'{"detail":"Not Found"}')
else:
self._send(404, "text/html", (root / "404.html").read_bytes())
return
if target.name == "ai-catalog.json":
ctype = "application/ai-catalog+json"
else:
ctype = _MIME.get(target.suffix, "application/octet-stream")
body = target.read_bytes().replace(sitegen.ORIGIN.encode(), origin.encode())
extra = {}
if self.path == "/":
extra["Link"] = '</.well-known/ai-catalog.json>; rel="ai-catalog"'
self._send(200, ctype, body, extra)
def do_POST(self) -> None:
"""Minimal MCP: answer initialize at /mcp in good mode only."""
self.rfile.read(int(self.headers.get("Content-Length", 0)))
if mode == "good" and self.path == "/mcp":
self._send(
200, "application/json", b'{"jsonrpc":"2.0","id":1,"result":{}}'
)
else:
self._send(200, "text/html", (root / "index.html").read_bytes())
return Handler
@pytest.fixture
def good_dir(tmp_path: Path) -> Path:
"""A compliant build output."""
return sitegen.build_good(tmp_path / "good")
@pytest.fixture
def bad_dir(tmp_path: Path) -> Path:
"""A build output failing every find/read item."""
return sitegen.build_bad(tmp_path / "bad")
@pytest.fixture
def serve() -> Iterator[Callable[..., str]]:
"""Start loopback servers on demand; return their base URL."""
servers: list[ThreadingHTTPServer] = []
def start(root: Path, mode: str = "good", block_ua: str = "") -> str:
server = ThreadingHTTPServer(
("127.0.0.1", 0), make_handler(root, mode, block_ua)
)
threading.Thread(target=server.serve_forever, daemon=True).start()
servers.append(server)
return f"http://127.0.0.1:{server.server_address[1]}"
yield start
for server in servers:
server.shutdown()
server.server_close()
@pytest.fixture
def dead_url() -> str:
"""A loopback URL with nothing listening (connection refused)."""
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}"

View File

@ -0,0 +1,80 @@
"""In-memory sites and CLI helpers for tests that need exact responses."""
from __future__ import annotations
import io
import pytest
from agent_ready.backends import DEFAULT_UA, HttpSite, Response, Site
from agent_ready.check import main
from agent_ready.context import Ctx
from agent_ready.report import Report
HTML = "text/html"
_MISSING = Response(404, HTML, b"")
def resp(body: str, ctype: str = HTML, status: int = 200, **headers: str) -> Response:
"""Build a Response; ``headers`` keys are already lowercase names."""
return Response(status, ctype, body.encode(), dict(headers))
class FakeSite(Site):
"""A dist-like (``live=False``) site answering from a dict."""
def __init__(self, pages: dict[str, Response]) -> None:
super().__init__("https://fake.test")
self.pages = pages
def _fetch(self, path: str, ua: str) -> Response:
return self.pages.get(path, _MISSING)
class FakeHttp(HttpSite):
"""A live site with no sockets: per-path (and per-UA) canned responses."""
def __init__(
self,
pages: dict[str, Response],
ua_pages: dict[tuple[str, str], Response] | None = None,
mcp: Response | None = None,
) -> None:
super().__init__("https://fake.test")
self.pages = pages
self.ua_pages = ua_pages or {}
self.mcp = mcp or _MISSING
def _fetch(self, path: str, ua: str) -> Response:
return self.ua_pages.get((path, ua)) or self.pages.get(path, _MISSING)
def request(
self,
path: str,
ua: str = DEFAULT_UA,
data: bytes | None = None,
headers: dict[str, str] | None = None,
) -> Response:
"""The canned MCP answer, whatever the request."""
return self.mcp
def make_ctx(site: Site, *, act: bool = False) -> tuple[Ctx, io.StringIO]:
"""A Ctx writing to a StringIO; returns it with that buffer."""
out = io.StringIO()
return Ctx(site=site, report=Report(out=out), act=act), out
def run_main(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, list[str]]:
"""Run the CLI in-process; return exit code and output lines."""
code = main(list(argv))
return code, capsys.readouterr().out.splitlines()
def lines_for(lines: list[str], ar_id: str, status: str = "") -> list[str]:
"""Output lines for ``ar_id`` (optionally only one status)."""
return [
ln
for ln in lines
if ln.split()[1] == ar_id and (not status or ln.startswith(status))
]

View File

@ -0,0 +1,153 @@
"""Build good and bad site trees in a temp dir (no static fixture files).
Generated at test time on purpose: mirror files such as ``index.md`` would
otherwise trip the repo's markdown-naming gate.
"""
from __future__ import annotations
import json
from pathlib import Path
ORIGIN = "https://example.test"
_TEXT = (
"Dice roller for tabletop games. Roll any combination of polyhedral dice, "
"keep the highest, and read the full breakdown of every throw. This text "
"is in the initial HTML so an agent that never runs JavaScript can read it."
)
_LD = json.dumps(
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Dice",
"url": ORIGIN,
}
)
CATALOG = {
"specVersion": "1.0",
"host": {"displayName": "kuhy", "identifier": "did:web:example.test"},
"entries": [
{
"identifier": "urn:air:example.test:docs:llms",
"displayName": "llms.txt",
"type": "text/plain",
"url": f"{ORIGIN}/llms.txt",
}
],
}
LLMS = f"""# Dice
> A dice roller for tabletop games.
Plain prose is allowed between sections.
## Pages
- [Home]({ORIGIN}/): what the roller does
- [About]({ORIGIN}/about/): who made it
"""
ROBOTS = f"""# robots.txt
User-agent: *
Disallow: /search/
Sitemap: {ORIGIN}/sitemap.xml
"""
SITEMAP = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>{ORIGIN}/</loc></url>
<url><loc>{ORIGIN}/about/</loc></url>
</urlset>
"""
def page(title: str, mirror: str, *, home: bool = False) -> str:
"""Return a server-rendered HTML page with every read-stage feature."""
head = [
f"<title>{title}</title>",
f'<link rel="alternate" type="text/markdown" href="{mirror}">',
]
if home:
head += [
'<link rel="ai-catalog" href="/.well-known/ai-catalog.json" type="application/ai-catalog+json">',
'<link rel="alternate" type="text/plain" href="/llms.txt">',
f'<script type="application/ld+json">{_LD}</script>',
]
return (
'<!doctype html><html lang="en"><head>' + "".join(head) + "</head><body>"
f"<h1>{title}</h1><p>{_TEXT}</p>"
'<pre><code class="language-python">print("d20")</code></pre>'
'<nav><a href="/">Home</a> <a href="/about/">About</a> <a href="/llms.txt">llms.txt</a></nav>'
"</body></html>"
)
def mirror(title: str) -> str:
"""Return a markdown mirror with a language-tagged fence."""
return f"# {title}\n\n{_TEXT}\n\n```python\nprint('d20')\n```\n"
def write(root: Path, rel: str, text: str) -> None:
"""Write ``text`` to ``root/rel``, creating parent dirs."""
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def build_good(root: Path) -> Path:
"""Create a fully compliant build output under ``root``."""
write(root, "index.html", page("Dice", "/index.md", home=True))
write(root, "index.md", mirror("Dice"))
write(root, "about/index.html", page("About", "/about/index.md"))
write(root, "about/index.md", mirror("About"))
write(
root,
"404.html",
"<!doctype html><html><body><h1>Not found</h1><p>No page at this address.</p></body></html>",
)
write(root, "robots.txt", ROBOTS)
write(root, "sitemap.xml", SITEMAP)
write(root, "llms.txt", LLMS)
write(root, ".well-known/ai-catalog.json", json.dumps(CATALOG))
return root
SHELL = '<!doctype html><html><head><title>App</title></head><body><div id="root"></div><script src="/app.js"></script></body></html>'
def build_bad(root: Path) -> Path:
"""Create a build output that violates every find/read item."""
write(root, "index.html", SHELL)
write(root, "about/index.html", SHELL)
write(
root,
"robots.txt",
"User-agent: *\nDisallow:\n\nUser-agent: GPTBot\nDisallow: /\n",
)
write(root, "sitemap.xml", "<html>not xml")
write(root, "llms.txt", "# Dice\n\n## Pages\n- [About](/about/)\n")
write(
root,
".well-known/ai-catalog.json",
json.dumps({"entries": [{"identifier": "x"}]}),
)
return root
def add_pages(root: Path, count: int, *, broken_last: bool = False) -> None:
"""Add ``count`` more sitemap pages; the last one an empty shell if asked."""
urls = []
for i in range(count):
title = f"Post {i}"
html = (
SHELL if broken_last and i == count - 1 else page(title, f"/p{i}/index.md")
)
write(root, f"p{i}/index.html", html)
write(root, f"p{i}/index.md", mirror(title))
urls.append(f" <url><loc>{ORIGIN}/p{i}/</loc></url>\n")
write(
root, "sitemap.xml", SITEMAP.replace("</urlset>", "".join(urls) + "</urlset>")
)

View File

@ -0,0 +1,75 @@
"""Backend edge cases: URL mapping, memoized failures, traversal, deadline."""
from __future__ import annotations
from pathlib import Path
import pytest
from agent_ready.backends import DirSite, HttpSite, Response, Site
from agent_ready.report import Undeterminable
from agent_ready.tests.fakes import FakeHttp, resp
class FlakySite(Site):
"""Raises Undeterminable on every fetch and counts the attempts."""
live = True
def __init__(self) -> None:
super().__init__("https://flaky.test")
self.attempts = 0
def _fetch(self, path: str, ua: str) -> Response:
self.attempts += 1
raise Undeterminable(f"{path}: boom")
def test_to_path_rejects_offsite_and_non_http(tmp_path: Path) -> None:
site = DirSite(tmp_path)
assert site.to_path("https://other.test/x") is None
assert site.to_path("mailto:a@b.test") is None
assert site.to_path("/rel/") == "/rel/"
assert site.to_path("https://dist.invalid") == "/"
def test_failures_are_memoized() -> None:
site = FlakySite()
for _ in range(2):
with pytest.raises(Undeterminable, match="boom"):
site.get("/x")
assert site.attempts == 1
def test_prefetch_swallows_failures_then_get_raises() -> None:
site = FlakySite()
site.prefetch(["/a", "/b", "/a"])
assert site.attempts == 2
with pytest.raises(Undeterminable):
site.get("/a")
def test_base_fetch_is_abstract() -> None:
with pytest.raises(NotImplementedError):
Site("https://x.test")._fetch("/", "ua")
def test_dist_never_serves_outside_root(tmp_path: Path) -> None:
root = tmp_path / "dist"
root.mkdir()
(tmp_path / "secret.txt").write_text("nope")
site = DirSite(root)
assert site.file_for("/../secret.txt") is None
assert site.get("/../secret.txt").status == 404
def test_exhausted_budget_is_undeterminable() -> None:
site = HttpSite("https://budget.test/anything", budget=0)
with pytest.raises(Undeterminable, match="time budget"):
site.get("/")
def test_fake_http_serves_canned_pages() -> None:
site = FakeHttp({"/": resp("hi")})
assert site.get("/").text == "hi"
assert site.get("/nope").status == 404

View File

@ -0,0 +1,153 @@
"""Dist-mode scenarios: one mutation of the good tree per failing branch."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from agent_ready.tests import sitegen
from agent_ready.tests.fakes import lines_for, run_main
OPENAPI_OP = {"get": {"operationId": "ping"}}
def test_robots_without_wildcard_group(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
robots = f"User-agent: Foo\nDisallow: /x\n\nSitemap: {sitegen.ORIGIN}/sitemap.xml\n"
sitegen.write(good_dir, "robots.txt", robots)
_, lines = run_main(capsys, "dist", str(good_dir))
assert "no 'User-agent: *' group" in lines_for(lines, "AR-FIND-01", "FAIL")[0]
def test_sitemap_found_without_robots_reference_sets_origin(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
sitegen.write(good_dir, "robots.txt", "User-agent: *\nDisallow:\n")
code, lines = run_main(capsys, "dist", str(good_dir))
assert code == 1
assert "no 'Sitemap:' line" in lines_for(lines, "AR-FIND-03", "FAIL")[0]
assert lines_for(lines, "AR-FIND-03", "FAIL")[1:] == []
assert lines_for(lines, "AR-READ-01", "PASS")
def test_sitemap_index_follows_children(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
child = f"{sitegen.ORIGIN}/sitemap-pages.xml"
index = (
'<?xml version="1.0"?><sitemapindex '
'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
f"<sitemap><loc>{child}</loc></sitemap></sitemapindex>"
)
sitegen.write(good_dir, "sitemap-pages.xml", sitegen.SITEMAP)
sitegen.write(good_dir, "sitemap.xml", index)
code, lines = run_main(capsys, "dist", str(good_dir))
assert code == 0, lines
assert "lists 2 pages" in lines_for(lines, "AR-FIND-03", "PASS")[-1]
def test_sitemap_with_offsite_locs_and_none_at_all(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
urlset = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{}</urlset>'
off = urlset.format("<url><loc>https://other.test/x/</loc></url>")
sitegen.write(good_dir, "sitemap.xml", off)
_, lines = run_main(capsys, "dist", str(good_dir))
assert "1 <loc> URLs are not on" in lines_for(lines, "AR-FIND-03", "FAIL")[0]
sitegen.write(good_dir, "sitemap.xml", urlset.format(""))
_, lines = run_main(capsys, "dist", str(good_dir))
assert "no <loc> entries" in lines_for(lines, "AR-FIND-03", "FAIL")[0]
assert "sitemap yielded no pages" in lines_for(lines, "AR-READ-01", "FAIL")[0]
def test_sitemap_pages_missing_or_copies_of_the_shell(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
home = (good_dir / "index.html").read_text()
sitegen.write(good_dir, "dup/index.html", home)
extra = "".join(
f"<url><loc>{sitegen.ORIGIN}/{p}/</loc></url>" for p in ("dup", "gone")
)
sitemap = (good_dir / "sitemap.xml").read_text()
sitegen.write(
good_dir, "sitemap.xml", sitemap.replace("</urlset>", extra + "</urlset>")
)
code, lines = run_main(capsys, "dist", str(good_dir))
assert code == 1
problem = lines_for(lines, "AR-READ-01", "FAIL")[0]
assert "/gone/ (HTTP 404)" in problem
assert "/dup/ (byte-identical to the homepage shell)" in problem
def test_markdown_mirror_with_untagged_fence(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
sitegen.write(good_dir, "about/index.md", "# About\n\n```\nplain\n```\n")
_, lines = run_main(capsys, "dist", str(good_dir))
assert "about/index.md (1 untagged fences)" in lines_for(lines, "AR-READ-05")[0]
def test_llms_with_broken_link(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
llms = (good_dir / "llms.txt").read_text() + "- [Gone](/gone/): missing\n"
sitegen.write(good_dir, "llms.txt", llms)
_, lines = run_main(capsys, "dist", str(good_dir))
assert "broken links: /gone/" in lines_for(lines, "AR-READ-07", "FAIL")[0]
def test_homepage_with_unparseable_json_ld(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
home = (good_dir / "index.html").read_text()
sitegen.write(good_dir, "index.html", home.replace('"@context"', "@context"))
_, lines = run_main(capsys, "dist", str(good_dir))
assert "1 JSON-LD blocks fail" in lines_for(lines, "AR-READ-08", "FAIL")[-1]
def test_webmcp_found_in_linked_script(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
home = (good_dir / "index.html").read_text()
scripts = '<script src="https://cdn.other.test/x.js"></script><script src="/app.js"></script>'
sitegen.write(good_dir, "index.html", home.replace("</body>", scripts + "</body>"))
sitegen.write(good_dir, "app.js", "document.modelContext.registerTool({});")
_, lines = run_main(capsys, "dist", str(good_dir), "--act")
assert lines_for(lines, "AR-ACT-07", "PASS")
@pytest.mark.parametrize(
("doc", "status", "needle"),
[
({"openapi": "3.1.0", "paths": {"/p": OPENAPI_OP}}, "PASS", "1 operations"),
(
{"openapi": "3.1.0", "paths": {"/p": {"get": {}}}},
"FAIL",
"lack operationId",
),
({"openapi": "3.1.0", "paths": {}}, "FAIL", "no operations in paths"),
({"openapi": "3.1.0", "paths": 5}, "FAIL", "no operations in paths"),
(
{
"openapi": "3.1.0",
"paths": {"/a": "x", "/b": {"parameters": [], "get": 1}},
},
"FAIL",
"1 operations lack",
),
],
)
def test_openapi_document_shapes(
capsys: pytest.CaptureFixture[str],
good_dir: Path,
doc: dict[str, object],
status: str,
needle: str,
) -> None:
sitegen.write(good_dir, "openapi.json", json.dumps(doc))
_, lines = run_main(capsys, "dist", str(good_dir), "--act")
found = lines_for(lines, "AR-ACT-02", status)
assert found and needle in found[0], lines_for(lines, "AR-ACT-02")

View File

@ -0,0 +1,83 @@
"""Live-mode checks driven by canned responses (no sockets, no network)."""
from __future__ import annotations
import pytest
from agent_ready import checks_act, checks_find, checks_read
from agent_ready.backends import Response
from agent_ready.tests import sitegen
from agent_ready.tests.fakes import FakeHttp, make_ctx, resp
LONG = "Readable text for agents that never run JavaScript. " * 4
MD_LINK = '</index.md>; rel="alternate"; type="text/markdown"'
def _home(*, ctype: str = "text/html", **headers: str) -> Response:
return resp(f"<html><body><p>{LONG}</p></body></html>", ctype, **headers)
def test_challenge_page_for_one_agent_is_reported() -> None:
challenge = resp("<html>Just a moment...</html>")
ua = checks_find.AGENT_UAS["GPTBot"]
site = FakeHttp({"/": _home()}, ua_pages={("/", ua): challenge})
ctx, out = make_ctx(site)
checks_find.check_agent_access(ctx)
assert out.getvalue().startswith("FAIL AR-FIND-01 GPTBot got a challenge page")
def test_sitemap_on_another_host_is_a_failure() -> None:
robots = "User-agent: *\nDisallow:\nSitemap: https://other.test/sitemap.xml\n"
site = FakeHttp({"/robots.txt": resp(robots, "text/plain")})
ctx, out = make_ctx(site)
checks_find.check_robots(ctx)
ctx.sitemap_pages = []
assert checks_find._sitemap_paths(ctx) == ["/sitemap.xml"]
assert (
"Sitemap is on another host: https://other.test/sitemap.xml" in out.getvalue()
)
def test_markdown_mirror_advertised_only_by_link_header() -> None:
site = FakeHttp(
{
"/": _home(link=MD_LINK),
"/index.md": resp(sitegen.mirror("Home"), "text/markdown"),
}
)
ctx, out = make_ctx(site)
checks_read.check_pages(ctx)
assert (
"PASS AR-READ-09 1 pages advertise a working markdown mirror" in out.getvalue()
)
def test_page_served_with_wrong_content_type() -> None:
ctx, out = make_ctx(FakeHttp({"/": _home(ctype="text/plain")}))
checks_read.check_pages(ctx)
assert (
"FAIL AR-READ-01 answer not in initial HTML: / (text/plain)" in out.getvalue()
)
def _mcp(status: int, **headers: str) -> Response:
return Response(status, "application/json", b"{}", dict(headers))
@pytest.mark.parametrize(
("answer", "status"),
[
(_mcp(401, **{"www-authenticate": "Bearer"}), "PASS"),
(_mcp(401), "FAIL"),
],
)
def test_mcp_behind_oauth_needs_www_authenticate(answer: Response, status: str) -> None:
ctx, out = make_ctx(FakeHttp({}, mcp=answer), act=True)
checks_act.check_mcp(ctx)
assert f"{status} AR-ACT-03" in out.getvalue()
def test_act_off_reports_a_single_skip() -> None:
ctx, out = make_ctx(FakeHttp({}))
checks_act.run_act(ctx)
assert out.getvalue().startswith("SKIP AR-ACT-10 act stage off")

View File

@ -0,0 +1,202 @@
"""End-to-end tests of ``check.py`` in dist and url mode."""
from __future__ import annotations
import re
import runpy
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
import pytest
from agent_ready.check import main
from agent_ready.tests import sitegen
LINE = re.compile(r"^(PASS|FAIL|SKIP) AR-(FIND|READ|ACT)-\d\d \S")
PKG = Path(__file__).resolve().parents[1]
def run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, list[str]]:
"""Run the CLI in-process; return exit code and output lines."""
code = main(list(argv))
lines = capsys.readouterr().out.splitlines()
bad = [ln for ln in lines if not LINE.match(ln)]
assert not bad, bad
return code, lines
def statuses(lines: list[str], ar_id: str) -> set[str]:
"""Return the set of statuses reported for ``ar_id``."""
return {ln.split()[0] for ln in lines if ln.split()[1] == ar_id}
def test_dist_good_passes(capsys: pytest.CaptureFixture[str], good_dir: Path) -> None:
code, lines = run(capsys, "dist", str(good_dir))
assert code == 0, "\n".join(lines)
assert not [ln for ln in lines if ln.startswith("FAIL")]
for ar_id in (
"AR-FIND-01",
"AR-FIND-03",
"AR-FIND-04",
"AR-READ-01",
"AR-READ-02",
"AR-READ-07",
"AR-READ-09",
):
found = statuses(lines, ar_id)
assert "PASS" in found and "FAIL" not in found, ar_id
assert statuses(lines, "AR-READ-11") == {"SKIP"}
assert statuses(lines, "AR-ACT-10") == {"SKIP"}
def test_dist_bad_fails_every_item(
capsys: pytest.CaptureFixture[str], bad_dir: Path
) -> None:
code, lines = run(capsys, "dist", str(bad_dir))
assert code == 1
for ar_id in (
"AR-FIND-01",
"AR-FIND-03",
"AR-FIND-04",
"AR-READ-01",
"AR-READ-02",
"AR-READ-03",
"AR-READ-06",
"AR-READ-07",
"AR-READ-08",
"AR-READ-09",
):
assert "FAIL" in statuses(lines, ar_id), ar_id
assert any("gptbot" in ln for ln in lines if "AR-FIND-01" in ln)
def test_dist_untagged_code_fails(
capsys: pytest.CaptureFixture[str], good_dir: Path
) -> None:
about = good_dir / "about/index.html"
about.write_text(about.read_text().replace(' class="language-python"', ""))
code, lines = run(capsys, "dist", str(good_dir))
assert code == 1
assert "FAIL" in statuses(lines, "AR-READ-05")
def test_dist_act_opt_in(capsys: pytest.CaptureFixture[str], good_dir: Path) -> None:
code, lines = run(capsys, "dist", str(good_dir), "--act")
assert code == 1
assert "FAIL" in statuses(lines, "AR-ACT-02")
def test_usage_errors(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
assert main(["dist", str(tmp_path / "missing")]) == 2
assert main(["url", "not-a-url"]) == 2
with pytest.raises(SystemExit) as exc:
main([])
assert exc.value.code == 2
def test_url_good_passes(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
code, lines = run(capsys, "url", serve(good_dir))
assert code == 0, "\n".join(lines)
assert statuses(lines, "AR-READ-02") == {"PASS"}
def test_url_spa_fallback_fails(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
"""Every path 200 text/html: a status-only check would wrongly pass."""
code, lines = run(capsys, "url", serve(good_dir, "spa"))
assert code == 1
for ar_id in (
"AR-FIND-01",
"AR-FIND-03",
"AR-FIND-04",
"AR-READ-01",
"AR-READ-02",
"AR-READ-07",
"AR-READ-09",
):
assert "FAIL" in statuses(lines, ar_id), ar_id
assert any("text/html" in ln for ln in lines if "AR-FIND-01" in ln)
assert any(
"does not exist" in ln for ln in lines if "AR-READ-06" in ln
) # HTML fallback != file
def test_url_json_404(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
code, lines = run(capsys, "url", serve(good_dir, "json404"))
assert code == 1
assert statuses(lines, "AR-READ-02") == {"PASS"}
assert "FAIL" in statuses(lines, "AR-FIND-01")
def test_url_blocked_agent(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
code, lines = run(capsys, "url", serve(good_dir, block_ua="GPTBot"))
assert code == 1
assert any(ln.startswith("FAIL AR-FIND-01") and "GPTBot" in ln for ln in lines)
def test_url_unreachable_is_3(
capsys: pytest.CaptureFixture[str], dead_url: str
) -> None:
assert main(["url", dead_url]) == 3
def test_entry_points_from_other_cwd(good_dir: Path, tmp_path: Path) -> None:
for cmd in (
["bash", str(PKG / "check.sh")],
[sys.executable, str(PKG / "check.py")],
):
proc = subprocess.run(
[*cmd, "dist", str(good_dir)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
def test_dist_checks_every_page_url_caps_visibly(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
"""Dist mode ignores --max-pages; url mode names the cap instead of hiding it."""
sitegen.add_pages(good_dir, 6, broken_last=True)
code, lines = run(capsys, "dist", str(good_dir), "--max-pages", "3")
assert code == 1
assert any(ln.startswith("FAIL AR-READ-01") and "/p5/" in ln for ln in lines)
code, lines = run(capsys, "url", serve(good_dir), "--max-pages", "3")
assert any(ln.startswith("SKIP AR-READ-01 checked 3 of 8 pages") for ln in lines), (
lines
)
def test_url_act_mcp(
capsys: pytest.CaptureFixture[str], good_dir: Path, serve: Callable[..., str]
) -> None:
_, lines = run(capsys, "url", serve(good_dir), "--act")
assert statuses(lines, "AR-ACT-03") == {"PASS"}
assert statuses(lines, "AR-ACT-10") == {"PASS"}
assert "FAIL" in statuses(lines, "AR-ACT-07")
code, lines = run(capsys, "url", serve(good_dir, "spa"), "--act")
assert code == 1
assert statuses(lines, "AR-ACT-03") == {"FAIL"}
assert statuses(lines, "AR-ACT-10") == {"FAIL"}
def test_script_entry_point_in_process(good_dir: Path) -> None:
"""Run check.py as ``__main__`` here so the guard lines count for coverage."""
argv = ["check.py", "dist", str(good_dir)]
with pytest.MonkeyPatch.context() as patch:
patch.setattr(sys, "argv", argv)
patch.setattr(sys, "path", list(sys.path)) # check.py prepends to it
with pytest.raises(SystemExit) as exc:
runpy.run_path(str(PKG / "check.py"), run_name="__main__")
assert exc.value.code == 0

View File

@ -0,0 +1,186 @@
"""Unit tests for the format validators and the HTML scanner."""
from __future__ import annotations
import pytest
from agent_ready import formats, htmlscan
from agent_ready.tests import sitegen
# Verbatim from spec/spec.md "llms.txt shape (informative)".
SPEC_LLMS = """# Acme
> Acme ships a payments API for developers.
## Docs
- [Quickstart](https://example.com/docs/quickstart): first charge in one request
- [Authentication](https://example.com/docs/auth): API keys, scopes, rotation
## API
- [OpenAPI](https://example.com/openapi.json): full request and response contract
"""
# Shape of https://vercel.com/.well-known/ai-catalog.json (2026-10-22), trimmed.
VERCEL_CATALOG = {
"specVersion": "1.0",
"host": {"displayName": "Vercel", "identifier": "https://vercel.com"},
"entries": [
{
"identifier": "urn:air:vercel.com:api:rest",
"displayName": "Vercel REST API",
"type": "application/json",
"url": "https://vercel.com/openapi.json",
"tags": ["api"],
}
],
}
def test_llms_spec_example_is_valid() -> None:
assert formats.llms_problems(SPEC_LLMS) == []
def test_llms_allows_prose_and_plain_bullets() -> None:
text = SPEC_LLMS.replace(
"## API", "Version: v2.0\n\n## Notes\n- plain bullet\n\n## API"
)
assert formats.llms_problems(text) == []
@pytest.mark.parametrize(
("mutate", "needle"),
[
(lambda t: t.replace("# Acme\n", ""), "H1"),
(
lambda t: t.replace("> Acme ships a payments API for developers.\n", ""),
"blockquote",
),
(lambda t: "# Acme\n> summary\n\nno sections\n", "link list"),
(lambda t: "<!doctype html><html></html>", "HTML"),
],
)
def test_llms_rejects(mutate: object, needle: str) -> None:
problems = formats.llms_problems(mutate(SPEC_LLMS)) # type: ignore[operator]
assert any(needle in p for p in problems), problems
def test_llms_links() -> None:
assert formats.llms_links(SPEC_LLMS)[0] == "https://example.com/docs/quickstart"
def test_catalog_valid_examples() -> None:
assert formats.catalog_problems(VERCEL_CATALOG) == []
assert formats.catalog_problems(sitegen.CATALOG) == []
minimal = {
"specVersion": "1.0",
"entries": [{"identifier": "a", "type": "t", "data": {}}],
}
assert formats.catalog_problems(minimal) == []
@pytest.mark.parametrize(
("doc", "needle"),
[
([], "object"),
({"entries": []}, "specVersion"),
({"specVersion": "1", "entries": []}, "Major.Minor"),
({"specVersion": "1.0"}, "entries"),
({"specVersion": "1.0", "entries": [{"type": "t", "url": "u"}]}, "identifier"),
({"specVersion": "1.0", "entries": [{"identifier": "i", "url": "u"}]}, "type"),
(
{
"specVersion": "1.0",
"entries": [{"identifier": "i", "type": "t", "url": "u", "data": 1}],
},
"exactly one",
),
({"specVersion": "1.0", "entries": [], "host": {}}, "host.displayName"),
],
)
def test_catalog_rejects(doc: object, needle: str) -> None:
assert any(needle in p for p in formats.catalog_problems(doc)), (
formats.catalog_problems(doc)
)
def test_robots_blanket_blocks() -> None:
text = "User-agent: *\nDisallow:\n\nUser-Agent: GPTBot\nUser-agent: CCBot\nDisallow: /*\n"
groups = formats.parse_robots(text)
assert formats.has_wildcard_group(groups)
assert formats.blocked_agents(groups) == ["gptbot", "ccbot"]
def test_robots_partial_disallow_and_allow_override() -> None:
text = "User-agent: *\nDisallow: /search/\n\nUser-agent: ClaudeBot\nDisallow: /\nAllow: /\n"
assert formats.blocked_agents(formats.parse_robots(text)) == []
assert formats.robots_sitemaps("Sitemap: https://x.test/s.xml\n") == [
"https://x.test/s.xml"
]
def test_sitemap_parse() -> None:
kind, locs = formats.parse_sitemap(sitegen.SITEMAP.encode())
assert kind == "urlset"
assert locs == ["https://example.test/", "https://example.test/about/"]
with pytest.raises(ValueError):
formats.parse_sitemap(b"<html>not xml")
def test_untagged_fences() -> None:
assert formats.untagged_fences("```python\nx\n```\n") == 0
assert formats.untagged_fences("```\nx\n```\n~~~\ny\n~~~\n") == 2
def test_htmlscan_good_home() -> None:
scan = htmlscan.scan(sitegen.page("Dice", "/index.md", home=True))
assert len(scan.text) > 150
assert scan.code_blocks == 1 and scan.untagged_code == 0
assert scan.link_href("alternate", "text/markdown") == "/index.md"
assert scan.link_href("ai-catalog") == "/.well-known/ai-catalog.json"
assert "/about/" in scan.anchors
assert scan.jsonld_ok == 1
def test_htmlscan_shell_has_no_text() -> None:
scan = htmlscan.scan(sitegen.SHELL)
assert scan.text.strip() == "" # <title> is head, not body text
assert scan.anchors == []
bare = htmlscan.scan(
'<pre><code>x</code></pre><script type="application/ld+json">{bad</script>'
)
assert bare.untagged_code == 1 and bare.jsonld_bad == 1
def test_sitemap_wrong_root_is_rejected() -> None:
with pytest.raises(ValueError, match="not <urlset>"):
formats.parse_sitemap(b"<feed><loc>https://x.test/</loc></feed>")
def test_catalog_entry_must_be_an_object() -> None:
doc = {"specVersion": "1.0", "entries": ["nope"]}
assert formats.catalog_problems(doc) == ["entries[0]: must be an object"]
def test_catalog_accessors_tolerate_invalid_docs() -> None:
for doc in ([], {"entries": "x"}):
assert formats.catalog_urls(doc) == []
assert formats.catalog_types(doc) == []
assert formats.catalog_urls(sitegen.CATALOG) == [f"{sitegen.ORIGIN}/llms.txt"]
def test_fence_inside_fence_is_not_a_new_block() -> None:
nested = "````md\n```\ninner\n```\n````\n"
assert formats.untagged_fences(nested) == 0
assert formats.untagged_fences("```python\n~~~\n```\n```\nz\n```\n") == 1
def test_htmlscan_jsonld_without_context_is_bad() -> None:
scan = htmlscan.scan(
'<script type="application/ld+json">{"name": "x"}</script>'
'<script type="application/ld+json">[]</script>'
)
assert scan.jsonld_ok == 0 and scan.jsonld_bad == 2
def test_htmlscan_unclosed_inner_hidden_tag_closes_with_outer() -> None:
scan = htmlscan.scan("<body><template><noscript>x</template><p>visible</p></body>")
assert scan.text == "visible"

View File

@ -0,0 +1,54 @@
"""Report policy, Link-header parsing and context helpers."""
from __future__ import annotations
import io
from agent_ready.backends import Response
from agent_ready.context import link_header_href
from agent_ready.report import Report, Undeterminable, summarize
from agent_ready.tests.fakes import FakeSite, make_ctx, resp
def _boom() -> None:
raise Undeterminable("refused")
def test_guard_turns_network_failure_into_skip_and_exit_3() -> None:
out = io.StringIO()
report = Report(out=out)
report.guard("AR-FIND-01", _boom)
assert out.getvalue() == "SKIP AR-FIND-01 undeterminable (network): refused\n"
assert report.exit_code() == 3
report.fail("AR-FIND-01", "x")
assert report.exit_code() == 1
def test_summarize_notes_the_overflow() -> None:
assert summarize(["a", "b"]) == "a, b"
assert summarize(list("abcdef")) == "a, b, c, d (+2 more)"
def _linked(link: str) -> Response:
return Response(200, "text/html", b"", {"link": link})
def test_link_header_skips_unusable_values() -> None:
header = (
"garbage, </no-rel>; type=x, </other>; rel=preload, "
'</a.md>; rel="alternate"; type="text/plain", '
'</b.md>; rel="alternate"; type="text/markdown"'
)
assert link_header_href(_linked(header), "alternate", "text/markdown") == "/b.md"
assert link_header_href(_linked(header), "alternate") == "/a.md"
assert link_header_href(_linked(header), "ai-catalog") is None
assert link_header_href(_linked(""), "alternate") is None
def test_exists_rejects_html_fallback_for_files() -> None:
ctx, _ = make_ctx(
FakeSite({"/f.txt": resp("<html><head></head>"), "/p/": resp("<html>")})
)
assert not ctx.exists("/f.txt")
assert ctx.exists("/p/")
assert not ctx.exists("/missing.txt")

View File

@ -0,0 +1,7 @@
"""Deterministic coverage-gap lister: a coverage report in, a task list out.
Replaces "spawn an LLM agent to explore where coverage is missing". Reads lcov,
Cobertura XML, JaCoCo XML or coverage.py JSON and lists, per file, exactly
which lines and branches are uncovered. Exit codes are the verdict (0 covered,
1 gaps, 2 unreadable) so CI and hooks adjudicate without a model.
"""

View File

@ -0,0 +1,5 @@
"""`python3 -m coverage_gaps` -- the entrypoint the `coverage-gaps` wrapper calls."""
from coverage_gaps.cli import main
raise SystemExit(main())

16
coverage_gaps/bin/coverage-gaps Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
# ============================================================================
# coverage-gaps -- per-file task list of uncovered lines/branches.
# Thin wrapper over `python3 -m coverage_gaps` (see coverage_gaps/cli.py).
# Usage: coverage-gaps <report> [--json] [--max-files N] [--min-missed N]
# [--path-prefix P] Exit: 0 covered | 1 gaps | 2 bad input
# ============================================================================
set -euo pipefail
BIN_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd -P)"
readonly BIN_DIR
PYTHONPATH="$BIN_DIR/../.." python3 -m coverage_gaps "$@"
exit $?

63
coverage_gaps/cli.py Normal file
View File

@ -0,0 +1,63 @@
"""Command line: `coverage-gaps REPORT [--json] [--max-files N] ...`."""
from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
from pathlib import Path
from coverage_gaps.models import ReportError
from coverage_gaps.parsers import load_report
from coverage_gaps.render import select, to_json, to_markdown
EXIT_COVERED = 0
EXIT_GAPS = 1
EXIT_UNREADABLE = 2
def build_parser() -> argparse.ArgumentParser:
"""Argument parser (format is auto-detected from file content)."""
parser = argparse.ArgumentParser(
prog="coverage-gaps",
description="List uncovered lines/branches from lcov, Cobertura, JaCoCo "
"or coverage.py JSON reports. Exit 0 covered, 1 gaps, 2 unreadable.",
)
parser.add_argument("report", type=Path, help="coverage report file")
parser.add_argument("--json", action="store_true", help="emit JSON, not markdown")
parser.add_argument(
"--max-files",
type=int,
default=0,
metavar="N",
help="list only the N worst files (0 = all)",
)
parser.add_argument(
"--min-missed",
type=int,
default=1,
metavar="N",
help="skip files with fewer than N missed lines+branches",
)
parser.add_argument(
"--path-prefix",
default="",
metavar="PREFIX",
help="only files whose path starts with PREFIX",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""Run the tool and return the process exit code."""
args = build_parser().parse_args(argv)
try:
fmt, gaps = load_report(args.report)
except ReportError as exc:
print(f"coverage-gaps: {exc}", file=sys.stderr)
return EXIT_UNREADABLE
files = select(gaps, path_prefix=args.path_prefix, min_missed=args.min_missed)
shown = args.max_files if args.max_files > 0 else len(files)
render = to_json if args.json else to_markdown
sys.stdout.write(render(fmt, files, shown) + ("\n" if args.json else ""))
return EXIT_GAPS if files else EXIT_COVERED

69
coverage_gaps/models.py Normal file
View File

@ -0,0 +1,69 @@
"""Format-neutral data model shared by every parser and renderer."""
from __future__ import annotations
from dataclasses import dataclass, field
class ReportError(Exception):
"""The input is unreadable, unrecognised or malformed (exit code 2)."""
@dataclass
class FileAccumulator:
"""Mutable per-file collector that merges repeated records for one path.
Reports may list a file more than once (lcov re-runs, Cobertura inner
classes). A line or branch counts as covered if ANY record covered it.
"""
lines: dict[int, bool] = field(default_factory=dict)
branches: dict[tuple[int, int, int], bool] = field(default_factory=dict)
def add_line(self, line: int, covered: bool) -> None:
"""Record a line, OR-merging with earlier records of the same line."""
self.lines[line] = self.lines.get(line, False) or covered
def add_branch(self, line: int, block: int, index: int, covered: bool) -> None:
"""Record one branch outcome, OR-merging on (line, block, index)."""
key = (line, block, index)
self.branches[key] = self.branches.get(key, False) or covered
@dataclass(frozen=True)
class FileGap:
"""Coverage state of one source file."""
path: str
total_lines: int
missed_lines: tuple[int, ...]
total_branches: int
missed_branch_lines: tuple[int, ...]
missed_branches: int
@property
def missed(self) -> int:
"""Missed lines plus missed branches: the sort key."""
return len(self.missed_lines) + self.missed_branches
@property
def percent(self) -> float:
"""Percent covered over lines and branches combined (100 if empty)."""
total = self.total_lines + self.total_branches
if total == 0:
return 100.0
return 100.0 * (total - self.missed) / total
def build_gap(path: str, acc: FileAccumulator) -> FileGap:
"""Freeze an accumulator into a FileGap."""
missed_lines = tuple(sorted(n for n, ok in acc.lines.items() if not ok))
missed_br = [key for key, ok in acc.branches.items() if not ok]
return FileGap(
path=path,
total_lines=len(acc.lines),
missed_lines=missed_lines,
total_branches=len(acc.branches),
missed_branch_lines=tuple(sorted({key[0] for key in missed_br})),
missed_branches=len(missed_br),
)

132
coverage_gaps/parsers.py Normal file
View File

@ -0,0 +1,132 @@
"""Parsers for lcov, Cobertura XML, JaCoCo XML and coverage.py JSON."""
from __future__ import annotations
import json
import re
import xml.etree.ElementTree as ET
from collections.abc import Callable
from pathlib import Path
from coverage_gaps.models import FileAccumulator, FileGap, ReportError, build_gap
_CONDITION = re.compile(r"\((\d+)/(\d+)\)")
def _finish(files: dict[str, FileAccumulator]) -> list[FileGap]:
return [build_gap(path, acc) for path, acc in files.items()]
def parse_lcov(text: str) -> list[FileGap]:
"""Parse lcov.info: SF / DA / BRDA records, `end_of_record` optional."""
files: dict[str, FileAccumulator] = {}
acc: FileAccumulator | None = None
for raw in text.splitlines():
line = raw.strip()
if line.startswith("SF:"):
acc = files.setdefault(line[3:], FileAccumulator())
elif acc is not None and line.startswith("DA:"):
num, hits, *_ = line[3:].split(",")
acc.add_line(int(num), int(hits) > 0)
elif acc is not None and line.startswith("BRDA:"):
num, block, branch, taken = line[5:].split(",")
covered = taken not in ("-", "0")
acc.add_branch(int(num), int(block), _branch_id(branch), covered)
elif line == "end_of_record":
acc = None
return _finish(files)
def _branch_id(branch: str) -> int:
return int(branch) if branch.isdigit() else 0
def parse_cobertura(text: str) -> list[FileGap]:
"""Parse Cobertura `coverage.xml` (coverage.py, gcovr, ...)."""
root = ET.fromstring(text)
files: dict[str, FileAccumulator] = {}
for cls in root.iter("class"):
acc = files.setdefault(cls.get("filename", ""), FileAccumulator())
for node in cls.iter("line"):
num = int(node.get("number", "0"))
acc.add_line(num, int(node.get("hits", "0")) > 0)
match = _CONDITION.search(node.get("condition-coverage", ""))
if match:
hit, total = int(match[1]), int(match[2])
for idx in range(total):
acc.add_branch(num, 0, idx, idx < hit)
return _finish(files)
def parse_jacoco(text: str) -> list[FileGap]:
"""Parse JaCoCo XML: per `sourcefile` line records (mi/ci/mb/cb)."""
root = ET.fromstring(text)
files: dict[str, FileAccumulator] = {}
for package in root.iter("package"):
base = package.get("name", "")
for source in package.iter("sourcefile"):
name = source.get("name", "")
acc = files.setdefault(
f"{base}/{name}" if base else name, FileAccumulator()
)
for node in source.iter("line"):
num = int(node.get("nr", "0"))
acc.add_line(num, int(node.get("ci", "0")) > 0)
missed_b, covered_b = int(node.get("mb", "0")), int(node.get("cb", "0"))
for idx in range(missed_b + covered_b):
acc.add_branch(num, 0, idx, idx >= missed_b)
return _finish(files)
def parse_coverage_json(text: str) -> list[FileGap]:
"""Parse `coverage json` output (with or without branch data)."""
files: dict[str, FileAccumulator] = {}
for path, data in json.loads(text).get("files", {}).items():
acc = files.setdefault(path, FileAccumulator())
for num in data.get("executed_lines", []):
acc.add_line(num, True)
for num in data.get("missing_lines", []):
acc.add_line(num, False)
missing = data.get("missing_branches", [])
for idx, (src, _dst) in enumerate(missing):
acc.add_branch(abs(src), 1, idx, False)
covered = data.get("summary", {}).get("covered_branches", 0)
for idx in range(covered):
acc.add_branch(0, 2, idx, True)
return _finish(files)
def detect_format(text: str) -> str:
"""Return 'lcov' | 'cobertura' | 'jacoco' | 'json' from file content."""
head = text.lstrip()[:4000]
if head.startswith("{"):
return "json"
if head.startswith("<"):
if "<report" in head:
return "jacoco"
if "<coverage" in head:
return "cobertura"
elif re.search(r"^(TN|SF):", head, re.MULTILINE):
return "lcov"
raise ReportError("unrecognised coverage report format")
_PARSERS: dict[str, Callable[[str], list[FileGap]]] = {
"lcov": parse_lcov,
"cobertura": parse_cobertura,
"jacoco": parse_jacoco,
"json": parse_coverage_json,
}
def load_report(path: Path) -> tuple[str, list[FileGap]]:
"""Read and parse a report; raise ReportError on any failure."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
raise ReportError(f"cannot read {path}: {exc.strerror}") from exc
fmt = detect_format(text)
try:
return fmt, _PARSERS[fmt](text)
except (ET.ParseError, ValueError, TypeError, AttributeError) as exc:
raise ReportError(f"malformed {fmt} report {path}: {exc}") from exc

102
coverage_gaps/render.py Normal file
View File

@ -0,0 +1,102 @@
"""Filtering and rendering: markdown (agent task list) and JSON."""
from __future__ import annotations
import json
from collections.abc import Iterable, Sequence
from coverage_gaps.models import FileGap
def compact_ranges(lines: Iterable[int]) -> str:
"""Collapse line numbers into '12-18, 40'."""
nums = sorted(set(lines))
parts: list[str] = []
idx = 0
while idx < len(nums):
end = idx
while end + 1 < len(nums) and nums[end + 1] == nums[end] + 1:
end += 1
parts.append(_span(nums[idx], nums[end]))
idx = end + 1
return ", ".join(parts)
def _span(start: int, end: int) -> str:
return str(start) if start == end else f"{start}-{end}"
def select(
gaps: Sequence[FileGap], *, path_prefix: str = "", min_missed: int = 1
) -> list[FileGap]:
"""Keep files with gaps under the prefix, worst first (ties by path)."""
kept = [
gap
for gap in gaps
if gap.path.startswith(path_prefix) and gap.missed >= max(min_missed, 1)
]
return sorted(kept, key=lambda gap: (-gap.missed, gap.path))
def _totals(files: Sequence[FileGap]) -> dict[str, int]:
return {
"files": len(files),
"missed_lines": sum(len(gap.missed_lines) for gap in files),
"missed_branches": sum(gap.missed_branches for gap in files),
}
def to_json(fmt: str, files: Sequence[FileGap], shown: int) -> str:
"""Machine-readable output; `files` is already sorted and filtered."""
entries = [
{
"path": gap.path,
"percent_covered": round(gap.percent, 2),
"missed": gap.missed,
"missed_lines": compact_ranges(gap.missed_lines),
"missed_line_numbers": list(gap.missed_lines),
"missed_branch_count": gap.missed_branches,
"missed_branch_lines": list(gap.missed_branch_lines),
}
for gap in files[:shown]
]
doc = {
"format": fmt,
"total": _totals(files),
"shown": len(entries),
"files": entries,
}
return json.dumps(doc, indent=2)
def to_markdown(fmt: str, files: Sequence[FileGap], shown: int) -> str:
"""Markdown task list meant to be pasted to an agent verbatim."""
total = _totals(files)
out = [
"# Coverage gaps",
"",
(
f"Format: {fmt}. Total: {total['files']} files with gaps, "
f"{total['missed_lines']} missed lines, "
f"{total['missed_branches']} missed branches."
),
]
if shown < len(files):
out.append(f"Showing the {shown} worst files.")
if not files:
return "\n".join(out) + "\n\nNo coverage gaps.\n"
out += ["", "Write tests that cover every item below, then re-run the report.", ""]
for rank, gap in enumerate(files[:shown], start=1):
out.append(f"## {rank}. `{gap.path}` - {gap.percent:.1f}% covered")
if gap.missed_lines:
out.append(
f"- missed lines ({len(gap.missed_lines)}): "
f"{compact_ranges(gap.missed_lines)}"
)
if gap.missed_branches:
out.append(
f"- missed branches ({gap.missed_branches}) at lines: "
f"{compact_ranges(gap.missed_branch_lines)}"
)
out.append("")
return "\n".join(out).rstrip() + "\n"

View File

View File

@ -0,0 +1,89 @@
"""Small fixture reports, one per supported format, all describing a.py/b.py.
a.py: lines 1-5 with 2,3 and 5 missed + one missed branch on line 4.
b.py: fully covered (must never be listed).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
LCOV = """TN:
SF:a.py
DA:1,1
DA:2,0
DA:3,0
DA:4,1
DA:5,0
BRDA:4,0,0,1
BRDA:4,0,1,-
end_of_record
SF:b.py
DA:1,3
end_of_record
SF:a.py
DA:1,1
DA:2,0
end_of_record
"""
COBERTURA = """<?xml version="1.0" ?>
<coverage version="7"><packages><package name="p"><classes>
<class filename="a.py"><lines>
<line number="1" hits="1"/><line number="2" hits="0"/><line number="3" hits="0"/>
<line number="4" hits="1" branch="true" condition-coverage="50% (1/2)"/>
<line number="5" hits="0"/></lines></class>
<class filename="b.py"><lines><line number="1" hits="2"/></lines></class>
<class filename="a.py"><lines><line number="1" hits="1"/></lines></class>
</classes></package></packages></coverage>
"""
JACOCO = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE report PUBLIC "-//JACOCO//DTD Report 1.1//EN" "report.dtd">
<report name="x"><package name="com/x">
<sourcefile name="A.kt">
<line nr="1" mi="0" ci="3"/><line nr="2" mi="2" ci="0"/><line nr="3" mi="1" ci="0"/>
<line nr="4" mi="0" ci="4" mb="1" cb="1"/><line nr="5" mi="1" ci="0"/></sourcefile>
<sourcefile name="B.kt"><line nr="1" mi="0" ci="1"/></sourcefile>
</package><package name=""><sourcefile name="Top.kt">
<line nr="1" mi="0" ci="1"/></sourcefile></package></report>
"""
COVERAGE_JSON = json.dumps(
{
"files": {
"a.py": {
"executed_lines": [1, 4],
"missing_lines": [2, 3, 5],
"missing_branches": [[4, 5]],
"summary": {"covered_branches": 1},
},
"b.py": {
"executed_lines": [1],
"missing_lines": [],
"missing_branches": [],
},
}
}
)
@pytest.fixture
def reports(tmp_path: Path) -> dict[str, Path]:
"""Write each fixture report to disk, keyed by format name."""
out = {}
for name, body in {
"lcov": LCOV,
"cobertura": COBERTURA,
"jacoco": JACOCO,
"json": COVERAGE_JSON,
}.items():
out[name] = tmp_path / f"{name}.rpt"
out[name].write_text(body, encoding="utf-8")
return out

View File

@ -0,0 +1,73 @@
"""Parser and format-detection tests."""
from __future__ import annotations
from pathlib import Path
import pytest
from coverage_gaps.models import FileGap, ReportError
from coverage_gaps.parsers import detect_format, load_report
def _by_path(gaps: list[FileGap]) -> dict[str, FileGap]:
return {gap.path: gap for gap in gaps}
@pytest.mark.parametrize("fmt", ["lcov", "cobertura", "json"])
def test_a_py_gaps_match_across_formats(reports: dict[str, Path], fmt: str) -> None:
detected, gaps = load_report(reports[fmt])
assert detected == fmt
a = _by_path(gaps)["a.py"]
assert a.missed_lines == (2, 3, 5)
assert a.missed_branch_lines == (4,)
assert a.missed_branches == 1
assert a.total_lines == 5
assert a.total_branches == 2
assert _by_path(gaps)["b.py"].missed == 0
def test_jacoco(reports: dict[str, Path]) -> None:
fmt, gaps = load_report(reports["jacoco"])
assert fmt == "jacoco"
by = _by_path(gaps)
assert by["com/x/A.kt"].missed_lines == (2, 3, 5)
assert by["com/x/A.kt"].missed_branch_lines == (4,)
assert by["com/x/A.kt"].total_branches == 2
assert "Top.kt" in by
def test_percent_and_empty_file() -> None:
gap = FileGap("x", 4, (1,), 4, (2,), 1)
assert gap.percent == 75.0
assert FileGap("y", 0, (), 0, (), 0).percent == 100.0
def test_lcov_ignores_records_outside_a_file_and_dash_branch_ids() -> None:
from coverage_gaps.parsers import parse_lcov
gaps = parse_lcov(
"DA:1,0\nBRDA:1,0,0,0\nSF:z\nBRDA:2,0,-,0\nend_of_record\nDA:9,0\n"
)
assert len(gaps) == 1
assert gaps[0].missed_lines == ()
assert gaps[0].missed_branch_lines == (2,)
def test_detect_unknown_and_xml_without_known_root() -> None:
for text in ("hello", "<other/>", ""):
with pytest.raises(ReportError):
detect_format(text)
def test_load_errors(tmp_path: Path) -> None:
with pytest.raises(ReportError, match="cannot read"):
load_report(tmp_path / "missing")
bad_xml = tmp_path / "bad.xml"
bad_xml.write_text("<coverage><oops", encoding="utf-8")
with pytest.raises(ReportError, match="malformed cobertura"):
load_report(bad_xml)
bad_json = tmp_path / "bad.json"
bad_json.write_text("{nope", encoding="utf-8")
with pytest.raises(ReportError, match="malformed json"):
load_report(bad_json)

View File

@ -0,0 +1,97 @@
"""Rendering, filtering and CLI exit-code tests."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from coverage_gaps.cli import main
from coverage_gaps.models import FileGap
from coverage_gaps.render import compact_ranges, select, to_markdown
def test_compact_ranges() -> None:
assert compact_ranges([]) == ""
assert compact_ranges([40, 12, 13, 14, 15, 16, 17, 18, 18]) == "12-18, 40"
assert compact_ranges([1, 3, 5, 6]) == "1, 3, 5-6"
def test_select_sorts_and_filters() -> None:
small = FileGap("src/b.py", 5, (1,), 0, (), 0)
big = FileGap("lib/a.py", 5, (1, 2), 2, (3,), 1)
clean = FileGap("src/c.py", 5, (), 0, (), 0)
assert [g.path for g in select([small, clean, big])] == ["lib/a.py", "src/b.py"]
assert [g.path for g in select([small, big], path_prefix="src")] == ["src/b.py"]
assert [g.path for g in select([small, big], min_missed=2)] == ["lib/a.py"]
def test_markdown_branch_only_gap() -> None:
out = to_markdown("lcov", [FileGap("z.py", 2, (), 2, (7,), 1)], 1)
assert "- missed lines" not in out
assert "missed branches (1) at lines: 7" in out
def test_module_entrypoint(
reports: dict[str, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
import runpy
monkeypatch.setattr("sys.argv", ["coverage_gaps", str(reports["lcov"])])
with pytest.raises(SystemExit) as exc:
runpy.run_module("coverage_gaps", run_name="__main__")
assert exc.value.code == 1
def test_markdown_no_gaps() -> None:
assert "No coverage gaps." in to_markdown("lcov", [], 0)
@pytest.mark.parametrize("fmt", ["lcov", "cobertura", "jacoco", "json"])
def test_cli_markdown_exit_1(
reports: dict[str, Path], fmt: str, capsys: pytest.CaptureFixture[str]
) -> None:
assert main([str(reports[fmt])]) == 1
out = capsys.readouterr().out
assert "2-3, 5" in out
assert "missed branches (1) at lines: 4" in out
assert "b.py" not in out.replace("A.kt", "")
def test_cli_json_and_max_files(
reports: dict[str, Path], capsys: pytest.CaptureFixture[str]
) -> None:
assert main([str(reports["jacoco"]), "--json", "--max-files", "1"]) == 1
doc = json.loads(capsys.readouterr().out)
assert doc["shown"] == 1
assert doc["total"] == {"files": 1, "missed_lines": 3, "missed_branches": 1}
assert doc["files"][0]["missed_lines"] == "2-3, 5"
def test_cli_markdown_truncation_note(
reports: dict[str, Path], capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
two = tmp_path / "two.info"
two.write_text("SF:x\nDA:1,0\nend_of_record\nSF:y\nDA:1,0\nDA:2,0\nend_of_record\n")
assert main([str(two), "--max-files", "1"]) == 1
out = capsys.readouterr().out
assert "Showing the 1 worst files." in out
assert "`y`" in out
assert "`x`" not in out
def test_cli_exit_0_when_filtered_clean(
reports: dict[str, Path], capsys: pytest.CaptureFixture[str]
) -> None:
assert main([str(reports["lcov"]), "--path-prefix", "b."]) == 0
assert main([str(reports["lcov"]), "--json", "--min-missed", "99"]) == 0
assert "No coverage gaps." in capsys.readouterr().out
def test_cli_exit_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
assert main([str(tmp_path / "nope")]) == 2
junk = tmp_path / "junk"
junk.write_text("junk")
assert main([str(junk)]) == 2
assert "coverage-gaps:" in capsys.readouterr().err

View File

@ -0,0 +1,146 @@
# Pre-commit Configuration for crdt-sync
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# Update: pre-commit autoupdate
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config crdt-sync/.pre-commit-config.yaml --all-files
files: ^crdt-sync/
default_language_version:
python: python3
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: [--maxkb=2000]
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements
- id: name-tests-test
args: [--pytest-test-first]
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- id: requirements-txt-fixer
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh crdt-sync
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: Block ruff noqa file-level comments
entry: '(?i)#\s*ruff:\s*noqa'
language: pygrep
types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.2
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.4.0
hooks:
- id: mypy
additional_dependencies:
- types-requests
args:
- --ignore-missing-imports
- --no-error-summary
- --disable-error-code=no-untyped-def
- --disable-error-code=no-untyped-call
- --disable-error-code=var-annotated
- --disable-error-code=no-any-unimported
- --disable-error-code=type-arg
- --disable-error-code=no-any-return
- --disable-error-code=misc
- --disable-error-code=unused-ignore
- --disable-error-code=unreachable
- --disable-error-code=assignment
- --disable-error-code=no-redef
- --disable-error-code=attr-defined
- --disable-error-code=arg-type
- --disable-error-code=union-attr
- --disable-error-code=call-overload
- --disable-error-code=return-value
- --disable-error-code=redundant-cast
- --disable-error-code=empty-body
- --disable-error-code=list-item
- repo: https://github.com/pylint-dev/pylint
rev: v4.0.8
hooks:
- id: pylint
args:
- --rcfile=crdt-sync/pyproject.toml
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest
- requests
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args:
- -c
- crdt-sync/pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(crdt-sync/crdt_sync/tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: scripts/run_subproject_tests.sh crdt-sync
language: script
types: [python]
pass_filenames: false
require_serial: true
stages: [pre-push]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --skip=*.json,*.lock,.git,__pycache__,.venv

21
crdt-sync/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Krzysztof Rudnicki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

120
crdt-sync/README.md Normal file
View File

@ -0,0 +1,120 @@
# crdt-sync
Shared CRDT merge scheme + GitHub-Contents-API sync transport, extracted for
reuse across four personal apps that each need cross-device sync:
[todo](https://github.com/kuhyx/todo-app),
[diet_guard](https://github.com/kuhyx/testsAndMisc/tree/main/python_pkg/diet_guard),
[wake_alarm](https://github.com/kuhyx/testsAndMisc/tree/main/python_pkg/wake_alarm),
and [screen-locker](https://github.com/kuhyx/screen-locker).
## Why
Two of those apps (diet_guard, and independently todo) had already converged
on the same shape of solution: treat a private GitHub repo as dumb file
storage via the REST Contents API (not a git clone), have each device push
its own file, and merge on read. diet_guard's merge logic
(entry-id-keyed, tombstone-wins) and todo's merge logic (`sqlite_crdt`'s
per-field HLC last-writer-wins) solve the same underlying problem —
convergent merge without a central server — for two different data shapes
(append-only logs vs. mutable per-field records). `crdt-sync` generalizes
both into one scheme so all four apps can share it, at the cost of todo's
notes and wake_alarm's single wake-time value both running through
CRDT machinery their own data doesn't strictly need — an explicit
consistency-over-leanness tradeoff.
## The merge scheme
A generic **LWW-map-with-sticky-remove**, built from three pieces:
- **`Hlc`** — a Hybrid Logical Clock: `(wall_time_ms, counter, node_id)`,
totally ordered by tuple comparison (`node_id` breaks ties between two
clocks with identical wall time and counter, so no two distinct devices'
clocks ever compare equal). `Hlc.new_tick(node_id, previous)` advances off
wall-clock time, or bumps `counter` when the wall clock hasn't moved past
`previous` — the standard HLC construction, guaranteeing monotonicity even
across clock skew or two ticks in the same millisecond.
- **`Record`** — `id`, a `fields` map of `name -> (value, Hlc)`, and a sticky
`deleted` flag. `merge_field` keeps whichever side's `Hlc` is greater,
per field — this is what lets two devices concurrently edit *different*
fields of the same record (or the same field) and converge deterministically
without a central authority. `merge_record`'s `deleted` is **not** itself
per-field LWW: it's `a.deleted or b.deleted`, monotonic and one-directional.
This preserves a guarantee diet_guard's original tombstone scheme already
relied on — a delete can never be silently undone by merging in an older
non-deleted copy pulled from a device that hasn't seen the delete yet.
- **`Log`** = `dict[id, Record]`. `merge_logs` takes the union of ids and
`merge_record`s the ones present on both sides.
All three merges are commutative and idempotent (provable directly from
`max`-by-`Hlc` and boolean-`or`), so pull order between devices never matters
and a repeated sync tick is a no-op — see `crdt_sync/tests/test_log.py`'s
property-style cases for the concrete proof.
**Per-app adapters are out of scope for this library.** Translating a `Note`,
a `FoodEntry`, a workout entry, or wake_alarm's `{hour, minute}` value to and
from `Record` is each app's own job — keeping this library domain-agnostic is
what makes it actually shared, rather than shaped around one app's schema.
## Install
```bash
pip install "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.2.0#subdirectory=crdt-sync"
```
## Usage
```python
from crdt_sync import GitHubSyncClient, Hlc, Record, sync_log
node_id = "pc"
clock = Hlc.new_tick(node_id)
record = Record(id="abc123", fields={"text": ("buy milk", clock)}, deleted=False)
client = GitHubSyncClient(owner="kuhyx", repo="my-app-sync", token=token)
result = sync_log(
client=client,
device_id=node_id,
path_prefix="devices",
local_log={record.id: record},
encode=lambda log: json.dumps({k: r.to_dict() for k, r in log.items()}),
decode=lambda text: {
k: Record.from_dict(v) for k, v in json.loads(text).items()
},
)
```
`sync_log` pulls every other device's last-pushed log from
`<path_prefix>/<other-device-id>/...`, merges each into the local log with
`merge_logs`, then pushes this device's own merged result back up — the same
pull-all/merge/push-own pattern diet_guard's `_sync.py` already used, made
domain-agnostic via the `encode`/`decode` callbacks so callers keep their own
on-disk JSON shape instead of `crdt-sync` dictating one.
## Local persistence
For persisting a `Log` to disk, `crdt_sync` ships load/save helpers that share
a canonical on-disk JSON shape with the Dart `crdt_sync_dart.LogStore`, so a log
written by one language parses in the other:
```python
from crdt_sync import dump_log, load_log, read_log, write_log
write_log(path, log) # atomic temp-file-then-rename
log = read_log(path) # empty on a missing or corrupt file
text = dump_log(log) # -> canonical JSON string
log = load_log(text) # <- parse it back
```
These are load/save only. The Dart side additionally offers a *reactive*
`LogStore` (a change stream to drive a live UI); that's a Flutter-only
convenience and has no Python equivalent by design — Python consumers here are
headless sync ticks.
## Development
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest
pre-commit install && pre-commit install --hook-type pre-push
```

View File

@ -0,0 +1,98 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Shared CRDT merge scheme + pluggable remote sync transports."""
from __future__ import annotations
from crdt_sync._config import (
CONFIG_DIR,
CONFIG_FILE,
PASSWORD_FILE,
ConfigError,
FirebaseConfig,
credential_store_for,
firebase_client_for,
mirror_client_for,
)
from crdt_sync._device import DeviceIdentity, load_device_identity
from crdt_sync._firebase import (
DatabaseNotFoundError,
FirebaseSyncClient,
FirebaseSyncError,
)
from crdt_sync._firebase_auth import (
CredentialStore,
FileCredentialStore,
FirebaseAuthError,
FirebaseCredentials,
FirebaseTokenProvider,
MemoryCredentialStore,
)
from crdt_sync._github import GitHubSyncClient, GitHubSyncError, RepoNotFoundError
from crdt_sync._hlc import Hlc
from crdt_sync._log import Log, merge_logs
from crdt_sync._mirror import MirrorSyncClient
from crdt_sync._record import Field, Record, merge_field, merge_record
from crdt_sync._remote import RemoteNotFoundError, RemoteStore, RemoteSyncError
from crdt_sync._store import dump_log, load_log, read_log, write_log
from crdt_sync._sync import (
FileSyncStateStore,
LogCodec,
MemorySyncStateStore,
RevisionTracking,
SyncState,
SyncStateStore,
SyncTarget,
default_revs_path,
revision_of,
sync_log,
)
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
"PASSWORD_FILE",
"ConfigError",
"CredentialStore",
"DatabaseNotFoundError",
"DeviceIdentity",
"Field",
"FileCredentialStore",
"FileSyncStateStore",
"FirebaseAuthError",
"FirebaseConfig",
"FirebaseCredentials",
"FirebaseSyncClient",
"FirebaseSyncError",
"FirebaseTokenProvider",
"GitHubSyncClient",
"GitHubSyncError",
"Hlc",
"Log",
"LogCodec",
"MemoryCredentialStore",
"MemorySyncStateStore",
"MirrorSyncClient",
"Record",
"RemoteNotFoundError",
"RemoteStore",
"RemoteSyncError",
"RepoNotFoundError",
"RevisionTracking",
"SyncState",
"SyncStateStore",
"SyncTarget",
"credential_store_for",
"default_revs_path",
"dump_log",
"firebase_client_for",
"load_device_identity",
"load_log",
"merge_field",
"merge_logs",
"merge_record",
"mirror_client_for",
"read_log",
"revision_of",
"sync_log",
"write_log",
]

View File

@ -0,0 +1,71 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Turning a Firebase auth response into a message, and reading its verdict.
Split from :mod:`crdt_sync._firebase_auth`, which keeps the token provider.
identitytoolkit reports failures as a nested JSON error whose useful part is
buried; these turn that into something a log line can carry, and decide
whether the failure is permanent (the refresh token is dead and the user must
sign in again) or worth retrying.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from crdt_sync._remote import RemoteSyncError
if TYPE_CHECKING:
import requests as _requests
class FirebaseAuthError(RemoteSyncError):
"""Raised for an authentication failure the caller must not ignore.
A :class:`crdt_sync.RemoteSyncError` so callers that only care about
"sync is broken" can catch one type, while a settings screen can single
this out to say "your password is wrong" rather than "the network is
down".
"""
def _reason(response: _requests.Response) -> str:
"""Return Google's machine-readable error reason, in parentheses.
Pulls ``INVALID_PASSWORD`` / ``TOKEN_EXPIRED`` / ``USER_DISABLED`` out of
the body so the raised message says what actually went wrong rather than
just reporting a status code.
"""
try:
data = response.json()
except ValueError:
# A non-JSON body (a proxy error page, say): the status code is all
# the detail there is.
return ""
if not isinstance(data, dict):
return ""
error = data.get("error")
if isinstance(error, dict):
return f" ({error.get('message')})"
if isinstance(error, str):
return f" ({error})"
return ""
#: Reasons that mean the refresh token is permanently dead. Only these clear
#: the session: a network error or a 5xx must not, because a device that signs
#: itself out whenever the wifi drops needs a manual sign-in to recover, which
#: is a worse failure than the stale banner this guards against.
_TERMINAL_AUTH_REASONS = (
"TOKEN_EXPIRED",
"USER_DISABLED",
"USER_NOT_FOUND",
"INVALID_REFRESH_TOKEN",
"INVALID_GRANT_TYPE",
"MISSING_REFRESH_TOKEN",
)
def _is_revoked(error: FirebaseAuthError) -> bool:
"""Return whether ``error`` means the refresh token is permanently dead."""
message = str(error)
return any(reason in message for reason in _TERMINAL_AUTH_REASONS)

View File

@ -0,0 +1,199 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The one place every app learns how to reach Firebase.
Before this module each caller re-read ``~/.config/crdt-sync/firebase.json``
by hand, so a change to the schema meant editing every consumer, and each
consumer got to invent its own half of the credential-cache convention. The
apps share one Firebase project and one account, so they should share one
loader.
Two things live here:
* :class:`FirebaseConfig` -- the parsed, validated config file.
* :func:`firebase_client_for` -- the whole "config file to usable client"
path, including the per-app credential cache that keeps a fresh process
from re-authenticating. ``wake_alarm``'s PC side runs every minute and
``diet_guard``'s every 15, so caching the refresh token is what makes the
sign-in cost negligible rather than constant.
Deliberately *not* here: any choice about which backend an app uses. That
stays a constructor change at the call site, per :mod:`crdt_sync._remote`.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import TYPE_CHECKING
from crdt_sync._firebase import _DEFAULT_TIMEOUT_SECONDS, FirebaseSyncClient
from crdt_sync._firebase_auth import FileCredentialStore, FirebaseTokenProvider
from crdt_sync._mirror import MirrorSyncClient
if TYPE_CHECKING:
from crdt_sync._remote import RemoteStore
CONFIG_DIR = Path.home() / ".config" / "crdt-sync"
CONFIG_FILE = CONFIG_DIR / "firebase.json"
PASSWORD_FILE = CONFIG_DIR / "password"
# The scaffold ships "_comment_*" keys explaining where each value comes
# from. They are documentation for a human filling the file in, not config.
_COMMENT_PREFIX = "_comment"
class ConfigError(Exception):
"""The shared Firebase config is missing, malformed or unfilled."""
@dataclass(frozen=True)
class FirebaseConfig:
"""Everything needed to reach the shared Firebase project.
``api_key`` is not a secret -- it ships inside the Android APKs, and the
security rules are what actually protect the data. The password is.
"""
api_key: str
database_url: str
project_id: str
uid: str
email: str
@classmethod
def load(cls, path: Path | None = None) -> FirebaseConfig:
"""Return the config at ``path``, defaulting to :data:`CONFIG_FILE`.
Raises:
ConfigError: If the file is absent, unparsable, missing a key, or
still holding a scaffold placeholder -- each reported with the
specific field at fault, because the alternative is a
confusing auth failure much later.
"""
source = path if path is not None else CONFIG_FILE
try:
raw = json.loads(source.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
msg = f"{source} does not exist; see utils/firebase/README.md"
raise ConfigError(msg) from exc
except OSError as exc:
msg = f"{source} could not be read: {exc}"
raise ConfigError(msg) from exc
except ValueError as exc:
msg = f"{source} is not valid JSON: {exc}"
raise ConfigError(msg) from exc
if not isinstance(raw, dict):
msg = f"{source} must contain a JSON object"
raise ConfigError(msg)
data = {k: v for k, v in raw.items() if not k.startswith(_COMMENT_PREFIX)}
fields = {
"api_key": "apiKey",
"database_url": "databaseUrl",
"project_id": "projectId",
"uid": "uid",
"email": "email",
}
missing = [key for key in fields.values() if not str(data.get(key, "")).strip()]
if missing:
msg = f"{source} is missing or has empty: {', '.join(missing)}"
raise ConfigError(msg)
unfilled = [key for key in fields.values() if "PASTE_" in str(data[key])]
if unfilled:
msg = f"{source} still holds the placeholder for: {', '.join(unfilled)}"
raise ConfigError(msg)
return cls(**{attr: str(data[key]) for attr, key in fields.items()})
def read_password(self, path: Path | None = None) -> str:
"""Return the sync account's password.
Stripped, because a text editor that appends a trailing newline would
otherwise turn into an authentication failure with no visible cause.
"""
source = path if path is not None else PASSWORD_FILE
try:
password = source.read_text(encoding="utf-8").strip()
except OSError as exc:
msg = f"{source} could not be read: {exc}"
raise ConfigError(msg) from exc
if not password or "PASTE_" in password:
msg = f"{source} is empty or still holds the scaffold placeholder"
raise ConfigError(msg)
return password
def credential_store_for(app_name: str) -> FileCredentialStore:
"""Return the credential cache for ``app_name``.
Per app rather than shared: two apps signing in as the same account still
refresh tokens on their own schedules, and a single file written by
several processes at once is a corruption waiting to happen.
"""
return FileCredentialStore(
Path.home() / ".config" / app_name / "firebase_auth.json"
)
def firebase_client_for(
app_name: str,
*,
config: FirebaseConfig | None = None,
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
) -> FirebaseSyncClient:
"""Return a signed-in client for ``app_name``.
Reuses the cached refresh token when there is one and signs in with the
password only when there is not, so the common path costs no
authentication round trip at all.
Args:
app_name: Names the credential cache, e.g. ``"diet_guard"``.
config: Overrides the on-disk config; for tests.
timeout_seconds: Per-request network timeout. Without this the
client fell back to its own default and a caller's tighter
budget reached the GitHub backend only.
Raises:
ConfigError: If the shared config is unusable.
FirebaseAuthError: If the credentials are rejected.
"""
resolved = config if config is not None else FirebaseConfig.load()
auth = FirebaseTokenProvider(resolved.api_key, credential_store_for(app_name))
if not auth.has_session():
auth.sign_in(resolved.email, resolved.read_password())
return FirebaseSyncClient(
resolved.database_url, auth, timeout_seconds=timeout_seconds
)
def mirror_client_for(
app_name: str,
github_client: RemoteStore,
*,
config: FirebaseConfig | None = None,
) -> MirrorSyncClient:
"""Return a Firebase-primary client that still mirrors to ``github_client``.
This is what an app uses *during* the cutover. Firebase is authoritative,
so its failures fail the tick; GitHub is kept in step but never allowed
to. Reads union both, which is what lets devices cut over one at a time
without a migrated PC losing sight of an un-migrated phone.
Rolling back is deleting the call: pass the GitHub client straight to
:func:`crdt_sync.sync_log` again. Retiring the mirror is swapping this for
:func:`firebase_client_for`. Both are one-line changes at the call site,
which is the whole point of the :class:`~crdt_sync.RemoteStore` seam.
Args:
app_name: Names the credential cache, e.g. ``"diet_guard"``.
github_client: The existing GitHub client, kept as the fallback.
config: Overrides the on-disk config; for tests.
"""
return MirrorSyncClient(
primary=firebase_client_for(app_name, config=config),
mirror=github_client,
)

View File

@ -0,0 +1,158 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Firebase credentials and the stores that persist them.
Split from :mod:`crdt_sync._firebase_auth`, which keeps the token provider.
A credential set is an ID token, a refresh token and an expiry; the stores
decide where it survives a restart -- memory for tests, a mode-0600 file for
real installs.
Re-exported from :mod:`crdt_sync._firebase_auth`, so existing imports work.
"""
from __future__ import annotations
from dataclasses import dataclass
import datetime as dt
import json
import logging
import os
from pathlib import Path
import tempfile
from typing import Protocol
_logger = logging.getLogger(__name__)
# Refresh this long before the token actually expires. An ID token lives ~1h;
# refreshing early means a tick that starts just under the wire still finishes
# with a valid token rather than 401ing halfway through a multi-file push.
_REFRESH_SKEW = dt.timedelta(minutes=5)
@dataclass(frozen=True)
class FirebaseCredentials:
"""One device's session: a short-lived ID token plus its refresh token.
Attributes:
----------
id_token:
The bearer credential for Realtime Database requests. Expires fast.
refresh_token:
The long-lived credential. **This is the secret worth protecting.**
expires_at:
When ``id_token`` stops being accepted, in UTC.
"""
id_token: str
refresh_token: str
expires_at: dt.datetime
def is_expired_at(self, now: dt.datetime) -> bool:
"""Return whether the token is expired, or close enough to it."""
return now + _REFRESH_SKEW >= self.expires_at
def to_json(self) -> dict[str, str]:
"""Return a JSON-serializable form."""
return {
"id_token": self.id_token,
"refresh_token": self.refresh_token,
"expires_at": self.expires_at.isoformat(),
}
@classmethod
def from_json(cls, data: dict[str, str]) -> FirebaseCredentials:
"""Rebuild credentials from :meth:`to_json` output."""
return cls(
id_token=data["id_token"],
refresh_token=data["refresh_token"],
expires_at=dt.datetime.fromisoformat(data["expires_at"]),
)
class CredentialStore(Protocol):
"""Where a device keeps its credentials between runs."""
def load(self) -> FirebaseCredentials | None:
"""Return the stored credentials, or ``None`` if not signed in."""
def save(self, credentials: FirebaseCredentials) -> None:
"""Persist ``credentials``."""
def clear(self) -> None:
"""Forget any stored credentials."""
class MemoryCredentialStore:
"""A :class:`CredentialStore` for tests and one-shot scripts."""
def __init__(self, credentials: FirebaseCredentials | None = None) -> None:
"""Start holding ``credentials`` (or nothing)."""
self._credentials = credentials
def load(self) -> FirebaseCredentials | None:
"""Return the held credentials."""
return self._credentials
def save(self, credentials: FirebaseCredentials) -> None:
"""Replace the held credentials."""
self._credentials = credentials
def clear(self) -> None:
"""Drop the held credentials."""
self._credentials = None
class FileCredentialStore:
"""A :class:`CredentialStore` backed by a ``0600`` JSON file.
Persistence is not an optimisation here: ``wake_alarm``'s PC side is a
fresh process every minute and ``diet_guard``'s every 15 minutes, so an
in-memory store would re-authenticate thousands of times a day.
"""
def __init__(self, path: Path) -> None:
"""Store credentials at ``path``, creating parent dirs as needed."""
self._path = path
def load(self) -> FirebaseCredentials | None:
"""Return the stored credentials, or ``None`` if the file is absent.
A corrupt or truncated file (an interrupted write) reads as "not
signed in" rather than raising: the caller's next step is to sign in
again, which repairs it.
"""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except OSError, ValueError:
return None
try:
return FirebaseCredentials.from_json(data)
except KeyError, TypeError, ValueError:
return None
def save(self, credentials: FirebaseCredentials) -> None:
"""Write ``credentials`` atomically, readable only by this user."""
self._path.parent.mkdir(parents=True, exist_ok=True)
# A unique temp name per write, not a fixed ``.tmp``: two processes
# (a long-running stream and a periodic sync) share one cache, and a
# fixed name let one rename the other's half-written file into place.
# mkstemp creates it 0600 from the outset -- writing then chmod'ing
# would leave the refresh token world-readable for the gap between.
fd, name = tempfile.mkstemp(
dir=self._path.parent, prefix=f".{self._path.name}.", suffix=".tmp"
)
temp = Path(name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(credentials.to_json()))
temp.replace(self._path)
except BaseException:
temp.unlink(missing_ok=True)
raise
def clear(self) -> None:
"""Delete the credentials file if it exists."""
self._path.unlink(missing_ok=True)
def _utcnow() -> dt.datetime:
return dt.datetime.now(dt.UTC)

View File

@ -0,0 +1,88 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""This device's stable sync identity.
Every device that pushes into a namespace needs an id that is *its own*: it
is the directory segment its log is pushed under
(``<path_prefix>/<device_id>/<filename>``), the revision key, and the node
component baked into every HLC stamp it writes (``<iso>-<counter>-<id>``).
Two devices sharing an id overwrite each other's pushed file on every tick,
so the id must be per-*install*, not per-role: a fixed ``"pc"``/``"phone"``
constant silently collides the moment a second machine takes the same role,
and a reinstall inherits the previous install's CRDT identity.
A migration from a role constant to a persisted uuid cannot rewrite history.
Stamps already written keep the old id, and the old path keeps the log that
was pushed under it -- so a device that has just switched ids must still
recognise its *former* id as itself. That is what [legacy_id] carries, and
why [DeviceIdentity.own_ids] exists: skip-own-writes has to test membership
of that set rather than equality with one id, or the device re-downloads and
re-merges its own pre-migration history as though a peer had written it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import uuid
if TYPE_CHECKING:
from pathlib import Path
@dataclass(frozen=True)
class DeviceIdentity:
"""This device's current sync id, plus the id it used to push under.
Attributes:
device_id: The id to push, stamp and key revisions under from now on.
legacy_id: The id this device pushed under before the migration to
persisted uuids, or ``None`` once its old path has been reclaimed.
"""
device_id: str
legacy_id: str | None = None
@property
def own_ids(self) -> frozenset[str]:
"""Every id that means "this device", for skip-own-writes checks."""
if self.legacy_id is None:
return frozenset({self.device_id})
return frozenset({self.device_id, self.legacy_id})
def is_own(self, other_device_id: str) -> bool:
"""Return whether [other_device_id] is one of this device's own ids."""
return other_device_id in self.own_ids
def load_device_identity(path: Path, *, legacy_id: str | None = None) -> DeviceIdentity:
"""Return the id persisted at [path], creating it on first call.
The file holds exactly one uuid4 and is written once; every later call
returns the same value, which is what makes the id stable across restarts
(and is why it must live in durable state, not a temp dir).
Args:
path: File holding this device's uuid. Parent dirs are created.
legacy_id: The role constant this device pushed under before
migrating, recorded on the returned identity so skip-own-writes
still recognises the old path as this device's own. Pass ``None``
once that path has been reclaimed.
Returns:
This device's [DeviceIdentity].
"""
try:
existing = path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
existing = ""
if existing:
return DeviceIdentity(device_id=existing, legacy_id=legacy_id)
# Deliberately not guarded: if the state directory cannot be created the
# id cannot be persisted, and returning an unpersisted one would mint a
# fresh uuid on every restart, stranding a device directory each time.
minted = str(uuid.uuid4())
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"{minted}\n", encoding="utf-8")
return DeviceIdentity(device_id=minted, legacy_id=legacy_id)

View File

@ -0,0 +1,26 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The errors the Firebase sync backend raises.
Split from :mod:`crdt_sync._firebase` for the 250-line cap, mirroring
:mod:`crdt_sync._gherrors` on the GitHub side.
Re-exported from :mod:`crdt_sync._firebase`, so existing imports keep working.
"""
from __future__ import annotations
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
class FirebaseSyncError(RemoteSyncError):
"""Raised for an RTDB failure the caller must not silently ignore."""
class DatabaseNotFoundError(FirebaseSyncError, RemoteNotFoundError):
"""Raised when the database is unreachable or the uid is not allowed.
The Firebase counterpart of :class:`crdt_sync.RepoNotFoundError`: "the
database URL is wrong, or the security rules reject this account", as
opposed to "nothing has been pushed to that path yet", which is benign
and surfaces as ``None``.
"""

View File

@ -0,0 +1,101 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Escaping arbitrary strings into Firebase Realtime Database keys.
Split from :mod:`crdt_sync._firebase`, which keeps the client. RTDB forbids
``.``, ``$``, ``#``, ``[``, ``]`` and ``/`` in a key, but the app stores
record ids and file paths that contain them, so every key is escaped on the
way in and unescaped on the way out. The pair has to round-trip exactly or a
device silently stops seeing its own writes.
Re-exported from :mod:`crdt_sync._firebase`, so existing imports keep working.
"""
from __future__ import annotations
# Characters Realtime Database forbids in a key, plus ``~`` itself because it
# is this module's escape character. ``/`` is absent deliberately: it is the
# path separator, handled by splitting into segments before escaping.
_ESCAPES = {
"~": "~7E",
".": "~2E",
"$": "~24",
"#": "~23",
"[": "~5B",
"]": "~5D",
}
_UNESCAPES = {escape: char for char, escape in _ESCAPES.items()}
_ESCAPE_LENGTH = 3
def encode_key(segment: str) -> str:
"""Escape one path segment into a legal Realtime Database key.
RTDB rejects ``. $ # [ ] /`` in keys, and the REST API's trailing
``.json`` is a *format suffix* rather than part of the path -- so a
filename like ``log.json`` cannot be stored verbatim. The mapping is a
reversible ``~XX`` escape (``log.json`` -> ``log~2Ejson``) rather than a
lossy "strip the extension", because callers see these names again:
todo-app lists *filenames*, not device directories, and must keep getting
``<uuid>.json`` back.
``~`` is the escape character because it is legal in RTDB keys and
unreserved in URLs, so the escaped form needs no percent-encoding --
which the server would otherwise decode back into the illegal character.
Parameters
----------
segment : str
One path segment, with no ``/`` in it.
Returns:
-------
str
The escaped key.
"""
return "".join(_ESCAPES.get(char, char) for char in segment)
def decode_key(key: str) -> str:
"""Reverse :func:`encode_key`.
Parameters
----------
key : str
An escaped Realtime Database key.
Returns:
-------
str
The original segment.
"""
out: list[str] = []
index = 0
while index < len(key):
escape = key[index : index + _ESCAPE_LENGTH]
char = _UNESCAPES.get(escape)
if char is None:
out.append(key[index])
index += 1
else:
out.append(char)
index += _ESCAPE_LENGTH
return "".join(out)
def encode_path(path: str) -> str:
"""Escape every segment of a ``/``-separated logical path.
Parameters
----------
path : str
A logical path such as ``"todo-sync/notes/abc.json"``.
Returns:
-------
str
The path with each segment escaped, empty segments dropped.
"""
return "/".join(encode_key(part) for part in path.split("/") if part)

View File

@ -0,0 +1,248 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Firebase Realtime Database as dumb keyed storage, over its REST API.
The RTDB REST endpoints are plain HTTPS, so this works identically under
systemd, in a venv, and alongside the Dart client on Android -- and the two
write byte-identical payloads, so a device on either implementation reads the
other's.
Why RTDB rather than Firestore: on the Spark (free) plan RTDB bills only
storage and bandwidth, with **no per-operation quota**, so a misbehaving sync
loop can never exhaust a daily budget and silently stop working mid-day.
Mirrors ``crdt_sync_dart``'s ``lib/src/firebase_client.dart``; keep the two in
step.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import requests as _requests
from crdt_sync._fberrors import DatabaseNotFoundError, FirebaseSyncError
from crdt_sync._fbkeys import decode_key, encode_key, encode_path
# Named explicitly so the autofixer cannot prune an import that exists for
# its re-export: the tests import encode_key/decode_key from this module.
__all__ = [
"DatabaseNotFoundError",
"FirebaseSyncClient",
"FirebaseSyncError",
"decode_key",
"encode_key",
"encode_path",
]
from crdt_sync._http import new_session
from crdt_sync._remote import RemoteSyncError
# Bound to the name ``requests`` so the call sites below -- and the tests
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
requests = new_session()
if TYPE_CHECKING:
from crdt_sync._firebase_auth import FirebaseTokenProvider
_DEFAULT_TIMEOUT_SECONDS = 15
_HTTP_UNAUTHORIZED = 401
_HTTP_FORBIDDEN = 403
class FirebaseSyncClient:
"""Realtime Database seen through the same contract as GitHub.
Reads and writes UTF-8 text blobs stored as JSON string leaves, so the
payload is byte-identical to what the GitHub backend stored and the two
can be mirrored against each other during migration.
"""
def __init__(
self,
database_url: str,
auth: FirebaseTokenProvider,
*,
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Create a client for ``database_url`` authenticated by ``auth``.
Args:
database_url: The database's HTTPS origin.
auth: Mints the ID token every request carries.
timeout_seconds: Per-request network timeout.
"""
self._database_url = database_url.rstrip("/")
self.auth = auth
self._timeout_seconds = timeout_seconds
def _url(self, path: str) -> str:
return f"{self._database_url}/{encode_path(path)}.json"
def _params(self, **extra: str) -> dict[str, str]:
return {"auth": self.auth.id_token(), **extra}
def _raise(self, what: str, response: _requests.Response) -> None:
"""Raise the right error type for a non-2xx response.
401/403 mean the rules rejected this uid or the token is bad.
Everything else -- including a Spark quota exhaustion, which answers
with an error rather than billing you -- is a plain
:class:`FirebaseSyncError`. Nothing is ever swallowed: a
quota-exhausted database that silently returned "no data" would look
exactly like "nothing to sync".
"""
detail = response.text.strip()
if response.status_code in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN):
msg = (
f"{what} rejected: HTTP {response.status_code} {detail} -- "
f"the database URL or the security rules do not allow this "
f"account"
)
raise DatabaseNotFoundError(msg)
msg = f"{what} failed: HTTP {response.status_code} {detail}"
raise FirebaseSyncError(msg)
def _get(self, path: str, **params: str) -> _requests.Response:
try:
return requests.get(
self._url(path),
params=self._params(**params),
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error reading {path}"
raise FirebaseSyncError(msg) from exc
def list_directory(self, path: str) -> list[str]:
"""Return the entry names directly under ``path``.
Uses ``shallow=true``, which returns ``{key: true}`` without any
values -- so a listing costs bytes rather than the whole subtree.
That is the difference between a sync tick costing hundreds of bytes
and hundreds of kilobytes.
Raises:
DatabaseNotFoundError: If the rules reject this account.
FirebaseSyncError: For any other non-2xx response or network
error.
"""
response = self._get(path, shallow="true")
if not response.ok:
self._raise(f"listing {path}", response)
data = response.json()
if not isinstance(data, dict):
return []
return [decode_key(key) for key in data]
def get_file_text(self, path: str) -> str | None:
"""Return the text stored at ``path``, or ``None`` if absent.
Raises:
DatabaseNotFoundError: If the rules reject this account.
FirebaseSyncError: If the value is not a text blob, or for any
other non-2xx response or network error.
"""
response = self._get(path)
if not response.ok:
self._raise(f"reading {path}", response)
data = response.json()
if data is None:
return None
if not isinstance(data, str):
msg = (
f"value at {path} is {type(data).__name__}, not the expected text blob"
)
raise FirebaseSyncError(msg)
return data
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Write ``text`` at ``path``.
Args:
path: A logical path such as ``"ns/devices/pc/log.json"``.
text: The full new content.
message: Ignored -- RTDB has no commit log to attach a reason to.
Present so this matches the ``RemoteStore`` contract.
Raises:
FirebaseSyncError: On any non-2xx response or network error.
"""
del message
try:
response = requests.put(
self._url(path),
params=self._params(),
json=text,
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error writing {path}"
raise FirebaseSyncError(msg) from exc
if not response.ok:
self._raise(f"writing {path}", response)
def get_string_map(self, path: str) -> dict[str, str]:
"""Return the map at ``path`` as ``key -> text``, empty if absent.
Tolerates a non-map or malformed value rather than raising: this
backs the revision cache, which is an *optimisation*. A corrupt revs
node must degrade into "fetch everything", never into a failed sync.
Raises:
FirebaseSyncError: Only for a transport-level failure.
"""
response = self._get(path)
if not response.ok:
self._raise(f"reading {path}", response)
data = response.json()
if not isinstance(data, dict):
return {}
return {
decode_key(key): value
for key, value in data.items()
if isinstance(value, str)
}
def delete_file(self, path: str, *, message: str = "crdt_sync: delete") -> None:
"""Delete ``path``. A no-op if it does not exist.
Args:
path: A logical path.
message: Ignored, as for :meth:`put_file_text`.
Raises:
FirebaseSyncError: On any non-2xx response or network error.
"""
del message
try:
response = requests.delete(
self._url(path),
params=self._params(),
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error deleting {path}"
raise FirebaseSyncError(msg) from exc
if not response.ok:
self._raise(f"deleting {path}", response)
def can_access_remote(self) -> bool:
"""Return whether the credential can reach the database.
Never raises -- a bad URL, a rejected token, a missing session or a
network failure all report ``False``, so a settings "Test connection"
button cannot blow up.
"""
try:
response = requests.get(
self._url(""),
params=self._params(shallow="true"),
timeout=self._timeout_seconds,
)
except _requests.RequestException:
return False
except RemoteSyncError:
# Includes FirebaseAuthError: "cannot get a token" is exactly
# "cannot access the remote".
return False
return response.ok

View File

@ -0,0 +1,245 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Firebase Authentication over its REST API, with no SDK dependency.
The Firebase Admin SDK authenticates with a service-account key, which
bypasses security rules entirely and would have to be distributed to every
device. These are single-user personal apps, so devices instead sign in as
one ordinary user and hold a refresh token -- the same path the Flutter side
takes, so both platforms behave identically.
Mirrors ``crdt_sync_dart``'s ``lib/src/firebase_auth_rest.dart``; keep the
two in step.
"""
from __future__ import annotations
import datetime as dt
import requests as _requests
from crdt_sync._autherrors import FirebaseAuthError, _is_revoked, _reason
from crdt_sync._credentials import (
CredentialStore,
FileCredentialStore,
FirebaseCredentials,
MemoryCredentialStore,
_utcnow,
)
from crdt_sync._http import new_session
__all__ = [
"CredentialStore",
"FileCredentialStore",
"FirebaseAuthError",
"FirebaseCredentials",
"FirebaseTokenProvider",
"MemoryCredentialStore",
]
# Bound to the name ``requests`` so the call sites below -- and the tests
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
requests = new_session()
_SIGN_IN_BASE = "https://identitytoolkit.googleapis.com/v1"
_REFRESH_BASE = "https://securetoken.googleapis.com/v1"
_DEFAULT_TIMEOUT_SECONDS = 15
class FirebaseTokenProvider:
"""Signs in and keeps a valid ID token available, refreshing as needed.
``api_key`` is the project's public Web API key -- not a secret, and safe
to ship inside an APK. The actual credential is the refresh token held by
the store.
"""
def __init__(
self,
api_key: str,
store: CredentialStore,
*,
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
clock: object = None,
) -> None:
"""Create a provider for ``api_key`` persisting through ``store``.
Args:
api_key: The project's public Web API key.
store: Where the refresh token lives between runs.
timeout_seconds: Per-request network timeout.
clock: A zero-arg callable returning "now" (tests inject one).
"""
self._api_key = api_key
self._store = store
self._timeout_seconds = timeout_seconds
self._clock = clock if callable(clock) else _utcnow
self._cached: FirebaseCredentials | None = None
def sign_in(self, email: str, password: str) -> None:
"""Exchange an email/password for a session and persist it.
Called once per device from a setup command; every later run reuses
the stored refresh token.
Raises:
FirebaseAuthError: If the credentials are rejected.
"""
body = self._post(
f"{_SIGN_IN_BASE}/accounts:signInWithPassword?key={self._api_key}",
{"email": email, "password": password, "returnSecureToken": True},
"sign in",
)
self._adopt(
id_token=body["idToken"],
refresh_token=body["refreshToken"],
expires_in_seconds=body["expiresIn"],
)
def sign_in_with_google(self, id_token: str, expected_uid: str = "") -> str:
"""Exchange a Google ID token for a session and persist it.
Mirrors ``crdt_sync_dart``'s ``signInWithGoogle``. Used to reseed a
machine's session after the sync account moved to Google-only sign-in:
the refresh token this stores is what keeps a headless job working
between runs, so the browser flow is needed once per machine, not once
per tick.
Deliberately *not* a service-account key. That would authenticate as
an admin and bypass the security rules entirely, so the pinned-uid
rule that protects this data would stop applying to every PC write.
Args:
id_token: A Google ID token whose audience is this project.
expected_uid: When given, asserted against the uid Firebase
returns. A mismatch raises without storing anything -- an
unlinked Google identity is *signed up* rather than refused,
and that session would then be denied every read and write.
Returns:
The email Firebase reports for the account.
Raises:
FirebaseAuthError: If the token is rejected, or resolves to a uid
other than ``expected_uid``.
"""
body = self._post(
f"{_SIGN_IN_BASE}/accounts:signInWithIdp?key={self._api_key}",
{
# The IdP credential travels form-encoded, not as JSON -- an
# identitytoolkit quirk. Sending it as a field returns
# INVALID_IDP_RESPONSE, which reads like a bad token.
"postBody": f"id_token={id_token}&providerId=google.com",
"requestUri": "http://localhost",
"returnSecureToken": True,
},
"sign in with Google",
)
uid = body.get("localId", "")
if expected_uid and uid != expected_uid:
msg = (
f"signed in as the wrong account: Google resolved to uid "
f"{uid!r}, but this data belongs to {expected_uid!r}"
)
raise FirebaseAuthError(msg)
self._adopt(
id_token=body["idToken"],
refresh_token=body["refreshToken"],
expires_in_seconds=body["expiresIn"],
)
return str(body.get("email", ""))
def id_token(self) -> str:
"""Return a currently-valid ID token, refreshing if needed.
Never returns a stale token and never silently no-ops: a sync that
quietly stops syncing is the failure mode this design exists to
prevent.
Raises:
FirebaseAuthError: If no session is stored, or the refresh token
has been revoked.
"""
credentials = self._load()
if credentials is None:
msg = "not signed in: no stored refresh token for this device"
raise FirebaseAuthError(msg)
if not credentials.is_expired_at(self._clock()):
return credentials.id_token
return self._refresh(credentials.refresh_token)
def has_session(self) -> bool:
"""Return whether this device has a stored session at all.
Distinguishes "sync is not configured" -- a normal state for
``screen-locker`` -- from "sync is configured and broken", an error.
"""
return self._load() is not None
def sign_out(self) -> None:
"""Forget the stored session, so the next token request fails."""
self._cached = None
self._store.clear()
def _load(self) -> FirebaseCredentials | None:
if self._cached is None:
self._cached = self._store.load()
return self._cached
def _refresh(self, refresh_token: str) -> str:
try:
body = self._post(
f"{_REFRESH_BASE}/token?key={self._api_key}",
{"grant_type": "refresh_token", "refresh_token": refresh_token},
"refresh the session",
)
except FirebaseAuthError as error:
# A revoked refresh token never becomes valid again, so keeping it
# is what let a dead device report "connected" and then fail every
# sync with TOKEN_EXPIRED. Drop it so has_session() is honest.
if _is_revoked(error):
self.sign_out()
raise
return self._adopt(
id_token=body["id_token"],
# A refresh may hand back a rotated token; keep the new one.
refresh_token=body["refresh_token"],
expires_in_seconds=body["expires_in"],
)
def _adopt(
self,
*,
id_token: str,
refresh_token: str,
expires_in_seconds: str,
) -> str:
credentials = FirebaseCredentials(
id_token=id_token,
refresh_token=refresh_token,
expires_at=self._clock() + dt.timedelta(seconds=int(expires_in_seconds)),
)
self._cached = credentials
self._store.save(credentials)
return id_token
def _post(
self,
url: str,
payload: dict[str, object],
what: str,
) -> dict[str, str]:
try:
response = requests.post(
url,
json=payload,
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error trying to {what}"
raise FirebaseAuthError(msg) from exc
if not response.ok:
reason = _reason(response)
msg = f"failed to {what}: HTTP {response.status_code}{reason}"
raise FirebaseAuthError(msg)
return response.json()

View File

@ -0,0 +1,31 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The errors the GitHub sync backend raises.
Split from :mod:`crdt_sync._github` for the 250-line cap. Kept separate so a
caller can catch them without importing the client -- and so the client file
is the client, not its taxonomy.
Re-exported from :mod:`crdt_sync._github`, so existing imports keep working.
"""
from __future__ import annotations
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
class GitHubSyncError(RemoteSyncError):
"""Raised for a GitHub API failure the caller must not silently ignore."""
class RepoNotFoundError(GitHubSyncError, RemoteNotFoundError):
"""Raised when the configured repo itself is unreachable.
Distinguished from a path-404 (nothing pushed to that path yet, which is
benign -- it just means no other device has synced before) so the caller
can tell "the repo name is wrong or the token isn't scoped to it" apart
from "no other device has synced yet".
Derives from both so existing ``except GitHubSyncError`` handlers still
cover it *and* backend-neutral callers can catch "the remote itself is
missing" without naming GitHub.
"""

View File

@ -0,0 +1,238 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Minimal GitHub Contents API client used as dumb file storage.
GitHub is used purely as file storage via the REST Contents API, not a git
clone -- there is no working tree and no git-level merge; the only merge is
the domain-level one in :mod:`crdt_sync._log`. Extracted from diet_guard's
``_sync_github.py`` (itself ported in spirit from ``todo``'s sync
transport), generalized to be domain-agnostic.
"""
from __future__ import annotations
import base64
import requests as _requests
from crdt_sync._gherrors import GitHubSyncError, RepoNotFoundError
from crdt_sync._http import new_session
# Bound to the name ``requests`` so the call sites below -- and the tests
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
requests = new_session()
_API_BASE = "https://api.github.com"
_HTTP_NOT_FOUND = 404
_DEFAULT_TIMEOUT_SECONDS = 15
class GitHubSyncClient:
"""Thin wrapper around the subset of the Contents API sync needs."""
def __init__(
self,
owner: str,
repo: str,
token: str,
*,
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Create a client scoped to one repo, authenticated with ``token``.
Args:
owner: The repo owner/org (e.g. ``"kuhyx"``).
repo: The repo name (e.g. ``"my-app-sync"``).
token: A GitHub PAT with contents read/write on that repo.
timeout_seconds: Per-request network timeout.
"""
self._owner = owner
self._repo = repo
self._timeout_seconds = timeout_seconds
self._headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
}
def _contents_url(self, path: str) -> str:
return f"{_API_BASE}/repos/{self._owner}/{self._repo}/contents/{path}"
def _get(self, path: str) -> _requests.Response:
try:
return requests.get(
self._contents_url(path),
headers=self._headers,
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error reading {path}"
raise GitHubSyncError(msg) from exc
def _repo_exists(self) -> bool:
try:
response = requests.get(
f"{_API_BASE}/repos/{self._owner}/{self._repo}",
headers=self._headers,
timeout=self._timeout_seconds,
)
except _requests.RequestException:
return False
return response.ok
def _raise_for_missing_path(self, path: str) -> None:
"""Raise :class:`RepoNotFoundError` only if the repo is unreachable.
A 404 on a path within a reachable repo just means nothing has been
pushed there yet, which is not an error worth raising on.
"""
if not self._repo_exists():
msg = (
f"{self._owner}/{self._repo} not found, private without "
f"access, or the token lacks contents permission "
f"(while reading {path})"
)
raise RepoNotFoundError(msg)
def get_file_text(self, path: str) -> str | None:
"""Return the decoded text content at ``path``, or None if unused.
Args:
path: A repo-relative file path, e.g. ``"devices/pc/log.json"``.
Returns:
The file's text content, or None if nothing has been pushed
there yet (but the repo itself is reachable).
Raises:
RepoNotFoundError: If the repo itself is unreachable.
GitHubSyncError: For any other non-2xx response or network error.
"""
response = self._get(path)
if response.status_code == _HTTP_NOT_FOUND:
self._raise_for_missing_path(path)
return None
if not response.ok:
msg = f"GET {path} failed: {response.status_code}"
raise GitHubSyncError(msg)
data = response.json()
content = data.get("content", "") if isinstance(data, dict) else ""
return base64.b64decode(content).decode("utf-8")
def _existing_sha(self, path: str) -> str | None:
response = self._get(path)
if response.status_code == _HTTP_NOT_FOUND:
self._raise_for_missing_path(path)
return None
if not response.ok:
msg = f"GET {path} (for sha) failed: {response.status_code}"
raise GitHubSyncError(msg)
data = response.json()
sha = data.get("sha") if isinstance(data, dict) else None
return sha if isinstance(sha, str) else None
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Create or update the file at ``path`` with ``text``.
Args:
path: A repo-relative file path.
text: The full new content (this device's complete merged log).
message: The commit message for this push.
Raises:
GitHubSyncError: On any non-2xx response or network error.
"""
sha = self._existing_sha(path)
payload: dict[str, object] = {
"message": message,
"content": base64.b64encode(text.encode("utf-8")).decode("ascii"),
}
if sha is not None:
payload["sha"] = sha
try:
response = requests.put(
self._contents_url(path),
headers=self._headers,
json=payload,
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error pushing {path}"
raise GitHubSyncError(msg) from exc
if not response.ok:
msg = f"PUT {path} failed: {response.status_code}"
raise GitHubSyncError(msg)
def list_directory(self, path: str) -> list[str]:
"""Return the entry names directly under ``path`` (empty if unused).
Args:
path: A repo-relative directory path, e.g. ``"devices"``.
Raises:
RepoNotFoundError: If the repo itself is unreachable.
GitHubSyncError: For any other non-2xx response or network error.
"""
response = self._get(path)
if response.status_code == _HTTP_NOT_FOUND:
self._raise_for_missing_path(path)
return []
if not response.ok:
msg = f"GET {path} (list) failed: {response.status_code}"
raise GitHubSyncError(msg)
data = response.json()
if not isinstance(data, list):
return []
return [
item["name"]
for item in data
if isinstance(item, dict) and isinstance(item.get("name"), str)
]
def can_access_repo(self) -> bool:
"""Return whether the token can read this repo (a connection test).
Hits the bare repo endpoint, so it succeeds even before any file has
been pushed. Never raises -- a network failure or missing repo is
reported as ``False``.
The GitHub-era name for :meth:`can_access_remote`; kept because app
settings screens call it. Both do the same probe.
"""
return self._repo_exists()
def can_access_remote(self) -> bool:
"""Return whether the token can reach the remote (a connection test).
The :class:`crdt_sync.RemoteStore` spelling of
:meth:`can_access_repo`.
"""
return self._repo_exists()
def delete_file(self, path: str, *, message: str = "crdt_sync: delete") -> None:
"""Delete the file at ``path``, resolving its current sha itself.
A no-op if ``path`` does not exist, so a repeated cleanup is safe.
Args:
path: A repo-relative file path.
message: The commit message for the deletion.
Raises:
GitHubSyncError: On any non-2xx response or network error.
"""
sha = self._existing_sha(path)
if sha is None:
return
try:
response = requests.delete(
self._contents_url(path),
headers=self._headers,
json={"message": message, "sha": sha},
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error deleting {path}"
raise GitHubSyncError(msg) from exc
if not response.ok:
msg = f"DELETE {path} failed: {response.status_code}"
raise GitHubSyncError(msg)

View File

@ -0,0 +1,96 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Hybrid Logical Clock: a totally-ordered, monotonic per-node timestamp.
An HLC combines wall-clock time with a logical counter so that two ticks
issued by the same node in the same millisecond still get a strict order,
and two ticks from different nodes are always comparable (the node id breaks
ties), without requiring synchronized clocks across devices.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import time
_ISO_PREFIX_LEN = 23 # len("YYYY-MM-DDTHH:MM:SS.mmm")
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
@dataclass(frozen=True, order=True)
class Hlc:
"""A Hybrid Logical Clock value.
Ordered by ``(wall_time_ms, counter, node_id)`` tuple comparison, which
is also the order ``@dataclass(order=True)`` derives from the field
declaration order below -- two clocks from different nodes never compare
equal, since ``node_id`` is the final, always-distinguishing tiebreaker.
"""
wall_time_ms: int
counter: int
node_id: str
@staticmethod
def new_tick(
node_id: str,
previous: Hlc | None = None,
wall_time_ms: int | None = None,
) -> Hlc:
"""Return the next clock value for ``node_id``.
Args:
node_id: The issuing node's identifier.
previous: This node's last-issued clock value, if any. Passing
the previous value is what makes the clock monotonic even
when the wall clock hasn't advanced (or has gone backwards).
wall_time_ms: Override for the current wall-clock time in
milliseconds since the Unix epoch; defaults to the real
clock. Exposed for deterministic tests.
Returns:
A new ``Hlc`` strictly greater than ``previous`` (if given).
"""
now = wall_time_ms if wall_time_ms is not None else int(time.time() * 1000)
if previous is None:
return Hlc(wall_time_ms=now, counter=0, node_id=node_id)
new_wall = max(now, previous.wall_time_ms)
counter = previous.counter + 1 if new_wall == previous.wall_time_ms else 0
return Hlc(wall_time_ms=new_wall, counter=counter, node_id=node_id)
def to_str(self) -> str:
"""Serialize to a human-readable, lexicographically sortable string.
Format: ``<iso8601-millis>Z-<counter:04x>-<node_id>``. The iso8601
portion is fixed-width (always exactly ``YYYY-MM-DDTHH:MM:SS.mmm``),
which is what makes plain string comparison agree with ``Hlc``
comparison for the wall-clock component.
"""
seconds, millis = divmod(self.wall_time_ms, 1000)
dt = datetime.fromtimestamp(seconds, tz=UTC)
iso = dt.strftime("%Y-%m-%dT%H:%M:%S")
return f"{iso}.{millis:03d}Z-{self.counter:04x}-{self.node_id}"
@classmethod
def from_str(cls, text: str) -> Hlc:
"""Parse the format produced by :meth:`to_str`.
Raises:
ValueError: If ``text`` is not in the expected format.
"""
iso_part, sep, rest = text.partition("Z-")
if not sep or len(iso_part) != _ISO_PREFIX_LEN:
msg = f"not a valid Hlc string: {text!r}"
raise ValueError(msg)
counter_hex, sep, node_id = rest.partition("-")
if not sep:
msg = f"not a valid Hlc string: {text!r}"
raise ValueError(msg)
dt = datetime.strptime(iso_part, "%Y-%m-%dT%H:%M:%S.%f").replace(tzinfo=UTC)
delta = dt - _EPOCH
wall_time_ms = (
delta.days * 86_400_000 + delta.seconds * 1000 + delta.microseconds // 1000
)
return cls(
wall_time_ms=wall_time_ms, counter=int(counter_hex, 16), node_id=node_id
)

View File

@ -0,0 +1,58 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pooled HTTP sessions, so every request does not re-handshake.
Every call site in this package used the module-level ``requests.get`` /
``put`` / ``post`` / ``delete`` helpers, each of which opens a throwaway
connection: fresh DNS, TCP and TLS on every single request. Measured against
the two real backends on 2026-08-21 that costs 82ms per request to
``api.github.com`` and 51ms to Realtime Database -- on a 27-request sync tick,
most of the wall clock.
A :class:`requests.Session` keeps the connection alive between requests, and
is a drop-in for the module-level helpers: same verb names, same signatures.
So each transport module binds one of these to the name ``requests`` and its
call sites are unchanged.
**One session per module, deliberately not one global.** The tests patch the
verb on the module attribute (``patch.object(fb.requests, "get", ...)``), and
several use sequence-consuming ``side_effect`` lists. Were all three modules
to share a single object, a call from one would consume another's queued
response and the failure would look like a logic bug. Per-module also happens
to be per-host here -- GitHub, Realtime Database, and the token endpoint are
three different origins -- so pooling loses nothing by the split.
Not thread-safe, in that :class:`requests.Session` is not documented to be;
this package spawns no threads and its consumers drive sync from one.
"""
from __future__ import annotations
import requests
from requests.adapters import HTTPAdapter, Retry
_RETRY_TOTAL = 3
_RETRY_BACKOFF_SECONDS = 0.3
_RETRY_STATUSES = (429, 500, 502, 503, 504)
def new_session() -> requests.Session:
"""Return a connection-pooling session that retries transient GET failures.
Retries are restricted to ``GET``. A ``PUT`` here carries a Contents-API
``sha`` (and a Realtime Database write is a full-node replace); replaying
one whose response was merely lost would push a second write built on a
now-stale precondition, against a remote another device reads. Reads are
idempotent, so only they are replayed.
"""
session = requests.Session()
retry = Retry(
total=_RETRY_TOTAL,
backoff_factor=_RETRY_BACKOFF_SECONDS,
status_forcelist=_RETRY_STATUSES,
allowed_methods=frozenset({"GET"}),
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session

View File

@ -0,0 +1,33 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Log: a collection of Records keyed by id, with a union-merge."""
from __future__ import annotations
from crdt_sync._record import Record, merge_record
Log = dict[str, Record]
def merge_logs(local: Log, remote: Log) -> Log:
"""Return the union of ``local`` and ``remote``, merging shared ids.
Commutative and idempotent: ``merge_logs(a, b) == merge_logs(b, a)`` and
``merge_logs(x, x) == x``, so pull order between devices never matters
and a repeated sync tick is a no-op.
Args:
local: This device's current full log (including tombstones).
remote: Another device's last-pushed full log.
Returns:
The merged log, containing every id present in either input.
"""
merged: Log = dict(local)
for record_id, remote_record in remote.items():
local_record = merged.get(record_id)
merged[record_id] = (
remote_record
if local_record is None
else merge_record(local_record, remote_record)
)
return merged

View File

@ -0,0 +1,231 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A remote store that writes to two backends and reads from both.
Exists for the GitHub -> Firebase cutover: each app can move to Firebase
while still mirroring to the old repo, so rolling back is a constructor
change rather than a data recovery.
Mirrors ``crdt_sync_dart``'s ``lib/src/mirror_store.dart``; keep the two in
step.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, TypeVar
from crdt_sync._remote import RemoteSyncError
if TYPE_CHECKING:
from collections.abc import Callable
from crdt_sync._remote import RemoteStore
_logger = logging.getLogger(__name__)
# PEP 695 type parameters need Python 3.12; this package targets 3.10.
_T = TypeVar("_T")
class MirrorSyncClient:
"""Writes to both backends; reads from both, preferring the primary.
**Reads consult both on purpose.** Each app spans two devices, and they
cut over one at a time. If reads were primary-only, a migrated PC would
never see an un-migrated phone's writes while still mirroring its own
back -- silent one-directional convergence, with no error raised. Because
:func:`crdt_sync.merge_logs` is commutative and idempotent, a union read
costs nothing semantically and makes the cutover order irrelevant.
Write asymmetry is deliberate too: the primary is authoritative, so a
primary failure fails the tick (fail-closed), while a mirror failure is
reported and otherwise tolerated. Once the mirror is retired, the old
backend going away must not break sync.
**Reads are resilient on BOTH sides; writes are not.** A read is only
fail-closed when neither backend can answer. Reads used to call the
primary unguarded, so a Firebase outage produced no data at all and the
mirror was never consulted -- the union read silently degraded to nothing,
contradicting the paragraph above. Writes keep the asymmetry: a write the
primary did not accept has not happened, so it must fail the tick.
"""
def __init__(
self,
primary: RemoteStore,
mirror: RemoteStore,
*,
on_mirror_failure: Callable[[str, Exception], None] | None = None,
) -> None:
"""Wrap ``primary`` (authoritative) and ``mirror`` (best effort).
Args:
primary: The backend whose failures fail the tick.
mirror: The backend kept in step but never allowed to fail a tick.
on_mirror_failure: Notified when the mirror misbehaves. Defaults
to a warning log -- a silent mirror failure would let the
fallback rot unnoticed until the day it was needed.
"""
self.primary = primary
self.mirror = mirror
self._on_mirror_failure = on_mirror_failure or _log_mirror_failure
def list_directory(self, path: str) -> list[str]:
"""Return the union of both backends' entries under ``path``.
A reachable primary that lists nothing is a real answer; a primary
that *raises* is not. Reads are the half of this class that exists to
be resilient, so a primary failure degrades to the mirror's entries
rather than failing the read -- returning nothing because one side is
down breaks the union promise exactly when the fallback is most
needed. Only a failure on BOTH sides propagates, since then there is
genuinely no answer to give.
"""
primary_names = self._try_primary_read(
f"list_directory {path}", lambda: self.primary.list_directory(path)
)
mirror_names = self._try_mirror(
f"list_directory {path}", lambda: self.mirror.list_directory(path)
)
if primary_names is None and mirror_names is None:
self._raise_both_failed(f"list_directory {path}")
names = list(primary_names or [])
seen = set(names)
for name in mirror_names or []:
if name not in seen:
seen.add(name)
names.append(name)
return names
def get_file_text(self, path: str) -> str | None:
"""Return ``path``'s text from the primary, else from the mirror.
Absent from the primary means "this device has not migrated yet",
not "no data" -- so it falls through rather than reporting nothing.
A primary that *raises* falls through for the same reason (see
:meth:`list_directory`); only a failure on both sides propagates.
"""
try:
text = self.primary.get_file_text(path)
except RemoteSyncError as error:
self._on_primary_read_failure(f"get_file_text {path}", error)
mirror_text = self._try_mirror(
f"get_file_text {path}", lambda: self.mirror.get_file_text(path)
)
if mirror_text is None:
self._raise_both_failed(f"get_file_text {path}")
return mirror_text
if text is not None:
return text
return self._try_mirror(
f"get_file_text {path}", lambda: self.mirror.get_file_text(path)
)
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Write to the primary, then best-effort to the mirror.
Raises:
RemoteSyncError: If the *primary* write fails.
"""
self.primary.put_file_text(path, text, message=message)
self._try_mirror(
f"put_file_text {path}",
lambda: self.mirror.put_file_text(path, text, message=message),
)
def delete_file(self, path: str, *, message: str = "crdt_sync: delete") -> None:
"""Delete from the primary, then best-effort from the mirror.
Raises:
RemoteSyncError: If the *primary* delete fails.
"""
self.primary.delete_file(path, message=message)
self._try_mirror(
f"delete_file {path}",
lambda: self.mirror.delete_file(path, message=message),
)
def can_access_remote(self) -> bool:
"""Return whether the **primary** is reachable.
Only the primary: a settings "Test connection" button must not report
success because the backend being retired happens to answer.
"""
return self.primary.can_access_remote()
def get_string_map(self, path: str) -> dict[str, str]:
"""Merge both backends' revision maps, primary winning on conflict.
A device that has not migrated publishes revisions only to the
mirror; without this it would look revision-less and be
re-downloaded every tick for the whole trial period.
Like the other reads, a primary failure degrades to the mirror rather
than failing the tick: revisions are a cache, and losing them re-reads
data rather than losing it.
"""
merged: dict[str, str] = {}
mirror_read = getattr(self.mirror, "get_string_map", None)
# A mirror that CANNOT do bulk reads contributes nothing, but that is
# not a failure -- it has no revisions to give, which is a real answer.
# Only a mirror that tried and threw leaves the read with no answer,
# and only then can a failing primary make this raise.
mirror_answered = mirror_read is None
if mirror_read is not None:
mirror_map = self._try_mirror(
f"get_string_map {path}", lambda: mirror_read(path)
)
mirror_answered = mirror_map is not None
merged.update(mirror_map or {})
primary_read = getattr(self.primary, "get_string_map", None)
if primary_read is not None:
primary_map = self._try_primary_read(
f"get_string_map {path}", lambda: primary_read(path)
)
if primary_map is None and not mirror_answered:
self._raise_both_failed(f"get_string_map {path}")
merged.update(primary_map or {})
return merged
def _try_mirror(self, operation: str, run: Callable[[], _T]) -> _T | None:
"""Run a mirror operation, reporting rather than propagating failure."""
try:
return run()
except RemoteSyncError as error:
self._on_mirror_failure(operation, error)
return None
def _try_primary_read(self, operation: str, run: Callable[[], _T]) -> _T | None:
"""Run a primary READ, reporting rather than propagating failure.
Reads only -- writes stay fail-closed, since a write the primary did
not accept has not happened and must fail the tick.
"""
try:
return run()
except RemoteSyncError as error:
self._on_primary_read_failure(operation, error)
return None
def _on_primary_read_failure(self, operation: str, error: Exception) -> None:
"""Warn that a read fell back to the mirror."""
_logger.warning(
"Primary backend failed during %s: %s — falling back to the mirror",
operation,
error,
)
def _raise_both_failed(self, operation: str) -> None:
"""Raise when neither backend could answer a read.
Fail closed rather than returning an empty result: an empty read is
indistinguishable from "no data", and callers act on that by treating
remote state as absent.
"""
msg = f"both primary and mirror backends failed during {operation}"
raise RemoteSyncError(msg)
def _log_mirror_failure(operation: str, error: Exception) -> None:
"""Warn loudly, so a rotting fallback is visible before it is needed."""
_logger.warning("Mirror backend failed during %s: %s", operation, error)

View File

@ -0,0 +1,109 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Downloading peers' logs, skipping the ones we already have.
Split from :mod:`crdt_sync._sync`, which keeps the push half and the
orchestration. The revision map is the optimisation that makes a tick cheap:
a peer whose published revision matches what we last merged is not fetched at
all, so a quiet device costs one small read instead of one read per peer.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from crdt_sync._revisions import revision_of
if TYPE_CHECKING:
from collections.abc import Callable
from crdt_sync._log import Log
from crdt_sync._remote import RemoteStore
from crdt_sync._syncstate import SyncState
_logger = logging.getLogger(__name__)
def _remote_revs(client: RemoteStore, revs_path: str) -> dict[str, str]:
"""Return every peer's published revision, cheaply where possible.
Degrades to an empty map -- meaning "fetch everything", the old behaviour
-- on any backend without a bulk-map read, so correctness never depends
on the optimisation being available.
"""
get_string_map = getattr(client, "get_string_map", None)
if get_string_map is None:
return {}
return get_string_map(revs_path)
@dataclass(frozen=True)
class _PullContext:
"""Everything :func:`_pull_remote_logs` needs for one tick's pull.
A bundle rather than eight positional parameters: they are always passed
together, from one call site, and several share a type -- exactly the
shape where a positional mix-up is silent.
"""
client: RemoteStore
own_ids: frozenset[str]
path_prefix: str
filename: str
decode: Callable[[str], Log]
remote_revs: dict[str, str]
state: SyncState
def _pull_remote_logs(
ctx: _PullContext,
seen_revs: dict[str, str],
) -> list[Log]:
"""Return every other device's last-pushed log, skipping this one.
A device whose pushed file is corrupt or unparsable (e.g. an interrupted
push) is logged and skipped, same as one that has never pushed at all --
the remote is an external system boundary, and one bad device's file must
not stall merging in every other device's.
``own_ids`` is a *set* rather than a single id because a device that has
migrated from a role constant to a persisted uuid still owns the log it
pushed under the old id. Matching only the current id would pull that
file back and re-merge this device's own pre-migration history as though
a peer had written it -- idempotent, but pure wasted transfer.
Records into ``seen_revs`` which peers are now merged in, so the next
tick can skip them.
"""
remote_logs: list[Log] = []
for other_device_id in ctx.client.list_directory(ctx.path_prefix):
if other_device_id in ctx.own_ids:
continue
remote_rev = ctx.remote_revs.get(other_device_id)
if remote_rev is not None and remote_rev == ctx.state.peer_revs.get(
other_device_id
):
# Unchanged since we last merged it, and that merge is already
# part of local_log -- so the (potentially hundreds of KB)
# download is pure waste. Carry the revision forward so it stays
# skipped next tick.
seen_revs[other_device_id] = remote_rev
continue
text = ctx.client.get_file_text(
f"{ctx.path_prefix}/{other_device_id}/{ctx.filename}"
)
if text is None:
continue
try:
remote_logs.append(ctx.decode(text))
except ValueError, KeyError, TypeError:
_logger.warning(
"Unparsable log pushed by device %r, skipping",
other_device_id,
)
# Deliberately not recorded as seen: a corrupt push must be
# retried next tick, not remembered as merged.
continue
seen_revs[other_device_id] = remote_rev or revision_of(text)
return remote_logs

View File

@ -0,0 +1,129 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Record: a per-field LWW map with a sticky (monotonic) delete flag.
This is the unit CRDT merges operate on. It's deliberately domain-agnostic --
translating an app's own model (a note, a food-log entry, a workout entry,
a wake-time value) to and from ``Record`` is each app's job, not this
library's.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from crdt_sync._hlc import Hlc
Field = tuple[Any, Hlc]
@dataclass(frozen=True)
class Record:
"""One CRDT record: an id, per-field LWW values, and a sticky tombstone.
Attributes:
id: Stable identifier, unique within one ``Log``.
fields: Field name -> ``(value, Hlc)``. Concurrent edits to the same
field converge to whichever side has the greater ``Hlc``.
deleted: Whether this record is tombstoned. Deletion is monotonic:
once true on either side of a merge, it stays true.
deleted_hlc: The clock value of the delete, if any -- kept for
informational/ordering purposes only; it does not participate in
the merge decision (that's a plain boolean OR, see
:func:`merge_record`).
"""
id: str
fields: dict[str, Field]
deleted: bool = False
deleted_hlc: Hlc | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize to a JSON-compatible dict."""
return {
"id": self.id,
"fields": {
name: [value, hlc.to_str()]
for name, (value, hlc) in self.fields.items()
},
"deleted": self.deleted,
"deleted_hlc": self.deleted_hlc.to_str()
if self.deleted_hlc is not None
else None,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Record:
"""Parse the format produced by :meth:`to_dict`."""
fields = {
name: (value, Hlc.from_str(hlc_str))
for name, (value, hlc_str) in data["fields"].items()
}
deleted_hlc_str = data.get("deleted_hlc")
deleted_hlc = (
Hlc.from_str(deleted_hlc_str) if deleted_hlc_str is not None else None
)
return cls(
id=data["id"],
fields=fields,
deleted=bool(data["deleted"]),
deleted_hlc=deleted_hlc,
)
def merge_field(a: Field, b: Field) -> Field:
"""Return whichever of ``a``, ``b`` has the greater ``Hlc``.
Ties (equal ``Hlc``) keep ``a``; two ticks never compare equal across
different nodes (see :class:`crdt_sync._hlc.Hlc`), so a tie only happens
when both sides are literally the same tick re-merged, in which case the
value is expected to be identical anyway.
"""
return a if a[1] >= b[1] else b
def _merge_deleted_hlc(a: Record, b: Record) -> Hlc | None:
"""Return the delete-clock to keep, informational only (see class docs)."""
if a.deleted and b.deleted:
if a.deleted_hlc is None:
return b.deleted_hlc
if b.deleted_hlc is None:
return a.deleted_hlc
return max(a.deleted_hlc, b.deleted_hlc)
if a.deleted:
return a.deleted_hlc
if b.deleted:
return b.deleted_hlc
return None
def merge_record(a: Record, b: Record) -> Record:
"""Merge two versions of the same record.
Per-field last-writer-wins over the union of field names, plus a sticky
delete: ``deleted = a.deleted or b.deleted``, never the other way around.
A tombstone can never be resurrected by merging in an older, non-deleted
copy pulled from a device that hasn't seen the delete yet.
Commutative and idempotent: ``merge_record(a, b) == merge_record(b, a)``
and ``merge_record(a, a) == a``.
Raises:
ValueError: If ``a.id != b.id`` -- merging two different records is
a caller bug, not a case to silently paper over.
"""
if a.id != b.id:
msg = f"cannot merge records with different ids: {a.id!r} != {b.id!r}"
raise ValueError(msg)
merged_fields = dict(a.fields)
for name, b_field in b.fields.items():
a_field = merged_fields.get(name)
merged_fields[name] = (
b_field if a_field is None else merge_field(a_field, b_field)
)
return Record(
id=a.id,
fields=merged_fields,
deleted=a.deleted or b.deleted,
deleted_hlc=_merge_deleted_hlc(a, b),
)

View File

@ -0,0 +1,83 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Backend-neutral remote-storage contract that sync talks through.
Deliberately tiny: :func:`crdt_sync.sync_log` and every app-level sync
service talk only to :class:`RemoteStore`, so swapping the storage backend
(GitHub Contents API, Firebase Realtime Database, a dual-writing mirror of
both) is a constructor change at the call site and nothing more.
Mirrors ``crdt_sync_dart``'s ``lib/src/remote_store.dart``; keep the two in
step.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
class RemoteSyncError(Exception):
"""Raised for a remote-storage failure the caller must not ignore.
The backend-neutral base of the error hierarchy: catch this to handle
"the sync transport failed" regardless of which backend is configured.
Backends narrow it -- :class:`crdt_sync.GitHubSyncError` and
:class:`crdt_sync.FirebaseSyncError` both derive from it -- so existing
``except GitHubSyncError`` handlers keep working while a caller that has
been migrated can catch the base type instead.
"""
class RemoteNotFoundError(RemoteSyncError):
"""Raised when the configured remote itself is unreachable.
Distinguished from a missing *path* (nothing pushed there yet, which is
benign -- it just means no other device has synced before) so the caller
can tell "the repo/database is wrong or the credential isn't scoped to
it" apart from "no other device has synced yet".
"""
@runtime_checkable
class RemoteStore(Protocol):
"""Dumb keyed storage of UTF-8 text blobs, with directory listing.
Implementations raise :class:`RemoteSyncError` (or a subclass) for any
failure the caller must not silently ignore, and treat a missing path as
a benign ``None`` / empty list rather than an error.
A ``Protocol`` rather than an ABC so the existing
:class:`crdt_sync.GitHubSyncClient` satisfies it structurally, with no
base class to add and no change to its own tests.
"""
def list_directory(self, path: str) -> list[str]:
"""Return the entry names directly under ``path``.
Returns an empty list when nothing has been written there yet.
Includes both files and subdirectories: the
``<path_prefix>/<device_id>/<filename>`` layout needs to discover
device *directories*, not just files.
"""
def get_file_text(self, path: str) -> str | None:
"""Return the text stored at ``path``, or ``None`` if absent."""
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Create or replace the text at ``path``.
``message`` is a human-readable reason for the write. Backends that
record one (GitHub commits) use it; backends that do not (Firebase)
ignore it.
"""
def delete_file(self, path: str, *, message: str = ...) -> None:
"""Delete ``path``. A no-op when ``path`` does not exist."""
def can_access_remote(self) -> bool:
"""Return whether the configured credential can reach the remote.
A lightweight connection test for a settings "Test connection"
button: it probes the remote root, so it succeeds even before any
file has been pushed. Never raises -- a network failure or missing
remote returns ``False``.
"""

View File

@ -0,0 +1,38 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The revision string a device publishes alongside its log.
Split from :mod:`crdt_sync._sync` so both the push and pull halves can import
it without importing each other. Pure: a revision is a function of the encoded
log and nothing else, which is what lets two devices agree on whether they are
looking at the same bytes.
Re-exported from :mod:`crdt_sync._sync`, so existing imports keep working.
"""
from __future__ import annotations
import hashlib
def revision_of(encoded_log: str) -> str:
"""Return the revision of a serialized log: a content hash.
A hash rather than the log's maximum HLC because merging a peer's *older*
record changes the content without raising the maximum -- with three
devices in one namespace (diet-guard has ``pc``, ``phone`` and
``desktop``) a clock-based revision can miss a peer's merged state. It is
also the same value used to suppress no-op pushes, so the two
optimisations share one mechanism.
Parameters
----------
encoded_log : str
The serialized log, exactly as it would be pushed.
Returns:
-------
str
A hex SHA-256 digest.
"""
return hashlib.sha256(encoded_log.encode("utf-8")).hexdigest()

View File

@ -0,0 +1,71 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Local JSON persistence for a ``Log``.
Mirrors ``crdt_sync_dart``'s ``logToJson``/``FileLogPersistence`` serialization
so a log written by the Dart side parses here and vice versa. Deliberately
*non-reactive*: the change-stream/reactive store is a Dart/Flutter-only
convenience (it exists to drive a live UI). On the Python side, where consumers
are headless sync ticks, this is plain load and (atomic) save.
"""
from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING
from crdt_sync._record import Record
if TYPE_CHECKING:
from pathlib import Path
from crdt_sync._log import Log
def dump_log(log: Log) -> str:
"""Serialize ``log`` to canonical JSON text (id -> record dict)."""
return json.dumps({rid: record.to_dict() for rid, record in log.items()})
def load_log(text: str) -> Log:
"""Parse text produced by :func:`dump_log` back into a ``Log``.
Raises ``ValueError`` (bad JSON), ``KeyError`` or ``TypeError`` (valid
JSON, wrong shape) on a corrupt payload -- callers reading from disk go
through :func:`read_log`, which turns those into an empty log.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"expected a JSON object mapping id -> record, got {type(raw).__name__}"
raise TypeError(msg)
return {rid: Record.from_dict(value) for rid, value in raw.items()}
def read_log(path: Path) -> Log:
"""Read a ``Log`` from ``path``; empty on a missing or unparsable file.
A truncated or corrupt file can never raise here: it is treated as "no log
yet", matching the Dart store's defensive load so a half-written file can't
brick a consumer.
"""
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return {}
try:
return load_log(text)
except ValueError, KeyError, TypeError:
return {}
def write_log(path: Path, log: Log) -> None:
"""Write ``log`` to ``path`` atomically (temp file then rename).
Writes to a per-process temp file and ``replace``s it over the real path,
so a concurrent reader never observes a half-written file. Mirrors the
Dart ``FileLogPersistence`` write scheme.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_text(dump_log(log), encoding="utf-8")
tmp.replace(path)

View File

@ -0,0 +1,184 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Domain-agnostic pull/merge/push sync orchestration.
Generalizes diet_guard's original ``_sync.py`` (pull every other device's
pushed log, merge with the local one, push this device's own merged result
back up) so any app can reuse the loop while keeping its own on-disk JSON
shape via the ``encode``/``decode`` callbacks -- this module has no opinion
on what a ``Record``'s fields actually mean.
Mirrors ``crdt_sync_dart``'s ``lib/src/sync.dart``; keep the two in step.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from crdt_sync._log import merge_logs
from crdt_sync._pull import _pull_remote_logs, _PullContext, _remote_revs
from crdt_sync._revisions import revision_of
from crdt_sync._syncargs import LogCodec, RevisionTracking, SyncTarget
from crdt_sync._syncstate import (
FileSyncStateStore,
MemorySyncStateStore,
SyncState,
SyncStateStore,
)
if TYPE_CHECKING:
from crdt_sync._log import Log
# Named explicitly so the autofixer cannot prune an import that exists for its
# re-export: crdt_sync/__init__.py imports all of these from here.
__all__ = [
"FileSyncStateStore",
"LogCodec",
"MemorySyncStateStore",
"RevisionTracking",
"SyncState",
"SyncStateStore",
"SyncTarget",
"default_revs_path",
"revision_of",
"sync_log",
]
_logger = logging.getLogger(__name__)
def default_revs_path(path_prefix: str) -> str:
"""Return where revisions live for ``path_prefix``.
A ``revs`` sibling of the device directory, e.g.
``diet-guard-sync/devices`` -> ``diet-guard-sync/revs`` and
``todo-sync/notes`` -> ``todo-sync/revs``.
Parameters
----------
path_prefix : str
The directory holding one subdirectory per device.
Returns:
-------
str
The revisions directory.
"""
head, separator, _ = path_prefix.rpartition("/")
return f"{head}/revs" if separator else f"{path_prefix}/revs"
def sync_log(
target: SyncTarget,
local_log: Log,
codec: LogCodec,
revisions: RevisionTracking | None = None,
) -> Log:
"""Run one full sync tick: pull every other device's log, merge, push.
Pulls from ``<path_prefix>/<other-device-id>/<filename>`` for every
device directory the remote reports under ``path_prefix``, merges each
into ``local_log`` with :func:`crdt_sync.merge_logs`, then pushes this
device's own merged result to ``<path_prefix>/<device_id>/<filename>``.
Args:
target: Which remote and device this tick is for.
local_log: This device's current full log (including tombstones).
codec: How to serialize and parse a log.
revisions: Pass one to skip downloading peers that have not changed
and skip pushing a log that has not changed. ``None`` fetches
everything and pushes unconditionally.
Returns:
The merged log, as pushed.
Notes:
Revisions live at ``<revs_path>/<device_id>`` -- one small text node
per device, each written only by its owner. Deliberately *not* one
shared map per namespace: a whole-map write would erase every other
device's entry, after which those peers would look permanently
unchanged and never be fetched again. Per-device keys make that
failure impossible rather than merely avoided.
"""
tracking = revisions if revisions is not None else RevisionTracking()
state_store = tracking.state_store
revs = (
tracking.revs_path
if tracking.revs_path is not None
else default_revs_path(target.path_prefix)
)
state = state_store.load() if state_store is not None else SyncState()
remote_revs = _remote_revs(target.client, revs) if state_store is not None else {}
seen_revs: dict[str, str] = {}
merged = _merge_peers(
_PullContext(
client=target.client,
own_ids=target.own_ids,
path_prefix=target.path_prefix,
filename=codec.filename,
decode=codec.decode,
remote_revs=remote_revs,
state=state,
),
local_log,
seen_revs,
)
encoded = codec.encode(merged)
revision = revision_of(encoded)
if not (state_store is not None and revision == state.pushed_rev):
_push(
target,
codec,
encoded,
_RevisionMarker(revs, revision) if state_store is not None else None,
)
if state_store is not None:
state_store.save(SyncState(pushed_rev=revision, peer_revs=seen_revs))
return merged
def _merge_peers(
ctx: _PullContext,
local_log: Log,
seen_revs: dict[str, str],
) -> Log:
"""Return ``local_log`` with every peer's pushed log merged in."""
merged = dict(local_log)
for remote_log in _pull_remote_logs(ctx, seen_revs):
merged = merge_logs(merged, remote_log)
return merged
@dataclass(frozen=True)
class _RevisionMarker:
"""The revision node a tracked push publishes after its log."""
revs_path: str
revision: str
def _push(
target: SyncTarget,
codec: LogCodec,
encoded: str,
marker: _RevisionMarker | None,
) -> None:
"""Push this device's merged log, then its revision marker."""
target.client.put_file_text(
f"{target.path_prefix}/{target.device_id}/{codec.filename}",
encoded,
message=codec.commit_message,
)
if marker is not None:
# Published after the log, never before: a peer that cached
# "seen rev X" against a log it never received would skip it
# forever.
target.client.put_file_text(
f"{marker.revs_path}/{target.device_id}",
marker.revision,
message=codec.commit_message,
)

Some files were not shown because too many files have changed in this diff Show More