Compare commits

...

67 Commits

Author SHA1 Message Date
b0fad2b2e5 sickdays 0.5.0 + deploy line for utils
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
The text-free sickdays GUI (9d6b17f) was never released: main still
said 0.4.0 while the code moved past sickdays-v0.4.0, so every gate app
kept the worded panel. scripts/deploy.sh installs the sickdays CLI from
the live checkout and fails unless the version tags match HEAD and the
live sickdays is the text-free one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:54:54 +01:00
c83d41a594 feedback_inbox: exact MANUAL titles, count fix
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
The server stores a new_note task as '<repo> todos: <title>' plus a '## <title>' section, so the title no longer started with MANUAL WORK REQUIRED and the entry's own ## heading became a runnable part. After add_task the inbox now rewrites the note with update_task (exact title, section-free body). Marker is written first, format 2 only after the rewrite: a failed rewrite is retried, never re-added; old markers are repaired. install.sh counts folders from the Paths property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXmfFFHX4fNhyFRqFq
2026-11-11 14:16:46 +01:00
2bbfbd9439 feedback_inbox: game feedback bundles -> todo
Watches ~/data/*_binaries/feedback via systemd path units and files one MANUAL WORK REQUIRED task per complete bundle. Marker written only after the todo server accepts the task (fail closed, retried by the 5-min sweep). Stdlib only, 47 tests, 100% branch coverage.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXmfFFHX4fNhyFRqFq
2026-11-11 14:09:48 +01:00
2d341dd41e gatelock 0.13.0: locks stay clickable together
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
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A keyring prompt (gcr-prompter) that grabbed input before the
leetcode lock armed left the lock visible but unclickable: every
click went to the prompt hidden under it. Each failed
grab_set_global also froze Tk ~1 s, so the lock barely repainted.

- Raise known credential prompts above the lock after 2 s of a
  blocked grab; games and other grabbers stay covered.
- Probe the grab on a side Xlib connection first so a blocked
  grab never stalls the Tk loop (worst gap 1.05 s -> 0.05 s).
- Log the Tk grab error and stop blaming our own claim.
- 1.5 s startup grace in wait_for_turn, and a weaker armed lock
  steps back (windowless, re-arms later) when a stronger one is
  live, so only one lock is ever on screen.
- Real-X checks: verify_prompt_yield, verify_grab_responsive,
  verify_all_lockers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZbpxQZj41WpbQPuxB
2026-11-11 12:49:40 +01:00
3084080d74 sync_settings_ui 0.7.0, gda 0.4.0: text-free
Some checks are pending
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
sync_settings_ui tests / test (push) Waiting to run
Glyph + spoken label replaces every app-written string on the shared
Sync settings screen and the GitHub device-code dialog (kuhy's
textgate rule). Old sentences are semantics labels; tests find them
there. The device user code stays visible (GitHub's data).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:27:57 +01:00
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 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
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled
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
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
99f4e171e3 Merge branch 'worktree-agent-a179fa4cf3532a65f' 2026-10-24 08:02:21 +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
434 changed files with 37300 additions and 3038 deletions

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

View File

@ -28,4 +28,4 @@ jobs:
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Run tests with coverage - name: Run tests with coverage
run: python -m pytest -v 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

View File

@ -13,7 +13,7 @@
# linters, and carry this same hook so committing with one of those configs is # 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 # 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 # the only one that covers the subprojects without a config of their own
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...). # (guard-lib/, crdt_sync_dart/, file_length/, ...).
# #
# The path is repo-relative on purpose. pre-commit always runs hooks from the # 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 # git root, so this resolves the same locally and on a CI runner -- an absolute
@ -66,9 +66,14 @@ repos:
# answer and passes with a loud banner; `--no-verify` is banned here, so # 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 # a hook that hard-failed on a flaky connection would leave no way to
# commit at all. CI, always online, is the strict authority. # 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 - id: dependency-freshness
name: dependencies on newest stable name: dependencies on newest stable
entry: bash scripts/check_dependency_freshness.sh entry: bash scripts/dep_freshness_changed.sh
language: system language: system
pass_filenames: true pass_filenames: true
files: >- files: >-

View File

@ -16,3 +16,4 @@ overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract` - 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: `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` - coverage-gaps: `coverage-gaps coverage.xml`
- deploy: `git -C ~/src/utils pull --ff-only && ~/src/utils/scripts/deploy.sh` (installs the sickdays CLI + launcher from the live checkout, then exits 1 unless every released library's version tag is on origin and matches HEAD and the live `sickdays` runs the text-free checkout; `--verify-only` for the health check alone)

View File

@ -1,45 +0,0 @@
# aur-publish
Hands-off publishing of kuhy's Flutter apps to the AUR.
```bash
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
~/src/utils/aur-publish/publish.sh --only habit-stack
```
## What it does
1. Polls `/register` until AUR signup reopens, opens it in a browser and
prints the SSH key to paste. **Signup is the only manual step** — it
requires email confirmation.
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
`pacman -U` → launch check → `.SRCINFO` → commit → push.
Every package is built, installed and **launched** before it is pushed, so a
broken package cannot reach the AUR. A package failing any gate is skipped and
reported; the others still publish. Safe to re-run.
## anubis_gate.py
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
`curl` therefore reports 200 for a page that is actually unreachable — which is
exactly how "is registration back?" gets answered wrong. This module solves the
challenge and reports the status of the *real* page behind it.
```bash
python3 anubis_gate.py /register # prints the true status code
```
## AUR rules enforced before pushing
The AUR does not review uploads, but per the submission guidelines "packages
that violate the rules may be deleted without warning". `check_aur_rules()`
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
not already in an official repo, `pkgdesc`/`url`/`license` present, and
`.SRCINFO` matching the PKGBUILD.

View File

@ -1,138 +0,0 @@
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
`curl` therefore reports 200 for a page that is actually unreachable, which is
exactly how "is /register back up?" gets answered wrong.
This module solves the challenge and reports the status of the *real* page
behind it, so the caller can distinguish:
* 200 -> genuinely available
* 503 -> reached the site; that endpoint is disabled server-side
* other-> whatever the app actually said
Exit codes mirror the HTTP status so shell callers can branch on them:
0 for 200, 1 for anything else, 2 for a local/transport failure.
"""
from __future__ import annotations
import argparse
import hashlib
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://aur.archlinux.org"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
CHALLENGE_RE = re.compile(
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
re.S,
)
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
# difficulty spike, so the poller degrades instead of spinning forever.
MAX_NONCE = 50_000_000
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Keep redirects visible instead of silently following them."""
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
return None
def _opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
)
opener.addheaders = [
("User-Agent", UA),
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
]
return opener
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
prefix = "0" * difficulty
started = time.time()
for nonce in range(MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
if digest.startswith(prefix):
return nonce, digest, time.time() - started
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
opener = _opener()
url = f"{BASE}{path}"
try:
with opener.open(url, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
status = resp.status
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
match = CHALLENGE_RE.search(body)
if not match:
return status, body
challenge = json.loads(match.group(1))["challenge"]
nonce, digest, elapsed = _solve(
challenge["randomData"], int(challenge["difficulty"])
)
query = urllib.parse.urlencode(
{
"id": challenge["id"],
"response": digest,
"nonce": str(nonce),
"redir": path,
"elapsedTime": str(int(elapsed * 1000)),
}
)
try:
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
except urllib.error.HTTPError:
# A redirect or non-2xx here is fine; what matters is the cookie the
# opener's jar just captured.
pass
try:
with opener.open(url, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="AUR path, e.g. /register")
parser.add_argument(
"--print-body", action="store_true", help="write the page body to stdout"
)
args = parser.parse_args()
try:
status, body = fetch(args.path)
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
print(f"transport error: {exc}", file=sys.stderr)
return 2
print(status)
if args.print_body:
sys.stdout.write(body)
return 0 if status == 200 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,107 +0,0 @@
#!/bin/bash
# ============================================================================
# Building, verifying and pushing one package to the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
publish_one() {
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
local dir="$AUR_ROOT/$pkg"
local version
log "$pkg"
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
version="$(newest_tag "$repo")"
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
info "newest release tag: v$version"
cd "$dir"
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
# computed after the tag exists — never committed ahead of time.
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
info "building"
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
warn "$pkg: makepkg failed"
return 1
fi
local built
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
# for /opt-installed apps, so only genuinely new errors are worth failing on.
local findings
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
if [[ -n "$findings" ]]; then
warn "$pkg: namcap errors:"
printf ' %s\n' "$findings"
return 1
fi
info "installing and launching"
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|| { warn "$pkg: pacman -U failed"; return 1; }
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
# Generated before the rule check, which verifies it matches the PKGBUILD.
makepkg --printsrcinfo > .SRCINFO
check_aur_rules "$pkg" || return 1
# The AUR repo is a separate remote living alongside the build dir; adding
# it here (rather than cloning into a fresh path) keeps the verified
# PKGBUILD and the thing being pushed byte-identical.
if [[ ! -d .git ]]; then
git init -q -b master .
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
fi
git remote get-url origin >/dev/null 2>&1 \
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
# Fetch first: an existing AUR package must be built on, not overwritten.
if git fetch -q origin master 2>/dev/null; then
git reset -q --soft FETCH_HEAD 2>/dev/null || true
fi
cat > .gitignore <<'EOF'
# Build products; the AUR tracks only the recipe.
*.pkg.tar.zst
*.tar.gz
src/
pkg/
EOF
git add PKGBUILD .SRCINFO .gitignore
if git diff --cached --quiet; then
info "already up to date on the AUR; nothing to push"
PUBLISHED+=("$pkg v$version (unchanged)")
return 0
fi
git -c user.name='Krzysztof Rudnicki' \
-c user.email='krzysztofrudnicki0@gmail.com' \
commit -q -m "$pkg $version-1"
if ((DRY_RUN)); then
info "DRY RUN — not pushing"
PUBLISHED+=("$pkg v$version (dry run)")
return 0
fi
info "pushing to the AUR"
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
PUBLISHED+=("$pkg v$version")
return 0
}

View File

@ -1,107 +0,0 @@
#!/bin/bash
# ============================================================================
# Waiting for AUR registration and SSH access to come up.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
register_status() {
python3 "$GATE" /register 2>/dev/null | head -1
}
open_browser() {
local url="$1"
local browser
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
if command -v "$browser" >/dev/null 2>&1; then
"$browser" "$url" >/dev/null 2>&1 &
info "Opened $url in $browser"
return 0
fi
done
warn "No browser found; open this yourself: $url"
}
ensure_key() {
if [[ ! -f "$SSH_KEY" ]]; then
log "Generating an AUR SSH key (none at $SSH_KEY)"
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
fi
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
log "Adding $AUR_HOST to ~/.ssh/config"
mkdir -p "$HOME/.ssh"
cat >> "$HOME/.ssh/config" <<EOF
# AUR uses a dedicated key so it stays separate from the GitHub identity.
Host $AUR_HOST
User aur
IdentityFile $SSH_KEY
IdentitiesOnly yes
EOF
chmod 600 "$HOME/.ssh/config"
fi
}
ssh_works() {
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
# successful key auth is the success signal, not an error.
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
grep -qiE 'interactive shell is disabled|Welcome to AUR'
}
wait_for_registration() {
local status
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "AUR registration is OPEN"
else
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
info "Ctrl-C to stop; re-run any time, nothing is lost."
while :; do
sleep "$POLL_SECONDS"
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "Registration is BACK (HTTP 200)"
break
fi
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
done
fi
open_browser "https://$AUR_HOST/register"
cat <<EOF
------------------------------------------------------------------
Sign up, and paste this into the "SSH Public Key" field:
$(sed 's/^/ /' "$SSH_KEY.pub")
You will need to confirm the address from your email before the
account works. This script waits for that automatically.
------------------------------------------------------------------
EOF
}
wait_for_ssh() {
log "Waiting for the SSH key to authenticate against the AUR"
local waited=0
until ssh_works; do
sleep 30
waited=$((waited + 30))
if ((waited % 300 == 0)); then
info "still waiting ($((waited / 60)) min) — key not accepted yet"
fi
done
log "SSH authentication OK"
}
# ---------------------------------------------------------------------------
# Phase 2: build, verify and publish one package
# ---------------------------------------------------------------------------

View File

@ -1,122 +0,0 @@
#!/bin/bash
# ============================================================================
# Gates a package must pass before it is allowed near the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
newest_tag() {
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
git -C "$1" ls-remote --tags origin 2>/dev/null |
awk '{print $2}' |
sed 's|refs/tags/||' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sed 's/^v//' |
sort -V |
tail -1
}
verify_installed() {
local binary="$1" expected="$2" wrapper="$3" output
# An instance left over from a previous run would make the wrapper print
# "already running" and hand off, which passes trivially and turns the
# strongest gate in this script into the weakest. Clear it first so the
# check always exercises a real cold start.
#
# Resolved through /proc/<pid>/exe rather than any form of name or
# command-line matching. Every argv-based approach selects the wrong
# processes here:
# * `pkill -x` compares against the kernel's 15-character comm field, and
# two of these wrappers are longer (habit_stack_desktop is 19), so a
# name match would silently never fire.
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
# any process whose command line merely *mentions* the path — this
# script, the shell running it, and the matching tool itself all
# qualify, so the run kills its own shell instead of the app. A
# $$/$PPID guard does not cover it, because the offending shell is
# often a more distant ancestor.
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
# being executed, so it cannot collide with an incidental mention.
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
# a glob instead of threading the package name through another parameter.
#
# The link is read without `-f`: it is already fully resolved, and by the
# time this runs `pacman -U` has replaced the binary, so a leftover process
# points at an unlinked inode that the kernel reports with a " (deleted)"
# suffix. That suffix has to be stripped or the match silently misses the
# one process this exists to kill.
local pid exe killed=()
for pid in /proc/[0-9]*; do
pid="${pid#/proc/}"
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
done
if ((${#killed[@]})); then
info "stopped a running instance so the launch check is meaningful"
# `kill` only reports that the signal was sent. Waiting for the process
# to actually go keeps a slow exit from failing the cold start below on
# a port that is still bound.
local waited=0
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
sleep 0.1
((waited++))
done
fi
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
if grep -qF "$expected" <<<"$output"; then
return 0
fi
warn "unexpected startup output: $output"
return 1
}
# The AUR does not review uploads, but "packages that violate the rules may be
# deleted without warning" (AUR submission guidelines). These are the rules that
# are mechanically checkable, enforced here so a violation blocks the push
# rather than surfacing later as a deletion request.
check_aur_rules() {
local pkg="$1" problems=()
grep -q '^# Maintainer:' PKGBUILD \
|| problems+=("missing '# Maintainer:' comment on line 1")
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
# `replaces` makes pacman rip out the named package on the next -Sy, which
# is only correct for a genuine rename. `conflicts` is the right tool.
grep -q '^replaces=' PKGBUILD \
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
# A package already in an official repo must never be duplicated.
if pacman -Si "$pkg" >/dev/null 2>&1; then
problems+=("$pkg exists in an official repo — must not be submitted")
fi
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|| problems+=("missing pkgdesc/url/license")
# A stale .SRCINFO is the single most common upload rejection.
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
problems+=(".SRCINFO does not match PKGBUILD")
fi
if ((${#problems[@]})); then
warn "$pkg: AUR rule violations:"
printf ' - %s\n' "${problems[@]}"
return 1
fi
info "AUR rule checks passed"
return 0
}

View File

@ -1,148 +0,0 @@
#!/bin/bash
# ============================================================================
# Publish kuhy's three Flutter apps to the AUR, hands-off.
#
# Run this once AUR registration is back. It:
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
# then opens it in a browser and prints the SSH key to paste.
# (Signup is the ONLY manual step: it needs an email confirmation, and
# creating accounts is not something this script will do for you.)
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
# 3. From there, fully unattended, for each package:
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
# -> commit -> push.
#
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
# broken package cannot reach the AUR. A package that fails any gate is
# skipped and reported; the others still publish.
#
# Idempotent and resumable: re-running skips work that is already done, and
# an already-published package is updated rather than duplicated.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
readonly AUR_HOST="aur.archlinux.org"
readonly SSH_KEY="$HOME/.ssh/aur"
readonly AUR_ROOT="$HOME/sdk/aur"
# package:repo:wrapper-process:cli-name:expected-startup-string
# The wrapper process name is NOT derivable from the package name
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
readonly PACKAGES=(
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
)
POLL_SECONDS=300
SKIP_WAIT=0
DRY_RUN=0
ONLY=""
FAILED=()
PUBLISHED=()
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--skip-wait Assume the account + SSH key already work; go straight
to building and publishing.
--dry-run Do everything except the final 'git push' to the AUR.
--only NAME Publish just one package (todo-flutter, habit-stack,
diet-guard-app).
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
-h, --help Show this help.
EOF
exit 0
}
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
require_tools() {
local missing=()
local tool
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
if ((${#missing[@]})); then
info "Installing missing tools: ${missing[*]}"
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|| die "could not install: ${missing[*]}"
fi
}
# ---------------------------------------------------------------------------
# Phase 1: wait for registration, then for the key to work
# ---------------------------------------------------------------------------
# The rest lives in lib/, sourced (not executed) so every function sees the
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
# shellcheck source=lib/aur_registration.sh
source "$SCRIPT_DIR/lib/aur_registration.sh"
# shellcheck source=lib/aur_verify.sh
source "$SCRIPT_DIR/lib/aur_verify.sh"
# shellcheck source=lib/aur_publish_one.sh
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
main() {
require_tools
[[ -f "$GATE" ]] || die "missing $GATE"
mkdir -p "$AUR_ROOT"
ensure_key
if ((SKIP_WAIT)); then
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
else
wait_for_registration
wait_for_ssh
fi
local entry pkg repo wrapper binary expected
for entry in "${PACKAGES[@]}"; do
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
FAILED+=("$pkg")
fi
done
log "Summary"
if ((${#PUBLISHED[@]})); then
printf ' published: %s\n' "${PUBLISHED[@]}"
fi
if ((${#FAILED[@]})); then
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
return 1
fi
if ((${#PUBLISHED[@]} == 0)); then
warn "nothing to do"
return 1
fi
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
return 0
}
while [[ $# -gt 0 ]]; do
case $1 in
--skip-wait) SKIP_WAIT=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--only) ONLY="$2"; shift 2 ;;
--poll) POLL_SECONDS="$2"; shift 2 ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -118,6 +118,16 @@ The public/private split is not cosmetic: every repo in this fleet is public.
APK — the security rules, not secrecy, protect the data. The account email and APK — the security rules, not secrecy, protect the data. The account email and
password identify *a person* and are entered once per device. password identify *a person* and are entered once per device.
### Sandbox namespace for test builds
`--dart-define=SYNC_NAMESPACE=sandbox/slot3` moves every RTDB read and write
under `/sandbox/slot3/...`, so a test build never touches real data. Sign-in
is unchanged (the real account). Empty, the default, keeps the exact current
paths. Only `^sandbox/[a-z0-9_-]+$` is accepted; anything else throws
`ConfigException` at startup (`firebaseClientFor` / `FirebaseRestClient`), never
falling back to production paths. The shipped rules (root-level uid pin) already
allow these paths.
### `password: ''` is legal ### `password: ''` is legal
The Google one-tap path and the seeded-session path both store an empty The Google one-tap path and the seeded-session path both store an empty

View File

@ -20,4 +20,5 @@ export 'src/remote_store.dart';
export 'src/secure_credential_store.dart'; export 'src/secure_credential_store.dart';
export 'src/store.dart'; export 'src/store.dart';
export 'src/sync.dart'; export 'src/sync.dart';
export 'src/sync_namespace.dart';
export 'src/sync_state.dart'; export 'src/sync_state.dart';

View File

@ -19,6 +19,7 @@ import 'firebase_auth_rest.dart';
import 'firebase_errors.dart'; import 'firebase_errors.dart';
import 'firebase_keys.dart'; import 'firebase_keys.dart';
import 'remote_store.dart'; import 'remote_store.dart';
import 'sync_namespace.dart';
const _httpUnauthorized = 401; const _httpUnauthorized = 401;
const _httpForbidden = 403; const _httpForbidden = 403;
@ -33,13 +34,22 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
required String databaseUrl, required String databaseUrl,
required this.auth, required this.auth,
http.Client? httpClient, http.Client? httpClient,
String namespace = syncNamespace,
}) : _databaseUrl = databaseUrl.endsWith('/') }) : _databaseUrl = databaseUrl.endsWith('/')
? databaseUrl.substring(0, databaseUrl.length - 1) ? databaseUrl.substring(0, databaseUrl.length - 1)
: databaseUrl, : databaseUrl,
_prefix = _prefixFor(checkSyncNamespace(namespace)),
_http = httpClient ?? http.Client(); _http = httpClient ?? http.Client();
final String _databaseUrl; final String _databaseUrl;
/// `<namespace>/` or empty: put in front of every RTDB path. Every request
/// goes through [_uri], so this is the single choke point.
final String _prefix;
static String _prefixFor(String namespace) =>
namespace.isEmpty ? '' : '$namespace/';
/// Mints the ID token every request carries. Public so a settings screen /// Mints the ID token every request carries. Public so a settings screen
/// can drive `signIn` / `signOut` through the same instance the client uses. /// can drive `signIn` / `signOut` through the same instance the client uses.
final FirebaseTokenProvider auth; final FirebaseTokenProvider auth;
@ -47,8 +57,12 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async { Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
final encoded = encodePath(path); final encoded = encodePath(path);
// A bare namespace (the root probe) must not leave a trailing slash.
final full = encoded.isEmpty && _prefix.isNotEmpty
? _prefix.substring(0, _prefix.length - 1)
: '$_prefix$encoded';
return Uri.parse( return Uri.parse(
'$_databaseUrl/$encoded.json', '$_databaseUrl/$full.json',
).replace(queryParameters: {'auth': await auth.idToken(), ...query}); ).replace(queryParameters: {'auth': await auth.idToken(), ...query});
} }

View File

@ -20,6 +20,7 @@ import 'firebase_credentials.dart';
import 'firebase_client.dart'; import 'firebase_client.dart';
import 'mirror_store.dart'; import 'mirror_store.dart';
import 'remote_store.dart'; import 'remote_store.dart';
import 'sync_namespace.dart';
/// The shared config is missing a field, or still holds a scaffold placeholder. /// The shared config is missing a field, or still holds a scaffold placeholder.
class ConfigException implements Exception { class ConfigException implements Exception {
@ -121,6 +122,8 @@ Future<FirebaseRestClient> firebaseClientFor({
String? expectedUid, String? expectedUid,
http.Client? httpClient, http.Client? httpClient,
}) async { }) async {
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
checkSyncNamespace(syncNamespace);
final auth = FirebaseTokenProvider( final auth = FirebaseTokenProvider(
apiKey: config.apiKey, apiKey: config.apiKey,
store: store, store: store,

View File

@ -7,8 +7,11 @@ import 'store.dart';
/// Writes atomically -- to a per-process temp file, then `rename`d over the /// Writes atomically -- to a per-process temp file, then `rename`d over the
/// real path -- so a concurrent reader (e.g. a background sync isolate writing /// real path -- so a concurrent reader (e.g. a background sync isolate writing
/// while the foreground app also writes) never observes a half-written file. /// while the foreground app also writes) never observes a half-written file.
/// The pid in the temp name keeps two writers from colliding. This generalizes /// The pid in the temp name keeps two processes from colliding; within one
/// diet_guard's hand-rolled `LogStorageService` write scheme. /// instance, writes are serialized in issue order (last issued wins), so
/// overlapping calls (e.g. per-keystroke saves) never share the temp file
/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService`
/// write scheme.
/// ///
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the /// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
/// main barrel, because it imports `dart:io` and so is unavailable on web -- /// main barrel, because it imports `dart:io` and so is unavailable on web --
@ -20,6 +23,10 @@ class FileLogPersistence implements LogPersistence {
final File _file; final File _file;
/// Completes when the most recently issued write has settled. Never
/// rejects, so one failed write cannot block the ones queued after it.
Future<void> _tail = Future<void>.value();
@override @override
Future<String?> read() async { Future<String?> read() async {
if (!_file.existsSync()) return null; if (!_file.existsSync()) return null;
@ -31,7 +38,14 @@ class FileLogPersistence implements LogPersistence {
} }
@override @override
Future<void> write(String text) async { Future<void> write(String text) {
final result = _tail.then((_) => _writeNow(text));
// The caller still sees its own error via [result]; the chain ignores it.
_tail = result.then((_) {}, onError: (Object _) {});
return result;
}
Future<void> _writeNow(String text) async {
await _file.parent.create(recursive: true); await _file.parent.create(recursive: true);
final tmp = File('${_file.path}.$pid.tmp'); final tmp = File('${_file.path}.$pid.tmp');
await tmp.writeAsString(text); await tmp.writeAsString(text);

View File

@ -0,0 +1,26 @@
/// Compile-time sandbox namespace for the Realtime Database.
///
/// Build a test app with `--dart-define=SYNC_NAMESPACE=sandbox/slot3` and every
/// RTDB path the library reads or writes moves under `/sandbox/slot3/`, so a
/// test build can never touch real data. Sign-in is unchanged (real account).
/// Empty, the default, leaves every path exactly as it was.
library;
import 'firebase_config.dart';
/// The `SYNC_NAMESPACE` dart-define; empty means production paths.
const String syncNamespace = String.fromEnvironment('SYNC_NAMESPACE');
final RegExp _allowed = RegExp(r'^sandbox/[a-z0-9_-]+$');
/// Returns [namespace] when it is empty or `sandbox/<slot>`, else throws.
///
/// Fails closed: a typo must stop the app at startup, never quietly fall back
/// to production paths. A trailing newline is rejected too.
String checkSyncNamespace(String namespace) {
if (namespace.isEmpty || _allowed.hasMatch(namespace)) return namespace;
throw ConfigException(
'SYNC_NAMESPACE "$namespace" is invalid: it must be empty or match '
r'^sandbox/[a-z0-9_-]+$ (e.g. sandbox/slot3)',
);
}

View File

@ -2,7 +2,7 @@ name: crdt_sync
description: >- description: >-
Shared CRDT merge scheme + GitHub-Contents-API sync transport for Shared CRDT merge scheme + GitHub-Contents-API sync transport for
cross-device apps. Pure Dart, no Flutter dependency. cross-device apps. Pure Dart, no Flutter dependency.
version: 0.11.3 version: 0.12.1
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@ -128,5 +128,32 @@ void main() {
await store.write('{"hello":"world"}'); await store.write('{"hello":"world"}');
expect(await store.read(), '{"hello":"world"}'); expect(await store.read(), '{"hello":"world"}');
}); });
test('50 overlapping writes all succeed and the last issued wins',
() async {
// Regression: every call shared one `<file>.<pid>.tmp`, so the first
// rename moved it away and a later rename threw PathNotFoundException.
final store = FileLogPersistence(File('${dir.path}/log.json'));
await Future.wait([
for (var i = 0; i < 50; i++) store.write('{"n":$i}'),
]);
expect(await store.read(), '{"n":49}');
expect(dir.listSync().map((e) => e.uri.pathSegments.last),
['log.json']);
});
test('a failed write rejects its caller but does not block later writes',
() async {
final target = File('${dir.path}/log.json');
// A directory at the target path makes the rename fail.
Directory(target.path).createSync();
final store = FileLogPersistence(target);
await expectLater(
store.write('{"n":0}'), throwsA(isA<FileSystemException>()));
Directory(target.path).deleteSync();
await store.write('{"n":1}');
expect(await store.read(), '{"n":1}');
});
}); });
} }

View File

@ -0,0 +1,170 @@
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _databaseUrl = 'https://example-default-rtdb.firebasedatabase.app';
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id-token',
refreshToken: 'refresh-token',
expiresAt: DateTime.utc(2099),
),
),
httpClient: http_testing.MockClient(
(_) async => throw StateError('auth must not be contacted'),
),
);
/// Client over an in-memory transport that records every request path.
(FirebaseRestClient, List<http.Request>) _client(String? namespace) {
final requests = <http.Request>[];
final mock = http_testing.MockClient((request) async {
requests.add(request);
return http.Response(jsonEncode(request.method == 'GET' ? null : 'x'), 200);
});
final client = namespace == null
? FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
)
: FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
namespace: namespace,
);
return (client, requests);
}
void main() {
group('compile-time default', () {
test('is empty when SYNC_NAMESPACE is not defined', () {
expect(syncNamespace, isEmpty);
});
test('empty namespace leaves paths byte-for-byte unchanged', () async {
final (client, requests) = _client(null);
await client.getFileText('devices/pc/log.json');
expect(requests.single.url.path, '/devices/pc/log~2Ejson.json');
});
});
group('prefixing', () {
test('every operation lands under the namespace', () async {
final (client, requests) = _client('sandbox/slot3');
await client.getFileText('devices/pc/log.json');
await client.putFileText('devices/pc/log.json', 't', message: 'm');
await client.listDirectory('devices');
await client.getStringMap('revs');
await client.patchValues('revs', {'a': 'b'});
await client.deleteFile('devices/pc/log.json');
await client.canAccessRemote();
expect(requests.map((r) => r.url.path), [
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3.json',
]);
});
test('an explicitly empty namespace is the unprefixed path', () async {
final (client, requests) = _client('');
await client.getStringMap('revs');
expect(requests.single.url.path, '/revs.json');
});
});
group('validation (fail closed)', () {
const bad = [
'sandbox',
'sandbox/',
'sandbox/Slot3',
'sandbox/slot 3',
'sandbox/slot3/',
'sandbox/a/b',
'/sandbox/slot3',
'prod/slot3',
'sandbox/slot3\n',
'Sandbox/slot3',
'sandbox/slot.3',
'sandbox/..',
];
for (final value in bad) {
test('rejects ${jsonEncode(value)}', () {
expect(
() => checkSyncNamespace(value),
throwsA(isA<ConfigException>()),
);
expect(
() => FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
namespace: value,
),
throwsA(isA<ConfigException>()),
);
});
}
test('the message names the define and the required shape', () {
expect(
() => checkSyncNamespace('typo'),
throwsA(
isA<ConfigException>().having(
(e) => e.message,
'message',
allOf(
contains('SYNC_NAMESPACE'),
contains(r'^sandbox/[a-z0-9_-]+$'),
),
),
),
);
});
test('accepts the allowed alphabet', () {
expect(() => checkSyncNamespace('sandbox/slot3'), returnsNormally);
expect(() => checkSyncNamespace('sandbox/a_b-c9'), returnsNormally);
expect(() => checkSyncNamespace(''), returnsNormally);
});
test(
'a bad compile-time define stops firebaseClientFor at startup',
() async {
// The define is compile-time, so only a fresh process can set it.
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/Typo',
'run',
'tool/namespace_probe.dart',
]);
expect(result.exitCode, isNot(0));
expect(
result.stderr,
contains('SYNC_NAMESPACE "sandbox/Typo" is invalid'),
);
},
);
test('a good compile-time define reaches the request path', () async {
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/slot3',
'run',
'tool/namespace_probe.dart',
]);
expect(result.stderr, isEmpty);
expect(result.stdout, '/sandbox/slot3/devices.json\n');
});
});
}

View File

@ -0,0 +1,31 @@
// Helper for test/sync_namespace_test.dart: the SYNC_NAMESPACE define is
// compile-time, so the test runs this in a fresh process with -D set. It signs
// in from an in-memory session (no network) and prints the first request path.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
Future<void> main() async {
final mock = http_testing.MockClient((request) async {
print(request.url.path);
return http.Response('null', 200);
});
final client = await firebaseClientFor(
config: const FirebaseConfig(
apiKey: 'k',
databaseUrl: 'https://example.firebasedatabase.app',
projectId: 'p',
uid: 'u',
email: 'e@x.y',
),
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'i',
refreshToken: 'r',
expiresAt: DateTime.utc(2099),
),
),
httpClient: mock,
);
await client.listDirectory('devices');
}

View File

@ -61,11 +61,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: crdt_sync_dart path: crdt_sync_dart
ref: "crdt_sync_dart-v0.11.3" ref: "crdt_sync_dart-v0.12.0"
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
url: "https://github.com/kuhyx/utils" url: "https://github.com/kuhyx/utils"
source: git source: git
version: "0.11.3" version: "0.12.0"
crypto: crypto:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -4,7 +4,7 @@ description: >-
persisted per-install device id, and a one-call bootstrap. Removes the persisted per-install device id, and a one-call bootstrap. Removes the
~150 lines of firebase_backend.dart every consuming app was copying. ~150 lines of firebase_backend.dart every consuming app was copying.
publish_to: 'none' publish_to: 'none'
version: 0.4.0 version: 0.5.1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@ -13,7 +13,7 @@ dependencies:
crdt_sync: crdt_sync:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.11.3 ref: crdt_sync_dart-v0.12.1
path: crdt_sync_dart path: crdt_sync_dart
crypto: 3.0.8 crypto: 3.0.8
flutter: flutter:

View File

@ -11,7 +11,7 @@ dependencies:
data_transfer_flutter: data_transfer_flutter:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.0 ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter path: data_transfer_flutter
``` ```

View File

@ -4,13 +4,13 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope. dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none' publish_to: 'none'
version: 0.2.0 version: 0.2.2
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
dependencies: dependencies:
file_picker: 13.3.1 file_picker: 13.3.3
file_selector: 1.1.0 file_selector: 1.1.0
flutter: flutter:
sdk: flutter sdk: flutter

View File

@ -91,6 +91,8 @@ NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json"
# --- Cache ------------------------------------------------------------------ # --- Cache ------------------------------------------------------------------
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE" CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
# Seconds; overrides every ecosystem TTL (a repo that wants a once-a-day gate).
TTL_ENV: Final = "DEP_FRESHNESS_TTL_SECONDS"
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness" DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
CACHE_FILE: Final = "registry.json" CACHE_FILE: Final = "registry.json"
TTL_SECONDS: Final = 6 * 3600 TTL_SECONDS: Final = 6 * 3600

View File

@ -21,6 +21,7 @@ from dep_freshness._tables import (
DEFAULT_CACHE_DIR, DEFAULT_CACHE_DIR,
GITCOMMIT, GITCOMMIT,
GITTAG, GITTAG,
TTL_ENV,
TTL_GITTAG_SECONDS, TTL_GITTAG_SECONDS,
TTL_SECONDS, TTL_SECONDS,
) )
@ -33,7 +34,14 @@ def cache_dir() -> Path:
def ttl_for(ecosystem: str) -> int: def ttl_for(ecosystem: str) -> int:
"""Git refs move rarely and cost a subprocess, so they cache longer.""" """Git refs move rarely and cost a subprocess, so they cache longer.
`DEP_FRESHNESS_TTL_SECONDS` overrides both; garbage is ignored, because a
typo in an env var must not break a commit hook.
"""
override = os.environ.get(TTL_ENV, "")
if override.isdigit() and int(override) > 0:
return int(override)
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS

View File

@ -12,6 +12,7 @@ from pathlib import Path
import pytest import pytest
from dep_freshness._tables import TTL_SECONDS
from dep_freshness.cache import Cache, Entry, ttl_for from dep_freshness.cache import Cache, Entry, ttl_for
from dep_freshness.models import Dep from dep_freshness.models import Dep
from dep_freshness.registries import http from dep_freshness.registries import http
@ -139,3 +140,16 @@ def _offline(*_args):
def _explode(*_args): def _explode(*_args):
raise RuntimeError("boom") raise RuntimeError("boom")
def test_ttl_env_overrides_the_registry_ttl(monkeypatch) -> None:
"""A repo can stretch the refresh window (once-a-day pre-push gates)."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "86400")
assert ttl_for("npm") == 86400
assert ttl_for("gittag") == 86400
def test_ttl_env_garbage_falls_back_to_the_default(monkeypatch) -> None:
"""A malformed value must never break a commit hook."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "soon")
assert ttl_for("npm") == TTL_SECONDS

View File

@ -8,9 +8,10 @@ import 'package:flutter/material.dart';
/// ///
/// Rows per page come from the height the parent gives it; the overflow moves /// Rows per page come from the height the parent gives it; the overflow moves
/// to further pages in a horizontal [PageView], marked by dots once there is /// to further pages in a horizontal [PageView], marked by dots once there is
/// more than one. [itemBuilder] gets the global item index and must fill the /// more than one (a "n / N" counter past 12 pages). [itemBuilder] gets the
/// cell it is given: a square of up to column width plus [labelHeight], the /// global item index and must fill the cell it is given: a square of up to
/// rows stretching to use up the leftover height. /// column width plus [labelHeight], the rows stretching to use up the
/// leftover height.
class FitGrid extends StatefulWidget { class FitGrid extends StatefulWidget {
/// Creates the grid. /// Creates the grid.
const FitGrid({ const FitGrid({
@ -54,6 +55,10 @@ class FitGrid extends StatefulWidget {
const double _dotSize = AppSpacing.sm; const double _dotSize = AppSpacing.sm;
const double _dotsHeight = AppSpacing.md + AppSpacing.xs; const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
// Above this many pages a row of dots outgrows a phone width, so the
// indicator becomes a "n / N" counter instead.
const int _maxDots = 12;
class _FitGridState extends State<FitGrid> { class _FitGridState extends State<FitGrid> {
final _controller = PageController(); final _controller = PageController();
var _page = 0; var _page = 0;
@ -159,22 +164,35 @@ class _Dots extends StatelessWidget {
return ExcludeSemantics( return ExcludeSemantics(
child: SizedBox( child: SizedBox(
height: _dotsHeight, height: _dotsHeight,
child: Row( child: count > _maxDots
mainAxisAlignment: MainAxisAlignment.center, ? Center(
children: [ child: Text(
for (var i = 0; i < count; i++) '${current + 1} / $count',
Container( key: const ValueKey<String>('fit-grid-page-counter'),
key: ValueKey<String>('fit-grid-dot-$i'), style: Theme.of(context).textTheme.labelSmall,
width: _dotSize, textScaler: TextScaler.noScaling,
height: _dotSize,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current ? scheme.primary : scheme.outlineVariant,
), ),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current
? scheme.primary
: scheme.outlineVariant,
),
),
],
), ),
],
),
), ),
); );
} }

View File

@ -5,7 +5,7 @@ description: >-
helpers that were each reimplemented in three or more repos. Owns the tokens helpers that were each reimplemented in three or more repos. Owns the tokens
-- consumers import them rather than transcribing the token table by hand. -- consumers import them rather than transcribing the token table by hand.
publish_to: 'none' publish_to: 'none'
version: 0.3.0 version: 0.3.1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -126,6 +126,30 @@ void main() {
expect(find.text('item $perPage').hitTestable(), findsNothing); expect(find.text('item $perPage').hitTestable(), findsNothing);
}); });
testWidgets('more than 12 pages: "n / N" counter instead of dots', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
final count = perPage * 13;
await _pump(tester, count: count);
expect(_dot, findsNothing);
final counter = find.byKey(const ValueKey('fit-grid-page-counter'));
expect(tester.widget<Text>(counter).data, '1 / 13');
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
expect(tester.widget<Text>(counter).data, '2 / 13');
expect(tester.takeException(), isNull);
});
testWidgets('exactly 12 pages still shows dots', (tester) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
await _pump(tester, count: perPage * 12);
expect(_dot, findsNWidgets(12));
expect(tester.takeException(), isNull);
});
testWidgets('swiping left shows the next page and moves the dot', ( testWidgets('swiping left shows the next page and moves the dot', (
tester, tester,
) async { ) async {

View File

@ -1,165 +0,0 @@
# Dopamine UX — session prompts
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
Paste **one file's contents** into a fresh Claude session. Each is
self-contained: it restates the findings it depends on, so no session needs the
context of another.
## The two tests — every change must pass both
The article's four mechanics (variable rewards, micro-animations, sound design,
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
two questions, not by an allow/deny list:
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
you are actually at 93%. A streak is legitimate iff it counts real days.
Dopamine may come from framing, timing and salience — **never from a
fabricated number**.
2. **Is the variance real?** A random pick from a real pool (next workout, next
task) is legitimate. Injected random bonus points to manufacture uncertainty
are not.
### The load-bearing corollary
**Reward the recording, never the number recorded.** In diet-guard the
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
otherwise the app rewards eating less rather than recording honestly, and
corrupts its own data. Same shape in workout_app (reward logging a workout, not
beating a PB) and todo (reward capture, not completion count).
## Running order
`01` gates everything. `07` gates `08`. Everything else is independent.
| # | File | Repo | Findings |
|---|---|---|---|
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
| 03 | todo note `85ca329b-9809-412c-a47f-eddf055322cf` | `~/src/diet-guard` | 4 |
| 04 | todo note `73a20837-6480-4b31-b966-1898a4e7272e` | `~/src/screen-locker` | 3 |
| 05 | todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
| 06 | `06-todo.md` | `~/src/todo` | 3 |
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
| 08 | todo note `e301e090-b2ee-4e26-8387-00f356b63521` | `~/src/dufs-cloud` | 4 |
| 09 | todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | `~/src/wake-alarm` | 2 |
**27 ranked findings total.**
### Dependency graph
```
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
├─→ 06 (bump design_system-v0.2.0)
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
02-structural-check (independent; run after 01 so it has motion tokens to check)
```
## The top three findings fleet-wide
Ranked by value-over-effort from the survey data:
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
populated and no renderer reads it. Zero new state, zero conftest change,
zero new dependency.
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
the `assets:` block and the VIBRATE permission are **already present**. Only
the call is missing.
## Distributed copies, and the completion protocol
Each prompt is **also copied into the repo it acts on**, at
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
of truth** — if a prompt needs correcting, fix it here and re-distribute.
| Prompt | Distributed to |
|---|---|
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
When a session finishes a prompt it **deletes its repo copy** (in the same
commit as the implementation) and **appends a row below**. A finished prompt left
in a repo is indistinguishable from a pending one, and the next session re-runs
it. Partial completion is never recorded as DONE — the file is edited to state
what remains instead.
### Completion log
| Prompt | Status | Impl commit | Note |
|---|---|---|---|
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
| 02-structural-check.md | not started | — | — |
| todo note `85ca329b-9809-412c-a47f-eddf055322cf` | not started | — | — |
| todo note `73a20837-6480-4b31-b966-1898a4e7272e` | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
| todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
| 07-dufs-theme.md | not started | — | — |
| todo note `e301e090-b2ee-4e26-8387-00f356b63521` | not started | — | — |
| todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | not started | — | — |
## Keeping these prompts honest
Every path and symbol cited across these files is machine-checked:
```
python3 ~/src/utils/dopamine-ux/verify_prompts.py
```
Exit 0 means every cited path resolves and every cited symbol is still greppable
in the file it is attributed to. **Re-run it after any refactor of the surveyed
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
path check alone cannot catch that (which is why it greps symbols too).
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
## Rules that apply to every prompt
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
bumping to those literal refs.
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
grep for it rather than trusting the line.
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
`@media (prefers-reduced-motion: reduce)`. Flutter:
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
honoured automatically; the sound opt-out is a separate user-facing toggle.
4. **Motion and haptics first, sound second.** Haptics need no dependency;
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
prompt stages them separately so a short session still lands the valuable
half, and sound stays independently revertable.
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
## Source
Article findings, verbatim from the piece:
- **Variable reward schedules** — unpredictable outcomes produce stronger
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
pull-to-refresh.
- **Micro-animations** — "a button that bounces on press, a like counter that
ticks upward with a slight delay". Replaces latency with feedback, so waiting
reads as responsiveness rather than frustration.
- **Sound design** — "operates below the threshold of conscious attention";
the article's judgement is that it is more powerful than visual design and
gets less critical attention.
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
discomfort that drives re-engagement, and works *even when the user is
explicitly told the mechanism*.
- Core claim: **dopamine is released in anticipation of a reward, not on
receipt. The uncertainty is the trigger.**

View File

@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""Verify every path and symbol cited in the dopamine-ux prompt files.
A copy-pasteable prompt that points at a file which does not exist is worse than
no prompt, so this gate adjudicates with an exit code rather than by eye.
Two checks:
1. Every cited on-disk path resolves.
2. Every cited symbol is greppable in the file it is attributed to. A path
check alone cannot catch a stale line number; a symbol grep can.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
HOME = Path.home()
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
# Not on-disk paths despite matching the shape above.
SKIP_SUBSTRINGS = (
"://",
"$",
"*",
"<",
">",
"…",
"devices/", # Firebase/crdt key path
"settings/advanced", # Firebase key path
"android.permission",
"android.intent",
"/sdcard/",
"/etc/",
"/dev/input/",
"/opt/",
"com.kuhy",
"app-release.apk",
"build/app/outputs", # build outputs
"flutter/services.dart",
"flutter/material.dart", # package imports
)
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
# -- but pairing each with its creating prompt still catches a typo.
CREATED_BY_PROMPT = {
"~/src/utils/unified-design-system/motion.md": "01",
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
}
# Cited precisely to state that they do NOT exist.
ASSERTED_ABSENT = {
"~/gatelock",
# Distributed prompt copies are `git rm`d on completion (see the protocol in
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
# table. Asserting ABSENT rather than dropping the row keeps the check
# two-directional: a finished prompt reappearing in a repo is itself a
# failure, because the next session would re-run it. Move a row here as each
# prompt completes.
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
}
# Generic prose fragments rather than citations of one specific file.
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
# Symbols each prompt claims exist, as (repo-relative file, symbol).
SYMBOL_CLAIMS = [
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
"workout_screen_session.dart",
"_playBreakEndCue",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
"workout_screen_finish.dart",
"_persistFinishedWorkout",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
"workout_summary_dialog.dart",
"WorkoutSummaryDialog",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
"storage_service.dart",
"_getSetting",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
"exercise_tile_rows.dart",
"AnimatedContainer",
),
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
]
def expand(raw: str) -> Path:
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
def candidate_paths(text: str) -> set[str]:
return {
raw
for raw in PATH_RE.findall(text)
if not any(s in raw for s in SKIP_SUBSTRINGS)
and raw not in PROSE_FRAGMENTS
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
}
def prompt_roots(text: str) -> list[Path]:
roots = []
for label in ("Repo", "App", "Flutter app"):
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
roots.append(expand(m.group(1)))
return roots
def check_paths() -> list[str]:
failures = []
checked = 0
for prompt in sorted(PROMPT_DIR.glob("*.md")):
text = prompt.read_text()
roots = prompt_roots(text)
for raw in sorted(candidate_paths(text)):
if raw in ASSERTED_ABSENT:
if expand(raw).exists():
failures.append(
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
)
continue
if raw in CREATED_BY_PROMPT:
owner = CREATED_BY_PROMPT[raw]
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
if not prompt.name.startswith(owner) and not cites_owner:
failures.append(
f"{prompt.name}: cites `{raw}` without noting prompt "
f"{owner} creates it"
)
continue
checked += 1
if raw.startswith(("~/", "/")):
if not expand(raw).exists():
failures.append(f"{prompt.name}: `{raw}` MISSING")
elif not any((r / raw).exists() for r in roots):
failures.append(
f"{prompt.name}: `{raw}` not found under any of "
f"{[str(r) for r in roots]}"
)
print(f"paths: checked {checked}")
return failures
def check_symbols() -> list[str]:
failures = []
for raw_file, symbol in SYMBOL_CLAIMS:
path = expand(raw_file)
if not path.exists():
failures.append(f"symbol source MISSING: {raw_file}")
continue
if symbol not in path.read_text(errors="replace"):
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
return failures
def main() -> int:
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
failures = check_paths() + check_symbols()
if failures:
print(f"\nFAIL -- {len(failures)} problem(s):")
for f in failures:
print(f" {f}")
return 1
print("\nPASS -- every cited path exists and every cited symbol is present")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -189,16 +189,17 @@ How far to trust a gate is computed from its ledger, never listed by name:
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row `workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
on its `day` only if `detail.declared_at` predates that day (else logged, no). on its `day` only if `detail.declared_at` predates that day (else logged, no).
## The focus project (0.19.0, cut 180 since 0.20.0) ## The focus project (0.19.0, cut 180 since 0.20.0, pays 240 since 0.21.0)
While `focus_project.active(day)` returns a project, `earners_for` drops the While `focus_project.active(day)` returns a project, `earners_for` drops the
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
(`max_units` = the day's `daily_on`) paying what they paid (shutdown by their rungs, (`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min): their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = 8 h):
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs `floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`: from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int), signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged). `detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
## Adding a gate ## Adding a gate

View File

@ -31,9 +31,13 @@ _warned: set[str] = set()
class FocusEarner(Earner): class FocusEarner(Earner):
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day. """Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
The gaming base cut is all of it, LeetCode's free hour too (kuhy 2026-11-02). ``cut_gaming_minutes``: the gaming base cut, all the replaced gates paid,
LeetCode's free hour too (0.20.0). The full-day gaming pay adds that free
hour once more, so a full day matches the old one (kuhy 2026-11-02, 0.21.0).
""" """
cut_gaming_minutes: int = 0
def gaming_for(self, units: int) -> int: def gaming_for(self, units: int) -> int:
"""Gaming minutes of ``units`` focused minutes, floored.""" """Gaming minutes of ``units`` focused minutes, floored."""
return self.capped(units) * self.gaming_minutes // (self.max_units or 1) return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
@ -66,11 +70,18 @@ def daily_on(project: Any, day: date) -> int: # noqa: ANN401
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401 def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``.""" """The ``focus`` earner paying what ``dropped`` paid, over ``project``.
Gaming: the cut is every dropped gate's full pay; a full day pays that plus
the pure bonuses (no ``penalty_from``) again: 180 + 60 = 240 at 120 min.
"""
cut = sum(e.max_gaming_minutes for e in dropped)
free = tuple(e for e in dropped if e.penalty_from is None)
return FocusEarner( return FocusEarner(
name="focus", name="focus",
label="Focus", label="Focus",
gaming_minutes=sum(e.max_gaming_minutes for e in dropped), gaming_minutes=cut + sum(e.max_gaming_minutes for e in free),
cut_gaming_minutes=cut,
shutdown_minutes=sum( shutdown_minutes=sum(
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
), ),

View File

@ -25,6 +25,7 @@ from datetime import UTC, date, datetime
import logging import logging
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
from earned_time._focus import FocusEarner
from earned_time._ladder import on_ladder, shutdown_ceiling_for from earned_time._ladder import on_ladder, shutdown_ceiling_for
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
from earned_time._maturity import confirmed_start, penalty_start from earned_time._maturity import confirmed_start, penalty_start
@ -45,6 +46,16 @@ def _today() -> date:
return datetime.now(tz=UTC).astimezone().date() return datetime.now(tz=UTC).astimezone().date()
def _gaming_cut(item: Earner) -> int:
"""What a penalty in force takes off the gaming base: all ``item`` can pay.
A focus earner cuts what the gates it replaced paid; it pays more (0.21.0).
"""
if isinstance(item, FocusEarner):
return item.cut_gaming_minutes
return item.max_gaming_minutes
def _penalised( def _penalised(
item: Earner, day: date, first_credits: Mapping[str, date | None] | None item: Earner, day: date, first_credits: Mapping[str, date | None] | None
) -> bool: ) -> bool:
@ -117,7 +128,7 @@ def _base(
penalised = [e for e in registry if _penalised(e, target, first_credits)] penalised = [e for e in registry if _penalised(e, target, first_credits)]
return Base( return Base(
gaming_minutes=base_minutes(leisure=on) gaming_minutes=base_minutes(leisure=on)
- sum(e.max_gaming_minutes for e in penalised), - sum(_gaming_cut(e) for e in penalised),
shutdown_minutes=_shutdown_floor(target, registry, penalised), shutdown_minutes=_shutdown_floor(target, registry, penalised),
) )

View File

@ -22,7 +22,7 @@ from earned_time import (
) )
from earned_time._focus import focus_earner from earned_time._focus import focus_earner
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
from earned_time.tests._maturity_rows import ledger_file from earned_time.tests._maturity_rows import ledger_file, signed
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -61,20 +61,24 @@ def test_a_project_that_changes_nothing_leaves_the_registry(
assert earners_for(DAY) is TUTOR_EARNERS assert earners_for(DAY) is TUTOR_EARNERS
def test_a_full_day_pays_what_the_replaced_gates_paid( def test_a_full_day_pays_the_cut_plus_the_free_leetcode_hour(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""kuhy 2026-11-02 (0.21.0): 2 gaming minutes per focus minute, 240 a day."""
item = focus(monkeypatch) item = focus(monkeypatch)
assert item.max_units == 120 assert item.max_units == 120
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180) assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (240, 180)
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180) assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (240, 180)
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0) assert (item.max_gaming_minutes, item.gaming_for(-5)) == (240, 0)
assert item.cut_gaming_minutes == 180
def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None: def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
item = focus(monkeypatch) item = focus(monkeypatch)
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [1, 3, 4, 10] assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [2, 4, 6, 14]
assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10] assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
odd = focus(monkeypatch, daily_minutes=90)
assert [odd.gaming_for(n) for n in (1, 2, 90)] == [2, 5, 240]
def test_many_small_rows_pay_what_one_row_pays( def test_many_small_rows_pay_what_one_row_pays(
@ -86,7 +90,21 @@ def test_many_small_rows_pay_what_one_row_pays(
one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8")) one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
units = credit_units(item, many, key_file, DAY) units = credit_units(item, many, key_file, DAY)
assert units == credit_units(item, one, key_file, DAY) == 7 assert units == credit_units(item, one, key_file, DAY) == 7
assert item.gaming_for(units or 0) == 10 assert item.gaming_for(units or 0) == 14
def test_carry_rows_pay_like_own_rows(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> None:
"""focus_project 0.6.0 banks overtime as ``<project>-<YYYYMMDD>-carry`` rows."""
item = focus(monkeypatch)
carry = row(30, 0, carry=True)
carry["entry_id"] = f"hr-{DAY:%Y%m%d}-carry"
top_up = row(5, 0, carry=True)
top_up["entry_id"] = f"hr-{DAY:%Y%m%d}-carry-m35"
rows = [signed(carry), signed(top_up), row(85, 85)]
ledger = ledger_file(tmp_path, rows)
assert credit_units(item, ledger, key_file, DAY) == 120
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None: def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
@ -95,26 +113,25 @@ def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch)
assert nothing.base.gaming_minutes == 300 - 180 assert nothing.base.gaming_minutes == 300 - 180
assert nothing.base.shutdown_minutes == 18 * 60 assert nothing.base.shutdown_minutes == 18 * 60
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False) half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False)
assert half.gaming_minutes == 120 + 90 assert half.gaming_minutes == 120 + 120
assert half.shutdown_minutes == 18 * 60 + 90 assert half.shutdown_minutes == 18 * 60 + 90
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False) full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
assert full.shutdown_minutes == 23 * 60 assert full.shutdown_minutes == 23 * 60
assert full.gaming_minutes == 300 - 180 + 240 + 120 == 8 * 60
def test_a_full_project_day_pays_what_the_replaced_gates_paid( def test_a_full_project_day_pays_what_the_replaced_gates_paid(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Shutdown matches the old full day; gaming loses LeetCode's free hour.""" """A full project day equals the old full day, LeetCode's free hour too."""
activate(monkeypatch, project()) activate(monkeypatch, project())
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120} done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
old = resolve(done, DAY, TUTOR_EARNERS, leisure=False) old = resolve(done, DAY, TUTOR_EARNERS, leisure=False)
new = resolve(done, DAY, leisure=False) new = resolve(done, DAY, leisure=False)
assert new.shutdown_minutes == old.shutdown_minutes assert new.shutdown_minutes == old.shutdown_minutes
# kuhy 2026-11-02: LeetCode's bonus hour was free on top of the base; the # kuhy 2026-11-02 (0.21.0): the day cuts 180 but pays 240, so a full day
# project day cuts it and pays it back, so the full day nets 60 less. # is the old full day (8 h); only an idle day is worse.
assert new.gaming_minutes == old.gaming_minutes - 60 assert new.gaming_minutes == old.gaming_minutes == 8 * 60
unsolved = resolve({**done, "leetcode": 0}, DAY, TUTOR_EARNERS, leisure=False)
assert new.gaming_minutes == unsolved.gaming_minutes
def test_an_idle_project_day_also_cuts_the_free_leetcode_hour( def test_an_idle_project_day_also_cuts_the_free_leetcode_hour(
@ -166,4 +183,5 @@ def test_the_path_unit_watches_the_focus_ledger() -> None:
def test_focus_pays_only_replaced_gates_that_exist() -> None: def test_focus_pays_only_replaced_gates_that_exist() -> None:
item = focus_earner(project(), (earned_time.LEETCODE,), DAY) item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60) assert (item.gaming_minutes, item.shutdown_minutes) == (120, 60)
assert item.cut_gaming_minutes == 60

View File

@ -0,0 +1,88 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
itself does not change; this pins that the gate reaches pay.
"""
from __future__ import annotations
from datetime import date, datetime
import importlib
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import pytest
from earned_time import credit_units, earners_for
if TYPE_CHECKING:
from types import ModuleType
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
def at(hour: int, minute: int) -> float:
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
@pytest.fixture
def real_fp(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> ModuleType:
"""The real focus_project on redirected state (HOME, data dir, key)."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
monkeypatch.syspath_prepend(str(_SIBLING))
fp = importlib.import_module("focus_project")
fp.create_project(
"hr",
name="HR",
start=DAY,
end=DAY,
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
return fp
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
log = tmp_path / "data" / "activity.jsonl"
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
harvest = importlib.import_module("focus_project.harvest")
assert harvest.harvest(now=at(23, 0)) == 0
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
def row(source: str, kind: str, ts: float) -> dict[str, object]:
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
TIMER = (
row("timer", "start", at(9, 0)),
row("timer", "checkin", at(9, 5)),
row("timer", "stop", at(9, 10)),
)
def test_timer_only_day_pays_zero(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
assert date(2026, 11, 8) == real_fp.BACKING_FROM
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
def test_backed_timer_day_still_pays(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
beat = row("tutor", "heartbeat", at(9, 5))
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10

View File

@ -70,8 +70,8 @@ def test_a_target_edit_never_reprices_past_days(
activate(monkeypatch, project(daily_on=edited, daily_minutes=60)) activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
before = earners_for(DAY)[-1] before = earners_for(DAY)[-1]
after = earners_for(DAY + timedelta(days=1))[-1] after = earners_for(DAY + timedelta(days=1))[-1]
assert (before.max_units, before.gaming_for(60)) == (120, 90) assert (before.max_units, before.gaming_for(60)) == (120, 120)
assert (after.max_units, after.gaming_for(60)) == (60, 180) assert (after.max_units, after.gaming_for(60)) == (60, 240)
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90) assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "earned-time" name = "earned-time"
version = "0.20.0" version = "0.21.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [] dependencies = []

View File

@ -0,0 +1,143 @@
# Pre-commit Configuration for feedback_inbox
# 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 feedback_inbox/.pre-commit-config.yaml --all-files
files: ^feedback_inbox/
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 feedback_inbox
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.4
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: v1.13.0
hooks:
- id: mypy
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=feedback_inbox/pyproject.toml
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args:
- -c
- feedback_inbox/pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(feedback_inbox/feedback_inbox/tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: scripts/run_subproject_tests.sh feedback_inbox
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

70
feedback_inbox/README.md Normal file
View File

@ -0,0 +1,70 @@
# feedback_inbox
Turns every game's dev-feedback bundle into one todo-app task, so feedback
kuhy saves with F12 is never forgotten.
A game saves `~/data/<repo>_binaries/feedback/<stamp>/` (`report.json`,
screenshots, logs) and appends a ready-to-run Claude prompt to
`feedback-<date>.md` beside it. This package imports each complete bundle once
as a task in the todo app for `kuhyx/<repo>`.
## Task shape
- Title: `MANUAL WORK REQUIRED: <repo> feedback: <first line of his note>` (note capped
at 120 characters). The prefix makes todoImplementer list it as a human part and never
run it. No note: `... feedback: <game> <combo> <HH:MM>`. A `report.json` that does
not parse still gets a task, tagged `[corrupt report.json]`; the note then
comes from the markdown entry.
- Body: a `Due: <next day 10:00>` line, then the bundle's markdown entry (the
prompt with absolute paths).
- The task is its own note (`add_task` with `new_note`). The server stores that as
`<repo> todos: <title>` plus a `## <title>` section, so right after adding, the inbox
rewrites it with `update_task` to the exact title and a section-free body (the entry's
own `##` headings become `###`). todoImplementer splits notes at `## ` headings, and a
title that does not start with `MANUAL WORK REQUIRED: ` would make a runnable part.
The marker records `format: 2` once the rewrite worked; a failed rewrite is retried on
the next sweep (never a second task), and markers without it are repaired the same way.
- Closing the task cancels the feedback. "Act on feedback" in a Claude session
means working every open `<repo> feedback:` task.
The todo app has no due-date or reminder field yet, so the due time is only
text in the body.
## How it runs
| Unit | Does |
|---|---|
| `feedback-inbox.path` | `PathChanged` on every existing `*_binaries/feedback` folder; runs `feedback-inbox run` |
| `feedback-inbox-sync.path` | `PathChanged` on `~/data` and every `*_binaries` folder; runs `sync` then `run` |
| `feedback-inbox-sync.timer` | every 5 min, the same sweep (events during a run are lost by `.path` units) |
`.path` units take no globs: `feedback-inbox sync` writes the folder list into
the drop-ins `feedback-inbox{,-sync}.path.d/dirs.conf` and reloads systemd only
when the set of games changed.
A bundle is complete when `report.json` is at least 2 s old and its markdown
entry exists (matched by the stamp folder in the entry's paths). Incomplete
bundles are retried; `run` waits up to 15 s for them.
The import marker is `.inbox-imported.json` inside the bundle (task id, title,
due). It is written only after the todo server accepted the task, so a server
outage leaves no marker, exits non-zero, and the next sweep retries.
## Install
```bash
scripts/run_subproject_tests.sh feedback_inbox # from the monorepo root
feedback_inbox/install.sh
```
`install.sh` builds a stdlib-only venv, links `feedback-inbox` into
`~/.local/bin`, mints a todo-server token into `~/.config/feedback-inbox/token`
(0600; revoke by deleting its line in `~/.config/todo-mcp/tokens`) and enables
the units. Enabling sweeps once, so existing bundles are imported then.
## Tests
`pytest` from this directory (100 % branch coverage enforced). Tests use a temp
`HOME`, a fake todo client, and a local HTTP server for the MCP client; the
autouse `conftest.py` redirects the one path resolver and points the todo URL
at a dead port.

View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Turns every game dev-feedback bundle into one todo-app task."""

View File

@ -0,0 +1,15 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``python -m feedback_inbox``."""
from __future__ import annotations
from feedback_inbox._cli import main
def run() -> int:
"""Run the CLI on ``sys.argv``."""
return main()
if __name__ == "__main__":
raise SystemExit(run())

View File

@ -0,0 +1,57 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Finding games' feedback folders and bundles, and the stamp arithmetic."""
from __future__ import annotations
from datetime import datetime, timedelta
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
BINARIES_SUFFIX = "_binaries"
STAMP_FORMAT = "%Y-%m-%dT%H-%M-%S"
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}$")
_REMINDER_HOUR = 10
def binaries_dirs(data_root: Path) -> list[Path]:
"""Every ``<game>_binaries`` directory directly under ``data_root``."""
if not data_root.is_dir():
return []
return sorted(
p
for p in data_root.iterdir()
if p.name.endswith(BINARIES_SUFFIX) and p.is_dir()
)
def feedback_dirs(data_root: Path) -> list[Path]:
"""Every existing ``<game>_binaries/feedback`` directory."""
return [
d / "feedback" for d in binaries_dirs(data_root) if (d / "feedback").is_dir()
]
def repo_name(feedback_dir: Path) -> str:
"""The game's repo name: ``cleanup-plate_binaries`` -> ``cleanup-plate``."""
return feedback_dir.parent.name.removesuffix(BINARIES_SUFFIX)
def bundles_in(feedback_dir: Path) -> list[Path]:
"""Bundle folders (named by a millisecond stamp), oldest first."""
return sorted(
p for p in feedback_dir.iterdir() if p.is_dir() and _STAMP.match(p.name)
)
def parse_stamp(stamp: str) -> datetime:
"""The local time a bundle was saved, from its folder name."""
return datetime.strptime(stamp[:19], STAMP_FORMAT)
def due_at(stamp: str) -> datetime:
"""The reminder time: the day after the bundle, at 10:00 local."""
day = parse_stamp(stamp) + timedelta(days=1)
return day.replace(hour=_REMINDER_HOUR, minute=0, second=0, microsecond=0)

View File

@ -0,0 +1,80 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``feedback-inbox run | sync``: what the systemd units execute."""
from __future__ import annotations
import argparse
import fcntl
import logging
import time
from typing import TYPE_CHECKING
from feedback_inbox._inbox import Importer, Outcome, Waiter, run_settled
from feedback_inbox._paths import resolve_paths
from feedback_inbox._todo import McpTodoClient
from feedback_inbox._units import run_systemctl, sync_units
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from feedback_inbox._todo import TodoClient
LOG = logging.getLogger("feedback_inbox")
OWNER = "kuhyx"
DEFAULT_WAIT = 15.0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="feedback-inbox")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="import new bundles as todo tasks")
run.add_argument("--wait", type=float, default=DEFAULT_WAIT)
sub.add_parser("sync", help="re-arm the path units for the current games")
return parser
def _run(wait: float, client: TodoClient | None) -> int:
paths = resolve_paths()
todo = client or McpTodoClient(paths.mcp_url, paths.token_file)
paths.lock_file.parent.mkdir(parents=True, exist_ok=True)
# Blocking lock: a second run waits, then finds the markers and skips.
with paths.lock_file.open("a", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
results = run_settled(
paths.data_root,
Importer(todo, OWNER),
Waiter(wait, time.time, time.sleep),
)
failed = 0
for result in results:
if result.outcome is Outcome.FAILED:
failed += 1
LOG.error("failed %s: %s", result.bundle, result.detail)
elif result.outcome is not Outcome.SKIPPED:
LOG.info("%s %s %s", result.outcome, result.bundle, result.task_id or "")
return 1 if failed else 0
def _sync(systemctl: Callable[[Sequence[str]], int]) -> int:
paths = resolve_paths()
try:
changed = sync_units(paths.data_root, paths.unit_dir, systemctl)
except RuntimeError:
LOG.exception("sync failed")
return 1
LOG.info("watch list %s", "rewritten" if changed else "unchanged")
return 0
def main(
argv: Sequence[str] | None = None,
*,
client: TodoClient | None = None,
systemctl: Callable[[Sequence[str]], int] = run_systemctl,
) -> int:
"""Entry point; ``client`` and ``systemctl`` are test seams."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
args = _parser().parse_args(argv)
if args.command == "run":
return _run(args.wait, client)
return _sync(systemctl)

View File

@ -0,0 +1,74 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A bundle's markdown entry and its report.json."""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
_SECTION = re.compile(r"(?m)^(?=## )")
_GAME = re.compile(r"Game `([^`]*)`, combo `([^`]*)`")
_BODY_END = re.compile(r"(?m)^- Screenshot")
@dataclass(frozen=True)
class Report:
"""The fields of report.json the inbox needs."""
note: str
game: str
combo: str
def find_entry(feedback_dir: Path, bundle: Path) -> str | None:
"""The ``feedback-<date>.md`` section whose paths point into ``bundle``.
Matched by the stamp folder in its paths, not by the heading time (which
drops the milliseconds), so a bundle never picks up a neighbour's entry.
"""
needle = f"/{bundle.name}/"
for md in sorted(feedback_dir.glob("feedback-*.md")):
try:
text = md.read_text(encoding="utf-8")
except OSError, UnicodeDecodeError:
continue
for section in _SECTION.split(text):
if needle in section:
return section.strip()
return None
def read_report(path: Path) -> Report | None:
"""Parse report.json; ``None`` when it cannot be read as a JSON object."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except OSError, UnicodeDecodeError, json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
return Report(
note=_text(data.get("note")),
game=_text(data.get("game")),
combo=_text(data.get("combo")),
)
def _text(value: object) -> str:
return value if isinstance(value, str) else ""
def note_from_entry(entry: str) -> str:
"""His note, as written between the heading and the file list."""
_head, _, rest = entry.partition("\n")
return _BODY_END.split(rest, maxsplit=1)[0].strip()
def game_and_combo(entry: str) -> tuple[str, str]:
"""Game and combo from the entry's ``Game ..., combo ...`` line."""
found = _GAME.search(entry)
return (found.group(1), found.group(2)) if found else ("", "")

View File

@ -0,0 +1,235 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Turn complete feedback bundles into todo tasks, once each."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import json
import logging
import re
from typing import TYPE_CHECKING
from feedback_inbox._bundle import (
bundles_in,
due_at,
feedback_dirs,
parse_stamp,
repo_name,
)
from feedback_inbox._entry import (
Report,
find_entry,
game_and_combo,
note_from_entry,
read_report,
)
from feedback_inbox._todo import TodoError
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from feedback_inbox._todo import TodoClient
LOG = logging.getLogger("feedback_inbox")
MARKER = ".inbox-imported.json"
# Marker format 2: the stored note was rewritten to our exact title and body.
FORMAT = 2
SETTLE_SECONDS = 2.0
TITLE_CAP = 120
MANUAL_PREFIX = "MANUAL WORK REQUIRED: "
CORRUPT_TAG = " [corrupt report.json]"
CORRUPT_LINE = "Warning: report.json is corrupt; the note below comes from the entry."
class Outcome(StrEnum):
"""What happened to one bundle in a run."""
IMPORTED = "imported"
SKIPPED = "skipped"
PENDING = "pending"
FAILED = "failed"
@dataclass(frozen=True)
class Result:
"""One bundle's outcome, for the journal and the exit code."""
bundle: Path
outcome: Outcome
task_id: str | None = None
detail: str = ""
@dataclass(frozen=True)
class TaskDraft:
"""The task to create: title, body and due time."""
title: str
body: str
due: str
def _demote(entry: str) -> str:
"""Turn ``## `` headings into ``### ``: no section may start a todo part.
The todo app splits a note into runnable parts at every ``## `` heading,
so the entry's own headings must not look like sections.
"""
return re.sub(r"(?m)^##(?=\s)", "###", entry)
def _first_line(note: str, fallback: str) -> str:
first = next((x.strip() for x in note.splitlines() if x.strip()), "")
if len(first) > TITLE_CAP:
first = first[: TITLE_CAP - 1] + "…"
return first or fallback
def build_task(repo: str, stamp: str, entry: str, report: Report | None) -> TaskDraft:
"""Compose the title and body from the entry and (maybe corrupt) report."""
found_game, found_combo = game_and_combo(entry)
note = note_from_entry(entry) if report is None else report.note
game = (report.game if report else "") or found_game
combo = (report.combo if report else "") or found_combo
clock = parse_stamp(stamp).strftime("%H:%M")
fallback = " ".join(x for x in (game, combo, clock) if x)
suffix = CORRUPT_TAG if report is None else ""
title = f"{MANUAL_PREFIX}{repo} feedback: {_first_line(note, fallback)}{suffix}"
due = due_at(stamp)
lines = [f"Due: {due:%Y-%m-%d %H:%M}", ""]
if report is None:
lines += [CORRUPT_LINE, ""]
return TaskDraft(title, "\n".join([*lines, _demote(entry)]), due.isoformat())
def _write_marker(bundle: Path, payload: dict[str, object]) -> None:
tmp = bundle / f"{MARKER}.tmp"
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
tmp.replace(bundle / MARKER)
def _read_marker(bundle: Path) -> dict[str, object] | None:
"""The bundle's marker, or None when it is not a usable marker."""
try:
data = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
except OSError, UnicodeDecodeError, json.JSONDecodeError:
return None
ok = isinstance(data, dict) and isinstance(data.get("task_id"), str)
return data if ok and isinstance(data.get("title"), str) else None
@dataclass(frozen=True)
class Importer:
"""Who files the tasks (``client``) under which GitHub owner."""
client: TodoClient
owner: str
settle: float = SETTLE_SECONDS
def import_bundle(self, feedback_dir: Path, bundle: Path, now: float) -> Result:
"""Import one bundle if complete; repair the title of one already filed."""
if (bundle / MARKER).exists():
return self._repair(feedback_dir, bundle)
report_path = bundle / "report.json"
entry = find_entry(feedback_dir, bundle)
if entry is None or not report_path.is_file():
return Result(bundle, Outcome.PENDING, detail="report or entry missing")
if now - report_path.stat().st_mtime < self.settle:
return Result(bundle, Outcome.PENDING, detail="report.json settling")
repo = repo_name(feedback_dir)
draft = build_task(repo, bundle.name, entry, read_report(report_path))
return self._file(bundle, f"{self.owner}/{repo}", draft)
def _file(self, bundle: Path, repo: str, draft: TaskDraft) -> Result:
"""Create the task, write the marker, then fix the title."""
try:
ref = self.client.add_task(repo, draft.title, draft.body)
except TodoError as exc:
return Result(bundle, Outcome.FAILED, detail=str(exc))
# Marker first: once the task exists a retry must never add a second.
# A merged task belongs to another note, which is not ours to rewrite.
payload: dict[str, object] = {
"task_id": ref.task_id,
"title": draft.title,
"due": draft.due,
"merged": ref.merged,
}
if ref.merged:
payload["format"] = FORMAT
try:
_write_marker(bundle, payload)
except OSError as exc:
return Result(
bundle,
Outcome.FAILED,
ref.task_id,
f"task {ref.task_id} filed but marker not written: {exc}",
)
if ref.merged:
return Result(
bundle, Outcome.IMPORTED, ref.task_id, "merged into an existing note"
)
return self._finish(bundle, payload, draft)
def _repair(self, feedback_dir: Path, bundle: Path) -> Result:
"""Finish a filed task whose rewrite never completed (or predates it)."""
data = _read_marker(bundle)
if data is None or data.get("merged") or data.get("format") == FORMAT:
return Result(bundle, Outcome.SKIPPED)
entry = find_entry(feedback_dir, bundle)
if entry is None:
return Result(bundle, Outcome.PENDING, detail="entry missing for repair")
report = read_report(bundle / "report.json")
draft = build_task(repo_name(feedback_dir), bundle.name, entry, report)
return self._finish(bundle, data, draft)
def _finish(
self, bundle: Path, data: dict[str, object], draft: TaskDraft
) -> Result:
"""Rewrite the stored note to the exact title/body, then mark it done."""
task_id = str(data["task_id"])
try:
self.client.update_task(task_id, draft.title, draft.body)
except TodoError as exc:
return Result(bundle, Outcome.FAILED, task_id, f"update: {exc}")
marked = {**data, "title": draft.title, "due": draft.due, "format": FORMAT}
try:
_write_marker(bundle, marked)
except OSError as exc:
return Result(bundle, Outcome.FAILED, task_id, f"marker not written: {exc}")
return Result(bundle, Outcome.IMPORTED, task_id)
@dataclass(frozen=True)
class Waiter:
"""How long to wait for half-written bundles, and the clock to do it by."""
wait: float
clock: Callable[[], float]
sleep: Callable[[float], None]
def run_once(data_root: Path, importer: Importer, now: float) -> list[Result]:
"""Import every complete, not yet imported bundle of every game."""
return [
importer.import_bundle(folder, bundle, now)
for folder in feedback_dirs(data_root)
for bundle in bundles_in(folder)
]
def run_settled(data_root: Path, importer: Importer, waiter: Waiter) -> list[Result]:
"""Like :func:`run_once`, retrying up to ``wait`` s while bundles are pending.
A bundle is written in pieces; the watch fires on the first piece, so a
short wait lets the rest land instead of leaving it to the timer.
"""
deadline = waiter.clock() + waiter.wait
while True:
results = run_once(data_root, importer, waiter.clock())
pending = any(r.outcome is Outcome.PENDING for r in results)
if not pending or waiter.clock() >= deadline:
return results
waiter.sleep(1.0)

View File

@ -0,0 +1,51 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Every on-disk and network location, resolved in one place.
Tests redirect :func:`resolve_paths` once; nothing else names a real path.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
MCP_URL = "http://127.0.0.1:8735/mcp"
# Port 9 (discard) refuses connections: a test that forgets to inject a client
# fails closed instead of reaching the live todo server.
DEAD_URL = "http://127.0.0.1:9/mcp"
@dataclass(frozen=True)
class Paths:
"""Where the inbox reads games, its token, its lock and its units."""
data_root: Path
token_file: Path
lock_file: Path
unit_dir: Path
mcp_url: str
@classmethod
def under(cls, root: Path) -> Paths:
"""A throwaway layout below ``root`` that can never reach the live app."""
return cls(
data_root=root / "data",
token_file=root / "token",
lock_file=root / "state" / "lock",
unit_dir=root / "units",
mcp_url=DEAD_URL,
)
def resolve_paths(given: Paths | None = None) -> Paths:
"""Return ``given`` or the real layout under the user's home."""
if given is not None:
return given
home = Path.home()
return Paths(
data_root=home / "data",
token_file=home / ".config" / "feedback-inbox" / "token",
lock_file=home / ".local" / "share" / "feedback-inbox" / "lock",
unit_dir=home / ".config" / "systemd" / "user",
mcp_url=MCP_URL,
)

View File

@ -0,0 +1,165 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The todo app's MCP server as a minimal ``add_task`` client."""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import TYPE_CHECKING, Protocol
import urllib.error
import urllib.request
if TYPE_CHECKING:
from pathlib import Path
_PROTOCOL = "2025-03-26"
_TIMEOUT = 20
_REPLY = re.compile(r"^(added|merged into|appended to) (\S+)")
class TodoError(Exception):
"""The todo server could not take the task."""
@dataclass(frozen=True)
class TaskRef:
"""The task a call created (or the note it was merged into)."""
task_id: str
merged: bool
class TodoClient(Protocol):
"""What the inbox needs from a todo app."""
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
"""Create one task in its own note and return its id."""
raise NotImplementedError
def update_task(self, task_id: str, title: str, body: str) -> None:
"""Replace the task's stored title and body with exactly these."""
raise NotImplementedError
class McpTodoClient:
"""Streamable-HTTP MCP client: initialize, then one ``add_task`` call."""
def __init__(self, url: str, token_file: Path) -> None:
"""Remember where the server and the (lazily read) token are."""
self._url = url
self._token_file = token_file
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
"""Create a task with its own note via ``add_task``.
The server stores a ``new_note`` title as ``<name> todos: <title>``;
:meth:`update_task` restores the exact title (and drops the
``## <title>`` section the server adds) afterwards.
"""
arguments = {"repo": repo, "title": title, "body": body, "new_note": True}
said = self._call("add_task", arguments)
found = _REPLY.match(said)
if not found:
msg = f"add_task refused: {said[:120]}"
raise TodoError(msg)
return TaskRef(task_id=found.group(2), merged=found.group(1) != "added")
def update_task(self, task_id: str, title: str, body: str) -> None:
"""Rewrite via ``update_task``: exact title, and body with no sections."""
said = self._call("update_task", {"id": task_id, "title": title, "body": body})
if not said.startswith("updated"):
msg = f"update_task refused: {said[:120]}"
raise TodoError(msg)
def _call(self, tool: str, arguments: dict[str, object]) -> str:
token = self._token()
session = self._initialize(token)
payload = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
}
return _tool_text(self._post(token, payload, session)[1])
def _token(self) -> str:
try:
return self._token_file.read_text(encoding="utf-8").strip()
except OSError as exc:
msg = f"token file unreadable: {self._token_file}"
raise TodoError(msg) from exc
def _initialize(self, token: str) -> str:
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": _PROTOCOL,
"capabilities": {},
"clientInfo": {"name": "feedback-inbox", "version": "0.1.0"},
},
}
headers, _body = self._post(token, init, None)
session = headers.get("mcp-session-id")
if not session:
msg = "todo server sent no session id"
raise TodoError(msg)
self._post(
token,
{"jsonrpc": "2.0", "method": "notifications/initialized"},
session,
)
return session
def _post(
self,
token: str,
payload: dict[str, object],
session: str | None,
) -> tuple[dict[str, str], str]:
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if session:
headers["mcp-session-id"] = session
request = urllib.request.Request(
self._url,
data=json.dumps(payload).encode(),
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp:
return (
{k.lower(): v for k, v in resp.headers.items()},
resp.read().decode(),
)
except urllib.error.HTTPError as exc:
exc.close()
msg = f"todo server answered HTTP {exc.code}"
raise TodoError(msg) from exc
except OSError as exc:
msg = f"todo server unreachable: {exc}"
raise TodoError(msg) from exc
def _tool_text(body: str) -> str:
"""The text a tool call answered; an MCP-level error becomes TodoError."""
text = body.strip()
if not text.startswith("{"):
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
text = next((x for x in lines if x.startswith("{")), "")
try:
result = json.loads(text)["result"]
said = str(result["content"][0]["text"])
failed = bool(result.get("isError"))
except (ValueError, KeyError, IndexError, TypeError) as exc:
msg = f"unparsable MCP reply: {body[:80]!r}"
raise TodoError(msg) from exc
if failed:
msg = f"tool refused: {said[:120]}"
raise TodoError(msg)
return said

View File

@ -0,0 +1,67 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Keep the path units' watch lists equal to the games that exist.
systemd ``.path`` units take no globs, so each ``*_binaries`` folder is listed
in a generated drop-in. The set changes only when a game is added or removed;
the drop-in is rewritten, and systemd reloaded, only then (a reload per tick
would be the daemon-reload loop the fleet rules forbid).
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from feedback_inbox._bundle import binaries_dirs, feedback_dirs
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from pathlib import Path
WATCH_PATH = "feedback-inbox.path"
SYNC_PATH = "feedback-inbox-sync.path"
SYSTEMCTL = "/usr/bin/systemctl"
_HEADER = "# Generated by `feedback-inbox sync`; do not edit.\n[Path]\n"
def _dropin(unit_dir: Path, unit: str) -> Path:
return unit_dir / f"{unit}.d" / "dirs.conf"
def _render(folders: list[Path]) -> str:
return _HEADER + "".join(f"PathChanged={d}\n" for d in folders)
def desired(data_root: Path, unit_dir: Path) -> dict[Path, str]:
"""The drop-ins that should exist: feedback folders, and game folders."""
return {
_dropin(unit_dir, WATCH_PATH): _render(feedback_dirs(data_root)),
_dropin(unit_dir, SYNC_PATH): _render(binaries_dirs(data_root)),
}
def run_systemctl(args: Sequence[str]) -> int:
"""Run ``systemctl --user <args>`` and return its exit code."""
return subprocess.run([SYSTEMCTL, "--user", *args], check=False).returncode
def sync_units(
data_root: Path,
unit_dir: Path,
systemctl: Callable[[Sequence[str]], int],
) -> bool:
"""Rewrite drifted drop-ins, then reload and re-arm once; True if changed."""
changed = False
for path, text in desired(data_root, unit_dir).items():
if path.is_file() and path.read_text(encoding="utf-8") == text:
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
changed = True
if not changed:
return False
for args in (["daemon-reload"], ["restart", WATCH_PATH, SYNC_PATH]):
if systemctl(args):
msg = f"systemctl --user {' '.join(args)} failed"
raise RuntimeError(msg)
return True

View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for feedback_inbox."""

View File

@ -0,0 +1,31 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Redirect the one path resolver, so no test can touch real user state.
Every writer resolves its paths through ``feedback_inbox._paths.resolve_paths``;
redirecting that one function moves all of them (kuhy's real ``~/data``,
token file, lock and systemd unit dir included).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from feedback_inbox._paths import Paths
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Paths:
"""Point every default path at ``tmp_path`` before any test runs."""
redirected = Paths.under(tmp_path / "home")
def _resolve(given: Paths | None = None) -> Paths:
return given if given is not None else redirected
monkeypatch.setattr("feedback_inbox._cli.resolve_paths", _resolve)
redirected.data_root.mkdir(parents=True)
return redirected

View File

@ -0,0 +1,42 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Discovery, stamps and due dates."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from feedback_inbox._bundle import bundles_in, due_at, feedback_dirs, repo_name
from feedback_inbox.tests.test_support import make_bundle
if TYPE_CHECKING:
from feedback_inbox._paths import Paths
def test_feedback_dirs_covers_every_game(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate")
make_bundle(paths.data_root, game="dread-grid")
(paths.data_root / "other" / "feedback").mkdir(parents=True)
(paths.data_root / "empty_binaries").mkdir()
(paths.data_root / "file_binaries").write_text("x", encoding="utf-8")
found = feedback_dirs(paths.data_root)
assert [repo_name(d) for d in found] == ["cleanup-plate", "dread-grid"]
def test_feedback_dirs_without_data_root(paths: Paths) -> None:
assert feedback_dirs(paths.data_root / "missing") == []
def test_bundles_in_keeps_only_stamp_folders(paths: Paths) -> None:
first = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124")
make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162")
feedback = first.parent
(feedback / "notes").mkdir()
(feedback / "2026-11-11T13-52-33-999").write_text("x", encoding="utf-8")
names = [b.name for b in bundles_in(feedback)]
assert names == ["2026-11-11T13-52-33-162", "2026-11-11T13-53-19-124"]
def test_due_is_next_day_ten(paths: Paths) -> None:
assert due_at("2026-11-11T13-52-33-162") == datetime(2026, 11, 12, 10, 0)
assert due_at("2026-12-31T23-59-59-001") == datetime(2027, 1, 1, 10, 0)

View File

@ -0,0 +1,103 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The command line: run, sync, exit codes, lock."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
from feedback_inbox import __main__ as entry
from feedback_inbox._cli import main
from feedback_inbox._inbox import MARKER
from feedback_inbox._paths import Paths, resolve_paths
from feedback_inbox.tests.test_support import FakeTodo, make_bundle
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
def test_run_imports_and_exits_zero(
paths: Paths, caplog: pytest.LogCaptureFixture
) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo()
with caplog.at_level(logging.INFO):
assert main(["run", "--wait", "0"], client=todo) == 0
assert (bundle / MARKER).exists()
assert len(todo.calls) == 1
assert "imported" in caplog.text
assert main(["run", "--wait", "0"], client=todo) == 0
assert len(todo.calls) == 1
def test_run_fails_loudly_when_todo_is_down(
paths: Paths, caplog: pytest.LogCaptureFixture
) -> None:
bundle = make_bundle(paths.data_root)
assert main(["run", "--wait", "0"], client=FakeTodo(fail=True)) == 1
assert not (bundle / MARKER).exists()
assert "todo down" in caplog.text
def test_run_pending_is_not_an_error(paths: Paths) -> None:
make_bundle(paths.data_root, entry=False)
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
def test_run_builds_the_real_client_from_paths(paths: Paths) -> None:
make_bundle(paths.data_root)
assert main(["run", "--wait", "0"]) == 1
def test_run_with_nothing_to_do_needs_no_token(paths: Paths) -> None:
assert main(["run", "--wait", "0"]) == 0
def test_run_takes_the_lock_file(paths: Paths) -> None:
make_bundle(paths.data_root)
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
assert paths.lock_file.exists()
def test_sync_command(paths: Paths) -> None:
make_bundle(paths.data_root)
calls: list[list[str]] = []
def fake(args: Sequence[str]) -> int:
calls.append(list(args))
return 0
assert main(["sync"], systemctl=fake) == 0
assert calls[0] == ["daemon-reload"]
def test_sync_failure_exit_code(paths: Paths, caplog: pytest.LogCaptureFixture) -> None:
make_bundle(paths.data_root)
assert main(["sync"], systemctl=lambda _a: 1) == 1
assert "daemon-reload" in caplog.text
def test_usage_error() -> None:
with pytest.raises(SystemExit):
main([])
def test_real_paths_are_under_home(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr("feedback_inbox._paths.Path.home", lambda: tmp_path)
real = resolve_paths()
assert real.data_root == tmp_path / "data"
assert real.token_file == tmp_path / ".config" / "feedback-inbox" / "token"
assert real.unit_dir == tmp_path / ".config" / "systemd" / "user"
assert real.mcp_url == "http://127.0.0.1:8735/mcp"
given = Paths.under(tmp_path)
assert resolve_paths(given) is given
def test_module_entry_point(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.argv", ["feedback-inbox", "run", "--wait", "0"])
assert entry.run() == 0

View File

@ -0,0 +1,68 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Finding a bundle's markdown entry and reading its report."""
from __future__ import annotations
from typing import TYPE_CHECKING
from feedback_inbox._entry import (
find_entry,
game_and_combo,
note_from_entry,
read_report,
)
from feedback_inbox.tests.test_support import make_bundle
if TYPE_CHECKING:
from pathlib import Path
from feedback_inbox._paths import Paths
def test_find_entry_picks_the_matching_section(paths: Paths) -> None:
first = make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162", note="alpha")
second = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124", note="beta")
entry = find_entry(first.parent, second)
assert entry is not None
assert "beta" in entry
assert "alpha" not in entry
assert find_entry(first.parent, first) is not None
def test_find_entry_missing(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, entry=False)
assert find_entry(bundle.parent, bundle) is None
(bundle.parent / "feedback-2026-11-11.md").write_bytes(b"\xff\xfe")
assert find_entry(bundle.parent, bundle) is None
def test_read_report(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, note=" hello \nworld")
report = read_report(bundle / "report.json")
assert report is not None
assert report.note == " hello \nworld"
assert (report.game, report.combo) == ("museum", "loop=P2")
def test_read_report_corrupt_forms(tmp_path: Path) -> None:
path = tmp_path / "report.json"
for bad in ("{broken", "[1]", '{"note": 3, "game": null}'):
path.write_text(bad, encoding="utf-8")
report = read_report(path)
if bad == '{"note": 3, "game": null}':
assert report is not None
assert (report.note, report.game, report.combo) == ("", "", "")
else:
assert report is None
path.write_bytes(b"\xff\xfe")
assert read_report(path) is None
def test_entry_fallbacks(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, note="line one\nline two")
entry = find_entry(bundle.parent, bundle)
assert entry is not None
assert note_from_entry(entry) == "line one\nline two"
assert game_and_combo(entry) == ("museum", "loop=P2,carry=d,id=c")
assert note_from_entry("## head only") == ""
assert game_and_combo("nothing here") == ("", "")

View File

@ -0,0 +1,194 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Import rules: complete, incomplete, idempotent, titles, due date, failures."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from feedback_inbox._entry import Report
from feedback_inbox._inbox import (
MARKER,
Importer,
Outcome,
Waiter,
build_task,
run_once,
run_settled,
)
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
if TYPE_CHECKING:
from pathlib import Path
from feedback_inbox._paths import Paths
NOW = 1_000_000.0
OWNER = "kuhyx"
MANUAL = "MANUAL WORK REQUIRED: "
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
def test_complete_bundle_becomes_one_task(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
((repo, title, body),) = todo.calls
assert repo == "kuhyx/cleanup-plate"
assert (
title
== MANUAL + "cleanup-plate feedback: I would like to see the painting IN FULL"
)
assert body.startswith("Due: 2026-11-12 10:00\n\n### 2026-11-11T13:52:33")
assert f"{bundle}/report.json" in body
marker = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
assert marker["task_id"] == "id0001" + "0" * 28
assert marker["due"] == "2026-11-12T10:00:00"
def test_rerun_never_duplicates(paths: Paths) -> None:
make_bundle(paths.data_root)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
assert _run(paths, todo) == [Outcome.SKIPPED]
assert len(todo.calls) == 1
def test_incomplete_bundles_wait(paths: Paths) -> None:
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", report=None)
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", entry=False)
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", age=0.5)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.PENDING] * 3
assert todo.calls == []
later = run_once(paths.data_root, Importer(todo, OWNER), NOW + 10)
assert [r.outcome for r in later] == [
Outcome.PENDING,
Outcome.PENDING,
Outcome.IMPORTED,
]
def test_multiple_games(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate", note="a")
make_bundle(paths.data_root, game="dread-grid", note="b")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED] * 2
assert [(c[0], c[1]) for c in todo.calls] == [
("kuhyx/cleanup-plate", "MANUAL WORK REQUIRED: cleanup-plate feedback: a"),
("kuhyx/dread-grid", "MANUAL WORK REQUIRED: dread-grid feedback: b"),
]
def test_corrupt_report_still_a_task(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, report="{broken", note="keep me")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
((_, title, body),) = todo.calls
assert (
title
== "MANUAL WORK REQUIRED: cleanup-plate feedback: keep me [corrupt report.json]"
)
assert "report.json is corrupt" in body
assert (bundle / MARKER).exists()
def test_corrupt_report_without_note(paths: Paths) -> None:
make_bundle(paths.data_root, report="[]", note="")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
title = todo.calls[0][1]
game_part = "museum loop=P2,carry=d,id=c 13:52"
assert title == f"{MANUAL}cleanup-plate feedback: {game_part} [corrupt report.json]"
def test_no_note_title_names_game_combo_time(paths: Paths) -> None:
make_bundle(paths.data_root, note="")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
assert (
todo.calls[0][1]
== "MANUAL WORK REQUIRED: cleanup-plate feedback: museum loop=P2 13:52"
)
def test_title_uses_first_line_and_caps_length() -> None:
report = Report(note="\n " + "x" * 300 + "\nsecond", game="g", combo="c")
draft = build_task("repo", STAMP, "## e", report)
assert draft.title == "MANUAL WORK REQUIRED: repo feedback: " + "x" * 119 + "…"
bare = build_task("repo", STAMP, "## e", Report(note="", game="", combo=""))
assert bare.title == "MANUAL WORK REQUIRED: repo feedback: 13:52"
def test_todo_failure_leaves_no_marker(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo(fail=True)
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
assert [r.outcome for r in results] == [Outcome.FAILED]
assert "todo down" in results[0].detail
assert not (bundle / MARKER).exists()
assert _run(paths, FakeTodo()) == [Outcome.IMPORTED]
def test_merge_is_flagged_but_marked(paths: Paths) -> None:
make_bundle(paths.data_root)
results = run_once(paths.data_root, Importer(FakeTodo(merged=True), OWNER), NOW)
assert results[0].outcome is Outcome.IMPORTED
assert "merged" in results[0].detail
def test_marker_write_failure_is_reported(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
(bundle / MARKER).mkdir()
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
assert results[0].outcome is Outcome.SKIPPED
(bundle / MARKER).rmdir()
bundle.chmod(0o500)
try:
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
finally:
bundle.chmod(0o700)
assert results[0].outcome is Outcome.FAILED
assert "marker" in results[0].detail
def test_run_settled_waits_then_gives_up(paths: Paths, tmp_path: Path) -> None:
make_bundle(paths.data_root, age=0.0, now=NOW)
clock = [NOW]
slept: list[float] = []
def sleep(seconds: float) -> None:
slept.append(seconds)
clock[0] += seconds
todo = FakeTodo()
results = run_settled(
paths.data_root, Importer(todo, OWNER), Waiter(5.0, lambda: clock[0], sleep)
)
assert [r.outcome for r in results] == [Outcome.IMPORTED]
assert slept == [1.0, 1.0]
other = tmp_path / "other"
other.mkdir()
make_bundle(other, report=None)
slept.clear()
results = run_settled(
other, Importer(todo, OWNER), Waiter(3.0, lambda: clock[0], sleep)
)
assert [r.outcome for r in results] == [Outcome.PENDING]
assert slept == [1.0, 1.0, 1.0]
def test_every_title_starts_with_the_manual_prefix(paths: Paths) -> None:
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", note="a")
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", note="")
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", report="{")
make_bundle(paths.data_root, game="dread-grid", note="b")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED] * 4
titles = [call[1] for call in todo.calls]
assert len(titles) == 4
assert all(t.startswith("MANUAL WORK REQUIRED: ") for t in titles)

View File

@ -0,0 +1,145 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Exact titles, section-free bodies, and repair of half-finished tasks."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from feedback_inbox._entry import Report
from feedback_inbox._inbox import (
FORMAT,
MARKER,
Importer,
Outcome,
build_task,
run_once,
)
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
if TYPE_CHECKING:
from pathlib import Path
from feedback_inbox._paths import Paths
NOW = 1_000_000.0
OWNER = "kuhyx"
MANUAL = "MANUAL WORK REQUIRED: "
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
def _marker(bundle: Path) -> dict[str, object]:
data: dict[str, object] = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
return data
def _no_extra_sections(text: str) -> bool:
return not any(line.startswith("## ") for line in text.splitlines())
def test_stored_note_is_one_manual_part(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
(stored,) = todo.stored.values()
assert stored.startswith(MANUAL)
assert _no_extra_sections(stored)
assert "### 2026-11-11T13:52:33" in stored
assert _marker(bundle)["format"] == FORMAT
def test_entry_headings_never_become_sections() -> None:
entry = "## a heading\ntext\n## another\n### kept"
draft = build_task("r", STAMP, entry, Report(note="n", game="", combo=""))
assert _no_extra_sections(draft.body)
assert "### a heading\ntext\n### another\n### kept" in draft.body
def test_failed_update_keeps_marker_and_retries_without_a_second_task(
paths: Paths,
) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo(fail_update=True)
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
assert [r.outcome for r in results] == [Outcome.FAILED]
assert "update refused" in results[0].detail
assert "format" not in _marker(bundle)
todo.fail_update = False
assert _run(paths, todo) == [Outcome.IMPORTED]
assert len(todo.calls) == 1
(stored,) = todo.stored.values()
assert stored.startswith(MANUAL)
assert _no_extra_sections(stored)
assert _run(paths, todo) == [Outcome.SKIPPED]
def test_old_marker_is_repaired_from_the_bundle(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
task_id = "abc" + "0" * 33
(bundle / MARKER).write_text(
json.dumps({"task_id": task_id, "title": "old", "title_fixed": True}),
encoding="utf-8",
)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
assert todo.calls == []
assert todo.stored[task_id].startswith(MANUAL)
assert _no_extra_sections(todo.stored[task_id])
assert _marker(bundle)["format"] == FORMAT
def test_old_marker_whose_entry_vanished_waits(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, entry=False)
(bundle / MARKER).write_text(
json.dumps({"task_id": "t" * 36, "title": "old"}), encoding="utf-8"
)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.PENDING]
assert todo.updated == []
def test_merged_task_is_not_rewritten(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo(merged=True)
assert _run(paths, todo) == [Outcome.IMPORTED]
assert todo.updated == []
assert _marker(bundle)["format"] == FORMAT
assert _run(paths, todo) == [Outcome.SKIPPED]
def test_old_merged_marker_is_left_alone(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
(bundle / MARKER).write_text(
json.dumps({"task_id": "t" * 36, "title": "x", "merged": True}),
encoding="utf-8",
)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.SKIPPED]
assert todo.updated == []
def test_unreadable_marker_is_left_alone(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
for junk in ("{broken", "[1]", '{"task_id": 5}'):
(bundle / MARKER).write_text(junk, encoding="utf-8")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.SKIPPED]
assert todo.calls == []
assert todo.updated == []
def test_repair_reports_an_unwritable_marker(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
(bundle / MARKER).write_text(
json.dumps({"task_id": "t" * 36, "title": "T"}), encoding="utf-8"
)
bundle.chmod(0o500)
try:
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
finally:
bundle.chmod(0o700)
assert results[0].outcome is Outcome.FAILED
assert "marker not written" in results[0].detail

View File

@ -0,0 +1,95 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Builders for fake feedback bundles and a fake todo client."""
from __future__ import annotations
from dataclasses import dataclass, field
import json
import os
from typing import TYPE_CHECKING
from feedback_inbox._todo import TaskRef, TodoError
if TYPE_CHECKING:
from pathlib import Path
STAMP = "2026-11-11T13-52-33-162"
NOTE = "I would like to see the painting IN FULL\n\nsecond paragraph"
def entry_text(bundle: Path, note: str = NOTE, game: str = "museum") -> str:
"""The markdown entry a game appends for ``bundle``."""
head = note.splitlines()[0][:70] if note else ""
parts = [f"## 2026-11-11T13:52:33: {head}", ""]
if note:
parts += [note, ""]
parts += [
f"- Screenshot: `{bundle}/annotated.png`",
f"- Report: `{bundle}/report.json`",
f"- Game `{game}`, combo `loop=P2,carry=d,id=c`, build abc, session `s`",
"",
"Task: Read the annotated screenshot and fix it.",
"",
]
return "\n".join(parts)
def make_bundle(
data_root: Path,
*,
game: str = "cleanup-plate",
stamp: str = STAMP,
note: str = NOTE,
report: str | None = "ok",
entry: bool = True,
age: float = 60.0,
now: float = 1_000_000.0,
) -> Path:
"""Create ``<game>_binaries/feedback/<stamp>/`` and its md entry."""
feedback = data_root / f"{game}_binaries" / "feedback"
bundle = feedback / stamp
bundle.mkdir(parents=True, exist_ok=True)
if report is not None:
path = bundle / "report.json"
if report == "ok":
body = json.dumps({"note": note, "game": "museum", "combo": "loop=P2"})
else:
body = report
path.write_text(body, encoding="utf-8")
os.utime(path, (now - age, now - age))
if entry:
md = feedback / f"feedback-{stamp[:10]}.md"
with md.open("a", encoding="utf-8") as handle:
handle.write(entry_text(bundle, note))
return bundle
@dataclass
class FakeTodo:
"""A todo server stand-in that stores notes the way the real one does."""
calls: list[tuple[str, str, str]] = field(default_factory=list)
stored: dict[str, str] = field(default_factory=dict)
fail: bool = False
fail_update: bool = False
merged: bool = False
updated: list[str] = field(default_factory=list)
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
if self.fail:
msg = "todo down"
raise TodoError(msg)
self.calls.append((repo, title, body))
task_id = f"id{len(self.calls):04d}" + "0" * 28
# A new_note task: title "<name> todos: <title>", then a "## <title>"
# section holding the body.
name = repo.rsplit("/", maxsplit=1)[-1]
self.stored[task_id] = f"{name} todos: {title}\n\n## {title}\n{body}"
return TaskRef(task_id=task_id, merged=self.merged)
def update_task(self, task_id: str, title: str, body: str) -> None:
if self.fail_update:
msg = "update refused"
raise TodoError(msg)
self.updated.append(task_id)
self.stored[task_id] = f"{title}\n\n{body}"

View File

@ -0,0 +1,200 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The MCP client against a real local HTTP server (never the live todo app)."""
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import threading
from typing import TYPE_CHECKING, ClassVar
import pytest
from feedback_inbox._todo import McpTodoClient, TodoError
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
FULL_ID = "a1b2c3d4" + "e" * 28
class _Handler(BaseHTTPRequestHandler):
seen: ClassVar[list[dict[str, object]]] = []
headers_seen: ClassVar[list[dict[str, str]]] = []
tool_text: ClassVar[str] = f"added {FULL_ID}\nline"
is_error: ClassVar[bool] = False
status: ClassVar[int] = 200
sse: ClassVar[bool] = True
def do_POST(self) -> None:
length = int(self.headers["Content-Length"])
body = json.loads(self.rfile.read(length))
type(self).seen.append(body)
type(self).headers_seen.append({k.lower(): v for k, v in self.headers.items()})
if type(self).status != 200:
self.send_response(type(self).status)
self.end_headers()
return
self.send_response(200)
self.send_header("mcp-session-id", "sess-1")
self.end_headers()
method = body.get("method")
if method == "initialize":
self.wfile.write(
b'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n'
)
elif method == "tools/call":
result: dict[str, object] = {
"content": [{"type": "text", "text": type(self).tool_text}],
}
if type(self).is_error:
result["isError"] = True
reply = json.dumps({"jsonrpc": "2.0", "id": 2, "result": result})
out = f"event: message\ndata: {reply}\n\n" if type(self).sse else reply
self.wfile.write(out.encode())
def log_message(self, *_args: object) -> None:
return
@pytest.fixture
def server() -> Iterator[str]:
_Handler.seen = []
_Handler.headers_seen = []
_Handler.tool_text = f"added {FULL_ID}\nline"
_Handler.is_error = False
_Handler.status = 200
_Handler.sse = True
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{httpd.server_port}/mcp"
httpd.shutdown()
httpd.server_close()
thread.join()
def _client(url: str, tmp_path: Path) -> McpTodoClient:
token = tmp_path / "token"
token.write_text("secret-token\n", encoding="utf-8")
return McpTodoClient(url, token)
def test_add_task_round_trip(server: str, tmp_path: Path) -> None:
ref = _client(server, tmp_path).add_task("kuhyx/cleanup-plate", "t", "b")
assert (ref.task_id, ref.merged) == (FULL_ID, False)
methods = [m.get("method") for m in _Handler.seen]
assert methods == ["initialize", "notifications/initialized", "tools/call"]
call = _Handler.seen[2]["params"]
assert call == {
"name": "add_task",
"arguments": {
"repo": "kuhyx/cleanup-plate",
"title": "t",
"body": "b",
"new_note": True,
},
}
assert _Handler.headers_seen[0]["authorization"] == "Bearer secret-token"
assert _Handler.headers_seen[2]["mcp-session-id"] == "sess-1"
def test_plain_json_reply_and_merge(server: str, tmp_path: Path) -> None:
_Handler.sse = False
_Handler.tool_text = f"merged into {FULL_ID}\nstart: skipped"
ref = _client(server, tmp_path).add_task("r/x", "t", "b")
assert (ref.task_id, ref.merged) == (FULL_ID, True)
def test_tool_error_is_reported(server: str, tmp_path: Path) -> None:
_Handler.is_error = True
_Handler.tool_text = "boom"
with pytest.raises(TodoError, match="boom"):
_client(server, tmp_path).add_task("r/x", "t", "b")
def test_unexpected_reply_text(server: str, tmp_path: Path) -> None:
_Handler.tool_text = "who knows"
with pytest.raises(TodoError, match="who knows"):
_client(server, tmp_path).add_task("r/x", "t", "b")
def test_http_error(server: str, tmp_path: Path) -> None:
_Handler.status = 403
with pytest.raises(TodoError, match="403"):
_client(server, tmp_path).add_task("r/x", "t", "b")
def test_unreachable_server(tmp_path: Path) -> None:
with pytest.raises(TodoError, match="unreachable"):
_client("http://127.0.0.1:9/mcp", tmp_path).add_task("r/x", "t", "b")
def test_missing_token(tmp_path: Path) -> None:
client = McpTodoClient("http://127.0.0.1:9/mcp", tmp_path / "none")
with pytest.raises(TodoError, match="token"):
client.add_task("r/x", "t", "b")
def test_missing_session_header(server: str, tmp_path: Path) -> None:
class _NoSession(_Handler):
def do_POST(self) -> None:
self.rfile.read(int(self.headers["Content-Length"]))
self.send_response(200)
self.end_headers()
self.wfile.write(b"{}")
httpd = HTTPServer(("127.0.0.1", 0), _NoSession)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
with pytest.raises(TodoError, match="session"):
client.add_task("r/x", "t", "b")
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
def test_garbage_json(server: str, tmp_path: Path) -> None:
_Handler.sse = False
_Handler.tool_text = "x"
class _Bad(_Handler):
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
self.send_response(200)
self.send_header("mcp-session-id", "s")
self.end_headers()
if body.get("method") == "tools/call":
self.wfile.write(b"not json")
httpd = HTTPServer(("127.0.0.1", 0), _Bad)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
with pytest.raises(TodoError, match="unparsable"):
client.add_task("r/x", "t", "b")
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
def test_update_task_replaces_title_and_body(server: str, tmp_path: Path) -> None:
_Handler.tool_text = f"updated {FULL_ID}\nline"
_client(server, tmp_path).update_task(FULL_ID, "MANUAL WORK REQUIRED: x", "b")
call = _Handler.seen[2]["params"]
assert call == {
"name": "update_task",
"arguments": {"id": FULL_ID, "title": "MANUAL WORK REQUIRED: x", "body": "b"},
}
def test_update_task_refused(server: str, tmp_path: Path) -> None:
_Handler.tool_text = "no such task"
with pytest.raises(TodoError, match="no such task"):
_client(server, tmp_path).update_task(FULL_ID, "t", "b")

View File

@ -0,0 +1,97 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The path-unit drop-ins follow the set of game folders, and only then reload."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from feedback_inbox._units import (
SYNC_PATH,
WATCH_PATH,
desired,
run_systemctl,
sync_units,
)
from feedback_inbox.tests.test_support import make_bundle
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from feedback_inbox._paths import Paths
class _Systemctl:
def __init__(self, code: int = 0) -> None:
self.calls: list[list[str]] = []
self.code = code
def __call__(self, args: Sequence[str]) -> int:
self.calls.append(list(args))
return self.code
def test_desired_lists_feedback_and_binaries_dirs(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate")
(paths.data_root / "dread-grid_binaries").mkdir()
wanted = desired(paths.data_root, paths.unit_dir)
watch = wanted[paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf"]
sync = wanted[paths.unit_dir / f"{SYNC_PATH}.d" / "dirs.conf"]
feedback = paths.data_root / "cleanup-plate_binaries" / "feedback"
assert f"PathChanged={feedback}\n" in watch
assert "dread-grid" not in watch
assert f"PathChanged={paths.data_root}/cleanup-plate_binaries\n" in sync
assert f"PathChanged={paths.data_root}/dread-grid_binaries\n" in sync
def test_sync_writes_reloads_and_restarts_once(paths: Paths) -> None:
make_bundle(paths.data_root)
ctl = _Systemctl()
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
assert ctl.calls == [
["daemon-reload"],
["restart", WATCH_PATH, SYNC_PATH],
]
again = _Systemctl()
assert sync_units(paths.data_root, paths.unit_dir, again) is False
assert again.calls == []
def test_new_game_triggers_a_rewrite(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate")
sync_units(paths.data_root, paths.unit_dir, _Systemctl())
make_bundle(paths.data_root, game="dread-grid")
ctl = _Systemctl()
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
text = (paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf").read_text(
encoding="utf-8"
)
assert "dread-grid_binaries/feedback" in text
def test_systemctl_failure_is_an_error(paths: Paths) -> None:
make_bundle(paths.data_root)
ctl = _Systemctl(code=1)
with pytest.raises(RuntimeError, match="daemon-reload"):
sync_units(paths.data_root, paths.unit_dir, ctl)
assert ctl.calls == [["daemon-reload"]]
def test_run_systemctl_uses_the_user_manager(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen: list[list[str]] = []
class _Done:
returncode = 3
def fake_run(cmd: list[str], *, check: bool) -> _Done:
assert check is False
seen.append(cmd)
return _Done()
monkeypatch.setattr("feedback_inbox._units.subprocess.run", fake_run)
assert run_systemctl(["restart", "x"]) == 3
assert seen == [["/usr/bin/systemctl", "--user", "restart", "x"]]

87
feedback_inbox/install.sh Executable file
View File

@ -0,0 +1,87 @@
#!/bin/bash
# ============================================================================
# Install feedback-inbox for the current user: its own venv, the CLI on PATH,
# a todo-server token, and the systemd user units that watch every game's
# ~/data/<game>_binaries/feedback/ folder. Idempotent; rerun after any change.
#
# Enabling the units runs the inbox once (the sweep timer fires at once), so
# every bundle already on disk becomes a task: that is the backfill.
# Run the test suite first (scripts/run_subproject_tests.sh feedback_inbox).
# ============================================================================
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_DIR
readonly STATE_DIR="${HOME}/.local/share/feedback-inbox"
readonly VENV_DIR="${STATE_DIR}/venv"
readonly BIN_DIR="${HOME}/.local/bin"
readonly UNIT_DIR="${HOME}/.config/systemd/user"
readonly CONFIG_DIR="${HOME}/.config/feedback-inbox"
readonly TOKEN_FILE="${CONFIG_DIR}/token"
readonly MINT="${HOME}/src/todo/mcp/mint_token.sh"
readonly SYSTEM_PYTHON="/usr/bin/python3"
readonly UNITS=(
feedback-inbox.service feedback-inbox.path
feedback-inbox-sync.service feedback-inbox-sync.path feedback-inbox-sync.timer
)
log() { printf 'install: %s\n' "$1" >&2; }
fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; }
build_venv() {
log "building the venv at $VENV_DIR"
mkdir -p "$STATE_DIR"
[[ -x "$VENV_DIR/bin/python" ]] || "$SYSTEM_PYTHON" -m venv "$VENV_DIR" \
|| fail "could not create the venv"
"$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \
|| fail "feedback-inbox itself (stdlib only, no other packages)"
mkdir -p "$BIN_DIR"
ln -sf "$VENV_DIR/bin/feedback-inbox" "$BIN_DIR/feedback-inbox"
}
ensure_token() {
# The service has no shell, so TODO_MCP_TOKEN from ~/.zshenv is invisible
# to it. It gets its own revocable token (delete its line in
# ~/.config/todo-mcp/tokens); the value goes straight to a 0600 file.
[[ -s "$TOKEN_FILE" ]] && return 0
[[ -x "$MINT" ]] || fail "$MINT is missing (the todo repo)"
log "minting a todo-server token for feedback-inbox"
mkdir -p "$CONFIG_DIR"
(umask 077 && "$MINT" feedback-inbox >"$TOKEN_FILE") || fail "token mint"
[[ -s "$TOKEN_FILE" ]] || fail "token file is empty"
}
install_units() {
log "installing and enabling the units"
mkdir -p "$UNIT_DIR"
local unit
for unit in "${UNITS[@]}"; do
install -m 644 "$REPO_DIR/systemd/$unit" "$UNIT_DIR/"
done
systemctl --user daemon-reload || fail "daemon-reload"
# Drop-ins first, so the paths are armed with every current game folder.
"$VENV_DIR/bin/feedback-inbox" sync || fail "writing the watch lists"
systemctl --user enable --now feedback-inbox.path feedback-inbox-sync.path \
feedback-inbox-sync.timer || fail "enabling the units"
}
verify() {
local unit
for unit in feedback-inbox.path feedback-inbox-sync.path feedback-inbox-sync.timer; do
systemctl --user is-active --quiet "$unit" || fail "$unit is not active"
done
local folders
folders="$(systemctl --user show feedback-inbox.path -p Paths | grep -c '/feedback (PathChanged)' || true)"
log "ok: watching ${folders} feedback folder(s)"
}
main() {
build_venv
ensure_token
install_units
verify
}
main "$@"

View File

@ -0,0 +1,175 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "feedback-inbox"
version = "0.1.0"
description = "Turns every game dev-feedback bundle into one todo-app task"
requires-python = ">=3.14"
dependencies = []
[project.scripts]
feedback-inbox = "feedback_inbox._cli:main"
[tool.setuptools.packages.find]
include = ["feedback_inbox*"]
[tool.ruff]
target-version = "py314"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203",
"D213",
"COM812",
"ISC001",
"S603",
# Stamps are local wall-clock names the games write; naive on purpose.
"DTZ001",
"DTZ007",
# The todo server is loopback http by design (MCP_URL), never user input.
"S310",
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["feedback_inbox"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = true
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
disallow_any_unimported = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_subclassing_any = true
strict_equality = true
extra_checks = true
ignore_missing_imports = true
show_error_codes = true
color_output = true
exclude = [".venv/"]
[tool.pylint.main]
# The repo root on the path, so pylint resolves the package's own imports the
# same way pytest does instead of reporting import-error for them.
init-hook = "import sys; sys.path.insert(0, 'feedback_inbox')"
analyse-fallback-blocks = true
persistent = true
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
# Test suites intentionally use patterns (protected-access, fixture-arg
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
# basename of what pylint is *given*, and pre-commit hands it explicit file
# paths, so a directory name in `ignore` never fires under the hook.
ignore-paths = ["^(.*/)?tests/.*$"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
enable = "all"
disable = []
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
max-attributes = 10
[tool.pylint.typecheck]
generated-members = [
".*\\.assert_called_once_with",
".*\\.assert_called_once",
".*\\.assert_called",
".*\\.assert_not_called",
".*\\.assert_any_call",
".*\\.call_args",
".*\\.call_args_list",
".*\\.call_count",
]
[tool.bandit]
exclude_dirs = ["tests", ".venv"]
[tool.pytest.ini_options]
testpaths = ["feedback_inbox/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=feedback_inbox",
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
source = ["feedback_inbox"]
branch = true
omit = [
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"raise AssertionError",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]
partial_branches = ["pragma: no branch"]

View File

@ -0,0 +1,10 @@
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
# floor is satisfied by anything newer, so it tells a checker nothing
# about whether this repo is actually current.
bandit[toml]==1.9.4
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
ruff==0.17.4

View File

@ -0,0 +1,12 @@
[Unit]
Description=feedback-inbox: a game folder appeared or went away
[Path]
# ~/data fires when a *_binaries folder is created; the generated drop-in
# feedback-inbox-sync.path.d/dirs.conf adds each existing *_binaries folder, so
# a game's later `feedback/` subfolder is seen too.
PathChanged=%h/data
Unit=feedback-inbox-sync.service
[Install]
WantedBy=default.target

View File

@ -0,0 +1,12 @@
[Unit]
Description=feedback-inbox: re-arm the watches for new games, then import
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
StartLimitIntervalSec=0
[Service]
Type=oneshot
WorkingDirectory=/
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox sync
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 0
Nice=10
TimeoutStartSec=2min

View File

@ -0,0 +1,12 @@
[Unit]
Description=feedback-inbox: fallback sweep for events the path units missed
[Timer]
# Events that land while a run is active are lost by .path units; this catches
# them. No Persistent=: a missed sweep is harmless, the next one runs soon.
OnBootSec=2min
OnUnitActiveSec=5min
Unit=feedback-inbox-sync.service
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,12 @@
[Unit]
Description=feedback-inbox: watch every game's feedback folder
[Path]
# systemd paths take no globs. The folders themselves are listed in the
# generated drop-in feedback-inbox.path.d/dirs.conf (`feedback-inbox sync`);
# this baseline is the token file, which exists once installed and never churns.
PathChanged=%h/.config/feedback-inbox/token
Unit=feedback-inbox.service
[Install]
WantedBy=default.target

View File

@ -0,0 +1,15 @@
[Unit]
Description=feedback-inbox: turn new game feedback bundles into todo tasks
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
# The .path unit fires on every write in a feedback folder; bursts (a bundle is
# written in pieces) must not trip the default start limit.
StartLimitIntervalSec=0
[Service]
Type=oneshot
WorkingDirectory=/
# Waits up to 15 s for a half-written bundle to finish before leaving it to the
# timer. Exits non-zero when the todo server refuses, so the failure is visible.
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 15
Nice=10
TimeoutStartSec=2min

View File

@ -16,8 +16,38 @@ Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`,
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent. `activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent.
Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of
the clock earns only once the clock reaches it); `record_activity` rejects a `ts` the clock earns only once the clock reaches it); `record_activity` rejects a `ts`
more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day` more than 120 s ahead (ValueError). Timer segments (`start` -> `checkin` ... -> `stop`)
lapse after a 600 s gap; a `tutor`/`heartbeat` is a beat too (0.8.0: a graded check-in card
extends the open segment, never opens one). `end_project(id, day)` rejects a past `day`
(ValueError); today or later is fine. The harvester pays every project day of the (ValueError); today or later is fine. The harvester pays every project day of the
last 31 days, so late-synced events for older days are credited (idempotent). last 31 days, so late-synced events for older days are credited (idempotent).
Overtime carries over: own minutes past a day's target bank (max 60) and pay on the
next project day; the bank never crosses the project's end. `minutes_for(day)` is the
capped pay figure, `status(day)["minutes"]` the real count (121/120) with `carried_in`,
`carry_out`, `own_minutes`, `counted_minutes`. Carry ledger rows: `<project>-<YYYYMMDD>-carry`
(`detail.carry`), never over `daily_minutes` together with own rows.
Edits only ratchet (0.7.0): `update_project`/`apply_update(id, today=...)` never lowers
today's obligation. A lower `daily_minutes`/`unlock_minutes`, or a gate added to `replaces`,
on a started project holds from TOMORROW (a `history` row ends today with the old values,
`replaces_on(day)`/`daily_on`/`unlock_on` read it); a mixed edit defers as a whole. Raises,
gates removed and edits to a project that has not started hold from today.
`apply_update(...).effective_from` says which. `end` cannot be before today, so an edit never
uncovers today. A NEW project starting today may replace gates and set any targets: it is a
new obligation, not a reduction (but see the floor).
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`). Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`). Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
Interview prep (`kind="interview"`, 0.9): `create_project(..., kind="interview", test_at=, invite_url=, trips=, weekend_answers=)`;
`end` must be the test day. Budget: 30 min weekdays / 120 Sat-Sun (`daily_on`), exempt from `daily_floor`, unlock clamped.
Milestones are generated, never stored: `milestones(project, now)` -> `rules`, `trip_ask:<sat>`, `phone:<trip start>`, `mock`, `slot`
with states pending/done/missed/late_done/unknown. `owed_lock(now)` = earliest overdue uncompleted one (never on test day; wire it
after focus-guard's sick/free-day stand-down). `status(day)` gains `milestones` and `missed`.
Evidence is append-only (`evidence/<id>.jsonl` + copied files): `record_evidence(id, milestone, kind, value, file=, now=)`
(`file`, `tap`, `score`, `slot`, `slot_confirm`, tutor's `phone_check`). `add_trip` / `answer_weekend` only get stricter (ValueError).
`run_checks(id)` runs the `phone` source (reads the latest `phone_check` row) and latches changes; `register_source` swaps it.

View File

@ -17,28 +17,50 @@ from __future__ import annotations
from focus_project._api import ( from focus_project._api import (
active, active,
create_project, create_project,
daily_floor,
end_project, end_project,
list_projects, list_projects,
stands_down, stands_down,
) )
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project._milestones import Milestone, milestones, owed_lock
from focus_project._model import Limits, Project from focus_project._model import Limits, Project
from focus_project._update import update_project from focus_project._mutate import add_trip, answer_weekend, record_evidence
from focus_project._trips import Trip
from focus_project._update import Update, apply_update, update_project
from focus_project.activity import record_activity from focus_project.activity import record_activity
from focus_project.minutes import minutes_for from focus_project.checks import register_source, run_checks
from focus_project.evidence import Evidence
from focus_project.minutes import minutes_for, unbacked_minutes_for
from focus_project.sources.activity import BACKING_FROM
from focus_project.status import status from focus_project.status import status
__all__ = [ __all__ = [
"BACKING_FROM",
"Evidence",
"Limits", "Limits",
"Milestone",
"Project", "Project",
"RegistryError", "RegistryError",
"Trip",
"Update",
"active", "active",
"add_trip",
"answer_weekend",
"apply_update",
"create_project", "create_project",
"daily_floor",
"end_project", "end_project",
"list_projects", "list_projects",
"milestones",
"minutes_for", "minutes_for",
"owed_lock",
"record_activity", "record_activity",
"record_evidence",
"register_source",
"run_checks",
"stands_down", "stands_down",
"status", "status",
"unbacked_minutes_for",
"update_project", "update_project",
] ]

View File

@ -4,19 +4,26 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from datetime import date # noqa: TC003
import logging import logging
import re import re
import time import time
from urllib.parse import urlsplit from typing import TYPE_CHECKING
from focus_project import _clock, _store from focus_project import _clock, _interview, _store
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project._kind import FOCUS
from focus_project._model import GATES, Project, normalize_gate from focus_project._model import GATES, Project, normalize_gate
if TYPE_CHECKING:
from datetime import date, datetime
from focus_project._trips import Trip
_LOG = logging.getLogger(__name__) _LOG = logging.getLogger(__name__)
_ID = re.compile(r"[a-z0-9][a-z0-9-]*") _ID = re.compile(r"[a-z0-9][a-z0-9-]*")
MAX_WORK_URL = 2000 MAX_WORK_URL = _interview.MAX_URL
# A project must ask real work: the focus earner pays per minute of ``daily``.
MIN_DAILY_PER_GATE = 30
def list_projects() -> list[Project]: def list_projects() -> list[Project]:
@ -46,22 +53,17 @@ def stands_down(gate: str, day: date) -> bool:
"focus_project registry unreadable, gate %s stays on: %s", gate, exc "focus_project registry unreadable, gate %s stays on: %s", gate, exc
) )
return False return False
return project is not None and project.replaces_gate(gate) return project is not None and project.replaces_gate(gate, day)
def _validate_work_url(url: str) -> None: def _validate_work_url(url: str) -> None:
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``.""" """Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
if not url: _interview.check_http_url(url, "work_url")
return
parts = urlsplit(url) if url.isprintable() and " " not in url else None
if ( def daily_floor(replaces: tuple[str, ...]) -> int:
parts is None """The least ``daily_minutes`` a project replacing these gates may ask."""
or len(url) > MAX_WORK_URL return MIN_DAILY_PER_GATE * max(1, len(replaces))
or parts.scheme not in {"http", "https"}
or not parts.netloc
):
msg = "work_url must be an http(s) URL without spaces (or empty)"
raise ValueError(msg)
def _validate(project: Project) -> None: def _validate(project: Project) -> None:
@ -78,8 +80,13 @@ def _validate(project: Project) -> None:
if not project.replaces or not set(project.replaces) <= GATES: if not project.replaces or not set(project.replaces) <= GATES:
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}" msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
raise ValueError(msg) raise ValueError(msg)
if project.daily_minutes <= 0: _interview.validate(project)
msg = "daily_minutes must be positive" floor = daily_floor(project.replaces)
if project.kind == FOCUS and project.daily_minutes < floor:
msg = (
f"daily_minutes must be at least {floor} "
f"({MIN_DAILY_PER_GATE} per replaced gate)"
)
raise ValueError(msg) raise ValueError(msg)
if not 0 <= project.unlock_minutes <= project.daily_minutes: if not 0 <= project.unlock_minutes <= project.daily_minutes:
msg = "unlock_minutes must be within 0..daily_minutes" msg = "unlock_minutes must be within 0..daily_minutes"
@ -98,8 +105,19 @@ def create_project( # noqa: PLR0913
subjects: list[str] | None = None, subjects: list[str] | None = None,
site_patterns: list[str] | None = None, site_patterns: list[str] | None = None,
work_url: str = "", work_url: str = "",
kind: str = FOCUS,
test_at: datetime | None = None,
invite_url: str = "",
trips: list[Trip] | None = None,
weekend_answers: dict[date, bool] | None = None,
) -> Project: ) -> Project:
"""Add a project; ValueError on bad input, a duplicate id or an overlap.""" """Add a project; ValueError on bad input, a duplicate id or an overlap.
``kind="interview"`` takes ``test_at`` (naive local; its date must be
``end``), ``invite_url``, ``trips`` and ``weekend_answers``; an interview
is exempt from :func:`daily_floor` and runs the 30/120 budget.
"""
created_ms = time.time_ns() // 1_000_000
project = Project( project = Project(
id=project_id, id=project_id,
name=name, name=name,
@ -111,7 +129,15 @@ def create_project( # noqa: PLR0913
subjects=tuple(subjects or ()), subjects=tuple(subjects or ()),
site_patterns=tuple(site_patterns or ()), site_patterns=tuple(site_patterns or ()),
work_url=work_url, work_url=work_url,
created_ms=time.time_ns() // 1_000_000, created_ms=created_ms,
kind=kind,
test_at=test_at,
invite_url=invite_url,
trips=tuple(
t if t.entered_ms else replace(t, entered_ms=created_ms)
for t in trips or ()
),
weekend_answers=tuple(sorted((weekend_answers or {}).items())),
) )
_validate(project) _validate(project)
with _store.locked(): with _store.locked():

View File

@ -0,0 +1,89 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Validation of the interview-only fields."""
from __future__ import annotations
from datetime import date, timedelta
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from focus_project._kind import FOCUS, KINDS, WEEKDAY_MINUTES
if TYPE_CHECKING:
from focus_project._model import Project
MAX_URL = 2000
SATURDAY = 5
def check_http_url(url: str, label: str) -> None:
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
if not url:
return
parts = urlsplit(url) if url.isprintable() and " " not in url else None
if (
parts is None
or len(url) > MAX_URL
or parts.scheme not in {"http", "https"}
or not parts.netloc
):
msg = f"{label} must be an http(s) URL without spaces (or empty)"
raise ValueError(msg)
def saturdays(project: Project) -> list[date]:
"""The Saturdays whose weekend lies inside D1..T-1."""
if project.test_at is None:
return []
last = project.test_at.date() - timedelta(days=1)
day = project.start + timedelta(days=(SATURDAY - project.start.weekday()) % 7)
found: list[date] = []
while day <= last:
found.append(day)
day += timedelta(days=7)
return found
def _has_interview_fields(project: Project) -> bool:
return bool(
project.test_at
or project.invite_url
or project.trips
or project.weekend_answers
)
def _validate_interview(project: Project) -> None:
if project.test_at is None or project.test_at.tzinfo is not None:
msg = "an interview needs test_at as a naive local datetime"
raise ValueError(msg)
test_day = project.test_at.date()
if project.end != test_day:
msg = f"end must be the test day {test_day}"
raise ValueError(msg)
if project.daily_minutes < WEEKDAY_MINUTES:
msg = f"daily_minutes must be at least {WEEKDAY_MINUTES}"
raise ValueError(msg)
for trip in project.trips:
if trip.end < trip.start or trip.start > test_day:
msg = f"trip {trip.start}..{trip.end} must start by the test day"
raise ValueError(msg)
weekends = saturdays(project)
for day, _ in project.weekend_answers:
if day not in weekends:
msg = f"{day} is not a weekend inside the project before the test day"
raise ValueError(msg)
def validate(project: Project) -> None:
"""ValueError unless the kind and its interview fields are coherent."""
if project.kind not in KINDS:
msg = f"kind must be one of {sorted(KINDS)}"
raise ValueError(msg)
check_http_url(project.invite_url, "invite_url")
if project.kind == FOCUS:
if _has_interview_fields(project):
msg = "test_at, invite_url, trips, weekend_answers need kind='interview'"
raise ValueError(msg)
return
_validate_interview(project)

View File

@ -0,0 +1,23 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Project kinds and the interview budget (the one place the numbers live)."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import date
FOCUS = "focus"
INTERVIEW = "interview"
KINDS = frozenset({FOCUS, INTERVIEW})
# kuhy, 2026-11-10: near-zero weekday energy, weekends are for learning.
WEEKDAY_MINUTES = 30
WEEKEND_MINUTES = 120
_FIRST_WEEKEND_DAY = 5 # Saturday
def interview_minutes(day: date) -> int:
"""The daily target of an interview project on ``day`` (Sat/Sun = 120)."""
return WEEKEND_MINUTES if day.weekday() >= _FIRST_WEEKEND_DAY else WEEKDAY_MINUTES

View File

@ -0,0 +1,235 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Interview milestones: generated from the project, judged from evidence.
Nothing here is stored or editable. ``due`` and the state are recomputed from
the project (kind, test_at, trips, weekend answers) and the append-only
evidence rows, so a milestone cannot be moved or ticked off by editing a row.
Failure modes (R8): an unanswered trip question reads as a trip (fail
closed); a check that could not run is ``unknown`` and counts as not done
(fail closed); an unreadable registry or evidence file arms no lock (fail
open, logged), matching focus-guard's "unreadable registry" rule.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
import logging
from typing import TYPE_CHECKING, Any, Literal
from focus_project import _api, _interview, evidence
from focus_project._errors import RegistryError
from focus_project._kind import INTERVIEW
from focus_project.sources import activity
if TYPE_CHECKING:
from collections.abc import Callable
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
State = Literal["pending", "done", "missed", "late_done", "unknown"]
PHONE_LEAD = timedelta(hours=12)
MOCK_SESSION_S = 90 * 60
_RED = frozenset({"missed", "unknown"})
@dataclass(frozen=True)
class Milestone:
"""One generated obligation of an interview project."""
id: str
due: datetime
state: State
completed: bool
@property
def red(self) -> bool:
"""Past due and not done (also: the check could not run)."""
return self.state in _RED
def to_dict(self) -> dict[str, Any]:
"""The status() row: id, due ISO, state."""
return {"id": self.id, "due": self.due.isoformat(), "state": self.state}
def _at(day: date, hour: int, minute: int = 0) -> datetime:
return datetime.combine(day, time(hour, minute))
def _trip_due(trip_start: date, entered_ms: int) -> datetime:
"""Start - 12 h, or the moment it was entered if that left < 12 h."""
nominal = _at(trip_start, 0) - PHONE_LEAD
if entered_ms > 0:
entered = evidence.from_ms(entered_ms)
if nominal - entered < PHONE_LEAD:
return entered
return nominal
def _is_trip(
*,
answered: bool | None,
answer_ms: int | None,
ask_due: datetime,
phone_due: datetime,
now: datetime,
) -> bool:
"""Whether the weekend counts as a trip (fail closed).
Unanswered once the question is due = a trip. A no-trip answer given
only after the phone prep was already due cannot erase that overdue
milestone: the answer row's own timestamp decides, not a stored flag.
"""
if answered is None:
return now >= ask_due
if answered:
return True
return answer_ms is not None and answer_ms >= evidence.to_ms(phone_due)
def plan(
project: Project, test_day: date, now: datetime, rows: list[evidence.Evidence]
) -> dict[str, datetime]:
"""Milestone id -> due; ``project`` must be a validated interview."""
dues = {
"rules": _at(project.start, 23, 59),
"mock": _at(test_day - timedelta(days=2), 23, 59),
"slot": _at(test_day - timedelta(days=1), 12),
}
answers = dict(project.weekend_answers)
for sat in _interview.saturdays(project):
ask_due = _at(sat - timedelta(days=2), 23, 59)
phone_due = _at(sat, 0) - PHONE_LEAD
dues[f"trip_ask:{sat}"] = ask_due
covered = any(t.covers(sat) for t in project.trips)
row = evidence.first(rows, f"trip_ask:{sat}", "answer")
answer_ms = None if row is None else row.ms
if not covered and _is_trip(
answered=answers.get(sat),
answer_ms=answer_ms,
ask_due=ask_due,
phone_due=phone_due,
now=now,
):
dues[f"phone:{sat}"] = phone_due
for trip_row in project.trips:
dues[f"phone:{trip_row.start}"] = _trip_due(trip_row.start, trip_row.entered_ms)
return dues
def _when(row: evidence.Evidence | None) -> datetime | None:
return None if row is None else row.at
Rows = list[evidence.Evidence]
Completion = tuple[datetime | None, bool]
def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
"""Done at the first evidence row of ``kind`` (rules file, mock score)."""
def judge(mid: str, rows: Rows, _project: Project) -> Completion:
return _when(evidence.first(rows, mid, kind)), False
return judge
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the first score row that follows a >= 90 min focus session.
The session (contiguous beats, :func:`activity.sessions`) must END at or
before the score row; a score with no such session completes nothing.
"""
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
for row in rows:
if (row.milestone, row.kind) == (mid, "score") and any(
end <= row.ms / 1000 for end in ends
):
return row.at, False
return None, False
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the answer row; an answer with no row was given at creation."""
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
return None, False
return _when(evidence.first(rows, mid, "answer")) or evidence.from_ms(0), False
def _slot(mid: str, rows: Rows, _project: Project) -> Completion:
"""Done once the latest slot entry has a confirm tap at or after it."""
slot = evidence.last(rows, mid, "slot")
if slot is None:
return None, False
confirm = evidence.first(rows, mid, "slot_confirm", since_ms=slot.ms)
return (None if confirm is None else max(slot.at, confirm.at)), False
def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
"""Done when the offline tap and a passing auto check both exist.
A tap with no verdict at all (or a dark source) is ``unknown``.
"""
tap = evidence.first(rows, mid, "tap")
passed, latest, _ = evidence.check_summary(rows, mid)
if tap is not None and passed is not None:
return max(tap.at, evidence.from_ms(passed)), False
return None, tap is not None and latest is None
_JUDGES = {
"rules": _by_evidence("file"),
"mock": _mock,
"trip_ask": _trip_ask,
"slot": _slot,
"phone": _phone,
}
def _judge(
mid: str, due: datetime, rows: Rows, project: Project, now: datetime
) -> Milestone:
done_at, unknown = _JUDGES[mid.partition(":")[0]](mid, rows, project)
if done_at is not None:
state: State = "done" if done_at <= due else "late_done"
return Milestone(mid, due, state, completed=True)
if now < due:
return Milestone(mid, due, "pending", completed=False)
return Milestone(mid, due, "unknown" if unknown else "missed", completed=False)
def milestones(project: Project, now: datetime) -> list[Milestone]:
"""The project's milestones at ``now`` (naive local), earliest due first.
Empty for a focus project. May raise OSError reading the evidence file.
"""
if project.kind != INTERVIEW or project.test_at is None:
return []
rows = evidence.read(project.id)
dues = plan(project, project.test_at.date(), now, rows)
found = [_judge(mid, due, rows, project, now) for mid, due in dues.items()]
return sorted(found, key=lambda m: (m.due, m.id))
def owed_lock(now: datetime) -> Milestone | None:
"""The earliest overdue, uncompleted milestone that locks, or None.
Locks (missed or unknown) from the moment it is due, every day of the
project except the test day itself, where nothing locks. It does not
consult sickdays/freedays: focus-guard's ``_off(today)`` precondition
already stands the lock down on those days, so wire this after it.
Unreadable state arms nothing (fail open, logged).
"""
try:
project = _api.active(now.date())
if project is None or project.test_at is None:
return None
if now.date() >= project.test_at.date():
return None
owed = [m for m in milestones(project, now) if not m.completed and now >= m.due]
except (RegistryError, OSError) as exc:
_LOG.warning("interview state unreadable, no milestone lock: %s", exc)
return None
return owed[0] if owed else None

View File

@ -4,10 +4,12 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime
from typing import Any from typing import Any
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project._kind import FOCUS, INTERVIEW, interview_minutes
from focus_project._trips import Trip
GATES = frozenset({"leetcode", "reading", "tutor"}) GATES = frozenset({"leetcode", "reading", "tutor"})
_ALIASES = {"book": "reading"} _ALIASES = {"book": "reading"}
@ -18,6 +20,17 @@ def normalize_gate(gate: str) -> str:
return _ALIASES.get(gate, gate) return _ALIASES.get(gate, gate)
def _answers(raw: dict[str, Any] | None) -> tuple[tuple[date, bool], ...]:
"""Parse the saturday -> bool map; a non-bool answer is a bad row."""
pairs = []
for key, value in (raw or {}).items():
if not isinstance(value, bool):
msg = f"weekend answer for {key} is not true/false"
raise TypeError(msg)
pairs.append((date.fromisoformat(key), value))
return tuple(sorted(pairs))
@dataclass(frozen=True) @dataclass(frozen=True)
class Limits: class Limits:
"""The minute targets that held up to and including ``until``.""" """The minute targets that held up to and including ``until``."""
@ -25,22 +38,29 @@ class Limits:
until: date until: date
daily_minutes: int daily_minutes: int
unlock_minutes: int unlock_minutes: int
# None = a row from before 0.7.0: the project's current gates apply.
replaces: tuple[str, ...] | None = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""The history row in projects.json.""" """The history row in projects.json."""
return { row: dict[str, Any] = {
"until": self.until.isoformat(), "until": self.until.isoformat(),
"daily_minutes": self.daily_minutes, "daily_minutes": self.daily_minutes,
"unlock_minutes": self.unlock_minutes, "unlock_minutes": self.unlock_minutes,
} }
if self.replaces is not None:
row["replaces"] = list(self.replaces)
return row
@classmethod @classmethod
def from_dict(cls, row: Any) -> Limits: # noqa: ANN401 def from_dict(cls, row: Any) -> Limits: # noqa: ANN401
"""Parse a history row (errors surface as RegistryError upstream).""" """Parse a history row (errors surface as RegistryError upstream)."""
gates = row.get("replaces")
return cls( return cls(
until=date.fromisoformat(row["until"]), until=date.fromisoformat(row["until"]),
daily_minutes=int(row["daily_minutes"]), daily_minutes=int(row["daily_minutes"]),
unlock_minutes=int(row["unlock_minutes"]), unlock_minutes=int(row["unlock_minutes"]),
replaces=None if gates is None else tuple(gates),
) )
@ -63,19 +83,35 @@ class Project:
work_url: str = "" work_url: str = ""
# Earlier targets, oldest first: an edit applies from its day, never back. # Earlier targets, oldest first: an edit applies from its day, never back.
history: tuple[Limits, ...] = () history: tuple[Limits, ...] = ()
# "focus" (every pre-interview row) or "interview"; the rest is interview-only.
kind: str = FOCUS
# Local naive datetime of the test; its date is the project's last day.
test_at: datetime | None = None
invite_url: str = ""
trips: tuple[Trip, ...] = ()
# Saturday -> True (trip that weekend) / False (no trip); sorted pairs.
weekend_answers: tuple[tuple[date, bool], ...] = ()
def _limits_on(self, day: date) -> Limits | None: def _limits_on(self, day: date) -> Limits | None:
return next((h for h in self.history if day <= h.until), None) return next((h for h in self.history if day <= h.until), None)
def daily_on(self, day: date) -> int: def daily_on(self, day: date) -> int:
"""The daily target that held on ``day``.""" """The daily target that held on ``day``."""
if self.kind == INTERVIEW:
return interview_minutes(day)
past = self._limits_on(day) past = self._limits_on(day)
return self.daily_minutes if past is None else past.daily_minutes return self.daily_minutes if past is None else past.daily_minutes
def unlock_on(self, day: date) -> int: def unlock_on(self, day: date) -> int:
"""The unlock target that held on ``day``.""" """The unlock target that held on ``day`` (clamped for an interview)."""
past = self._limits_on(day) past = self._limits_on(day)
return self.unlock_minutes if past is None else past.unlock_minutes held = self.unlock_minutes if past is None else past.unlock_minutes
return min(held, interview_minutes(day)) if self.kind == INTERVIEW else held
def replaces_on(self, day: date) -> tuple[str, ...]:
"""The gates this project switched off on ``day``."""
past = self._limits_on(day)
return self.replaces if past is None or past.replaces is None else past.replaces
@property @property
def last_day(self) -> date: def last_day(self) -> date:
@ -86,13 +122,13 @@ class Project:
"""Whether this project is active on ``day``.""" """Whether this project is active on ``day``."""
return self.start <= day <= self.last_day return self.start <= day <= self.last_day
def replaces_gate(self, gate: str) -> bool: def replaces_gate(self, gate: str, day: date) -> bool:
"""Whether this project switches ``gate`` off.""" """Whether this project switches ``gate`` off on ``day``."""
return normalize_gate(gate) in self.replaces return normalize_gate(gate) in self.replaces_on(day)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""The projects.json row.""" """The projects.json row."""
return { row: dict[str, Any] = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"start": self.start.isoformat(), "start": self.start.isoformat(),
@ -106,13 +142,23 @@ class Project:
"created_ms": self.created_ms, "created_ms": self.created_ms,
"work_url": self.work_url, "work_url": self.work_url,
"history": [h.to_dict() for h in self.history], "history": [h.to_dict() for h in self.history],
"kind": self.kind,
} }
if self.kind == INTERVIEW:
row["test_at"] = self.test_at.isoformat() if self.test_at else None
row["invite_url"] = self.invite_url
row["trips"] = [t.to_dict() for t in self.trips]
row["weekend_answers"] = {
day.isoformat(): trip for day, trip in self.weekend_answers
}
return row
@classmethod @classmethod
def from_dict(cls, row: Any) -> Project: # noqa: ANN401 def from_dict(cls, row: Any) -> Project: # noqa: ANN401
"""Parse a registry row, or raise :class:`RegistryError`.""" """Parse a registry row, or raise :class:`RegistryError`."""
try: try:
ended = row.get("ended_on") ended = row.get("ended_on")
test_at = row.get("test_at")
return cls( return cls(
id=str(row["id"]), id=str(row["id"]),
name=str(row["name"]), name=str(row["name"]),
@ -127,6 +173,11 @@ class Project:
created_ms=int(row.get("created_ms", 0)), created_ms=int(row.get("created_ms", 0)),
work_url=str(row.get("work_url") or ""), work_url=str(row.get("work_url") or ""),
history=tuple(Limits.from_dict(h) for h in row.get("history", ())), history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
kind=str(row.get("kind") or FOCUS),
test_at=datetime.fromisoformat(test_at) if test_at else None,
invite_url=str(row.get("invite_url") or ""),
trips=tuple(Trip.from_dict(t) for t in row.get("trips", ())),
weekend_answers=_answers(row.get("weekend_answers")),
) )
except (KeyError, TypeError, ValueError, AttributeError) as exc: except (KeyError, TypeError, ValueError, AttributeError) as exc:
msg = f"bad project row {row!r}: {exc}" msg = f"bad project row {row!r}: {exc}"

View File

@ -0,0 +1,178 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Interview mutations: trips, weekend answers, evidence. Stricter only."""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime
import math
from typing import TYPE_CHECKING
from focus_project import _interview, _milestones, _store, evidence
from focus_project._kind import INTERVIEW
from focus_project._trips import Trip, add
if TYPE_CHECKING:
from pathlib import Path
from focus_project._model import Project
# Which evidence kinds each milestone family accepts from a caller.
# ``check`` and ``answer`` are internal: written by run_checks/answer_weekend.
_ACCEPTS = {
"rules": {"file"},
"mock": {"score"},
"slot": {"slot", "slot_confirm"},
"phone": {"tap"},
}
# tutor's worker reports phone readiness under the bare id "phone".
_PHONE_REPORT = "phone"
def local_now(now: datetime | None) -> datetime:
"""``now`` or the clock's local time."""
return evidence.clock_now() if now is None else now
def find(existing: list[Project], project_id: str) -> tuple[int, Project]:
"""Index and interview project; KeyError unknown, ValueError focus kind."""
for i, project in enumerate(existing):
if project.id == project_id:
if project.kind != INTERVIEW:
msg = f"{project_id!r} is not an interview project (no milestones)"
raise ValueError(msg)
return i, project
raise KeyError(project_id)
def add_trip(
project_id: str, start: date, end: date, *, now: datetime | None = None
) -> Project:
"""Announce a trip; it may only grow (see :func:`focus_project._trips.add`)."""
stamp = local_now(now)
with _store.locked():
existing = _store.read_all()
i, project = find(existing, project_id)
trips = add(project.trips, Trip(start, end, evidence.to_ms(stamp)))
updated = replace(project, trips=trips)
_interview.validate(updated)
existing[i] = updated
_store.write_all(existing)
return updated
def answer_weekend(
project_id: str, saturday: date, *, trip: bool, now: datetime | None = None
) -> Project:
"""Answer the trip question of a weekend; True never flips back to False."""
stamp = local_now(now)
with _store.locked():
existing = _store.read_all()
i, project = find(existing, project_id)
if saturday not in _interview.saturdays(project):
msg = f"{saturday} is not a weekend of {project_id!r} before its test day"
raise ValueError(msg)
answers = dict(project.weekend_answers)
current = answers.get(saturday)
if current is trip:
return project
if current is True:
msg = "a trip answer may only get stricter: it stays True"
raise ValueError(msg)
if not trip and any(t.covers(saturday) for t in project.trips):
msg = f"an announced trip covers {saturday}: it cannot be a no-trip weekend"
raise ValueError(msg)
updated = replace(
project, weekend_answers=tuple(sorted({**answers, saturday: trip}.items()))
)
# Evidence first: a crash between the two leaves the question open
# (fail closed), never an answer with no time.
evidence.append(project_id, f"trip_ask:{saturday}", "answer", trip, stamp)
existing[i] = updated
_store.write_all(existing)
return updated
def _number(value: object) -> float:
if (
isinstance(value, bool)
or not isinstance(value, int | float)
or not math.isfinite(value)
or value < 0
):
msg = "a score must be a finite, non-negative number"
raise ValueError(msg)
return value
def _count(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
msg = f"{label} must be a non-negative integer"
raise ValueError(msg)
return value
def _clean(
project_id: str, kind: str, value: object, file: Path | None, stamp: datetime
) -> object:
"""The value to store for ``kind``; ValueError if it is not acceptable."""
if kind == "file":
if file is None or not file.is_file() or not file.stat().st_size:
msg = "a non-empty file is required"
raise ValueError(msg)
return evidence.store_file(project_id, file, stamp)
if kind == "score":
return _number(value)
if kind == "slot":
moment = datetime.fromisoformat(str(value)) if isinstance(value, str) else None
if moment is None or moment.tzinfo is not None:
msg = "slot must be a naive local ISO datetime"
raise ValueError(msg)
return moment.isoformat()
if kind == "phone_check":
if not isinstance(value, dict) or not isinstance(value.get("plan"), bool):
msg = "phone_check needs {plan: bool, due: int, seen_ms: int}"
raise ValueError(msg)
return {
"plan": value["plan"],
"due": _count(value.get("due"), "due"),
"seen_ms": _count(value.get("seen_ms"), "seen_ms"),
}
if kind == "slot_confirm" and value not in {True, None}:
msg = "slot_confirm is a tap: value True"
raise ValueError(msg)
return True # tap, slot_confirm
def record_evidence( # noqa: PLR0913
project_id: str,
milestone: str,
kind: str,
value: object = None,
*,
file: Path | None = None,
now: datetime | None = None,
) -> evidence.Evidence:
"""Append evidence for a milestone; ValueError if it does not fit.
Kinds: ``file`` (rules, copied from ``file``), ``tap`` (phone:*),
``score`` (mock), ``slot`` (ISO datetime) and ``slot_confirm`` (slot),
``phone_check`` ({plan, due, seen_ms}, milestone "phone", from tutor).
There is no way to delete a row.
"""
stamp = local_now(now)
_, project = find(_store.read_all(), project_id)
if milestone == _PHONE_REPORT:
accepted = {"phone_check"}
else:
known = {m.id for m in _milestones.milestones(project, stamp)}
accepted = _ACCEPTS.get(milestone.partition(":")[0], set())
if milestone not in known:
msg = f"{milestone!r} is not a milestone of {project_id!r}"
raise ValueError(msg)
if kind not in accepted:
msg = f"{milestone!r} takes evidence kinds {sorted(accepted)}, not {kind!r}"
raise ValueError(msg)
return evidence.append(
project_id, milestone, kind, _clean(project_id, kind, value, file, stamp), stamp
)

View File

@ -0,0 +1,31 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A project day's own worked minutes: the union of every source's intervals."""
from __future__ import annotations
from typing import TYPE_CHECKING
from focus_project import sources
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
def union_seconds(spans: list[sources.Interval]) -> float:
"""Total length of the union of ``spans``."""
total = 0.0
end = float("-inf")
for lo, hi in sorted(spans):
if hi <= end:
continue
total += hi - max(lo, end)
end = hi
return total
def own_minutes_of(project: Project, day: date, moment: float) -> int:
"""Whole minutes worked on ``day``, uncapped, nothing after ``moment``."""
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
return int(union_seconds(spans) // 60)

View File

@ -30,3 +30,18 @@ def activity_path() -> Path:
def lock_path() -> Path: def lock_path() -> Path:
"""The registry's writer lock.""" """The registry's writer lock."""
return data_dir() / "projects.lock" return data_dir() / "projects.lock"
def evidence_dir() -> Path:
"""Interview-prep evidence: one jsonl per project plus copied files."""
return data_dir() / "evidence"
def evidence_path(project_id: str) -> Path:
"""The append-only evidence rows of one project."""
return evidence_dir() / f"{project_id}.jsonl"
def evidence_files_dir(project_id: str) -> Path:
"""Where evidence files (screenshots) of one project are copied."""
return evidence_dir() / project_id

View File

@ -0,0 +1,61 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Trips: days away from the PC. They can only grow (ratchet)."""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import date
from typing import Any
@dataclass(frozen=True)
class Trip:
"""An inclusive away range; ``entered_ms`` is when it was announced."""
start: date
end: date
entered_ms: int = 0
def covers(self, day: date) -> bool:
"""Whether ``day`` is inside the trip."""
return self.start <= day <= self.end
def to_dict(self) -> dict[str, Any]:
"""The projects.json row."""
return {
"start": self.start.isoformat(),
"end": self.end.isoformat(),
"entered_ms": self.entered_ms,
}
@classmethod
def from_dict(cls, row: dict[str, Any]) -> Trip:
"""Parse a trip row (errors surface as RegistryError upstream)."""
return cls(
start=date.fromisoformat(row["start"]),
end=date.fromisoformat(row["end"]),
entered_ms=int(row.get("entered_ms", 0)),
)
def add(trips: tuple[Trip, ...], new: Trip) -> tuple[Trip, ...]:
"""``trips`` plus ``new``; ValueError if that would loosen an old trip.
A new range touching an old trip must cover it: it replaces the old one
(keeping its entry time when the start did not move earlier). Anything
that starts later or ends earlier than what is on file is refused.
"""
kept: list[Trip] = []
for old in trips:
if old.start > new.end or new.start > old.end:
kept.append(old)
elif new.start <= old.start and new.end >= old.end:
if new.start == old.start:
new = replace(new, entered_ms=old.entered_ms)
else:
msg = (
f"a trip may only get stricter: pass a range that covers "
f"{old.start}..{old.end}"
)
raise ValueError(msg)
return tuple(sorted([*kept, new], key=lambda t: t.start))

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace from dataclasses import dataclass, replace
from datetime import date, timedelta from datetime import date, timedelta
from typing import Any from typing import Any
@ -27,7 +27,7 @@ _EDITABLE = frozenset(
_TUPLES = ("subjects", "site_patterns") _TUPLES = ("subjects", "site_patterns")
def _apply(project: Project, changes: dict[str, Any], today: date) -> Project: def _apply(project: Project, changes: dict[str, Any], today: date) -> Update:
"""The edited project; ValueError for what an edit may not touch.""" """The edited project; ValueError for what an edit may not touch."""
if project.ended_on is not None: if project.ended_on is not None:
msg = f"project {project.id!r} ended on {project.ended_on}" msg = f"project {project.id!r} ended on {project.ended_on}"
@ -50,37 +50,81 @@ def _apply(project: Project, changes: dict[str, Any], today: date) -> Project:
if "end" in changes and changes["end"] < today: if "end" in changes and changes["end"] < today:
msg = f"end {changes['end']} is in the past" msg = f"end {changes['end']} is in the past"
raise ValueError(msg) raise ValueError(msg)
edited = replace(project, **fields) return _schedule(project, replace(project, **fields), today)
return _keep_past_limits(project, edited, today)
def _keep_past_limits(old: Project, new: Project, today: date) -> Project: def _held(project: Project, day: date) -> Limits:
"""Freeze the old targets for the days before ``today`` if they changed.""" """What held on ``day``: both targets and the replaced gates."""
changed = (old.daily_minutes, old.unlock_minutes) != ( return Limits(
day,
project.daily_on(day),
project.unlock_on(day),
project.replaces_on(day),
)
def _reduces(held: Limits, new: Project) -> bool:
"""Whether ``new`` asks less than ``held``: lower target or a gate more."""
return (
new.daily_minutes < held.daily_minutes
or new.unlock_minutes < held.unlock_minutes
or not set(new.replaces) <= set(held.replaces or ())
)
def _schedule(old: Project, new: Project, today: date) -> Update:
"""Date the edit: a reduction of a started project waits for tomorrow.
A raise (or a project that has not started) applies from ``today``; the
days before keep what held. Anything that lowers a target or switches a
gate off defers the whole edit one day, so no edit can undo a lock or
cheapen the pay of a day already running.
"""
if (old.daily_minutes, old.unlock_minutes, old.replaces) == (
new.daily_minutes, new.daily_minutes,
new.unlock_minutes, new.unlock_minutes,
new.replaces,
):
return Update(new, today)
deferred = today >= old.start and _reduces(_held(old, today), new)
first = today + timedelta(days=1) if deferred else today
last = first - timedelta(days=1)
history = [h for h in old.history if h.until < first]
held = _held(old, last)
same = (held.daily_minutes, held.unlock_minutes, held.replaces) == (
new.daily_minutes,
new.unlock_minutes,
new.replaces,
) )
cutoff = today - timedelta(days=1) if last >= old.start and not same and not (history and history[-1].until == last):
if not changed or cutoff < old.start: history.append(held)
return new return Update(replace(new, history=tuple(history)), first)
if old.history and old.history[-1].until == cutoff:
return new
row = Limits(cutoff, old.daily_minutes, old.unlock_minutes)
return replace(new, history=(*old.history, row))
def update_project( @dataclass(frozen=True)
class Update:
"""An edited project and the first day its new targets and gates hold."""
project: Project
effective_from: date
def apply_update(
project_id: str, project_id: str,
*, *,
today: date | None = None, today: date | None = None,
**changes: Any, # noqa: ANN401 **changes: Any, # noqa: ANN401
) -> Project: ) -> Update:
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit. """Edit ``project_id``; ValueError on bad input, overlap or a past edit.
Editable: name, start, end, replaces, daily_minutes, unlock_minutes, Editable: name, start, end, replaces, daily_minutes, unlock_minutes,
subjects, site_patterns, work_url. A started project's start cannot move; changed subjects, site_patterns, work_url. A started project's start cannot move.
minute targets apply from ``today`` (default: now) on, earlier days keep An edit never lowers today's obligation (0.7.0): a lower daily or unlock
theirs. KeyError for an unknown id. target, or a gate added to ``replaces``, holds from tomorrow, the earlier
days (today included) keep theirs. Raises, gates removed and edits to a
project that has not started hold from ``today`` (default: now). The
result says which: ``effective_from``. A mixed edit defers as a whole.
KeyError for an unknown id.
""" """
unknown = sorted(set(changes) - _EDITABLE) unknown = sorted(set(changes) - _EDITABLE)
if unknown: if unknown:
@ -92,7 +136,8 @@ def update_project(
for i, project in enumerate(existing): for i, project in enumerate(existing):
if project.id != project_id: if project.id != project_id:
continue continue
edited = _apply(project, changes, today) done = _apply(project, changes, today)
edited = done.project
_validate(edited) _validate(edited)
for other in existing: for other in existing:
if other.id != project_id and ( if other.id != project_id and (
@ -102,5 +147,15 @@ def update_project(
raise ValueError(msg) raise ValueError(msg)
existing[i] = edited existing[i] = edited
_store.write_all(existing) _store.write_all(existing)
return edited return done
raise KeyError(project_id) raise KeyError(project_id)
def update_project(
project_id: str,
*,
today: date | None = None,
**changes: Any, # noqa: ANN401
) -> Project:
""":func:`apply_update` returning just the project."""
return apply_update(project_id, today=today, **changes).project

View File

@ -0,0 +1,75 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Overtime carries over: minutes past a day's target bank for the next day.
Contract (kuhy, 2026-11-02): own minutes beyond ``daily_on(day)`` are banked
(at most :data:`BANK_CAP`) and pay on the NEXT project day. The bank never
crosses the project's last day (what is left then expires) and a carry never
pays on a day with no project. The balance is derived from the activity
record, never stored, so it cannot drift; a project day's counted minutes
are ``min(daily, own + carried_in)``. The bank is spent first, so what a day
takes from it is fixed when the day starts and only own minutes grow.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
import logging
from typing import Final
from focus_project import _api, _clock
from focus_project._errors import RegistryError
from focus_project._own import own_minutes_of
BANK_CAP: Final = 60
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class Balance:
"""One project day: worked minutes, bank taken in, bank left for tomorrow."""
own: int
carried_in: int
carry_out: int
def paid_bank(self, daily: int) -> int:
"""Minutes of the day paid from the bank (fixed at the day's start)."""
return min(self.carried_in, daily)
def paid_own(self, daily: int) -> int:
"""Minutes of the day paid from own work, after the bank."""
return min(self.own, daily - self.paid_bank(daily))
def counted(self, daily: int) -> int:
"""Minutes that count on the day: own plus carried, capped at ``daily``."""
return min(daily, self.own + self.carried_in)
_NONE: Final = Balance(0, 0, 0)
def balance(day: date, now: float | None = None) -> Balance:
"""The balance of ``day`` (zeros with no project or an unreadable registry)."""
moment = _clock.now() if now is None else now
try:
project = _api.active(day)
if project is None:
return _NONE
bank = 0
cursor = project.start
while True:
own = own_minutes_of(project, cursor, moment)
total = own + bank
left = min(BANK_CAP, max(0, total - project.daily_on(cursor)))
if cursor >= day:
return Balance(own, bank, left)
bank, cursor = left, cursor + timedelta(days=1)
except (RegistryError, OSError) as exc:
_LOG.warning("focus_project registry unreadable, nothing banked: %s", exc)
return _NONE
def own_minutes(day: date, now: float | None = None) -> int:
"""Minutes worked on ``day``, not capped at the target (0 with no project)."""
return balance(day, now).own

View File

@ -0,0 +1,106 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pluggable check sources for the auto-checked milestones, and latching.
A source is ``fn(project, now) -> True | False | None``; ``None`` means "could
not check" (logged) and counts as not done. Only ``phone`` is auto-checked
(the calendar slot check was dropped, 2026-11-10: ``slot`` is two taps).
:func:`run_checks` is the only place sources run: status()/milestones() stay
cheap and read the latched ``check`` rows it appends (only on a change).
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
from focus_project import _milestones, _mutate, _store, evidence
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
CheckSource = Callable[[Project, datetime], bool | None]
DAY_MS = 24 * 3600 * 1000
# A phone check starts 24 h before the milestone is due.
WINDOW = timedelta(hours=24)
def _report_ok(value: object, report_ms: int) -> bool:
"""A plan exists, cards are due, the phone was seen <= 24 h before the report."""
if not isinstance(value, dict):
return False
try:
return bool(
value["plan"] is True
and int(value["due"]) > 0
and report_ms - int(value["seen_ms"]) <= DAY_MS
)
except KeyError, TypeError, ValueError:
return False
def phone_source(project: Project, now: datetime) -> bool | None:
"""Read tutor's latest ``phone_check`` report (<= 24 h old).
No report, a stale one or one stamped ahead of the clock = ``None``
(the worker is not reporting). A fresh one passes only if the plan
exists, cards are due and the phone was seen within 24 h of the report.
"""
report = evidence.last(evidence.read(project.id), "phone", "phone_check")
if report is None:
_LOG.warning("phone check: no tutor report for %s", project.id)
return None
if not 0 <= evidence.to_ms(now) - report.ms <= DAY_MS:
_LOG.warning("phone check: tutor report for %s is stale or ahead", project.id)
return None
return _report_ok(report.value, report.ms)
SOURCES: dict[str, CheckSource] = {"phone": phone_source}
def register_source(name: str, source: CheckSource) -> None:
"""Replace the callable behind an auto check (``phone``)."""
if name not in SOURCES:
msg = f"unknown check source {name!r}; known: {sorted(SOURCES)}"
raise ValueError(msg)
SOURCES[name] = source
def _run(project: Project, now: datetime) -> bool | None:
try:
return SOURCES["phone"](project, now)
except Exception as exc: # noqa: BLE001 (pluggable code: any failure = unknown)
_LOG.warning("phone check source failed: %s", exc)
return None
def run_checks(
project_id: str, *, now: datetime | None = None
) -> dict[str, bool | None]:
"""Run the auto checks of open phone milestones; latch what changed.
Returns milestone id -> verdict for the ones checked (inside their
24 h window, not yet done). KeyError for an unknown project; a focus
project has nothing to check.
"""
stamp = _mutate.local_now(now)
project = next((p for p in _store.read_all() if p.id == project_id), None)
if project is None:
raise KeyError(project_id)
open_ids = [
m.id
for m in _milestones.milestones(project, stamp)
if m.id.startswith("phone:")
and m.state not in {"done", "late_done"}
and stamp >= m.due - WINDOW
]
if not open_ids:
return {}
verdict = _run(project, stamp)
rows = evidence.read(project_id)
for mid in open_ids:
_, latest, seen = evidence.check_summary(rows, mid)
if verdict != latest and not (verdict is None and not seen):
evidence.append(project_id, mid, "check", verdict, stamp)
return dict.fromkeys(open_ids, verdict)

View File

@ -0,0 +1,159 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The interview evidence store: append-only rows plus copied files.
One jsonl per project (``{ms, milestone, kind, value}``) under the
:mod:`focus_project._paths` seam. There is deliberately no delete or rewrite
function: a milestone once evidenced stays evidenced.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import itertools
import json
import logging
import os
import re
import shutil
from typing import TYPE_CHECKING
from focus_project import _clock, _paths
if TYPE_CHECKING:
from pathlib import Path
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class Evidence:
"""One evidence row."""
ms: int
milestone: str
kind: str
value: object
@property
def at(self) -> datetime:
"""When it was recorded, as a naive local datetime."""
return from_ms(self.ms)
def to_ms(moment: datetime) -> int:
"""Unix milliseconds of a naive local datetime."""
return int(moment.timestamp() * 1000)
def from_ms(ms: int) -> datetime:
"""Naive local datetime of unix milliseconds."""
return datetime.fromtimestamp(ms / 1000, tz=UTC).astimezone().replace(tzinfo=None)
def clock_now() -> datetime:
"""The clock seam (:mod:`focus_project._clock`) as a naive local datetime."""
return from_ms(int(_clock.now() * 1000))
def append(
project_id: str, milestone: str, kind: str, value: object, now: datetime
) -> Evidence:
"""Append one row (fsynced) and return it."""
row = Evidence(to_ms(now), milestone, kind, value)
path = _paths.evidence_path(project_id)
path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(
{"ms": row.ms, "milestone": milestone, "kind": kind, "value": value}
)
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
handle.flush()
os.fsync(handle.fileno())
return row
def store_file(project_id: str, source: Path, now: datetime) -> str:
"""Copy ``source`` beside the rows under a name that never collides."""
target = _paths.evidence_files_dir(project_id)
target.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9._-]", "_", source.name)[:80]
stamp = to_ms(now)
for attempt in itertools.count():
name = f"{stamp}-{safe}" if not attempt else f"{stamp}-{attempt}-{safe}"
try:
with (target / name).open("xb") as out, source.open("rb") as src:
shutil.copyfileobj(src, out)
out.flush()
os.fsync(out.fileno())
except FileExistsError:
continue
return name
raise AssertionError # pragma: no cover (itertools.count never ends)
def _parse(line: str) -> Evidence | None:
try:
row = json.loads(line)
if not isinstance(row, dict) or isinstance(row["ms"], bool):
return None
return Evidence(
int(row["ms"]), str(row["milestone"]), str(row["kind"]), row["value"]
)
except ValueError, KeyError, TypeError:
return None
def read(project_id: str) -> list[Evidence]:
"""Every row in file order; a missing file is empty, bad lines are skipped.
A torn last line (crash mid-append) must not wipe out the whole history,
so malformed lines are logged and ignored; an unreadable file raises.
"""
try:
path = _paths.evidence_path(project_id)
text = path.read_text(encoding="utf-8", errors="replace")
except FileNotFoundError:
return []
rows: list[Evidence] = []
for number, line in enumerate(text.splitlines(), start=1):
parsed = _parse(line)
if parsed is None:
_LOG.warning(
"evidence %s line %d: skipping malformed row", project_id, number
)
else:
rows.append(parsed)
return rows
def first(
rows: list[Evidence], milestone: str, kind: str, since_ms: int = 0
) -> Evidence | None:
"""The earliest matching row at or after ``since_ms``."""
return next(
(
r
for r in rows
if (r.milestone, r.kind) == (milestone, kind) and r.ms >= since_ms
),
None,
)
def last(rows: list[Evidence], milestone: str, kind: str) -> Evidence | None:
"""The latest matching row."""
return next(
(r for r in reversed(rows) if (r.milestone, r.kind) == (milestone, kind)),
None,
)
def check_summary(
rows: list[Evidence], milestone: str
) -> tuple[int | None, bool | None, bool]:
"""``(first passing ms, latest verdict, any check row)`` for a milestone."""
checks = [r for r in rows if (r.milestone, r.kind) == (milestone, "check")]
passed = next((r.ms for r in checks if r.value is True), None)
latest = checks[-1].value if checks else None
return passed, (latest if isinstance(latest, bool) else None), bool(checks)

View File

@ -7,6 +7,9 @@ one-minute ``.timer``). Harvests every project day of the last
after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so
settled days cost one read and write nothing. Failure modes, by path: settled days cost one read and write nothing. Failure modes, by path:
Overtime past a day's target banks for the next day (``carry``): that day's
carry row pays it from the day's start, own-work rows pay the rest.
* a source that errors: logged, contributes 0, the run continues (fail closed); * a source that errors: logged, contributes 0, the run continues (fail closed);
* an unreadable registry, signing key or ledger: logged, exit status 1, so * an unreadable registry, signing key or ledger: logged, exit status 1, so
the unit shows ``failed`` instead of silently crediting nothing. the unit shows ``failed`` instead of silently crediting nothing.
@ -18,9 +21,8 @@ from datetime import date, timedelta
import logging import logging
import sys import sys
from focus_project import _api, _clock, ledger from focus_project import _api, _clock, carry, ledger
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project.minutes import minutes_for
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
_LOG = logging.getLogger("focus_project.harvest") _LOG = logging.getLogger("focus_project.harvest")
@ -43,11 +45,18 @@ def harvest_day(day: date, now: float) -> int:
project = _api.active(day) project = _api.active(day)
if project is None: if project is None:
return 0 return 0
total = minutes_for(day, now=now) daily = project.daily_on(day)
added = ledger.credit_to(project, day, total, now=now) bank = carry.balance(day, now)
added = ledger.credit_to(project, day, bank.paid_bank(daily), now=now, carry=True)
added += ledger.credit_to(project, day, bank.paid_own(daily), now=now)
if added: if added:
_LOG.info( _LOG.info(
"credited %d min to %s on %s (total %d)", added, project.id, day, total "credited %d min to %s on %s (total %d, %d from the bank)",
added,
project.id,
day,
bank.counted(daily),
bank.paid_bank(daily),
) )
return added return added

View File

@ -93,10 +93,19 @@ def _write_rows(path: Path, rows: list[Any]) -> None:
Path(handle.name).replace(path) Path(handle.name).replace(path)
def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int: def paid_minutes(
rows: list[Any],
key: bytes,
project_id: str,
day: date,
*,
carry: bool | None = None,
) -> int:
"""Minutes already paid for ``project_id`` on ``day`` by verified rows. """Minutes already paid for ``project_id`` on ``day`` by verified rows.
A repeated ``entry_id`` pays once, at its smallest count. ``carry`` picks one class (True: banked-overtime rows, False: own-work
rows); None sums both. A repeated ``entry_id`` pays once, at its smallest
count.
""" """
by_id: dict[str, int] = {} by_id: dict[str, int] = {}
for row in rows: for row in rows:
@ -112,6 +121,7 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
or not isinstance(minutes, int) or not isinstance(minutes, int)
or isinstance(minutes, bool) or isinstance(minutes, bool)
or minutes < 1 or minutes < 1
or (carry is not None and bool(detail.get("carry")) != carry)
or not earned_time.verified(row, key) or not earned_time.verified(row, key)
): ):
continue continue
@ -121,13 +131,31 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
def build_row( # noqa: PLR0913 def build_row( # noqa: PLR0913
project_id: str, day: date, total: int, minutes: int, key: bytes, *, now: float project_id: str,
day: date,
total: int,
minutes: int,
key: bytes,
*,
now: float,
carry: bool = False,
first_carry: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""The signed row paying ``minutes`` that bring the day's total to ``total``.""" """The signed row paying ``minutes`` that bring the day's total to ``total``.
A ``carry`` row pays banked overtime: ``<project>-<YYYYMMDD>-carry``, or
``-carry-m<total>`` when it tops up an earlier carry row (late data for
the day before grew the bank).
"""
ended = min(now, day_bounds(day)[1] - 1) ended = min(now, day_bounds(day)[1] - 1)
stem = f"{project_id}-{day:%Y%m%d}"
if not carry:
entry_id = f"{stem}-m{total}"
else:
entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}"
row: dict[str, Any] = { row: dict[str, Any] = {
"kind": CREDIT, "kind": CREDIT,
"entry_id": f"{project_id}-{day:%Y%m%d}-m{total}", "entry_id": entry_id,
"day": day.isoformat(), "day": day.isoformat(),
"created_at": float(now), "created_at": float(now),
"detail": { "detail": {
@ -135,6 +163,7 @@ def build_row( # noqa: PLR0913
"minutes": int(minutes), "minutes": int(minutes),
"total": int(total), "total": int(total),
"ended_at": float(ended), "ended_at": float(ended),
**({"carry": True} if carry else {}),
}, },
} }
row["hmac"] = str(earned_time.entry_signature(row, key)) row["hmac"] = str(earned_time.entry_signature(row, key))
@ -142,10 +171,18 @@ def build_row( # noqa: PLR0913
def credit_to( def credit_to(
project: Project, day: date, total: int, *, now: float | None = None project: Project,
day: date,
total: int,
*,
now: float | None = None,
carry: bool = False,
) -> int: ) -> int:
"""Pay the day up to ``total`` cumulative minutes; return minutes added. """Pay the day up to ``total`` cumulative minutes; return minutes added.
``carry`` pays the banked-overtime class and counts only its own rows;
the default pays (and counts) own work.
Idempotent: nothing is written when the ledger already pays ``total`` or Idempotent: nothing is written when the ledger already pays ``total`` or
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
write) so the harvester can fail loudly instead of crediting nothing quietly. write) so the harvester can fail loudly instead of crediting nothing quietly.
@ -159,11 +196,18 @@ def credit_to(
with path.with_suffix(".lock").open("w") as lock: with path.with_suffix(".lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX) fcntl.flock(lock, fcntl.LOCK_EX)
rows = read_rows(path) rows = read_rows(path)
paid = paid_minutes(rows, key, project.id, day) paid = paid_minutes(rows, key, project.id, day, carry=carry)
if total <= paid: if total <= paid:
return 0 return 0
row = build_row( row = build_row(
project.id, day, total, total - paid, key, now=now or time.time() project.id,
day,
total,
total - paid,
key,
now=now or time.time(),
carry=carry,
first_carry=paid == 0,
) )
if any( if any(
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
@ -172,7 +216,7 @@ def credit_to(
raise LedgerError(msg) raise LedgerError(msg)
_write_rows(path, [*rows, row]) _write_rows(path, [*rows, row])
stored = read_rows(path) stored = read_rows(path)
if paid_minutes(stored, key, project.id, day) != total: if paid_minutes(stored, key, project.id, day, carry=carry) != total:
msg = f"row {row['entry_id']} not found verified on read-back" msg = f"row {row['entry_id']} not found verified on read-back"
raise LedgerError(msg) raise LedgerError(msg)
return total - paid return total - paid

View File

@ -1,12 +1,17 @@
# Copyright (c) 2026 Krzysztof Rudnicki # Copyright (c) 2026 Krzysztof Rudnicki
"""Credited focus minutes per day: the union of every source's intervals. """Credited focus minutes per day: the union of every source's intervals.
Contract: union of activity intervals on the local calendar day, capped at Contract: union of activity intervals on the local calendar day plus the
the active project's ``daily_minutes``; overlaps never double count; whole overtime carried in from the day before (:mod:`focus_project.carry`), capped
at the active project's ``daily_minutes``; overlaps never double count; whole
minutes (floor). Time not yet lived is never credited: every interval is minutes (floor). Time not yet lived is never credited: every interval is
clipped at "now", so an event stamped ahead of the clock (a fast phone clock) clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
earns only once the real clock reaches it. An unreadable registry reads as 0 earns only once the real clock reaches it. An unreadable registry reads as 0
(logged): fail closed. (logged): fail closed.
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
(:mod:`focus_project.sources.activity`) and no other source covers, on the
local day up to "now"; carry and the cap play no part. Shown, never paid.
""" """
from __future__ import annotations from __future__ import annotations
@ -14,24 +19,14 @@ from __future__ import annotations
from datetime import date # noqa: TC003 from datetime import date # noqa: TC003
import logging import logging
from focus_project import _api, _clock, sources from focus_project import _api, _clock, carry, sources
from focus_project._errors import RegistryError from focus_project._errors import RegistryError
from focus_project._own import union_seconds
from focus_project.sources import activity
_LOG = logging.getLogger(__name__) _LOG = logging.getLogger(__name__)
def union_seconds(spans: list[sources.Interval]) -> float:
"""Total length of the union of ``spans``."""
total = 0.0
end = float("-inf")
for lo, hi in sorted(spans):
if hi <= end:
continue
total += hi - max(lo, end)
end = hi
return total
def minutes_for(day: date, now: float | None = None) -> int: def minutes_for(day: date, now: float | None = None) -> int:
"""Minutes of focus work credited on ``day`` (0 with no project active). """Minutes of focus work credited on ``day`` (0 with no project active).
@ -45,6 +40,29 @@ def minutes_for(day: date, now: float | None = None) -> int:
if project is None: if project is None:
return 0 return 0
moment = _clock.now() if now is None else now moment = _clock.now() if now is None else now
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment)) return carry.balance(day, moment).counted(project.daily_on(day))
seconds = union_seconds(spans)
return min(int(seconds // 60), project.daily_on(day))
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
"""Whole minutes of timer time on ``day`` that the gate did not count.
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
or with an unreadable registry (logged).
"""
try:
project = _api.active(day)
if project is None:
return 0
lo, hi = sources.day_bounds(day)
bounds = (lo, min(hi, _clock.now() if now is None else now))
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
counted = sources.clip(sources.collect(day, project), bounds)
except (RegistryError, OSError) as exc:
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
return 0
# |unbacked minus counted| = |unbacked union counted| - |counted|.
gap = union_seconds(unbacked + counted) - union_seconds(counted)
return int(gap // 60)
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]

View File

@ -8,23 +8,41 @@ starts a fresh one. Time after the last check-in is not credited until the
next event vouches for it. Every other source (tutor, sql_drills, ...) is a next event vouches for it. Every other source (tutor, sql_drills, ...) is a
heartbeat covering the five minutes before it. Only events recorded for heartbeat covering the five minutes before it. Only events recorded for
``project`` count. ``project`` count.
0.8.0: a ``tutor`` ``heartbeat`` (a graded check-in card) is also a beat of
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
beat it extends the segment; later it ends the segment at that last beat. It
never opens a segment. Other heartbeat sources never chain the timer.
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
if real activity of the same project backs it, whole span or nothing. A
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
accepted LeetCode submission (leetcode projects only); ``browser`` never
backs. A span ``(a, b)`` is backed if a backing event lies in
``[a - 300, b]``, or between two consecutive backing events at most
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
rule (every span counts), so paid days never change.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date
import importlib
from itertools import pairwise
import json import json
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Final
from focus_project._paths import activity_path from focus_project._paths import activity_path
from focus_project.sources import Interval, heartbeat_interval from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project from focus_project._model import Project
MAX_GAP_S = 600.0 MAX_GAP_S = 600.0
BACKING_FROM: Final = date(2026, 11, 8)
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
BRIDGE_S: Final = 1200.0
_LOG = logging.getLogger(__name__) _LOG = logging.getLogger(__name__)
@ -55,26 +73,92 @@ def read_events(project_id: str) -> list[dict[str, Any]]:
def timer_intervals(events: list[dict[str, Any]]) -> list[Interval]: def timer_intervals(events: list[dict[str, Any]]) -> list[Interval]:
"""Credited spans of the ``timer`` events (already time-ordered).""" """Credited spans of the ``timer`` events and tutor beats (time-ordered)."""
spans: list[Interval] = [] spans: list[Interval] = []
last: float | None = None # the last event of the open segment last: float | None = None # the last beat of the open segment
for event in events: for event in events:
ts = float(event["ts"]) ts = float(event["ts"])
kind = event.get("kind") kind = event.get("kind")
if kind not in {"start", "checkin", "stop"}: beat = _is_beat(event)
if not beat and kind not in {"start", "checkin", "stop"}:
continue continue
on_time = False
if last is not None and ts - last <= MAX_GAP_S: if last is not None and ts - last <= MAX_GAP_S:
spans.append((last, ts)) spans.append((last, ts))
last = None if kind == "stop" else ts on_time = True
# A beat never opens a segment; a stop closes it; start/checkin open it.
keep = on_time if beat else kind != "stop"
last = ts if keep else None
return spans return spans
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 def _is_beat(event: dict[str, Any]) -> bool:
"""Timer segments plus heartbeats of ``project`` (the registry clips).""" """A tutor heartbeat: it chains the timer segment (0.8.0)."""
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
def sessions(project_id: str) -> list[Interval]:
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
Built from the same timer/tutor-beat segments (and so the same
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
session, which ends at its last beat.
"""
events = read_events(project_id)
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
merged: list[Interval] = []
for lo, hi in timer_intervals(timer):
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
else:
merged.append((lo, hi))
return merged
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
"""Sorted times of the events that may back a timer span.
The LeetCode ledger is read in its own guard: if it fails, only the
submissions are lost, never the heartbeats (fail closed, narrow).
"""
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
try:
# Imported lazily: it pulls in earned_time, and the timer must keep
# working (heartbeat backing only) where that is not installed.
leetcode = importlib.import_module("focus_project.sources.leetcode")
times += leetcode.submission_times(project)
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
_LOG.exception("leetcode backing unavailable, heartbeats only")
return sorted(times)
def is_backed(span: Interval, times: list[float]) -> bool:
"""Whether a backing event lies in or just before ``span``, or bridges it."""
lo, hi = span
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
return True
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
def split_timer(
day: date, project: Project, events: list[dict[str, Any]] | None = None
) -> tuple[list[Interval], list[Interval]]:
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
found = read_events(project.id) if events is None else events
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
spans = timer_intervals(chain)
if day < BACKING_FROM:
return spans, []
times = backing_times(found, project)
backed = [s for s in spans if is_backed(s, times)]
return backed, [s for s in spans if s not in backed]
def intervals(day: date, project: Project) -> list[Interval]:
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id) events = read_events(project.id)
timer = [e for e in events if e.get("source") == "timer"]
beats = [e for e in events if e.get("source") != "timer"] beats = [e for e in events if e.get("source") != "timer"]
return [ return [
*timer_intervals(timer), *split_timer(day, project, events)[0],
*(heartbeat_interval(float(e["ts"])) for e in beats), *(heartbeat_interval(float(e["ts"])) for e in beats),
] ]

View File

@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
return None return None
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 def submission_times(project: Project) -> list[float]:
"""One heartbeat per accepted submission (the registry clips to the day).""" """Times of verified accepted submissions; [] for a non-leetcode project."""
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns): if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
return [] return []
key = ledger.read_key() key = ledger.read_key()
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
_LOG.warning("leetcode source: no signing key, contributes 0") _LOG.warning("leetcode source: no signing key, contributes 0")
return [] return []
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file())) stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
return [heartbeat_interval(ts) for ts in stamps if ts is not None] return [ts for ts in stamps if ts is not None]
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
return [heartbeat_interval(ts) for ts in submission_times(project)]

View File

@ -3,31 +3,79 @@
from __future__ import annotations from __future__ import annotations
from datetime import date # noqa: TC003 import logging
from typing import Any from typing import TYPE_CHECKING, Any
from focus_project import _api, minutes from focus_project import _api, _milestones, carry, evidence, minutes
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
def _milestone_block(project: Project) -> tuple[list[dict[str, Any]], bool]:
"""Milestone rows and whether any is red; unreadable evidence shows none.
Display path, so it fails open (logged): the lock path, ``owed_lock``,
has its own handling.
"""
now = evidence.clock_now()
try:
items = _milestones.milestones(project, now)
except OSError as exc:
_LOG.warning("interview evidence unreadable, no milestones shown: %s", exc)
return [], False
return [m.to_dict() for m in items], any(m.red for m in items)
def status(day: date) -> dict[str, Any]: def status(day: date) -> dict[str, Any]:
"""Progress of the project active on ``day``; ``project`` is None if none.""" """Progress of the project active on ``day``; ``project`` is None if none.
``minutes`` is what was really worked plus what the day took from the
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
is the capped figure that pays. ``unbacked_minutes`` is timer time the
anti-cheat gate refused (shown as "not counted", never paid).
"""
project = _api.active(day) project = _api.active(day)
if project is None: if project is None:
return { return {
"project": None, "project": None,
"name": None, "name": None,
"minutes": 0, "minutes": 0,
"own_minutes": 0,
"counted_minutes": 0,
"carried_in": 0,
"carry_out": 0,
"daily_minutes": None, "daily_minutes": None,
"unlock_minutes": None, "unlock_minutes": None,
"ends": None, "ends": None,
"replaces": [], "replaces": [],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
} }
daily = project.daily_on(day)
bank = carry.balance(day)
rows, missed = _milestone_block(project)
return { return {
"project": project.id, "project": project.id,
"name": project.name, "name": project.name,
"minutes": minutes.minutes_for(day), # Real minutes behind the day (own + banked in), never capped: the
"daily_minutes": project.daily_on(day), # number to show. Pay is ``counted_minutes``; earned_time caps it.
"minutes": bank.own + bank.carried_in,
"own_minutes": bank.own,
"counted_minutes": bank.counted(daily),
"carried_in": bank.carried_in,
"carry_out": bank.carry_out,
"daily_minutes": daily,
"unlock_minutes": project.unlock_on(day), "unlock_minutes": project.unlock_on(day),
"ends": project.last_day.isoformat(), "ends": project.last_day.isoformat(),
"replaces": list(project.replaces), "replaces": list(project.replaces_on(day)),
"unbacked_minutes": minutes.unbacked_minutes_for(day),
# Interview projects: [{id, due ISO, state}] and whether any is red.
"milestones": rows,
"missed": missed,
} }

View File

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from focus_project import _clock from focus_project import _clock, _paths, checks
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -40,3 +40,16 @@ def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
def data(tmp_path: Path) -> Path: def data(tmp_path: Path) -> Path:
"""The redirected data directory (may not exist yet).""" """The redirected data directory (may not exist yet)."""
return tmp_path / "data" return tmp_path / "data"
@pytest.fixture(autouse=True)
def _interview_isolation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, _redirect_state: None
) -> None:
"""Evidence lives under the redirected data dir; sources are a private copy.
Fails closed (R3): if the path seam ever resolves outside ``tmp_path`` the
test errors before it can write a production evidence row.
"""
assert _paths.evidence_dir().is_relative_to(tmp_path)
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))

View File

@ -0,0 +1,65 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Shared fixtures-as-functions for the interview-prep tests."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
import json
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project._paths import activity_path
if TYPE_CHECKING:
from pathlib import Path
START = date(2026, 11, 10) # Tuesday: D1
TEST_DAY = date(2026, 11, 27) # Friday: T
TEST_AT = datetime.combine(TEST_DAY, time(10, 0))
SAT1 = date(2026, 11, 14)
SAT2 = date(2026, 11, 21)
def at(day: int, hour: int = 0, minute: int = 0) -> datetime:
"""A November 2026 local datetime."""
return datetime.combine(date(2026, 11, day), time(hour, minute))
def make(pid: str = "iv", /, **kw: object) -> fp.Project:
"""Create an interview project D1 = Nov 10, T = Nov 27."""
args: dict[str, object] = {
"name": "Interview",
"start": START,
"end": TEST_DAY,
"replaces": ["leetcode", "reading", "tutor"],
"daily_minutes": 120,
"unlock_minutes": 30,
"kind": "interview",
"test_at": TEST_AT,
"invite_url": "https://app.codility.com/c/run/abc",
}
args.update(kw)
return fp.create_project(pid, **args) # type: ignore[arg-type]
def shot(tmp_path: Path, name: str = "rules.png") -> Path:
"""A non-empty screenshot stand-in."""
path = tmp_path / name
path.write_bytes(b"\x89PNG rules")
return path
def states(project: fp.Project, now: datetime) -> dict[str, str]:
"""Milestone id -> state."""
return {m.id: m.state for m in fp.milestones(project, now)}
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
"""A timer session start -> check-in every ``step`` min -> stop."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for offset in range(0, minutes + 1, step):
kind = "start" if offset == 0 else "checkin"
when = start + timedelta(minutes=offset)
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date, datetime
import importlib import importlib
import json import json
from pathlib import Path # noqa: TC003 from pathlib import Path # noqa: TC003
@ -13,12 +13,16 @@ import time
import pytest import pytest
import focus_project as fp import focus_project as fp
from focus_project import _clock, minutes from focus_project import _clock
from focus_project._paths import data_dir from focus_project._paths import data_dir
D1 = date(2026, 11, 2) D1 = date(2026, 11, 2)
def _at(hour: int, minute: int) -> float:
return datetime(2026, 11, 2, hour, minute).astimezone().timestamp()
def _project() -> None: def _project() -> None:
fp.create_project( fp.create_project(
"hr", "hr",
@ -115,19 +119,27 @@ def test_record_activity_concurrent_appends(data: Path) -> None:
assert all(json.loads(x)["source"] == "tutor" for x in lines) assert all(json.loads(x)["source"] == "tutor" for x in lines)
def test_status_active_uses_minutes_module(monkeypatch: pytest.MonkeyPatch) -> None: def test_status_active_reports_the_worked_minutes() -> None:
_project() _project()
assert fp.status(D1)["minutes"] == 0 # the stub assert fp.status(D1)["minutes"] == 0
monkeypatch.setattr(minutes, "minutes_for", lambda _day: 45) for k in range(1, 10): # nine heartbeats, five minutes each
fp.record_activity("tutor", "heartbeat", ts=_at(8, 5 * k))
st = fp.status(D1) st = fp.status(D1)
assert st == { assert st == {
"project": "hr", "project": "hr",
"name": "HackerRank prep", "name": "HackerRank prep",
"minutes": 45, "minutes": 45,
"own_minutes": 45,
"counted_minutes": 45,
"carried_in": 0,
"carry_out": 0,
"daily_minutes": 120, "daily_minutes": 120,
"unlock_minutes": 30, "unlock_minutes": 30,
"ends": "2026-11-05", "ends": "2026-11-05",
"replaces": ["leetcode"], "replaces": ["leetcode"],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
} }
@ -141,10 +153,17 @@ def test_status_inactive() -> None:
"project", "project",
"name", "name",
"minutes", "minutes",
"own_minutes",
"counted_minutes",
"carried_in",
"carry_out",
"daily_minutes", "daily_minutes",
"unlock_minutes", "unlock_minutes",
"ends", "ends",
"replaces", "replaces",
"unbacked_minutes",
"milestones",
"missed",
} }

View File

@ -0,0 +1,181 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""0.10.0: a timer span earns only if a same-project beat backs it."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import _api, minutes
from focus_project.sources import activity
from focus_project.tests import test_minutes as tm
if TYPE_CHECKING:
import pytest
DAY = date(2026, 11, 8) # BACKING_FROM
def at(hour: int, minute: int = 0) -> float:
return tm.at(hour, minute, day=DAY)
def proj() -> None:
fp.create_project(
"hr",
name="HR prep",
start=DAY,
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
def rows(*items: tuple[str, str, float]) -> None:
tm.events(*items)
def timer_only() -> None:
rows(
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 10)),
)
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
proj()
timer_only()
assert minutes.minutes_for(DAY) == 0
assert minutes.unbacked_minutes_for(DAY) == 10
assert fp.status(DAY)["unbacked_minutes"] == 10
def test_a_beat_in_the_span_backs_all_of_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 5)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_backs_only_the_spans_it_touches() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 7)))
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
assert minutes.unbacked_minutes_for(DAY) == 2
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
proj()
rows(
("timer", "start", at(9)),
("timer", "stop", at(9, 10)),
("sql_drills", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_just_before_the_span_backs_it() -> None:
proj()
rows(
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
("timer", "start", at(9)),
("timer", "stop", at(9, 5)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_a_late_beat_does_not_back_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 40)))
assert minutes.unbacked_minutes_for(DAY) == 10
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 50)),
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 30)),
("timer", "start", at(9)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 40)),
)
assert minutes.unbacked_minutes_for(DAY) == 8
def test_browser_never_backs() -> None:
proj()
timer_only()
rows(("browser", "heartbeat", at(9, 7)))
# The beat pays its own 9:02-9:07 but backs no timer span.
assert minutes.unbacked_minutes_for(DAY) == 5
assert minutes.minutes_for(DAY) == 5
def test_days_before_backing_from_keep_the_old_rule() -> None:
fp.create_project(
"hr",
name="Old",
start=date(2026, 11, 2),
end=date(2026, 11, 5),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
old = date(2026, 11, 2)
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
assert minutes.minutes_for(old) == 10
assert minutes.unbacked_minutes_for(old) == 0
def test_no_project_is_zero_unbacked() -> None:
assert minutes.unbacked_minutes_for(DAY) == 0
assert fp.status(DAY)["unbacked_minutes"] == 0
def test_unbacked_is_clipped_at_now() -> None:
proj()
timer_only()
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
def test_split_timer_returns_backed_and_unbacked() -> None:
proj()
timer_only()
project = _api.active(DAY)
assert project is not None
backed, unbacked = activity.split_timer(DAY, project)
assert backed == []
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
def test_backing_from_is_exported() -> None:
assert date(2026, 11, 8) == fp.BACKING_FROM
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(_day: date) -> None:
raise OSError
monkeypatch.setattr("focus_project._api.active", boom)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -0,0 +1,131 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
The core rule lives in ``test_backing.py``; these pin the edges the spec
named (C5-C7) with leetcode-ledger and other-project fixtures.
"""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import minutes
from focus_project._paths import activity_path
from focus_project.tests.test_leetcode_source import ac_row, write
from focus_project.tests.test_minutes import at
if TYPE_CHECKING:
from pathlib import Path
DAY = date(2026, 11, 9) # after the cutover
PRE = date(2026, 11, 7) # before it
@pytest.fixture(autouse=True)
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Nothing may fall back to the real home directory."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
fp.create_project(
pid,
name=pid,
start=date(2026, 11, 6),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=240,
unlock_minutes=30,
site_patterns=list(patterns),
)
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for item in items:
source, kind, ts = item[:3]
pid = item[3] if len(item) == 4 else "hr"
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
handle.write(json.dumps(row) + "\n")
def timer(day: date, *marks: tuple[int, int]) -> None:
"""A segment: start at the first mark, check-ins, stop at the last."""
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
rows(
*(
("timer", k, at(h, m, day=day))
for k, (h, m) in zip(kinds, marks, strict=True)
)
)
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
rows((source, "heartbeat", at(h, m, day=day), pid))
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
beat(source, 9, 5)
assert minutes.minutes_for(DAY) == 10
def test_browser_and_other_project_beats_do_not_back() -> None:
make() # "other" needs no registry entry: rows are filtered by id
timer(DAY, (9, 0), (9, 5), (9, 10))
beat("browser", 9, 4)
beat("tutor", 9, 4, pid="other")
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
make("hr", ("sqlzoo.net",))
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 0
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
make()
rows(
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
)
assert minutes.minutes_for(date(2026, 11, 7)) == 2
assert minutes.minutes_for(date(2026, 11, 8)) == 0
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
nothing bridges it, so it shows as unbacked.
"""
make()
rows(
("timer", "start", at(9, 0, day=DAY)),
("timer", "checkin", at(9, 8, day=DAY)),
)
beat("tutor", 9, 16)
assert minutes.minutes_for(DAY) == 8
assert minutes.unbacked_minutes_for(DAY) == 8

View File

@ -0,0 +1,183 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Overtime carries over: own minutes, the bank, pay and the carry ledger rows."""
from __future__ import annotations
from datetime import date, timedelta
import json
from pathlib import Path # noqa: TC003
from typing import Any
import earned_time
import pytest
import focus_project as fp
from focus_project import carry, harvest, ledger, minutes
from focus_project.tests.test_minutes import D1, at, events, project
KEY = b"k" * 32
D2 = D1 + timedelta(days=1)
D3 = D2 + timedelta(days=1)
D4 = D3 + timedelta(days=1) # the last day of project()
D5 = D4 + timedelta(days=1) # after the end
@pytest.fixture(autouse=True)
def _key(tmp_path: Path) -> None:
(tmp_path / "hmac.key").write_bytes(KEY)
def work(day: date, mins: int) -> None:
"""``mins`` focused minutes on ``day``: tutor heartbeats every five minutes."""
events(
*(
("tutor", "heartbeat", at(8, 0, day=day) + 300 * k)
for k in range(1, mins // 5 + 1)
)
)
def rows() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = json.loads(ledger.ledger_path().read_text())["entries"]
return out
def test_own_minutes_are_not_capped_but_pay_is() -> None:
project()
work(D1, 125)
assert carry.own_minutes(D1) == 125
assert minutes.minutes_for(D1) == 120
def test_overtime_banks_into_the_next_day() -> None:
project()
work(D1, 125)
assert carry.balance(D1) == carry.Balance(own=125, carried_in=0, carry_out=5)
assert carry.balance(D2) == carry.Balance(own=0, carried_in=5, carry_out=0)
assert minutes.minutes_for(D2) == 5
def test_carry_adds_to_own_minutes_on_the_next_day() -> None:
project()
work(D1, 125)
work(D2, 100)
assert minutes.minutes_for(D2) == 105
assert carry.balance(D2).carry_out == 0
def test_the_bank_is_capped_at_sixty() -> None:
project()
work(D1, 200)
assert carry.balance(D1).carry_out == carry.BANK_CAP == 60
def test_overtime_chains_over_days() -> None:
project()
work(D1, 130)
work(D2, 125)
assert carry.balance(D2) == carry.Balance(own=125, carried_in=10, carry_out=15)
assert minutes.minutes_for(D3) == 15
def test_the_bank_never_crosses_the_project_end() -> None:
project()
work(D4, 150)
assert carry.balance(D4).carry_out == 30
assert minutes.minutes_for(D5) == 0
assert carry.balance(D5) == carry.Balance(0, 0, 0)
def test_the_first_day_has_nothing_banked_in() -> None:
project()
assert carry.balance(D1 - timedelta(days=1)).carried_in == 0
assert carry.balance(D1).carried_in == 0
def test_a_past_target_is_used_for_a_past_day() -> None:
project()
work(D1, 125)
fp.update_project("hr", today=D2, daily_minutes=100, unlock_minutes=30)
assert carry.balance(D1).carry_out == 5 # D1 still judged at 120
assert carry.balance(D2).carried_in == 5
def test_status_reports_real_minutes_and_the_bank() -> None:
project()
work(D1, 125)
shown = fp.status(D1)
assert shown["minutes"] == 125
assert shown["own_minutes"] == 125
assert shown["counted_minutes"] == 120
assert (shown["carried_in"], shown["carry_out"]) == (0, 5)
nxt = fp.status(D2)
assert (nxt["minutes"], nxt["carried_in"]) == (5, 5)
def test_status_without_a_project_has_zero_bank() -> None:
shown = fp.status(D1)
assert (shown["own_minutes"], shown["carried_in"], shown["carry_out"]) == (0, 0, 0)
def test_unreadable_registry_banks_nothing(data: Path) -> None:
data.mkdir(parents=True)
(data / "projects.json").write_text("{")
assert carry.balance(D2) == carry.Balance(0, 0, 0)
assert carry.own_minutes(D2) == 0
def test_harvest_pays_own_rows_then_one_carry_row() -> None:
project()
work(D1, 125)
work(D2, 100)
harvest.harvest(now=at(23, 0, day=D2))
got = {r["entry_id"]: r for r in rows()}
assert got["hr-20261102-m120"]["detail"]["minutes"] == 120
assert got["hr-20261103-carry"]["detail"]["minutes"] == 5
assert got["hr-20261103-carry"]["detail"]["carry"] is True
assert got["hr-20261103-m100"]["detail"]["minutes"] == 100
assert all(earned_time.verified(r, KEY) for r in got.values())
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 105
def test_pay_never_exceeds_the_daily_target() -> None:
project()
work(D1, 125)
work(D2, 130)
harvest.harvest(now=at(23, 0, day=D2))
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
assert minutes.minutes_for(D2) == 120
def test_carry_is_paid_at_the_start_of_the_day_and_not_again() -> None:
project()
work(D1, 125)
harvest.harvest(now=at(0, 5, day=D2))
before = rows()
assert [r["entry_id"] for r in before if r["day"] == "2026-11-03"] == [
"hr-20261103-carry"
]
harvest.harvest(now=at(0, 6, day=D2))
assert rows() == before
def test_late_data_for_yesterday_pays_only_the_carry_delta() -> None:
project()
work(D1, 125)
harvest.harvest(now=at(9, 0, day=D2))
work(D1, 130) # a late phone sync: five more minutes
harvest.harvest(now=at(9, 5, day=D2))
paid = [r for r in rows() if r["detail"].get("carry")]
assert [r["entry_id"] for r in paid] == [
"hr-20261103-carry",
"hr-20261103-carry-m10",
]
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 10
def test_own_rows_leave_room_for_the_carry() -> None:
project()
work(D1, 125)
work(D2, 120)
harvest.harvest(now=at(23, 0, day=D2))
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
assert "hr-20261103-m115" in {r["entry_id"] for r in rows()}

View File

@ -0,0 +1,193 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pluggable check sources, the phone source, and latching."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _paths, checks, evidence
from focus_project.tests.helpers import SAT1, at, make
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
MID = "phone:2026-11-14"
def _ms(moment: datetime) -> int:
return int(moment.timestamp() * 1000)
def _report(
moment: datetime,
*,
plan: bool = True,
due: int = 5,
seen: datetime | None = None,
) -> None:
value = {"plan": plan, "due": due, "seen_ms": _ms(seen or moment)}
fp.record_evidence("iv", "phone", "phone_check", value, now=moment)
def _phone(now: datetime) -> bool | None:
return checks.phone_source(fp.list_projects()[0], now)
def test_phone_source_without_a_report_is_unknown(
caplog: pytest.LogCaptureFixture,
) -> None:
make()
with caplog.at_level("WARNING"):
assert _phone(at(13, 9)) is None
assert "phone" in caplog.text
def test_phone_source_passes_on_a_fresh_good_report() -> None:
make()
_report(at(13, 8), seen=at(12, 20))
assert _phone(at(13, 9)) is True
@pytest.mark.parametrize(
"bad",
[{"plan": False}, {"due": 0}, {"seen": at(11, 7)}],
)
def test_phone_source_fails_on_any_bad_part(bad: dict[str, object]) -> None:
make()
_report(at(13, 8), **bad) # type: ignore[arg-type]
assert _phone(at(13, 9)) is False
def test_phone_source_stale_or_future_report_is_unknown() -> None:
make()
_report(at(13, 8))
assert _phone(at(14, 8, 1)) is None # the worker stopped reporting
assert _phone(at(13, 7)) is None # stamped ahead of the clock
def test_phone_source_uses_the_latest_report() -> None:
make()
_report(at(13, 8), plan=False)
_report(at(13, 9))
assert _phone(at(13, 10)) is True
def test_phone_source_hand_edited_row_is_a_failure() -> None:
make()
_report(at(13, 8))
path = _paths.evidence_path("iv")
row = {"ms": _ms(at(13, 9)), "milestone": "phone", "kind": "phone_check"}
path.write_text(path.read_text() + json.dumps({**row, "value": "ok"}) + "\n")
assert _phone(at(13, 10)) is False
def _patch(
monkeypatch: pytest.MonkeyPatch, fn: Callable[[fp.Project, datetime], bool | None]
) -> None:
monkeypatch.setitem(checks.SOURCES, "phone", fn)
def _trip_project() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
def test_run_checks_latches_a_pass(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
_patch(monkeypatch, lambda p, n: True)
assert fp.run_checks("iv", now=at(13, 9)) == {MID: True}
assert fp.run_checks("iv", now=at(13, 10)) == {MID: True}
rows = [r for r in evidence.read("iv") if r.kind == "check"]
assert [(r.milestone, r.value) for r in rows] == [(MID, True)]
def test_run_checks_only_inside_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
_patch(monkeypatch, lambda p, n: True)
assert fp.run_checks("iv", now=at(12, 11, 59)) == {} # >24 h before due
assert fp.run_checks("iv", now=at(12, 12)) == {MID: True}
def test_run_checks_records_changes_not_every_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_trip_project()
answers: list[bool | None] = [None, False, False, None, None]
_patch(monkeypatch, lambda p, n: answers.pop(0))
for minute in range(5):
fp.run_checks("iv", now=at(13, 9, minute))
rows = [r.value for r in evidence.read("iv") if r.kind == "check"]
assert rows == [False, None] # leading None writes nothing
def test_run_checks_source_crash_is_unknown_and_loud(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
_trip_project()
def boom(p: fp.Project, n: datetime) -> bool:
msg = "tutor down"
raise OSError(msg)
_patch(monkeypatch, boom)
with caplog.at_level("WARNING"):
assert fp.run_checks("iv", now=at(13, 9)) == {MID: None}
assert "tutor down" in caplog.text
def test_run_checks_skips_finished_milestones(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
evidence.append("iv", MID, "check", True, at(12, 10))
_patch(monkeypatch, lambda p, n: pytest.fail("already done"))
assert fp.run_checks("iv", now=at(13, 9)) == {}
def test_run_checks_ignores_focus_projects() -> None:
fp.create_project(
"f",
name="F",
start=at(10).date(),
end=at(12).date(),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.run_checks("f", now=at(11, 9)) == {}
with pytest.raises(KeyError):
fp.run_checks("ghost", now=at(11, 9))
def test_register_source_swaps_the_callable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
def fn(p: fp.Project, n: datetime) -> bool:
return True
fp.register_source("phone", fn)
assert checks.SOURCES["phone"] is fn
with pytest.raises(ValueError, match="source"):
fp.register_source("calendar", fn)
def test_end_to_end_phone_milestone_goes_done() -> None:
_trip_project()
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
_report(at(13, 9))
assert fp.run_checks("iv", now=at(13, 9, 30)) == {MID: True}
state = {m.id: m.state for m in fp.milestones(fp.list_projects()[0], at(13, 10))}
assert state[MID] == "done"
def test_report_missing_a_field_fails() -> None:
make()
path = _paths.evidence_path("iv")
path.parent.mkdir(parents=True, exist_ok=True)
row = {"ms": _ms(at(13, 8)), "milestone": "phone", "kind": "phone_check"}
path.write_text(json.dumps({**row, "value": {"plan": True}}) + "\n")
assert _phone(at(13, 9)) is False

Some files were not shown because too many files have changed in this diff Show More