Files
Linux_post_install/AGENT_TODO.md
T
he 8026300005
gates / consistency-and-conventions (push) Successful in 45s
chore(ci): report gate result as ci-ok/ci-fail tags + scripts/ci-status.sh
2026-08-14 17:19:28 -04:00

126 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENT_TODO — Worklist & Idea Backlog
Living list of what we are doing, what is next, and what we might do later.
Deep history lives in git: `git log --follow AGENT_TODO.md`, `git blame`, and
the individual feature commits — the **Done** section below is just a readable
summary (newest last).
## Conventions
- **Now** — items actively being worked on this session (only a few).
- **Next** — queued, well-scoped items.
- **Later** — idea backlog. Ideas marked **NOT NOW** were evaluated and rejected
for the stated reason; revisit only if circumstances change.
- When a task is completed: move it from Now/Next into **Done** (dated one-line)
in the same commit that finishes the work.
## Now
## Next
- - Wire alerting into more tools as they are added (default: source
`lib/notify.sh`, call `notify_send` on success/failure).
## Later
- **Tier 2: `pos health` extras** — temperature/fan/load average thresholds,
`ss -tln` port checks for known services, SMART status for disks.
- **Tier 3: backup rotation + remote target** — keep-N rotations, upload to
rclone remote after verify, `--remote` flag, digest reports rotation age.
- **Tier 3: `pos secret` vault** — gpg/age-encrypted key-value store; backend
for future tools that need stored tokens.
- **Tier 3: `pos inventory`** — machine manifest (OS, packages, services,
mounted disks, USB devices) exportable as markdown/JSON.
- **Tier 4: `pos self update`** — pull repo, `make gen && make check`,
re-run install.sh to refresh `/usr/local/bin`.
- **Tier 4: `pos new`** — scaffold a new tool from `templates/pos-tool.sh`
(category, name, POS header, exec bit, doc stubs).
- **NOT NOW:** per-category `bin/` subdirectories — flat `bin/` + filename
dispatch scales fine; revisit only if `bin/` passes ~40 files.
- **NOT NOW:** split `lib/entertainment-lib.sh` — fine under 600 lines; revisit
if it grows.
## Done
- **2026-08-14** — `pos system backup` — smart USB detection: lsblk TRAN (lsusb/by-id cross-check), mount offer for plugged-in-but-unmounted sticks, sha256-verified copy (stub-suite 54/54).
- **2026-08-05** — `pos communication telegram``--parse-mode` (plain/markdown/html).
- **2026-08-05** — doc/code sync gate — `make gen` + `make check` + pre-commit hook.
- **2026-08-05** — `pos usb server` — USB Redirector control tool (494eae2).
- **2026-08-05** — `pos <category> --help` auto-discovery in the dispatcher.
- **2026-08-05** — AGENTS.md with lazy-loaded DOC references.
- **2026-08-06** — Fix entertainment timer `1h` not firing — `interval_to_oncalendar`
emitted invalid `OnCalendar=*-*-* */N:00:00` (systemd rejects `*/N` in the hour
field); now `*-*-* 00/N:00:00`. Dropped the cron fallback entirely: scheduling
is systemd user timers only (`sync_cron`/`interval_to_cron`/`cron_block`
removed), `status` simplified, `Nd` intervals rejected with a clear error.
- **2026-08-06** — Nested `pos` subcommands — `# POS_SUBCMDS:` header annotation (telegram,
docker-compose, docker-vbox) + `make gen` emits a `_pos_subcmds` completion map;
nested tools (`telegram listener`) auto-list under their parent instead of as a
flat sibling (`telegram-listener`) in `pos <category>` and tab-completion; generic
tool-level completion (subcommands + flags + `--help`).
- **2026-08-06** — Telegram **listener**`pos communication telegram listener`:
interactive `/command` → bash map editor + owner-only polling daemon as a
systemd user service (map in `~/.config/linux_post_install/telegram_commands.env`,
re-read per message; `/help`, unknown-command reply, 60s timeout, stdout reply).
- **2026-08-06** — NFS in `pos system``pos system nfs-server` (status/share/
unshare/list/reload/enable/disable, idempotent /etc/exports edits, generic
default with Tailscale/WireGuard/LAN examples) + `pos system nfs-client`
(mount/unmount/list + persistent mounts as systemd `.mount` units ordered
after network-online.target, no fstab); `nfs-kernel-server` + `nfs-common`
added to preinstall PACKAGES.
- **2026-08-06** — `pos` HOW-TO guide set — `DOC/HOWTO.md` index + per-category
`DOC/howto/*.md` (network, docker, media, system, ssh, usb, communication,
entertainment) with flags, recipes, config, and troubleshooting; wired into
DOC/README, root README, AGENTS.md.
- **2026-08-06** — Multi-platform alerting — `lib/notify.sh` routes via `NOTIFY_PLATFORM`
(`notify.env`, default telegram; sender contract for Matrix/Synapse later),
`system.env` shared config for health/backup, dynamic effective values in
`--help`, telegram `--markdown` alias.
- **2026-08-06** — Tier 1 — `pos system health` (dashboard + `--send`), `lib/notify.sh`
(wired into backup + firewall), daily digest timer via postinstall.
- **2026-08-06** — Document Map index + Entertainment section in AGENT_Context (cf36780).
- **2026-08-06** — Entertainment module — plugins (weather/joke/gold), `pos
entertainment config/enable/disable/send/status`, auto-trigger + Telegram send.
- **2026-08-07** — `pos system health --send` notification-only; listener `@quiet` prefix (run mapped command without replying, for commands that self-notify). `/status=@quiet pos system health --send` = exactly one digest.
- **2026-08-09** — Matrix/Synapse `communication` tools — `pos communication matrix sender` + `listener`, completing the second notify platform `lib/notify.sh` was designed for (`NOTIFY_PLATFORM=telegram,matrix` fan-out; the sender implements the `send <value> [--markdown]` contract via `notify_sender_name()`'s default key→tool mapping, no lib changes). **Sender** (`bin/pos-communication-matrix-sender`): `send <value> [--markdown] [--room <id|alias>]` PUTs `m.room.message` (`m.text`) to the client-server API v3 — room ids/aliases URL-encoded (`#pos:example.org` → `%23pos%3A…`), unique per-message txn id, `--markdown` sends `org.matrix.custom.html` via a best-effort markdown→HTML converter (bold/italic/code/fences/strike/links/headers/lists, escapes HTML, never fails the send); `login --user <@id>` (masked password prompt → `m.login.password` → saves `access_token`+`user_id`); `test`. Config scope `matrix` (`~/.config/linux_post_install/matrix.env`, `MATRIX_HOMESERVER`/`MATRIX_ACCESS_TOKEN`/`MATRIX_USER_ID`/`MATRIX_ROOM_ID`, secret masked) registered via `# POS_CONFIG:` → `pos config matrix` + tab-completion scope. **Listener** (`bin/pos-communication-matrix-listener`): systemd **user** daemon (`pos-matrix-listener.service`) long-polling `/sync` (30s timeout, per-sync `since` token, compact filter dropping presence/account_data/device noise, `m.room.message` only); reacts to `MATRIX_USER_ID`'s own messages (resolved via `/account/whoami` if unset), `MATRIX_ROOM_ID` restricts to one room; `/` and `!` both resolve; replies threaded `m.in_reply_to`; `@quiet` no-reply marker; `/cmd::desc=…` map descriptions; `ai …` bridge (`pos ai gemini ask`, per-room session `matrix-<room>`, `ai /reset` clears, markdown stripped); interactive editor (`--status`/`--enable`/`--disable`/`--run`), 60s command timeout, exit-code prefix, ~3800-char truncation. `communication-matrix-listener` added to `INTERACTIVE_CMDS` (stdin editor + forever-loop daemon). Docs: POS.md rows + "in detail" sections + ai bridge note, howto/communication.md rewritten Matrix sections, HOWTO.md index + config table + platform note, `bin/pos` usage EXAMPLES; `make gen && make check` green. Verified against a mock homeserver: send plain/markdown/`--room`/test request shape (URL-encoding, Bearer auth, JSON body), login token save, listener owner-filter + `/status` reply + `/help` + `@quiet` silence + non-zero exit reply + interactive editor add. — state-based threshold rule monitors (eventer). Each line of `~/.config/linux_post_install/event.env` is an independent rule: `["<msg>" if ] <check-command> <op> <threshold>` (op `> < >= <= == !=`, unit suffix ok `60c`/`80%`). The check command is run on every pass and its **first numeric output** compared float-safe; operator detected as the rightmost `op threshold` pair so checks containing their own `>`/`<` (awk, redirection) parse fine. Alerts once on false→true plus one recovery message on true→false (no repeats while a condition holds); per-rule state in `~/.local/share/linux_post_install/eventer/state/` keyed by rule-line hash (editing a rule resets its state). Subcommands: `run` (timer entrypoint), `config` (interactive add/remove/edit with validation by test-running the check), `list` (rules + live values), `enable [interval]` (systemd **user** timer `pos-event-trigger.timer` + oneshot service; `5m…weekly` or `OnCalendar=…`; graceful warnings when no user systemd manager, `loginctl enable-linger` attempt), `disable`, `status`. `--dry-run` honors the DEV.md dry-run convention. Alerts via `lib/notify.sh` (Telegram default; other platforms via `NOTIFY_PLATFORM`). New: `bin/pos-system-event-trigger`, `lib/eventer-lib.sh`, `config/event.env` template (installed no-clobber by postinstall), `lib/eventer-lib.sh` installed by install.sh, `system-event-trigger` added to `INTERACTIVE_CMDS`, usage EXAMPLES row. Docs: POS.md system row, HOWTO.md index row, howto/event-trigger.md; `make gen && make check` green; functional tests covered trigger/recovery/no-repeat, float + unit parsing, editor add/remove/edit + validation + dry-run, timer enable/disable/status (graceful), dispatcher routing.
- **2026-08-09** — `pos media mp3`/`mp4` hardened + smart format selection. Both tools: yt-dlp calls go through `spawn` (honor `$DRY_RUN`; `--dry-run` prints the exact command and skips dep checks), `-o/--output`, `--no-playlist`, `--cookies` (file existence check), clean ffmpeg/yt-dlp guards, `# POS_FLAGS:` for completion, full metadata (`--embed-metadata --embed-chapters --embed-thumbnail --no-overwrites`, mp3 also `--convert-thumbnails jpg` + `--parse-metadata "%(artist,uploader)s:%(artist)s"` so the uploader fills the artist tag). mp3 gains `--by-artist` (`~/Music/<artist>/<title>.mp3`). mp4 gains `-f <id>` / `--best` / `--worst` (no prompt), conflict validation, and an interactive picker that shows a **curated** `-F` table (`[audio]`/`[video]`/`[combo]` grouping, raw clutter dropped) on stderr — stdout carries only the chosen id (ui_pick lesson) — with id validation against the real table and empty/best default. Docs: howto/media.md rewritten (flags, metadata, by-artist, troubleshooting); `make gen && make check` green.
- **2026-08-09** — Telegram `ai …` now answers about a message you reply to: the listener extracts `reply_to_message.text` (falls back to `caption`) from each update and passes it to `handle_message`; the AI bridge prefixes the prompt with `[Reply context — the message you are replying to]`. So replying to a `/status` output and asking `ai check this details` gives the model the actual output. Applies only to the AI bridge (mapped `/commands` untouched); reply context rides in the user turn so the session records what was analyzed. Docs: howto/ai.md bridge section.
- **2026-08-09** — `pos ai gemini` sessions + Telegram-friendly replies. `--session <name>` gives `ask`/`chat` persistent memory (`~/.local/share/linux_post_install/ai/<name>.json`, capped at 40 turns, pruning keeps the first user turn as scene); new `sessions` subcommand (list / `reset <name>`). Telegram listener now keeps one session per chat (`telegram-<chat_id>`) with `ai /reset` to clear. New `--system "<text>"` flag injects a Gemini `systemInstruction` (via `jq` merge) sent every turn but never stored in the session file; the listener passes a Telegram-voice prompt ("reply like a friendly Telegram chat, use emojis") and strips markdown (`**`, `*`, backticks, `#`, links, lists, blockquotes) from replies before `sendMessage`, since messages go out as plain text. Docs: howto/ai.md (flags, sessions, bridge memory/formatting), `make gen && make check` green.
- **2026-08-09** — Fixed `pos config` secret-value corruption: `cfg_read_secret`'s cursor-advance `echo` went to stdout and, since the function is called via `$(...)`, a leading `\n` ended up inside every secret value → the env file got `AI_GEMINI_API_KEY="\n<key>"`, unreadable by `cfg_value`/`load_config` (menu showed `(not set)`, `pos ai gemini` demanded a key). The newline now goes to the terminal (`echo >&2`). Defense in depth: `cfg_write`/`write_config_key` strip CR and truncate multi-line pastes (warn), `cfg_value` and the ai/telegram `load_config`s strip CR on read. Verified on a real PTY (piped tests couldn't reproduce — non-TTY stdin skips the echo path).
- **2026-08-09** — `ai` category — `pos ai gemini` (ask/chat/models) via Google Gemini REST API. `ask` prints only the answer (pipe/script/Telegram-friendly), `chat` is a multi-turn REPL (q/quit/Ctrl+C, `/reset`, empty input re-prompts), `models` lists generateContent-capable ids and flags the default; `--model` override; default `gemini-2.5-flash`. Config scope `ai` (`AI_GEMINI_API_KEY` secret + `AI_GEMINI_MODEL`) in `~/.config/linux_post_install/ai.env`, edited via `pos config ai`; `config/ai.env` template installed no-clobber by postinstall; `ai-gemini` added to `INTERACTIVE_CMDS`. Telegram listener now answers non-command messages starting with `ai ` via `pos ai gemini ask` (owner chat only; error replies carry the `pos config ai` hint) — future intents (reminders) slot in as more case arms in `handle_message`. Docs: POS.md `ai` section + listener bridge, howto/ai.md, HOWTO/README index rows, `bin/pos` usage example.
- **2026-08-09** — Entertainment plugins `gold` + `weather` now emit emoji-visualized Telegram messages. Gold: headline is USD/**gram** (XAU/oz ÷ 31.1034768), ounce as reference, bid/ask, cleaned timestamp (`+00:00`/fractional seconds stripped). Weather: per-WMO-code emoji (☀️/🌙 day-night aware for clear sky), °C + feels-like, humidity, wind with unit spacing. Both verified live; emojis are safe in the default plain send mode.
- **2026-08-09** — `pos tree`: prints the live `pos` command tree (categories → commands → subcommands) by deriving the hierarchy from `bin/pos-*` filenames + `# POS:` / `# POS_SUBCMDS:` headers, so it always matches what the dispatcher can run. Category-less like `pos-config`; `--depth N` limit; `pos help tree` works. Docs: POS.md `tree` section, `bin/pos` usage example, `make gen` regenerated the AGENT_Context tree/dispatch/filetable + `_pos_flags[tree]`.
- **2026-08-09** — Telegram sender `config` / `config set` removed — redundant with `pos config telegram` (same `# POS_CONFIG:` registry, masked token display + input, chat-id validation, chmod 600); sender/listener error hints now point there. Deep-review bugfixes in the same commit: mapped `/command` values containing `|` are no longer truncated (`load_map` switched from a `|` to a `\x1f` delimiter — previously `/up=echo hi | head` silently ran `echo hi `); `pos entertainment send <plugin> [args…]` actually forwards the extra args (every arg was `shift`ed in the flag loop, so `$@` was empty) and passes `--` before the message so leading-`-` plugin output isn't parsed as an option; `write_config_key` (entertainment-lib) and `cfg_write` (config-ui) replaced unescaped `sed -i "s|^K=.*|K=\"$v\"|"` with grep-v+append so values with `&`/`|`/`\` no longer mangle (also the path all telegram config now flows through); `sync_systemd` daemon-reloads after removing timer units; `digits` config validation accepts negative group/supergroup chat ids (`-100…`).
- **2026-08-09** — Fixed telegram listener editor crash on remove/edit/test: `ui_pick` printed its menu listing to **stdout**, so `idx="$(ui_pick)"` captured the menu *and* the number, and `MAP_CMDS[$idx]` (arithmetic array subscript) blew up with "syntax error in expression". Menu decoration now goes to stderr; only the picked index is emitted on stdout. Pre-existing bug (before the `::desc` work), exposed by the description column.
- **2026-08-09** — Telegram listener pushes its mapped `/commands` to the bot's `/` menu via `setMyCommands` (auto after every map edit, on `--enable`, and at daemon start; manual `--sync-commands` flag). Map lines may carry a menu description: `/cmd::short description=bash command` (falls back to the bash command, ~40 chars). Names are validated against Telegram's lowercase `[a-z0-9_]` rule — invalid ones are skipped from the menu with a warning but still resolve when typed; empty map clears the menu. Fixed latent bugs found by the sync work: `map_has` (awk `END{exit 1}` overrode the match), and `warn()` went to stdout so it leaked into the generated JSON (now stderr).
- **2026-08-09** — `pos config <TAB>` scope completion is now cached at gen time (`_pos_config_scopes` array emitted by `make gen` from the `# POS_CONFIG:` registry) instead of scanning ~40 tools per TAB — a per-keypress subshell storm that wedged interactive shells for minutes on the loaded homelab box. Two stuck `-bash` sessions (69%/38% CPU) killed. `plugin_marker`/`plugin_keys` hardened with `|| true` so `config_keys` no longer aborts mid-scan under `set -euo pipefail` on mixed lib/plugin dirs (installed layout) — fixes missing plugin keys in `pos config entertainment`.
- **2026-08-09** — `pos config <scope>` interactive config editor: reads the `# POS_CONFIG:` registry across tools into a single runtime config (`~/.config/linux_post_install/*.env`, one file per scope, chmod 600); secret masking with show/hide toggle, `digits:`/`num:`/`url:` validation, `-` to clear, blank keeps; `*plugins` marker expands plugin vars (entertainment) from `entertainment-lib.sh`; `desc::example` value-format hints shown in the editor; gen-docs now handles category-less tools (`pos-config`), fixed a `set -e`+`pipefail` bug that truncated the header registry.
- **2026-08-09** — `pos-communication-telegram``pos-communication-telegram-sender`: one canonical `send` (dropped the legacy `--send` flag, which duplicated the `send` subcommand in completion). `pos communication telegram <TAB>` now completes to just `sender listener`. `lib/notify.sh` maps platform `telegram``telegram-sender` via `notify_sender_name()`; entertainment-send + health `--send` check updated. Removed phantom subcommands from howto/communication.md (webhook/logs/broadcast/file never existed).
- **2026-08-09** — Structure/convention audit fix: `--dry-run` now truly dry (`spawn()` honors `DRY_RUN`, install.sh exports it to child phases, postinstall mutations run-wrapped); `gen-docs.sh` no longer chmods regenerated files to 0600; `make check` now syntax-checks apps/entertainment/features/templates; `.gitignore` protects `config/authorized_keys` + `config/rclone.conf`; honest `--send` confirmation; docs refreshed (notify.sh in lib lists, pos-health systemd units, tsui, scripts/, INTERACTIVE_CMDS).
- **2026-08-11** — Docs: DEV.md / AGENTS.md / AGENT_Context improved from the SMB session's lessons. DEV.md: new "Testing tools that need root / systemd / missing deps" (env-override test seams — `FLAGS_DIR`/`SMB_CONF`/`SMB_CREDS_DIR`/`UNIT_DIR` precedents — + stub-PATH fakes + PTY prompt driving via `script`); new Best Practice "Managed Config Blocks" (start/end marker idiom incl. the `inblock == 1` awk guard, validate-then-apply, hot reload); deps-guards-run-before-`--help` made explicit (previously only inferable by reading the NFS tools); "Update the docs" checklist completed (howto index/section, Common Tasks row, AGENTS.md Quick facts, AGENT_TODO Done move). AGENTS.md: clarified which filetable line-count rows are hand-maintained (non-`pos-*` files above the marker) + when to bump them; deps-guard clause added to Quick facts. AGENT_Context "Adding a New Tool" steps 67 mirror the above. `make gen && make check` green.
- **2026-08-11** — `share` category grows SMB: `pos share smb server` (`bin/pos-share-smb-server`) + `pos share smb client` (`bin/pos-share-smb-client`), completing the share trio (usb/nfs/smb). **Server:** `status`/`share`/`unshare`/`list`/`adduser`/`deluser`/`reload`/`enable`/`disable`; idempotent marker blocks in `/etc/samba/smb.conf` (`# >>> pos-managed share: <name>``# <<< end pos-managed share` — hand edits outside markers survive; `inblock==1`-guarded awk so removing one block never eats another's end marker), `testparm` validation before apply + `smbcontrol smbd reload-config` hot reload; `--read-only`/`--guest`/`--users u1,u2` flags with unrestricted-share warnings; `smbpasswd` user management (prompts, requires system user first). **Client:** `mount`/`unmount`/`list`/`persist`/`unpersist`; password prompt via `/dev/tty`, throwaway chmod-600 credentials for one-shot mounts, persistent creds at `/etc/samba/credentials/<name>` (chmod 600); `persist` writes a systemd `.mount` unit (`systemd-escape`) with `x-systemd.automount` + `_netdev` — mounts on first access, never blocks boot. Both source `lib/notify.sh` for mutations; added to `INTERACTIVE_CMDS` (prompting subcommands). Deps: `samba` + `cifs-utils` added to preinstall PACKAGES. `SMB_CONF`/`SMB_CREDS_DIR`/`UNIT_DIR` env-overridable for tests (FLAGS_DIR precedent). Docs: POS.md share rows, howto/share.md SMB sections, HOWTO index row, AGENT_Context Common Tasks, AGENTS.md categories. `make gen && make check` green; logic tested via stubbed PATH + temp config (marker idempotency, guest + user persist flows).
- **2026-08-11** — `pos network checkport` nmap overhaul: two-pass engine — pass 1 = fast `-Pn -T4 --max-retries 1` scan of only the asked ports (was: all 65535) with per-port state + nmap service names; pass 2 (`--versions`, opt-in) = `-sV --version-light` on open ports only (generous host-timeout — version probing a silent service otherwise made nmap skip the host entirely), fallback fast banner probe for open TCP with no version info; TCP fast path ~2s for 3 ports. Unprivileged UDP now falls back to the nc engine (Debian nmap `-sU` requires root and quit outright); IPv6 hosts get `-6`; `no output`/filtered states set rc=1; `--timeout` scales nmap host-timeouts. New `--versions` flag in `# POS_FLAGS:` (completions regenerated) + usage text; port-metadata fallback retained. `make gen && make check` green.
- **2026-08-11** — `pos communication matrix sender login` error reporting: captures HTTP status + Matrix `errcode`/`error` from the JSON body (temp file, not stdout) instead of a generic "wrong credentials?" message — distinguishes unreachable homeserver from rejected credentials; auto-prepends `@` when `--user` is bare (e.g. `--user alice:example.org``@alice:example.org`).
- **2026-08-11** — New `share` category — `usb` and `nfs` moved out of `pos usb` / `pos system` into `pos share`: `pos share usb server` (was `pos-usb-server`), `pos share nfs server` + `pos share nfs client` (were `pos-system-nfs-*`). Renamed the three tools (`bin/pos-share-*`), updated `# POS:` headers/usage strings, `INTERACTIVE_CMDS` (`usb-server``share-usb-server`), `bin/pos` usage() EXAMPLES, and the notify-scope comment in `pos-system-backup`. Docs: new `DOC/howto/share.md` (USB + NFS consolidated; `howto/usb.md` deleted, NFS sections stripped from `howto/system.md`), POS.md `### share` section (replaces `### usb`, nfs rows moved out of `### system`), HOWTO/README indices, AGENT_Context hand-written spots, root README, DEV.md `INTERACTIVE_CMDS` example, AGENTS.md categories. Category is the home for future `smb`. `make gen && make check` green; `/usr/local/bin` refreshed.
- **2026-08-12** — `pos network download` (`bin/pos-network-download`) — aria2 JSON-RPC daemon + queue control. **Daemon:** persistent `aria2c` as a systemd **user** service (`pos-aria2.service`, `${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user`, `enable --now` + linger warning on headless boxes), `--rpc-listen-port=6800`, generated `RPC_SECRET` in `~/.config/linux_post_install/download.env` (chmod 600, env override), unit flags `--continue=true --max-connection-per-server=16 --split=16 --seed-time=0 --dir=$HOME/Downloads`. **Commands (18):** `start`/`stop`/`status` (+ bare overview = status+list), `add <url…> [--dir --out --split --tmux]`, `torrent <file|magnet…> [--dir --seed --tmux]` (base64 `addTorrent`), `metalink <file|url> [--tmux]`, `list` (active/waiting/stopped table), `info`/`files`/`peers <gid>`, `pause|resume|remove [gid|all]` (`--force``force*`), `purge`, `move <gid> <pos>`, `limit [gid] <speed>` (`--upload`, 0=unlimited, `2M`/`512K`), `set <k=v…> [--gid]`, `watch [gid]` (2s live repoll; exits when that gid completes). `--tmux` opens a detached `dl-<name>` session running `watch <gid>` (name from `--out`/URL basename, sanitized, 40-char truncate, `-2` on collision; closes itself on completion). Deps: `aria2c`/`jq`/`curl` guards before `--help`; `aria2` added to preinstall PACKAGES. No stdin → not in `INTERACTIVE_CMDS`. JSON built with `jq -nc --arg` (never string interpolation — fixes JSON-quote bugs); `# POS_SUBCMDS:` (18) + `# POS_FLAGS:` → completions. Test seams `RPC_PORT`/`RPC_SECRET`/`DOWNLOAD_DIR`/`USER_SYSTEMD_DIR`/`ACTIVE_MARKER`; 76-case stub-PATH behavior suite green (unit content, secret 600, add→gid, tables, queue ops, error paths). Docs: POS.md network row+detail, howto/network.md section, HOWTO index, AGENT_Context Common Tasks row. `make gen && make check` green.
- **2026-08-12** — `pos network download` grows outage resilience: `restart <gid>` (re-queue from history — torrents via rebuilt magnet `urn:btih:` + `&tr=` trackers, HTTP via original URIs with `dir`/`out` preserved, `--continue=true` resumes partials; options `--dir/--seed/--split/--tmux`), `retry <gid|all>` (smart retry — waits out internet outages via `NET_PROBE` seam, re-queues, `retry_verify` polls the new gid; aria2 error 3 = real problem → diagnosed + marked permanent in `~/.config/linux_post_install/download.retry` as `url:<uri>`/`bt:<infohash>`, `retry all` skips them, manual restart overrides; `--once`/`--quiet` timer mode; `--interval`/`--max-wait`), and the **retry healer** systemd user pair (`pos-aria2-retry.service` oneshot `retry all --once --quiet` + `pos-aria2-retry.timer` 2min, `Persistent`) that arms on download start (`add`/`torrent`/`metalink`/`restart`) and disables itself when nothing is left; `watch <gid>` now auto-restarts its download after an outage. Fixes from stub-suite review: `ensure_healer` was missing from the three submit paths; `RESTART_NAME` was lost across `do_restart`'s process-substitution subshell (now a `download_name()` helper); `restart` exited 1 because the `[ tmux -eq 1 ] && tmux_watch` test was the function's last statement. Verification: stub-based test harness (`/tmp/opencode/dl-test` — curl/systemctl stubs with tellStatus fixtures, `NET_PROBE` file-flip, unit enable/disable logging) 119/119 green, incl. new restart/retry/healer/watch-heal cases. Docs: POS.md download rows + outage-resilience paragraph, howto/network.md outage recipe, SYSTEMD.md per-user units section, AGENT_Context + completions regenerated. `make gen && make check` green.
- **2026-08-12** — `pos network download replace <gid> <url>` + fresh-link status advisory. `status` now flags stopped errored downloads whose source is marked permanently failing in `download.retry` (`needs fresh link: <name> (<gid>) — pos network download replace … <new-url>`; one `tellStopped` RPC, id-match in jq). `replace <gid> <url>` re-queues a dead single-file HTTP/FTP download with a new URL keeping the same `dir` + file name (partial resumes via `--continue=true`), unmarks the old source (`retry_unmark`, literal `grep -vxF` — URL-safe), and reuses `retry_verify` so a dead replacement link is diagnosed + marked permanent; torrents/active/multi-file are rejected with hints; `--dir/--split/--tmux` supported. `retry_verify` hardened to `${quiet:-0}` so it works outside `cmd_retry`. Stub suite grew a `replace` section (advisory match, success + unmark + advisory-clear, dead new link marked, torrent/active/arg errors, prefix gid) — tellStopped fixtures gained `uris` (real aria2 includes them). 141/141 green; docs: POS.md row + outage paragraph, howto/network.md dead-link recipe. `make gen && make check` green.
- **2026-08-12** — `pos system event-trigger` (eventer) generalized into `pos system schedule` — the scheduler replaces the single-timer threshold monitor with **per-job systemd user timers** (`pos-schedule-<name>.{timer,service}`, `Persistent`, ExecStart `run <name>`, reconciled on `enable`/`disable` — orphan units + the legacy `pos-event-trigger` timer auto-removed). Each job is a chmod-600 file `~/.config/linux_post_install/schedule.d/<name>.env`: `INTERVAL` (`5m..59m`/`1h..23h`/`hourly`/`daily`/`weekly`/`OnCalendar=…`), `NOTIFY` policy, optional `MSG`, `RULE` (threshold only), and `COMMAND` = **literal remainder of the line** (pipes/quotes/`sudo` need no escaping). Policies: `always` (full output every run), `onchange` (diff vs last run, first run always sends), `onerror` (non-zero exit or empty output), `threshold` (old event-trigger behavior: first numeric vs `RULE`, alert on false→true + recovery, per-job firing state), `never` (silent side-effect jobs — no notify; run log + last-run record still kept). Per-run logs/state in `~/.local/share/linux_post_install/schedule/{logs,state}/`. Subcommands: `run [name|all]`, `list`, `config` (interactive add/edit/remove/enable/disable with validation), `enable [name|all]`, `disable [name|all]`, `status`, `migrate` (converts legacy `event.env` rules → `schedule.d/rule-N.env` threshold jobs, adopts the legacy timer's OnCalendar or 5m, removes the old timer). Files: `bin/pos-system-event-trigger``bin/pos-system-schedule`, `lib/eventer-lib.sh``lib/scheduler-lib.sh` (git mv; installed by install.sh), `config/event.env` + `config/event-rules.template``config/schedule.d/` starter jobs (nvme-health via `sudo -n smartctl` with the user's exact grep — sudoers NOPASSWD documented; cpu-temp + disk-root thresholds; silent log-cleanup), postinstall installs them no-clobber into an empty `schedule.d/` (legacy `event.env` users get a migrate hint instead). `bin/pos` EXAMPLES + INTERACTIVE_CMDS (`system-schedule config`) updated. Supersedes the "Tier 2: watch plugins" backlog idea. Docs: POS.md system row rewritten, howto/event-trigger.md → howto/schedule.md (job syntax, policies, NVMe recipe, migration), HOWTO.md index row + config table + scheduling bullet, AGENT_Context lib row + Common Tasks row. `make gen && make check` green; stub-harness suite (fake `systemctl`/`sudo`/`smartctl`/`sensors`/`df` + fake telegram sender logging, env seams `SCHEDULE_DIR`/`SCHEDULE_STATE_DIR`/`SCHEDULE_LOG_DIR`/`USER_SYSTEMD_DIR`/`SCHED_LEGACY_ENV`) covers all 5 policies (threshold cross/recover/no-repeat, onchange first/diff/same, onerror, always, never-silent), COMMAND literal-pipe parsing, enable/disable/status + orphan/legacy cleanup, migrate (incl. skip-existing + dry-run), and dispatch.
- **2026-08-13** — `pos communication scrcpy` audio control: scrcpy already forwards device audio to the desktop by default (answer: yes, default is sound-to-desktop). Added `SCRCPY_AUDIO` config key (`pos config scrcpy`, default `true`): `false`/`no`/`0``--no-audio`, `true`/`yes`/`1` → nothing (default), anything else → error. Docs: POS_CONFIG header, POS.md config table, howto/communication.md Mirror section, HOWTO.md env row. Verified: harness +7 tests (47/47 green — false/true/yes/0/invalid/combined-order), `bash -n`, `make gen && make check` green.
- **2026-08-13** — `pos communication scrcpy --new-display` support: new `SCRCPY_NEW_DISPLAY` config key (`pos config scrcpy`) — `true`/`yes` → bare `--new-display` (default size/dpi), `1920x1080`, `1920x1080/420` or `/240``--new-display=<value>`; inline validation in `_mirror` (err runs in the main shell, not a process-substitution subshell) rejects anything else with the accepted forms. Docs: POS_CONFIG header, POS.md command+config tables, howto/communication.md Mirror section, HOWTO.md env row, usage() example. CLI pass-through `pos communication scrcpy --new-display=1920x1080` also works verbatim. Verified: harness +8 tests (40/40 green — WxH, true, WxH/DPI, /DPI, invalid-rejected, env>config, combined order), `bash -n`, `make gen && make check` green.
- **2026-08-13** — Fix `pos communication scrcpy` mirror failure on the real box (`ERROR: Unexpected additional argument:` on every mirror, bare or with flags): `_extra_flags()` ran `printf '%s\n'` with an empty array expansion, which prints one blank line; `_mirror()`'s `while read` turned that into an empty-string arg passed to scrcpy. Fix: `_extra_flags` now returns early when `SCRCPY_EXTRA_FLAGS` is empty (and `printf '%s\n' "${extra[@]}"` when set), and `_mirror` defensively skips blank entries (`[ -n "$f" ] && cmd+=("$f")`). Rebuilt the stub-PATH suite (`/tmp/scrcpy-run-test.sh`, outside the wiped `$TEST_DIR`) — 32/32 green incl. the regression (bare mirror → zero args to scrcpy) and EXTRA_FLAGS + passthrough mixed. `bash -n`, `make gen && make check` green.
- **2026-08-13** — Fix scrcpy apt install on the live box: preinstall `apt install` failed with `Unable to locate package scrcpy` (Debian/Ubuntu need contrib/universe for `scrcpy`, and the apt build is older anyway). Removed `scrcpy` from `preinstall.sh` PACKAGES (kept `adb`); `scrcpy` now installs via the existing optional app `apps/media/scrcpy.sh` (GitHub latest, bundles adb) — docs (POS.md, howto/communication.md) and the tool's deps-guard error reworded to lead with that path. Re-verified: `bash -n`, stub suite 21/21, `make gen && make check` green.
- **2026-08-13** — `pos communication scrcpy` (`bin/pos-communication-scrcpy`): wrapper over scrcpy+adb for Android mirroring/control. Subcommands: bare `scrcpy` (mirror — config defaults + verbatim pass-through of any scrcpy flag; no device → friendly error + hints), `devices` (`adb devices -l`), `record [file] [--headless]` (default `$SCRCPY_RECORD_DIR/<device>_<date>.mp4`, `--headless` = `--no-playback` for headless servers), `tcpip [port]` (USB→wireless switch + prints `connect` with the auto-detected device IP), `connect <ip[:port]>` (adb connect + mirror `-s`), `push` (default `/sdcard/Download` = scrcpy's own default), `pull`, `screenshot` (`adb exec-out screencap -p` → PNG in RECORD_DIR), `info` (model/android/sdk/serial via getprop). Config scope `scrcpy` (`~/.config/linux_post_install/scrcpy.env`, `pos config scrcpy`): `SCRCPY_SERIAL/MAX_SIZE/MAX_FPS/BIT_RATE/FULLSCREEN/RECORD_DIR/PUSH_TARGET/EXTRA_FLAGS`, env-var precedence. Deps `scrcpy` + `adb` added to preinstall PACKAGES; docs note the apt build is older and point to the existing `apps/media/scrcpy.sh` app installer (GitHub latest, bundles adb) — researched 2026 releases (current v4.1). Conventions: `# POS:`/`# POS_SUBCMDS:`/`# POS_CONFIG:` headers, deps guards before `-h|--help`, no stdin → no INTERACTIVE_CMDS. Verified: `bash -n`, stub-PATH suite `/tmp/opencode/scrcpy-run-test.sh` 21/21 green (fake adb/scrcpy echo-args, HOME isolation, env/file precedence, rc paths, screenshot bytes), `make gen && make check` green, dispatch via `pos communication scrcpy --help`. Docs: POS.md communication table + detail block, howto/communication.md section, HOWTO.md index + env row, AGENT_Context Common Tasks row + gen'd tree/dispatch/filetable.
- **2026-08-13** — Entertainment-module hardening (approved Tier 1 + Tier 2): delivery moved to `notify_send` (platform follows `NOTIFY_PLATFORM`, default Telegram) via `lib/notify.sh` sourced by `bin/pos-entertainment-send`; a **last-run state** is recorded per plugin (`~/.local/share/linux_post_install/entertainment/last/<plugin>` — rc + timestamp) on every non-`--print` run and shown by `pos entertainment status`, which also lists installed-but-not-enabled plugins; a send that fails **while fired by a timer** (gated on `$INVOCATION_ID`) additionally notifies the configured platforms. New **message-safe plugin lib** `lib/entertainment-plugin-lib.sh` (defines only `plugin_*`, never writes stdout — the stdout contract stays "message only"): `plugin_load_config` (entertainment.env + env precedence), `plugin_have`, `plugin_require`, `plugin_err`, `plugin_http_json <url> [--key <jq>] [-H <header>]` (curl `--max-time 20 --retry 2`); `weather`/`joke`/`gold` refactored onto it. `pos entertainment config` gains `get|unset|ls|edit` (edit via the shared `pos config` UI — added to `INTERACTIVE_CMDS`). **Tier 2**: new shared lib `lib/user-timers-lib.sh` (only `ut_*`: `ut_interval_to_oncalendar`, `ut_interval_label`, `ut_unit_name`, `ut_write_unit_pair` incl. `TimeoutStopSec=5s` + `Persistent` + network-online deps, `ut_ensure_linger`, `USER_SYSTEMD_DIR`) dedupes the systemd user-timer machinery between `lib/entertainment-lib.sh` and `lib/scheduler-lib.sh` (the latter's `sched_*` duplicates deleted; both source it; collides-with-nothing). `install.sh` Phase 2 lib list += the two new libs; SCRIPTS.md/DEV.md/POS.md/howto/entertainment.md/AGENT_Context updated (hand-maintained lib rows: entertainment-lib 354→311, scheduler-lib 830→760, +112 user-timers-lib, +67 plugin lib). Verified: `bash -n` everywhere; smoke-tested in an isolated `HOME=/tmp/enttest` (status, config get/set/unset/ls, send path rc=0, failing plugin records rc=1, error-case message hygiene); `make gen && make check` green.
- **2026-08-13** — Fast pos-unit shutdown: every systemd unit a pos tool writes (or `systemd/` ships) now sets `TimeoutStopSec=5s` (+ `KillMode=control-group` on the daemons) so a stuck process can't stall a reboot for the 90s systemd default. Applied at all 7 template sites: `pos-communication-telegram-listener`, `pos-communication-matrix-listener` (also gained a `trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT` in `run_daemon` so stop returns sub-second), `pos-network-download` (aria2 + retry-healer units), `lib/scheduler-lib.sh` `sched_write_units`, `lib/entertainment-lib.sh` `write_units`, and `systemd/{ssh-agent,autostart,usb-automount}.service`. Legacy-unit cleanup: the repo no longer ships `pos-health.{service,timer}` / `pos-entertainment.service` (they were documented but postinstall never created them — found stale only on the live box, FAILED); removed their stale references from SYSTEMD.md (deleted the `pos-health.service` section + gating special-case, added a new **Stop behavior** section), POS.md, HOWTO.md, howto/system.md (now documents the `pos system schedule` job replacement + removal commands), AGENT_Context (tree, phase description, selfcontained table). DEV.md Best Practices gains a **Systemd units** convention (TimeoutStopSec=5s + TERM trap + regeneration caveat). Verified: `bash -n` on all edited scripts; `make gen && make check` green (filetable rows for the two listeners + network-download auto-regenerated, hand-maintained lib rows bumped 350→354 / 822→830). Live-box application is manual (this session was a Google Cloud Shell, not the real machine): regenerate units via `pos network download start`, `pos communication telegram listener --enable`, `pos system schedule enable <job>`, `pos entertainment enable <plugin>`, then `sudo systemctl disable --now pos-health.timer pos-health.service 2>/dev/null; sudo rm -f /etc/systemd/system/pos-health.{service,timer} && sudo systemctl daemon-reload`.
- **2026-08-13** — Bootstrap output transparency (`install.sh` / `preinstall.sh` / `postinstall.sh`): removed the redundant `apt update` (preinstall.sh owns it — install.sh previously ran it twice, showing two identical `OK apt update` lines); Phase 2 now names what it installs — libs line (`libs -> /usr/local/bin (644): common.sh flags.sh …`), plugin names in the count line, x64_bin names, per-feature `feature installed/overwritten` + `feature flag set` logs with a `N features installed: …` summary — and the misleading `"47 scripts + libs"` label is fixed to `47 scripts + 6 libs` (the 6 libs were outside the counter); preinstall prints `Installing N packages (apt install -y):` with the 40-name list wrapped at 80 cols; postinstall now logs silent skips — `config/authorized_keys is empty — nothing to add` (empty file previously looped zero times with no message), `schedule.d already exists, keeping it` (restructured the condition so the message is accurate when the dest exists vs config/schedule.d absent), and a per-service `service enabled: <name>` line. No output-layer changes (no `--verbose`, no log file — decided scope). Verified: `bash -n` + `--dry-run` smokes of phases 1/2/3 showing every new line (learned: `install.sh:19` hardcodes `export DRY_RUN=0`, so an env `DRY_RUN=1` is ignored — the flag `--dry-run` is required), hand-maintained filetable count rows bumped (install.sh 206→223, preinstall.sh 73→75, postinstall.sh 163→168), `make gen && make check` green. usb-automount left live (user choice).
- **2026-08-13** — `usb-automount` feature, integrated exactly like `autostart`: `features/usb-automount.sh` (root-guard re-exec via sudo; first-root-run self-install of udev rule `/etc/udev/rules.d/99-usb-automount.rules``ACTION=="add", KERNEL=="sd[a-z]*", SUBSYSTEM=="block", ENV{ID_BUS}=="usb", TAG+="systemd", SYSTEMD_WANTS="usb-automount.service"` — + `udevadm control --reload` + `trigger --subsystem-match=block`; an existing/edited rule is never overwritten; scans `lsblk -J` for unmounted removable partitions/raw whole-disk filesystems, mounts each at `/media/<label>` — vfat/exfat/ntfs world-writable via `-o umask=000`, fallback plain mount, label-collision bump `-2`/`-3`, no label → `usb-<name>`, logs `${HOME:-/root}/.usb-automount.log`) + `systemd/usb-automount.service` (`Type=oneshot`, `WantedBy=multi-user.target` — boot + hotplug + manual `systemctl start usb-automount`), gated in postinstall.sh's systemd loop exactly like autostart (`flag_is_set usb-automount` → skip with hint). Purpose: a plugged-in stick is auto-mounted world-writable, ready for `pos system backup`'s post-verify USB copy. Docs: SYSTEMD.md (service section + gating code block), SCRIPTS.md (feature section + systemd bullet + TOC), AGENT_Context tree + filetable rows (postinstall.sh count corrected 152→163 — it was already 6 lines stale), README index rows. Verified with a stub suite (`/tmp/opencode/usb-automount-test` — lsblk JSON fixtures, mount/mountpoint/udevadm/sudo stubs, `MOUNT_BASE`/`UDEV_RULES_DIR` seams, HOME isolation): 47/47 green. `make gen && make check` green. Gotcha learned: `${VAR:-{...}}` with a `{` inside the parameter-expansion default mis-parses in bash (emits a stray `}` — printf of a multi-line value showed `}}`); avoid braces in `:-` defaults.
- **2026-08-13** — `pos system backup` copies the finished backup to a USB stick. Detection runs **after** the archive verifies (so a stick plugged in while the backup ran is found; if none is mounted, one re-scan prompt before giving up — `s` skips, EOF from cron skips silently, rc stays 0). Single stick → y/N confirm; several → numbered pick (0 = skip). Copy lands in `<usb>/backups/` (mkdir -p; `chmod 600` best-effort — vfat chmod failures warn, never fail), and the transfer is proven 100% by **sha256 source-vs-copy** before any success is announced: mismatch → warn with both hashes + `notify_send "USB copy FAILED…"` + rc=1 (the ERR trap is re-armed mid-script so a USB-phase failure no longer notifies "Backup FAILED"). Detection: `lsblk -J` → recursive jq filter (rm==true && mounted && type part|disk, space-safe via JSON) or pinned `BACKUP_USB_ROOT` seam (= `<root>/backups/`, skips detection — also the test seam). Docs: usage() Environment, POS.md backup row, howto/system.md (USB section + env table + mismatch troubleshooting), DEV.md system.env list. Verified with a stub suite (`/tmp/opencode/backup-test` — sudo/gpg/lsblk/sender stubs, HOME isolation, per-test lsblk JSON fixtures, corrupting-cp + vfat-chmod override stubs): 40/40 green (skip s/EOF, seam y/n, detect single, multi pick 2/0, re-scan after replug, corrupt copy rc=1 + honest notify, vfat tolerance). `make gen && make check` green.
- **2026-08-13** — `pos share smb-server share` now guards the two common `NT_STATUS_ACCESS_DENIED` causes at share time (warnings only): `--users` entries missing from the Samba passdb (`pdbedit -L`, cut to user column, `grep -qxF` per user — pointer to `pos share smb-server adduser <user>`), and ancestors of the share path lacking `other:+x` traversal (sticky dirs like `/tmp` count as traversable via the `t` slot; fix hint `chmod o+x <dir>`). Both wired into the `share` case after `require_root_dir`; howto/share.md SMB section + troubleshooting updated. Rooted in `reports/bug-report-smb-server-access-denied.md` (committed as the spec). Verified with a stub-PATH suite (`/tmp/opencode/smb-test` — pdbedit/systemctl/smbcontrol/testparm/smbpasswd stubs, `SMB_CONF` seam): 16/16 green.
- **2026-08-13** — Docs hardening from the schedule-session review (sole-developer call: terse, session-learned). DEV.md §7 env-seam registry now lists `USER_SYSTEMD_DIR` (`bin/pos-network-download`, `bin/pos-communication-{telegram,matrix}-listener`, `lib/scheduler-lib.sh`) + the scheduler's `SCHEDULE_*` seams, and documents the missing-`:-`-guard gotcha (a `VAR="${XDG…:-…}"` without leading `VAR:-` overrides the seam — stub runs then silently write to the real `$HOME`; fix: `USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-…}"`). New-tool test checklist gains an env-seam review step (grep for unguarded config writes + prove with `VAR=/tmp/x`). §7 notes stub harnesses are throwaway by design — build in `/tmp/opencode/<tool>-test/`, leave there, keep only the pattern. howto/schedule.md documents that `migrate` copies the rule LHS **verbatim** as `COMMAND` (old tool never had `disk root`/`loadavg` shorthands — rewrite those jobs with real commands). `make check` green.
- **2026-08-14** — Gitea Actions gate is now **live and green end-to-end**: act_runner (v0.6.1, labels `ubuntu-latest`) registered on `100.100.1.2` (`~/srv/gitea/runner/`, standalone compose next to the ScaleTail gitea; `CONFIG_FILE=/config.yaml` env required or `run.sh` never reads the config; `--add-host gitea.skink-platy.ts.net:100.111.241.54` so the job container reaches gitea). First real runs **caught a deterministic gen-drift**: plain `sort` in `scripts/gen-docs.sh` is locale-dependent (category-less tool keys start with `|`, which collates after letters under the CI container's locale → `pos-config`/`pos-tree` reordered), so the `git diff --exit-code` step failed. Fixed with `export LC_ALL=C` in gen-docs.sh (byte-order sort) + regenerated `DOC/AGENT_Context_Project.md` (config/tree now sort after the letter categories); `make check` OK, `make lint` 0 FAIL / 0 WARN. Live CI verdicts: the run for `e0b5b11` (workflow commit) and the empty trigger `98a767c` both **FAILED** on the drift; the run for `9d058b7` (the fix) **SUCCEEDED** (`🏁 Job succeeded`).
- **2026-08-14** — Gitea Actions gate added: `.gitea/workflows/lint.yml` runs `make gen` + `git diff --exit-code` (gen-drift) + `make check` + `make lint` on every push/PR. Verified locally the exact four steps pass (gen idempotent, check OK, lint 0 FAIL / 0 WARN). "no CI" lines updated in AGENTS.md (Quick facts → CI bullet, notes a registered act_runner is required) and DEV.md (stub harnesses note: CI runs static gates only, not behaviour suites). Gitea 1.26.4 confirmed reachable; runner registration completed the same day (see the entry above).
- **2026-08-14** — Convention-drift maintenance fix session (completed the audit backlog `MAINTENANCE.md`, M-001..M-023, all VERIFIED; gate `scripts/lint-conventions.sh` + `make lint` now 0 FAIL / 0 WARN; `make gen && make check` green). P0 bugs: M-002/003/004 added `docker-compose docker-vbox network-hotspot` to `INTERACTIVE_CMDS` (stdin/log-pipe prompt swallow); M-005 `install.sh --steps` now expands documented `N-M` ranges via `normalize_steps_spec()` (dry-run verified); M-006 feature-vs-docs decision: `--send`/`--markdown` **not restored** (health is a console-only reporter by design since fe7708f; scheduler `NOTIFY=always` covers delivery) — 5 docs corrected instead; M-007 `lib/notify.sh:57` fallback routed to stderr (stdout-leak on standalone source). P1: M-008..M-014 deps guards moved **before** `-h|--help` in docker-health/docker-ps (converted to `command -v X || err`), network-scan, share-usb-server, media-mp3/mp4 (guards before help with a `--dry-run` pre-scan preserving the documented no-deps preview); system-health documented as the sanctioned graceful-degradation no-guard pattern in DEV.md — lint refined accordingly (`first_guard_line` only matches real guards; `first_line` skips comments; precision fixes, not weakenings); M-015 system-firewall gained `usage()`+`-h|--help` (root-gated first; verified via sudo); M-016 `ffmpeg` added to preinstall PACKAGES. P2: M-017/M-018 autostart + usb-automount gained the feature-template preamble (flags.sh load, usage); M-019 `chmod +x apps/media/scrcpy.sh`; M-020 `SCALE_DIR`/`CONFIG_ENV` `:-` seams in pos-docker-compose (verified via overrides; follow-on fix: `DIM` color var missing from common.sh crashed `pos docker compose config` — added it); M-021 `CONFIG_DIR` centralized as the canonical XDG-aware seam in common.sh, per-file duplicates dropped (standalone-sourced notify.sh/config-ui.sh/matrix+telegram tools keep an identical guarded copy — "no shared lib? inline fallbacks"); M-022 `plugin_*` prefix collision resolved by renaming the internal registry helpers to `ent_plugin_*` (the documented plugin-authoring API `plugin_have`/`plugin_require`/`plugin_load_config`/`plugin_http_json` kept for user plugins); M-023 six tools (pos-config, pos-tree, pos-entertainment-{config,enable,disable,status}) now filename-referenced in DOC/POS.md. Hand-maintained AGENT_Context line-count rows bumped (install.sh 223→248, preinstall 75→76, common.sh 144→151, notify.sh 76→87 stale-corrected, autostart 14→50, usb-automount 134→138); `make lint` target wired in the Makefile. `MAINTENANCE.md` kept as the working record (uncommitted by design).
- **2026-08-14** — `pos system backup` optional encryption (`--no-encrypt` flag + `BACKUP_ENCRYPT=0` env, flag-or-env — user chose "Flag + env only"): plain path keeps a verified `.tar.gz` with no password prompt (headless/cron safe); encrypt path unchanged (prompt → gpg AES-256 → decrypt-verify; the gpg dep-guard moved into the encrypt branch so plain backups no longer require `gnupg`). Arg parsing rewritten as a loop over `"$@"` so `pos system backup <folder> --no-encrypt` works with the flag after the folder; usage() documents all three forms + the plain artifact name; `# POS_FLAGS: --service --no-encrypt`; `config/system.env` template gains `#BACKUP_ENCRYPT=0`; POS.md row + howto/system.md section updated. Verified: stub suite +2 cases (T18 flag / T19 env: plain .tar.gz artifact, gpg never called via `$GPG_CALLED`, USB copy + sha256 of the plain archive, notify wording) — 65/65 green; `bash -n`, `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
- **2026-08-14** — CI green-check via plain git (no SSH to the runner, no API tokens — user chose "CI tags + git ls-remote" + "scripts/ci-status.sh helper"): `.gitea/workflows/lint.yml` scoped to `on: push: branches: [main]` (tag pushes no longer re-trigger it) and the gate step now reports its own outcome as a lightweight tag — `ci-ok/$GITHUB_SHA` on success / `ci-fail/$GITHUB_SHA` on failure, pushed over HTTP with the jobs automatic `GITEA_TOKEN` to `http://oauth2:${GITEA_TOKEN}@gitea.skink-platy.ts.net:3000/admin/Linux_post_install.git` (runner container already host-maps that hostname to 100.111.241.54); `steps.gates.conclusion` decides ok/fail, `if: always()` (guarded to `push` events) covers failed gate runs, and an existing-tag guard makes re-runs idempotent. New executable `scripts/ci-status.sh [--wait] [<sha>]` reads the tags via `git ls-remote` (origin, `CI_STATUS_REMOTE` override): GREEN (0) / RED (1) / PENDING (2); `--wait` polls every 10s up to 10 min. DEV.md §CI gains a "Checking green without SSH" bullet. Verified: `bash -n`, yaml-parse OK, `make gen && make check`, `make lint` 0 FAIL / 0 WARN; first live-tag verification pending the push (fallback if Gitea clamps token-push: PAT as workflow secret).