diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml index 9f552753..ac0af9da 100644 --- a/.github/workflows/shell-tests.yml +++ b/.github/workflows/shell-tests.yml @@ -32,6 +32,7 @@ jobs: tests=( test_i3blocks_gaming_budget.sh test_i3blocks_persist_common.sh + test_i3blocks_shutdown_hint.sh test_setup_dns_blocker.sh ) failed=0 diff --git a/docs/superpowers/evidence/i3blocks-shutdown-earner-hint-2026-10-06.json b/docs/superpowers/evidence/i3blocks-shutdown-earner-hint-2026-10-06.json new file mode 100644 index 00000000..3a99390a --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-shutdown-earner-hint-2026-10-06.json @@ -0,0 +1,40 @@ +{ + "intent": "The i3 shutdown block shows which earners can still push shutdown later, like the gaming-budget block does: '⏻ 22:00 +🧩1h +📖1h +🗂30m +⚙30m →max 23:00'.", + "scope": [ + "linux_configuration/i3blocks/shutdown_countdown.sh", + "linux_configuration/tests/test_i3blocks_shutdown_hint.sh", + "linux_configuration/tests/test_i3blocks_efficiency.sh", + ".github/workflows/shell-tests.yml", + "Non-goal: no hint in the override or SHUTDOWN states; no change to how shutdown is computed (screen-locker owns that)" + ], + "changes": [ + "earner_hint reads the enforcer's /api/budget .rules.earners and lists those with earned_seconds == 0, using bonus_seconds as the shutdown amount (equal for every earned_time earner today)", + "'→max 23:00' is appended when the listed bonuses exceed the headroom to SHUTDOWN_CEILING_MINUTES (default 1380, earned_time's ceiling); no hint at all at the ceiling", + "A dead or erroring server shows the bare time, which still comes from /etc/shutdown-schedule.conf", + "Efficiency test stubs curl as a dead server and allows 2 execs (bash + curl); new behaviour test runs in the shell-tests CI job" + ], + "verification": [ + { + "command": "bash linux_configuration/i3blocks/shutdown_countdown.sh (live enforcer, conf at 22:00)", + "result": "pass", + "evidence": "'⏻ 22:00 +🧩1h +📖1h +🗂30m +⚙30m →max 23:00'; after the LeetCode solve was applied: '⏻ 23:00'" + }, + { + "command": "bash linux_configuration/tests/test_i3blocks_shutdown_hint.sh", + "result": "pass", + "evidence": "All shutdown hint tests passed (listing, ceiling note, all-earned, 1h30 padding, unknown earner label, server down / ok:false / non-JSON)" + }, + { + "command": "bash linux_configuration/tests/test_i3blocks_efficiency.sh && bash linux_configuration/tests/test_i3blocks_gaming_budget.sh", + "result": "pass", + "evidence": "All i3blocks efficiency regression tests passed; All gaming_budget checks passed" + } + ], + "risks": [ + "If an earner's shutdown_minutes ever differs from its gaming_minutes, the hint shows the gaming amount; /api/budget does not expose shutdown amounts.", + "The 23:00 ceiling is duplicated from earned_time as a default; a registry change needs SHUTDOWN_CEILING_MINUTES or this default updated." + ], + "rollback": [ + "git revert this commit; the block shows the bare '⏻ HH:MM' again" + ] +} diff --git a/docs/superpowers/evidence/prune-superpowers-artifacts-2026-08-23.json b/docs/superpowers/evidence/prune-superpowers-artifacts-2026-08-23.json deleted file mode 100644 index abf9b0a9..00000000 --- a/docs/superpowers/evidence/prune-superpowers-artifacts-2026-08-23.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "intent": "Prune the accumulated per-commit evidence and contract artifacts, which were the largest single driver of tracked-file growth in the repo.", - "scope": [ - "docs/superpowers/evidence - 455 tracked files down to 21", - "docs/superpowers/contracts - 241 tracked files down to 21", - "Non-goal: docs/superpowers/sessions, which is append-only and enforced by check_append_only_sessions.sh. Untouched.", - "Non-goal: .hippo/, reported on separately rather than pruned in the same pass." - ], - "changes": [ - "Kept the 20 most recently committed artifacts in each of evidence/ and contracts/, plus both template.json files, and removed the other 655.", - "Selected by real git commit date (git log -1 --format=%cs per file) rather than by parsing filenames: the corpus mixes -2026-05, -20260604 and undated slugs, so a filename date filter would have silently mis-sorted ~48 files.", - "Used a count cap rather than a time window because growth is bursty: 487 of the 695 artifacts were committed in 2026-08 alone, so even a 7-day window would have kept 393 files and bounded nothing going forward." - ], - "verification": [ - { - "command": "grep -c template prune_final.txt", - "result": "pass", - "evidence": "0 - neither template.json was in the prune list. Confirmed present on disk after the removal; the hooks tell users to copy them, so deleting one would have broken the documented workflow." - }, - { - "command": "git ls-files docs/superpowers/{evidence,contracts,sessions} | wc -l", - "result": "pass", - "evidence": "evidence 21, contracts 21 (20 kept + template each), sessions 3 unchanged. Arithmetic closes: 695 = 40 kept + 655 pruned." - }, - { - "command": "./meta/scripts/check_ai_evidence.sh; ./meta/scripts/check_agent_contract.sh; ./meta/scripts/check_append_only_sessions.sh", - "result": "pass", - "evidence": "All three exit 0 after the prune. Verified beforehand by grep that no tooling reads the historical corpus - every reference is either template.json or validation of files staged in the current commit." - }, - { - "command": "git ls-files | wc -l", - "result": "pass", - "evidence": "1993 -> 1340 tracked files. check_repo_size still reports ~1050 source files, correctly unchanged: docs/superpowers/ is excluded from that count by design." - } - ], - "risks": [ - "Historical artifacts are now reachable only through git history (git log --diff-filter=D -- docs/superpowers/). Accepted: nothing reads them programmatically and their review value decays within days.", - "The 20-file cap is enforced by nothing - it is a one-off prune, so the directories will grow again at roughly one artifact per commit. A recurring prune or a hard cap would be the durable fix if this recurs." - ], - "rollback": [ - "git revert the commit restores all 655 files, since this is a pure deletion with no accompanying logic change.", - "After rollback, confirm the three superpowers hooks still exit 0 and that both template.json files are present." - ] -} diff --git a/linux_configuration/i3blocks/shutdown_countdown.sh b/linux_configuration/i3blocks/shutdown_countdown.sh index cf77dd1a..59f587b8 100755 --- a/linux_configuration/i3blocks/shutdown_countdown.sh +++ b/linux_configuration/i3blocks/shutdown_countdown.sh @@ -8,6 +8,9 @@ set -euo pipefail SHUTDOWN_CONFIG=${SHUTDOWN_CONFIG:-/etc/shutdown-schedule.conf} SKIP_DATES_FILE=${SKIP_DATES_FILE:-/etc/shutdown-skip-dates} OVERRIDES_FILE=${OVERRIDES_FILE:-/etc/shutdown-schedule-overrides.conf} +BUDGET_API=${GAMING_BUDGET_API:-http://127.0.0.1:8000/api/budget} +# earned_time's SHUTDOWN_CEILING_MINUTES (23:00): no earner pushes past it. +SHUTDOWN_CEILING_MINUTES=${SHUTDOWN_CEILING_MINUTES:-1380} # Function to show error state in i3blocks and exit show_error() { @@ -148,6 +151,31 @@ if override_match=$(find_override_covering "$shutdown_epoch_today"); then exit 0 fi +# Earners not yet done today, rendered like gaming_budget.sh: " +🧩1h +📖1h". +# The enforcer's /api/budget reports gaming seconds per earner; every +# earned_time earner moves shutdown by the same amount, so that one source +# serves both blocks. Past the earned_time shutdown ceiling the extra is lost, +# hence the "→max" note. Server down = no hint: the time above is still right. +earner_hint() { + local headroom_seconds=$(((SHUTDOWN_CEILING_MINUTES - shutdown_time_minutes) * 60)) + local ceiling_hhmm budget_json + ((headroom_seconds > 0)) || return 0 + command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || return 0 + budget_json=$(curl -fsS --max-time 2 "$BUDGET_API" 2>/dev/null) || return 0 + printf -v ceiling_hhmm '%02d:%02d' $((SHUTDOWN_CEILING_MINUTES / 60)) $((SHUTDOWN_CEILING_MINUTES % 60)) + jq -r --argjson headroom "$headroom_seconds" --arg ceiling "$ceiling_hhmm" ' + def bonus_hm: (. / 60 | round) as $m + | if $m < 60 then "\($m)m" + else "\($m / 60 | floor)h" + ($m % 60 | if . == 0 then "" else "\(.)" | if length < 2 then "0" + . else . end end) end; + {workout: "💪", leetcode: "🧩", reading: "📖", anki: "🗂", automation: "⚙"} as $icon + | select(.ok == true) + | [.rules.earners[]? | select((.earned_seconds // 0) == 0 and .bonus_seconds > 0)] + | select(length > 0) + | " " + (map("+\($icon[.name] // .label)\(.bonus_seconds | bonus_hm)") | join(" ")) + + (if (map(.bonus_seconds) | add) > $headroom then " →max \($ceiling)" else "" end) + ' <<<"$budget_json" 2>/dev/null || true +} + minutes_until_shutdown=$((shutdown_time_minutes - current_time_minutes)) if [[ $minutes_until_shutdown -le 30 ]]; then @@ -160,6 +188,6 @@ else color="#6272A4" fi -echo "⏻ $(format_hhmm "$shutdown_epoch_today")" +echo "⏻ $(format_hhmm "$shutdown_epoch_today")$(earner_hint)" echo "⏻" echo "$color" diff --git a/linux_configuration/tests/test_i3blocks_efficiency.sh b/linux_configuration/tests/test_i3blocks_efficiency.sh index 666d9ed9..1d75b633 100755 --- a/linux_configuration/tests/test_i3blocks_efficiency.sh +++ b/linux_configuration/tests/test_i3blocks_efficiency.sh @@ -55,6 +55,14 @@ count_execs() { grep -c 'execve(' "$log_file" } +# A dead budget server: shutdown_countdown.sh's earner hint must never reach +# the real enforcer from a test, and with no hint it runs no jq either. +cat >"$BIN_DIR/curl" <<'EOF' +#!/bin/bash +exit 7 +EOF +chmod +x "$BIN_DIR/curl" + cat >"$BIN_DIR/pacman" <<'EOF' #!/bin/bash exit 1 @@ -190,7 +198,8 @@ assert_equals '⏻ 21:00' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n 'shutdown countdown should show the exact clock time of the next shutdown' assert_equals '#F1FA8C' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '3p')" \ 'shutdown countdown should show yellow for two hours or less remaining' -assert_le "$(count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 1 \ +# bash itself plus the one curl for the earner hint; no date helpers. +assert_le "$(count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 2 \ 'shutdown countdown should avoid date helpers in the hot path' shutdown_window_epoch=$(epoch_utc '2026-05-01 21:15:00') diff --git a/linux_configuration/tests/test_i3blocks_shutdown_hint.sh b/linux_configuration/tests/test_i3blocks_shutdown_hint.sh new file mode 100755 index 00000000..f5b363fa --- /dev/null +++ b/linux_configuration/tests/test_i3blocks_shutdown_hint.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Behaviour tests for shutdown_countdown.sh's earner hint: which earners are +# listed, their amounts, the 23:00 ceiling note, and the states that show no +# hint. curl is stubbed on PATH to serve a fixture, so nothing talks to the +# real enforcer. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +BLOCK="$REPO_DIR/i3blocks/shutdown_countdown.sh" +TMP_DIR=$(mktemp -d) + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + if [[ "$1" != "$2" ]]; then + fail "$3 (expected '$1', actual '$2')" + fi +} + +printf '{}' | jq -e . >/dev/null 2>&1 || + fail 'a working jq is required for the shutdown hint tests (pacman -S jq)' + +# Stub curl: print $FIXTURE, or fail like `curl -f` on a dead server. +BIN_DIR="$TMP_DIR/bin" +mkdir -p "$BIN_DIR" +cat >"$BIN_DIR/curl" <<'STUB' +#!/bin/bash +[[ -r ${FIXTURE:-} ]] || exit 7 +cat "$FIXTURE" +STUB +chmod +x "$BIN_DIR/curl" + +FIXTURE="$TMP_DIR/budget.json" +SCHEDULE="$TMP_DIR/schedule.conf" +NO_OVERRIDES="$TMP_DIR/overrides.conf" +: >"$NO_OVERRIDES" +# 2026-05-01 is a Friday, so THU_SUN_MINUTES applies. +NOW=$(TZ=UTC date -d '2026-05-01 18:00:00' +%s) + +# write_earners NAME:EARNED:BONUS ... -- the /api/budget earners list. +write_earners() { + local rows=() name earned bonus + for spec in "$@"; do + IFS=: read -r name earned bonus <<<"$spec" + rows+=("{\"name\":\"$name\",\"label\":\"$name\",\"earned_seconds\":$earned,\"bonus_seconds\":$bonus}") + done + local IFS=, + printf '{"ok":true,"rules":{"earners":[%s]}}\n' "${rows[*]}" >"$FIXTURE" +} + +# first_line SHUTDOWN_MINUTES -- the block's full_text at 18:00. +first_line() { + printf 'THU_SUN_MINUTES=%s\nMON_WED_MINUTES=%s\nMORNING_END_MINUTES=300\n' \ + "$1" "$1" >"$SCHEDULE" + TZ=UTC NOW_EPOCH="$NOW" PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" \ + SHUTDOWN_CONFIG="$SCHEDULE" OVERRIDES_FILE="$NO_OVERRIDES" \ + SKIP_DATES_FILE=/dev/null bash "$BLOCK" | sed -n 1p +} + +printf 'Checking unearned earners are listed with icon and amount...\n' +write_earners workout:7200:7200 leetcode:0:3600 reading:0:3600 anki:0:1800 automation:0:1800 +assert_eq '⏻ 20:00 +🧩1h +📖1h +🗂30m +⚙30m' "$(first_line 1200)" \ + 'earned ones hidden, the rest listed; 3h fits under 23:00' + +printf 'Checking the ceiling note once the bonuses overflow 23:00...\n' +assert_eq '⏻ 22:00 +🧩1h +📖1h +🗂30m +⚙30m →max 23:00' "$(first_line 1320)" \ + '3h of bonuses with 1h of headroom' + +printf 'Checking nothing is listed at the ceiling or with all earned...\n' +assert_eq '⏻ 23:00' "$(first_line 1380)" 'no headroom, no hint' +write_earners leetcode:3600:3600 reading:3600:3600 +assert_eq '⏻ 20:00' "$(first_line 1200)" 'everything earned' + +printf 'Checking odd amounts and unknown earners...\n' +write_earners leetcode:0:5400 newgate:0:900 +assert_eq '⏻ 20:00 +🧩1h30 +newgate15m' "$(first_line 1200)" \ + '1h30 padded like gaming_budget.sh; unknown earner falls back to label' + +printf 'Checking a dead or unhappy server shows the bare time...\n' +rm -f "$FIXTURE" +assert_eq '⏻ 20:00' "$(first_line 1200)" 'server down' +printf '{"ok":false}\n' >"$FIXTURE" +assert_eq '⏻ 20:00' "$(first_line 1200)" 'budget error' +printf 'not json\n' >"$FIXTURE" +assert_eq '⏻ 20:00' "$(first_line 1200)" 'unreadable reply' + +printf 'All shutdown hint tests passed.\n'