mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
Adopt the four markdown namespaces and gate them
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md / TODO*.md so a future session can tell a live task from a finished record without reading the file to find out. TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted when the work lands. Enforced by the md-naming pre-commit hook and CI workflow, both delegating to the shared gate in kuhyx/utils so this repo's idea of a valid name cannot drift from every other repo's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
This commit is contained in:
parent
0c348a5ee9
commit
21a989ce21
38
.github/workflows/md-naming.yml
vendored
Normal file
38
.github/workflows/md-naming.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
name: md-naming
|
||||
|
||||
# The four markdown namespaces, enforced on push as well as at commit time.
|
||||
# The local pre-commit hook can be skipped (a fresh clone that never ran
|
||||
# `pre-commit install` has no hooks at all), so the convention needs a second
|
||||
# gate that no one can forget to install.
|
||||
#
|
||||
# Deliberately a SEPARATE workflow from any release job: a badly named doc
|
||||
# should fail lint, not block publishing an artifact.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
md-naming:
|
||||
name: markdown naming convention
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The gate and its exemption list live in the shared utils repo so every
|
||||
# repo enforces the same namespaces. Checking it out beats vendoring a
|
||||
# copy here, which is exactly how a gate and a survey start disagreeing.
|
||||
- name: Check out the shared gate
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: kuhyx/utils
|
||||
path: .utils
|
||||
|
||||
- name: Enforce the naming convention
|
||||
run: bash .utils/scripts/check_md_naming.sh --all
|
||||
|
||||
# Warns only: no machine can prove a task is finished, so an old TODO
|
||||
# gets a nudge, never a failure.
|
||||
- name: Warn about stale TODOs
|
||||
run: PYTHONPATH=.utils python3 .utils/md_naming/stale.py
|
||||
@ -154,8 +154,20 @@ repos:
|
||||
files: '^wake_alarm/.*\.py$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: max-file-length
|
||||
name: Max file length (500 lines)
|
||||
entry: python3 scripts/check_file_length.py
|
||||
# The shared 250-line cap. This repo used to run a local script with a
|
||||
# higher limit under this same hook id, so it looked gated while letting
|
||||
# through files every other repo rejects.
|
||||
- id: file-length
|
||||
name: file length <= 250 lines
|
||||
entry: scripts/check_file_length.sh
|
||||
language: system
|
||||
types_or: [python, shell]
|
||||
|
||||
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
||||
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
||||
# Shared gate in ~/utils owns the namespaces and the exemption list, so
|
||||
# one repo's idea of a valid name cannot drift from every other repo's.
|
||||
- id: md-naming
|
||||
name: markdown naming convention
|
||||
entry: scripts/check_md_naming.sh
|
||||
language: system
|
||||
files: \.(md|markdown)$
|
||||
|
||||
@ -1,251 +0,0 @@
|
||||
# Design audit — wake-alarm
|
||||
|
||||
Generated against safe-design-rules (anthonyhobday.com/sideprojects/saferules).
|
||||
Report only — nothing in this repo was changed by the audit itself.
|
||||
|
||||
This repo has two distinct UI surfaces, audited separately below:
|
||||
1. Flutter app — `phone_app/lib/` (theme in `main.dart`, screens in `screens/`)
|
||||
2. Python/Tkinter alarm dismiss-challenge UI — `wake_alarm/_alarm.py`
|
||||
|
||||
Passing/violation/N-A counts per surface are given at the top of each section
|
||||
so all 28 rules are accounted for even though only violations get expanded.
|
||||
|
||||
---
|
||||
|
||||
## Flutter app (`phone_app/`)
|
||||
|
||||
Theme entry point: `phone_app/lib/main.dart:17-21` —
|
||||
`ThemeData.dark(useMaterial3: true).copyWith(colorScheme:
|
||||
ColorScheme.fromSeed(seedColor: Colors.orange, brightness: Brightness.dark))`.
|
||||
Material 3's `fromSeed` auto-generates near-black surfaces, tinted neutrals,
|
||||
and contrast-checked roles, so rules 1/2/3/9/10/18 pass "for free" via the
|
||||
theme — the actual findings are places where code reaches for a literal
|
||||
`Colors.*` constant instead of a role from that scheme, or a shadow/padding
|
||||
value the theme doesn't control.
|
||||
|
||||
Verdict roster: Pass — 1,2,3,8,10,17,18,23,26. Violation — 4,9(partial,
|
||||
see notes),11,20,26(Card),28. N/A — 5,6,7,12,13,14,15,16,19,21,22,24,25,27.
|
||||
(9 and 26 appear in both lists — see the per-rule notes.)
|
||||
|
||||
### Violations
|
||||
|
||||
- **Rule 4 (everything deliberate)** — `phone_app/lib/screens/home_screen.dart:198-199` and
|
||||
`settings_screen.dart:126-128,161,245` use raw `Colors.redAccent` /
|
||||
`Colors.greenAccent` / `Colors.red` for status/error text instead of
|
||||
`Theme.of(context).colorScheme.error` (or a `tertiary`/success role);
|
||||
`home_screen.dart:207` uses raw `Colors.orange` and `settings_screen.dart:138`
|
||||
uses raw `Colors.grey` for the same reason. None of these are derived from
|
||||
the seeded `ColorScheme`, so they won't stay in sync if the seed color ever
|
||||
changes, and they bypass whatever contrast guarantee `fromSeed` gives the
|
||||
rest of the app. → Replace with `colorScheme.error` / a defined success
|
||||
color / `colorScheme.onSurfaceVariant`.
|
||||
- **Rule 9 (distinct brightness / no competing colors)** — `home_screen.dart:198-199`
|
||||
vs `settings_screen.dart:126-128,161`: `Colors.redAccent`/`Colors.greenAccent`
|
||||
are reused ad hoc as the only "status" signal color pair across two separate
|
||||
screens with no shared definition — not a brightness clash per se, but an
|
||||
undeliberate ad-hoc pairing (same root cause as rule 4).
|
||||
- **Rule 11 (mathematically related measurements)** — the app mostly uses an
|
||||
8px scale (`home_screen.dart:149,154,165,170,191,203` = 32/16/8/32/24/16;
|
||||
`settings_screen.dart:93,98,121,131` = 16/16/16/32) but breaks it twice:
|
||||
`settings_screen.dart:111` (`padding: EdgeInsets.symmetric(vertical: 14)`)
|
||||
and `settings_screen.dart:163` (`SizedBox(width: 12)`) — 14 and 12 aren't on
|
||||
the 8px grid the rest of the file uses. → change to 16/8 or otherwise round
|
||||
to the established scale.
|
||||
- **Rule 20 (body text ≥16px)** — `settings_screen.dart:138`:
|
||||
`TextStyle(fontSize: 12, color: Colors.grey)` for the PC-config help text is
|
||||
below the 16px minimum. `home_screen.dart:153` (`fontSize: 16`) and other
|
||||
`fontSize: 18` usages are fine.
|
||||
- **Rule 26 (no shadows in dark interfaces)** — `settings_screen.dart:154`
|
||||
(`return Card(...)`) is an unmodified Material 3 `Card`, which renders with
|
||||
default elevation (a soft shadow/tonal overlay) inside a `ThemeData.dark()`
|
||||
app (`main.dart:17`). Per the rule, dark UIs should avoid shadows entirely.
|
||||
→ Either set `elevation: 0` and rely on a filled/tonal surface color for
|
||||
the card, or accept the default and drop this from future review — flagging
|
||||
now since it's the only place shadows appear at all (see rule 27 below).
|
||||
- **Rule 28 (lower icon contrast next to text)** — `settings_screen.dart:159-162`
|
||||
(`Icon(connected ? Icons.check_circle : Icons.cancel, color: connected ?
|
||||
Colors.greenAccent : Colors.redAccent)`) sits directly next to `Text(...)`
|
||||
at full theme-text contrast — the icon isn't dimmed relative to the
|
||||
adjacent label. Same issue for the settings-gear `IconButton` at
|
||||
`home_screen.dart:133-134` (default `Icons.settings` color, undimmed, next
|
||||
to the "Wake Alarm Sync" AppBar title).
|
||||
|
||||
### Not applicable
|
||||
|
||||
- Rule 5 (optical alignment) — only stock Material icons/text used, no custom
|
||||
shapes needing eyeballed alignment.
|
||||
- Rule 6 (letter-spacing/line-height by size) — no explicit `letterSpacing`/
|
||||
`height` overrides anywhere in `phone_app/lib/` (grep confirmed); relies on
|
||||
Material defaults, nothing to audit.
|
||||
- Rule 7 (border contrast) — no `Border`/`BorderRadius`/`border:` usage found
|
||||
anywhere under `phone_app/lib/` (grep confirmed); no borders exist.
|
||||
- Rule 12 (order by visual weight) — single linear `Column` per screen, no
|
||||
competing-weight layout to order.
|
||||
- Rule 13 (12-column grid) — single-column mobile screens, no grid layout.
|
||||
- Rule 14 (space between high-contrast points) — can't be assessed from
|
||||
source; spacing is via `SizedBox`/`Padding` bounding boxes, not measured
|
||||
edge-to-edge.
|
||||
- Rule 15 (closer = lighter) — the one elevated element (`Card`,
|
||||
`settings_screen.dart:154`) follows M3's automatic surface-tint elevation
|
||||
overlay, which already lightens on elevation — no manual z-stack to check.
|
||||
- Rule 16 (shadow blur = 2x distance) — shadow is Material's default (see
|
||||
rule 26), not a custom `BoxShadow`, so there's no explicit offset/blur pair
|
||||
to check a ratio on.
|
||||
- Rule 19 (outer padding ≥ inner) — `settings_screen.dart:93` outer body
|
||||
padding (16) vs `settings_screen.dart:156` `_StatusCard`'s own inner
|
||||
padding (16): equal, satisfies "≥". `home_screen.dart:149` outer padding
|
||||
(32) vs the button's inner vertical padding (16, `home_screen.dart:186`):
|
||||
outer > inner. Both pass, no violation to report.
|
||||
- Rule 21 (line length ~70 chars) — only short status strings/labels; the one
|
||||
paragraph (`settings_screen.dart:132-137`) is already manually broken into
|
||||
short lines, no long unbroken runs to measure.
|
||||
- Rule 22 (button padding horizontal = 2x vertical) — `home_screen.dart:185-188`
|
||||
and `settings_screen.dart:110-113` only override `vertical` padding
|
||||
(16 and 14) and leave horizontal at the `ElevatedButton` theme default —
|
||||
the actual rendered ratio can't be read from source alone.
|
||||
- Rule 24 (nest corners) — no custom `BorderRadius` anywhere (grep
|
||||
confirmed), only default Material shapes.
|
||||
- Rule 25 (adjacent hard divides) — only one `Divider` in the whole app
|
||||
(`settings_screen.dart:131`), not adjacent to another border/transition.
|
||||
- Rule 27 (don't mix depth techniques) — only one depth technique appears at
|
||||
all (the `Card`'s default elevation, rule 26) — nothing to mix.
|
||||
|
||||
### Notes
|
||||
|
||||
- No dedicated `theme.dart` — the `ColorScheme`/`ThemeData` is inline in
|
||||
`main.dart:17-21`. That's fine at this size (2 screens), but every raw
|
||||
`Colors.*` literal found above is a spot that would have been caught by a
|
||||
lint/review pass if a shared palette module existed to import from instead.
|
||||
|
||||
---
|
||||
|
||||
## Python/Tkinter alarm UI (`wake_alarm/_alarm.py`)
|
||||
|
||||
No shared style/constants module exists for color or font — confirmed via
|
||||
`find` (no `theme`/`style`/`colors` module under `wake_alarm/`) and via
|
||||
`_constants.py`, which defines timing/day constants but nothing visual.
|
||||
Every `tk.Label`/`tk.Frame`/`tk.Entry` call in `_alarm.py` repeats its own
|
||||
hex-color and `font=(...)` tuple literal inline (confirmed: `_alarm.py:166,
|
||||
172-174,181-183,190-192,200-206,209-211,219-224,228-232,326-331,409`). This
|
||||
structural absence is itself the rule 4 finding — it also means the same
|
||||
rule (e.g. rule 2, rule 10) is violated independently at every call site
|
||||
rather than in one place, so a future fix should introduce a small
|
||||
`_style.py`/constants block rather than patch each `tk.Label(...)` call.
|
||||
|
||||
Verdict roster: Pass — 3,8,17,20,21,23,26,27. Violation — 1(partial),
|
||||
2,4,9(mixed reuse—see below),10,11,12. N/A — 5,6,7,13,14,15,16,18,19,22,24,25,28.
|
||||
|
||||
### Violations
|
||||
|
||||
- **Rule 1 (near-black/near-white, not pure)** — `_alarm.py:166` (`bg="#1a1a1a"`)
|
||||
correctly uses near-black, not pure black — that part passes. But
|
||||
`_alarm.py:191-192` (`info_label`, `fg="white"`) uses **pure white** text,
|
||||
which the rule explicitly calls out to avoid. → use an off-white like
|
||||
`#e8e8e8`/`#f0f0ee` instead of the literal `"white"`.
|
||||
- **Rule 2 (saturate your neutrals)** — the only "neutral" grey values used
|
||||
are perfectly desaturated (zero-chroma) greys: `_alarm.py:232`
|
||||
(`timer_label`, `fg="#aaaaaa"`) and `_alarm.py:305` (hidden-code flash
|
||||
state, `fg="#555555"`). Neither has any color tint, so they read as
|
||||
generic gray rather than part of the warm/cool palette the rest of the
|
||||
screen uses (see rule 10). → tint both toward the same hue family as the
|
||||
accent colors (e.g. a warm dark gray like `#4a3f3f` / `#8a7d7d`).
|
||||
- **Rule 4 (everything deliberate / centralize styling)** — see the surface
|
||||
intro above; every color/font is a repeated inline literal across
|
||||
`_alarm.py:166-234` and `_alarm.py:326-331`, with no single source of
|
||||
truth. → extract a small `_COLORS`/`_FONTS` mapping (mirroring the
|
||||
existing `_constants.py` pattern for non-visual constants) and reference
|
||||
it from every widget call.
|
||||
- **Rule 9 (distinct brightness / deliberate reuse)** — `title_label`
|
||||
(`_alarm.py:173`, `fg="#ff4444"`) and `status_label`
|
||||
(`_alarm.py:223`, `fg="#ff4444"`) reuse the exact same color for two
|
||||
different semantic roles ("WAKE UP!" heading vs. a transient error/status
|
||||
message) with no evidence it was a deliberate choice rather than
|
||||
copy-paste — flagging alongside rule 4 since fixing the missing style
|
||||
module would force this decision to be made explicitly.
|
||||
- **Rule 10 (warm OR cool, not both)** — the screen simultaneously uses warm
|
||||
reds/ambers (`_alarm.py:173` `#ff4444`, `:182` `#ffaa00`, `:409`
|
||||
`#ff0000` flash) and a fully saturated cool green (`_alarm.py:204,263,274,
|
||||
347` `#00ff00`) on the same screen at the same time. → pick one
|
||||
temperature family for the "safe/positive" accent (e.g. a warm amber-green
|
||||
or switch the alert red to a cooler red) so the palette reads as one
|
||||
coherent set rather than a stoplight.
|
||||
- **Rule 11 (mathematically related measurements)** — `pady` values across
|
||||
the widget stack are `20, 5, 10, 30, 10, 10, 5` (`_alarm.py:176, 185, 194,
|
||||
207, 215, 226, 235`) — mostly multiples of 5/10, but `pady=5` at lines 185
|
||||
and 235 breaks the 10/20/30 pattern used by the surrounding widgets.
|
||||
→ round the two `pady=5` calls to `10` for a consistent scale.
|
||||
- **Rule 12 (order by visual weight)** — the largest, boldest element on
|
||||
screen is `code_label` at up to 72pt bold (`_alarm.py:198-206`,
|
||||
`code_font_size = 48 if ... else 72`), sitting in the visual middle of the
|
||||
stack, while the actual title (`_alarm.py:172`, 48pt bold) anchors the top
|
||||
edge and the lightest elements (`timer_label`, 14pt, `_alarm.py:231`) sit
|
||||
at the bottom edge. The rule wants the heaviest element toward an outer
|
||||
edge, not buried in the middle. → this is likely intentional here (the
|
||||
code you must type back is the whole point of the screen), so treat as a
|
||||
judgment-call violation to confirm rather than an unambiguous bug.
|
||||
|
||||
### Not applicable
|
||||
|
||||
- Rule 5 (optical alignment) — only text `Label`s stacked via `.pack()`, no
|
||||
asymmetric shapes/icons to align by eye.
|
||||
- Rule 6 (letter-spacing/line-height by size) — Tkinter's `font=(family,
|
||||
size, weight)` tuple has no letter-spacing/line-height control at all;
|
||||
not expressible in this toolkit.
|
||||
- Rule 7 (border contrast) — no `relief`/`highlightthickness`/`borderwidth`
|
||||
set on any widget (grep confirmed); nothing has a visible border.
|
||||
- Rule 13 (12-column grid) — single fullscreen widget centered via
|
||||
`.place(relx=0.5, rely=0.5)` (`_alarm.py:167`), no grid layout in use.
|
||||
- Rule 14 (space between high-contrast points) — `pack(pady=...)` controls
|
||||
bounding-box spacing, not measured edge-to-edge visual contrast; not
|
||||
determinable from source.
|
||||
- Rule 15 (closer = lighter) — Tkinter has no z-stack/elevation concept;
|
||||
this is a flat 2D layout on one background plane.
|
||||
- Rule 16 (shadow blur = 2x distance) — no shadow effects implemented
|
||||
anywhere in the file.
|
||||
- Rule 18 (container brightness limits) — only one background plane exists
|
||||
(`#1a1a1a`, toggled with a full-screen alert flash to `#ff0000` at
|
||||
`_alarm.py:409` — not a stacked container), so there's no second surface
|
||||
brightness to compare it against.
|
||||
- Rule 19 (outer padding ≥ inner) — `container` (`_alarm.py:166`) has no
|
||||
configured padding of its own; only individual children set `pady`. No
|
||||
outer/inner pair exists to compare.
|
||||
- Rule 22 (button padding) — no `tk.Button` widget exists anywhere in the
|
||||
file (grep confirmed) — dismissal is via `tk.Entry` + `<Return>` bind
|
||||
(`_alarm.py:209-217`), not a button.
|
||||
- Rule 24 (nest corners) — Tkinter widgets are square by default with no
|
||||
corner-radius concept; not implemented here.
|
||||
- Rule 25 (adjacent hard divides) — no border/divider lines used anywhere.
|
||||
- Rule 28 (icon contrast next to text) — no icons used anywhere in this UI;
|
||||
text-only.
|
||||
|
||||
### Passes (brief)
|
||||
|
||||
- Rule 3 (high contrast for important elements) — `_alarm.py:172-174`
|
||||
"WAKE UP!" title in `#ff4444` on `#1a1a1a` background is clearly the
|
||||
highest-contrast element on screen.
|
||||
- Rule 8 (align with something) — `container.place(relx=0.5, rely=0.5,
|
||||
anchor="center")` (`_alarm.py:167`) plus uniform `.pack()` centering means
|
||||
every widget shares the same center-alignment axis.
|
||||
- Rule 17 (simple on complex) — flat solid `#1a1a1a` background behind flat
|
||||
text (`_alarm.py:166`) is plain-on-plain throughout; no visual noise.
|
||||
- Rule 20 (body text ≥16px) — smallest font used is 14pt
|
||||
(`_alarm.py:231`, `timer_label`) ≈ 18-19px at typical DPI, above the 16px
|
||||
floor; all other labels are 18pt+ (`_alarm.py:190,222`) or larger.
|
||||
- Rule 21 (line length ~70 chars) — only short single-line status strings
|
||||
are used (longest is ~55 characters, `_alarm.py:372`); no wrapped
|
||||
paragraph text to overflow the readable range.
|
||||
- Rule 23 (two typefaces max) — exactly two families used throughout,
|
||||
`"Arial"` and `"Courier"` (`_alarm.py:172,181,190,203,211,222,231,329`) —
|
||||
at the limit, not over it.
|
||||
- Rule 26 (no shadows in dark interfaces) — trivially satisfied: no shadow
|
||||
effects are implemented anywhere in this file.
|
||||
- Rule 27 (don't mix depth techniques) — trivially satisfied: zero depth
|
||||
techniques are used, so none can conflict.
|
||||
|
||||
### Notes
|
||||
|
||||
- The most impactful single fix for this surface is structural, not any one
|
||||
color/font value: introduce one `_style.py` (or a `_COLORS`/`_FONTS` dict
|
||||
in `_constants.py`) and reference it from every `tk.Label`/`tk.Frame` call
|
||||
in `_alarm.py`. That alone would make rules 2, 9, 10, and 11 fixable in one
|
||||
place instead of ~15 separate call sites.
|
||||
89
MIGRATION.md
89
MIGRATION.md
@ -1,89 +0,0 @@
|
||||
# Migration: GitHub Gist → shared `crdt_sync`
|
||||
|
||||
`phone_app`'s alarm sync was moved off a bespoke GitHub Gist onto the shared
|
||||
[`crdt_sync`](https://github.com/kuhyx/utils/tree/main/crdt_sync_dart) library
|
||||
(pinned tag `crdt_sync_dart-v0.2.0` — see `phone_app/pubspec.yaml`, which is
|
||||
the authority; `pubspec.lock` resolves it to `5be99ab`), matching how the other
|
||||
companion apps sync. A `crdt_sync_dart-v0.3.0` tag also exists upstream and is
|
||||
*not* in use here — bumping to it is an untaken decision, not a drift to fix.
|
||||
|
||||
## What changed
|
||||
|
||||
- **phone_app (Dart):** `gist_service.dart` is gone. `AlarmSyncService` stores
|
||||
the alarm locally in a `crdt_sync` `LogStore` (one HLC-stamped `Record`) and
|
||||
pushes it to `wake-alarm-sync/devices/phone/alarm.json` in `kuhyx/syncs` via
|
||||
`crdt_sync`'s `GitHubClient`. The device-flow OAuth scope moved `gist` →
|
||||
`repo` (the Contents API needs private-repo access).
|
||||
- **shutdown-wrapper.sh (PC, Bash):** now reads that alarm from the Contents
|
||||
API (`Accept: application/vnd.github.raw`, so no base64) and parses the
|
||||
`crdt_sync` `Record` log with `jq` (`.alarm.fields.alarm_hour[0]`). Config
|
||||
moved from `~/.config/wake_alarm/gist_sync.json` `{token, gist_id}` to
|
||||
`~/.config/wake_alarm/sync.json` `{token}` (repo-scoped).
|
||||
|
||||
## Setup after pulling this
|
||||
|
||||
1. On the phone: Settings → **Connect GitHub** (now requests `repo` scope; the
|
||||
old gist-scoped token won't reach the Contents API).
|
||||
2. On the PC: `~/.config/wake_alarm/sync.json` → `{ "token": "<repo-scoped>" }`,
|
||||
then re-run `install.sh` so `/usr/local/bin/shutdown` picks up the new
|
||||
reader.
|
||||
|
||||
## Surprises / non-obvious decisions
|
||||
|
||||
- **The consumer is a Python-less Bash reader** (`shutdown-wrapper.sh` runs at
|
||||
shutdown, when a Python runtime may not be available), so it can't use the
|
||||
`crdt_sync` library — it parses the log with `jq`. What makes that tractable:
|
||||
`Hlc.toStr()` is **lexicographically sortable == chronological**, so
|
||||
"latest wins" across device dirs is a plain string max, no date math.
|
||||
- **The phone is push-only** (sole writer); there is no pull/merge. Its local
|
||||
`LogStore` is always the source of truth.
|
||||
- Any parse miss still falls back to `WAKE_AFTER_HOURS`, so a bad read can
|
||||
never brick the morning wake.
|
||||
|
||||
# Migration: hibernate-scheduled → per-minute trigger timer
|
||||
|
||||
The PC is no longer shut down at night, which broke the whole scheduling
|
||||
model: the wake time used to be decided once, at hibernate, and baked into the
|
||||
RTC. With no shutdown there is no resume, so nothing fired the alarm at all —
|
||||
and `wake-alarm.service` was `WantedBy=graphical-session.target`, so on an
|
||||
always-on machine it fired at *login* time instead (`_should_run_alarm()` has
|
||||
no time gate).
|
||||
|
||||
## What changed
|
||||
|
||||
- **New `wake-alarm-trigger.timer`** ticks every minute and runs
|
||||
`wake_alarm._trigger`, which fires `morning-routine.service` when the
|
||||
current local time is inside
|
||||
`[phone alarm, phone alarm + TRIGGER_CATCHUP_MINUTES)`. This is now the
|
||||
primary path; the phone can move the alarm at any hour and the PC follows.
|
||||
- **New `wake_alarm/_sync.py`** — a Python reader for the same alarm file
|
||||
`shutdown-wrapper.sh` parses with `jq`, plus an on-disk cache
|
||||
(`~/.config/wake_alarm/alarm_cache.json`).
|
||||
- **`wake-alarm.service` lost its `[Install]` section** and is no longer
|
||||
enabled; `install.sh` disables it to clear stale symlinks.
|
||||
- **Token resolution gained a fallback** to the sibling apps'
|
||||
`~/.config/{diet_guard,screen_locker}/sync_token`, in both readers.
|
||||
- `shutdown-wrapper.sh` and `sleep-hook.sh` keep working unchanged, now as the
|
||||
PC-is-powered-down fallback rather than the main mechanism.
|
||||
|
||||
## Surprises / non-obvious decisions
|
||||
|
||||
- **A timer, not a resident scheduler.** Each tick is a fresh process, so
|
||||
there is no daemon to die silently and no wall-clock jump to reason about
|
||||
across suspend/resume. `Persistent=true` plus the catch-up window is what
|
||||
makes a suspended PC still fire on resume.
|
||||
- **Per-minute ticks, but a 5-minute cache.** Ticking every 5 minutes would
|
||||
also quantise *firing* to 5 minutes (07:03 → 07:05). Ticking every minute
|
||||
and only hitting the network when the cache is stale gives exact-minute
|
||||
firing at ~288 GitHub reads/day.
|
||||
- **Degraded reads log at INFO.** At one tick a minute, a WARNING on a
|
||||
persistent condition is 1440 journal lines a day. The single WARNING that
|
||||
survives is `_trigger`'s "firing now on a degraded source".
|
||||
- **The sync had been dead since the crdt_sync migration.** `wake-alarm-sync/`
|
||||
did not exist in `kuhyx/syncs` at all — the phone had never successfully
|
||||
pushed, and `~/.config/wake_alarm/sync.json` was never created, so the
|
||||
hibernate path had been silently using the `+8h` fallback. The phone app
|
||||
needs one successful "Set Alarm & Sync" to create that path.
|
||||
- **Still open:** alarms edited directly in the Android Clock app do not reach
|
||||
the sync. Closing that needs an `ACTION_NEXT_ALARM_CLOCK_CHANGED` receiver
|
||||
in the phone app — planned, not yet built.
|
||||
@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit hook: fail if any file exceeds MAX_LINES lines."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
MAX_LINES = 500
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Return 1 if any file exceeds the line limit, else 0."""
|
||||
failed = False
|
||||
for filepath in sys.argv[1:]:
|
||||
try:
|
||||
with Path(filepath).open(encoding="utf-8", errors="replace") as fh:
|
||||
count = sum(1 for _ in fh)
|
||||
except OSError:
|
||||
failed = True
|
||||
continue
|
||||
if count > MAX_LINES:
|
||||
failed = True
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
32
scripts/check_file_length.sh
Executable file
32
scripts/check_file_length.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if any file in the commit exceeds the shared 250-line cap.
|
||||
#
|
||||
# Thin delegate to the shared gate in ~/utils, which owns the cap and the
|
||||
# exemption list (generated / vendored / data files). This repo previously ran
|
||||
# a LOCAL check_file_length.py with its own higher limit, so it reported
|
||||
# "gated" while permitting files every other repo rejects -- which is why its
|
||||
# violations went untouched. Delegating is what stops that recurring.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check_file_length.sh <file> [<file> ...] # pre-commit passes these
|
||||
# scripts/check_file_length.sh --all # whole tree, from cwd
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_file_length.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared file-length gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
32
scripts/check_md_naming.sh
Executable file
32
scripts/check_md_naming.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if any markdown file breaks the shared naming convention.
|
||||
#
|
||||
# Thin delegate to the shared gate in ~/utils, which owns the four namespaces
|
||||
# (README / CLAUDE* / DOCS* / TODO*), the "REMOVE ME AFTER FINISH" marker rule
|
||||
# and the exemption list. Copying that logic here is what lets one repo's idea
|
||||
# of a valid name drift from every other repo's -- so this script only locates
|
||||
# the shared checker and forwards its arguments.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check_md_naming.sh <file> [<file> ...] # pre-commit passes these
|
||||
# scripts/check_md_naming.sh --all # whole tree, from cwd
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_md_naming.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared markdown-naming gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue
Block a user