Each provider now has its own key: AI_GEMINI_API_KEY and OPENROUTER_API_KEY.
No more shared AI_API_KEY that caused cross-provider key leakage (gemini
getting openrouter key → 400 error). resolve_key() sets AI_API_KEY internally
from the active provider's key for adapter use. Config UI shows both keys.
- POS_CONFIG header: replace | with 'or' in AI_PROVIDER description
(bare | was parsed as field separator, splitting one entry into two)
- render_markdown: check /dev/tty as fallback when shell hook redirects
stdout through tee (breaks [ -t 1 ] but /dev/tty stays writable)
--last now compares mtime of pos dispatcher logs vs captured output
(last_cmd_output) and uses whichever is newer, instead of always
preferring pos logs even when they are hours old.
New bin/pos-docker-stack: docker ps output grouped by Docker Compose
project (stack). Each stack is a sorted section (name, status, ports);
containers without a compose project land in a Standalone group at the
end. Running only by default, -a|--all includes stopped/exited. Status
colored on a terminal; summary line 'Stacks: N containers: N
standalone: N'; exit 0 when empty.
Data via docker ps --format with \x1f delimiters (project label
com.docker.compose.project from compose v2); parsed with awk -F'\x1f'
+ IFS=$'\x1f' read — tab/pipe delimiters are IFS whitespace or appear
in values (DEV.md:213). Dash padding via sed, not tr (multi-byte).
Deps guard (docker) before --help; no stdin.
Docs: POS.md docker row + detail, howto/docker.md table + section,
bin/pos usage EXAMPLES, AGENT_Context Common Tasks row. Verified:
stub suite 23/23, live daemon runs, dispatch, make gen && make check,
make lint 0 FAIL / 0 WARN.
usb_detect offered a Ventoy stick's 32M VTOYEFI ESP as a sync/backup
target: with the data partition unmounted it was the only mounted
candidate, and cp died mid-copy with 'No space left on device'.
Detection now reads FSTYPE/PARTTYPENAME and drops EFI system
partitions from both the mounted list and the mount-offer list;
USB_MOUNTED entries carry mp|label|size|model|fs and usb_pick_root
shows that in the single-stick confirm and the multi-stick/partition
picker (USB_ROOT stays a bare mountpoint). pos-media-sync pre-flights
the exact payload size vs df free space (err, or warn under --dry-run)
before any copy.
GNU find (default -P) does not descend a command-line symlink to a
directory, so `find $HOME/Music ...` returned zero files and the tool
reported '0 added, 0 updated, 0 unchanged' without creating the target.
Switch to `find -H` (follows only command-line symlinks; inner-symlink
semantics unchanged).
CI is now live (act_runner on the Gitea host), so the docs stop saying a
runner 'needs to be registered' and record how the gate works:
- AGENTS.md: CI bullet now notes the live runner, red run = merge-blocker,
and the byte-order deterministic generator rule (LC_ALL=C, learned when the
CI container's locale reordered the category-less pos-config/pos-tree keys
and the gen-drift gate caught it).
- DEV.md: definition-of-done mentions the live CI re-run; new 'CI: Gitea
Actions Gate' section (runner location, CONFIG_FILE run.sh gotcha,
--add-host pin, one-time tokens, runnerv1 status enum 1=success/2=failure,
deterministic-generator convention, static-only limits).
- AGENT_Context step 7: pushing re-runs the gates, red run blocks.
- AGENT_TODO: dropped a duplicated 'gate added' Done entry and fixed its
stale 'runner pending' phrasing.
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 moved to the end of the tree/dispatch/filetable
blocks). The new Gitea Actions gate caught this as a gen-drift failure on a
clean checkout. Force LC_ALL=C for byte-order sort and regenerate the doc
(config/tree now deterministically sort after the letter categories).
- .gitea/workflows/lint.yml: on push + pull_request runs make gen, then
git diff --exit-code (gen-drift check), then make check, then make lint
- requires a registered act_runner (ubuntu-latest label) to execute
- docs: AGENTS.md Quick facts 'no CI' -> CI bullet (runner required, gates
still run locally); DEV.md stub-harness note clarifies CI is static-gates
only; AGENT_TODO Done entry + Next item for runner registration
- verified locally: gen idempotent (0 gen-managed files changed), check OK,
lint 0 FAIL / 0 WARN
- DEV.md: new 'Convention Lint Gate' section enumerating all check classes
(shebang/strict-mode, exec bits, POS headers, -h|--help after deps guards,
INTERACTIVE_CMDS, POS.md coverage, plugins/apps/units/wrappers/secrets/env
seams) + 'refine never weaken' rule; test step and definition of done now
include make lint (0 FAIL / 0 WARN)
- AGENTS.md: make gen -> make check -> make lint is the definition of done;
both gates described, lint not in the pre-commit hook
- AGENT_Context_Project.md: How-to-modify step 7 updated
- MAINTENANCE.md: next-session brief uses make lint; gate commit marked DONE
x-systemd.automount in a unit file's Options= is only honored when read
from /etc/fstab, so persisted mounts never armed an automount; the unit was
also static (no [Install]) so enable was a no-op. Result: after a reboot the
share sat inactive and the disk never mounted on access.
persist now writes both <name>.mount and <name>.automount units (the
automount with WantedBy=multi-user.target) and enables/starts the automount;
unpersist stops/disables/removes both units.
persist uses x-systemd.automount, so the CIFS share is never actually
mounted until first access and findmnt -t cifs stays empty — list reported
'No SMB mounts' despite configured persistent units. list now also scans
$UNIT_DIR/*.mount for Type=cifs and reports them under 'Persistent
(automount)' (where, what). Empty output still prints 'No SMB mounts'.
features/usb-automount.sh (installed via ./install.sh --feature, flag
usb-automount) mounts every unmounted removable block device at
/media/<label> — world-writable via -o umask=000 (fallback plain mount),
label-collision bump -2/-3, no-label -> usb-<name>. First root run
self-installs the hotplug udev rule (/etc/udev/rules.d/99-usb-automount.rules,
SYSTEMD_WANTS=usb-automount.service) + udevadm reload/trigger; an existing
rule is never overwritten. systemd/usb-automount.service (Type=oneshot,
WantedBy=multi-user.target) covers boot + hotplug + manual start, gated in
postinstall.sh's systemd loop exactly like autostart. Purpose: a plugged-in
stick is ready for pos system backup's post-verify USB copy without manual
mounting. Docs: SYSTEMD/SCRIPTS/README/AGENT_Context (tree, filetable —
postinstall.sh count corrected 152->163, it was already stale). Verified:
stub suite /tmp/opencode/usb-automount-test 47/47 green; make gen && make check.
Once the archive verifies, USB detection runs (so a stick plugged in while
the backup ran is found): mounted removable storage is auto-detected via
lsblk -J + a recursive jq filter (rm, mounted, type part|disk — JSON makes
spacey mountpoints safe), or BACKUP_USB_ROOT pins a fixed stick and skips
detection. None mounted → one re-scan prompt ('s' skips, EOF from cron
skips silently, rc stays 0); one stick → y/N confirm; several → numbered
pick (0 = skip). The copy lands in <usb>/backups/ (mkdir -p, chmod 600
best-effort — a vfat chmod failure warns, never fails the copy) and the
transfer is proven 100% by sha256 source-vs-copy before any success is
announced; a mismatch warns with both hashes, notifies 'USB copy FAILED',
and exits 1. The ERR trap is re-armed before the USB phase so a copy
failure no longer notifies 'Backup FAILED'. Docs: usage() Environment,
POS.md backup row, howto/system.md (USB section + env table + mismatch
troubleshooting), DEV.md system.env list. Stub suite
(/tmp/opencode/backup-test, HOME-isolated, sudo/gpg/lsblk/sender stubs,
corrupting-cp + vfat-chmod overrides, per-test lsblk JSON fixtures):
40/40 green.
Session-learned hardening (sole-developer project — terse, actionable):
- §7 env-override precedent list gains USER_SYSTEMD_DIR (network-download,
communication listeners, scheduler-lib) + the scheduler's SCHEDULE_* seams,
plus the missing-:-guard gotcha that silently writes to the real $HOME
under stub runs.
- 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: migrate copies the rule LHS verbatim as COMMAND — the
old tool never had disk root/loadavg shorthands; rewrite those jobs.