# Architecture: `pos ai alias` — robust activation mechanism **Date:** 2026-08-26 **Author:** Architect (ox-alpha) **Status:** DECISION_READY (supersedes activation decisions in `AgentsReport/architect/2026-08-26_ai-alias-architecture.md`) --- ## TL;DR | Decision | Choice | |----------|--------| | D1 — Activation artifact | **Option B**: executable wrapper scripts at `~/.local/bin/`, generated from the ENV source of truth. No shell aliases. | | D2 — Migration | Internal `_alias_sync()` runs on every `pos ai alias` invocation; reconciles wrappers ↔ ENV both directions; legacy `ai-aliases.sh` auto-removed when marker-guarded, else manual guidance. `.sh` generation stops entirely (no deprecated shim). | | D3 — Edge cases | Name collisions refused (foreign file or other binary on PATH); empty set → sync deletes all owned wrappers; dead provider → existing `pos-ai:121` error is sufficient; list/show formats unchanged, show gains wrapper path. | | D4 — Scope fence | Only `bin/pos-ai-alias` + small marker-scan addition to `bin/pos-system-uninstall` + docs. NOT: `bin/pos-ai`, ENV format, config-ui, postinstall.sh, menu flows. | | Postinstall gap | The deferred `.bashrc` wiring is closed by **obsolescence**, not implementation — no wiring needed anymore. | **Open items:** None blocking. One provisional nuance in D2 (legacy-file heuristic guard) flagged inline. --- ## Problem statement - Aliases were frozen at source-time: editing provider gemini→openrouter left the stale alias live in running shells → Gemini 429 while `list` showed openrouter (live failure). - Activation required a `.bashrc` source line that postinstall never wired — the feature was broken-by-omission even before staleness. - Non-interactive contexts (cron, scripts, ssh non-login) could never use aliases at all. ## Evidence base - `bin/pos-ai-alias:62-104` — current `_alias_regen()` writes `ai-aliases.sh`; success messages at :308 say "Reload shell: source ~/.bashrc". - `postinstall.sh:80` — PATH export already includes `$HOME/.local/bin` on pos-managed machines (Debian default `~/.profile` also adds it when the dir exists). - `bin/pos-system-uninstall:96,261` — established precedent for marker-managed user-local binaries (`$HOME/.local/bin/pos-ai-hook.sh`): discovery list + removal pass already exist as a pattern. - `bin/pos-ai:658-665, :118-121` — provider resolution flag > env > default; unknown provider yields `err "Unknown provider '$p' — available: …"` (good runtime failure quality, no pos-ai change needed). - Prior architect report D5/D10 chose `.sh` + `.bashrc` wiring; the wiring was never implemented. This report supersedes those two decisions; storage (ENV format) and CRUD UX decisions carry forward unchanged. --- ## Decision 1: Activation artifact ### Options evaluated **Option A: keep generated bash aliases + wire `.bashrc` + louder hints + unalias guards** - Advantages: smallest diff; familiar alias UX. - Costs: staleness is *inherent* — the artifact is a snapshot copied into each shell at source time; the live failure (stale gemini alias) can only be mitigated, never eliminated. Requires postinstall `.bashrc` wiring (the deferred gap), reload-hint UX that demonstrably fails ("users miss it"), and per-shell unalias guard logic for a mechanism bash makes awkward to retract. - Risks: cron/scripts/ssh non-interactive shells get nothing. Two truths (env file vs sourced copy) persist forever. - Compatibility impact: none. Operational impact: permanent "did you re-source?" support burden. **Option B: executable wrapper scripts at `~/.local/bin/`** - Architecture: ENV stays the single source of truth; tool renders one standalone script per alias: ```bash #!/usr/bin/env bash # Managed by pos ai alias — regenerated automatically; hand-edits are overwritten. # Alias: assist | provider: openrouter | session: assist set -euo pipefail exec pos ai openrouter ask --session assist --system "$@" ``` (empty prompt → omit `--system`; prompt embedded via existing double-layer `printf %q` mechanics, reused from `_alias_quote_cmd`, so it lands as exactly ONE shell word; `"$@"` passes user args through). - Advantages: **staleness eliminated** — next invocation reads current file bytes; no shell integration of any kind (kills the postinstall gap instead of closing it); works identically in interactive shells, scripts, cron, ssh non-login; removal = delete one marker-identified file; no INTERACTIVE_CMDS/tee-pipe interaction changes; content edits bypass `hash` caching entirely (bash caches paths, not contents) and new names are found on first PATH scan. - Costs: PATH-presence dependency (`~/.local/bin` must be on PATH — already guaranteed by `postinstall.sh:80` and Debian default `~/.profile`, but needs a runtime check + guidance); collision policy needed (scripts are filesystem entries, aliases weren't); ~40 lines more logic than Option A. - Risks: name shadows a real binary → mitigated by refusal policy (D3); user hand-edits wrapper → healed by sync regeneration (D2), and the header says so. - Compatibility impact: activation semantics change (documented). Operational impact: self-healing artifacts; zero shell-config coupling. Migration impact: handled by D2. **Option C: hybrid — wrappers primary + optional still-generated alias file** - Advantages: covers users attached to aliases. - Costs: keeps the stale-snapshot mechanism alive alongside the fix — two activation paths, two truth-drift surfaces, double the validation matrix. Directly contradicts the motivation ("more robust" = fewer failure modes, not one more). - Risks: the exact reported bug remains reachable through the optional path. ### Decision: Option B Staleness was an architectural property of source-time snapshots, not an implementation bug — no amount of hints or guards fixes Option A. Option B removes the class of bug (artifact always equals source of truth at invocation time) and deletes the deferred `.bashrc` wiring requirement rather than implementing it. Option C preserves the bug class for zero new capability. Smallest robust design wins. **Artifact specification (binding for Builder):** | Property | Value | |----------|-------| | Location | `${HOME}/.local/bin/` | | Permissions | `0755` | | Ownership marker | Line 2 contains literal `Managed by pos ai alias` (grep target for all ownership checks) | | Body | `set -euo pipefail` + single `exec pos ai ask --session [ --system <%q prompt>] "$@"` | | Quoting | Reuse `_alias_quote_cmd` verbatim (double-layer `%q` mechanics preserved per brief) | | Atomic write | mktemp in same dir → `mv` → `chmod 755` (same pattern as current `_alias_regen`) | | Pre-commit validation | `bash -n` on rendered wrapper; on failure warn + keep previous file | | Secrets | None inside (prompt is content, not credential) | [DECIDED] ## Decision 2: Migration & back-compat ### Regeneration trigger: `_alias_sync()` on every invocation New internal function, called at the top of **every** subcommand dispatch entry (`create`, `edit`, `remove`, `list`, `show`, interactive menu) before the subcommand's own logic. It reconciles `~/.local/bin` against the ENV file in both directions: 1. For each ENV entry: render expected wrapper content; if target is missing **or differs byte-wise** → atomically install. This means: - first run after upgrade materializes wrappers for all existing aliases (one-time migration happens on any command, including a harmless `list`); - every create/edit/remove leaves artifacts consistent by construction; - hand-edited or half-deleted wrappers are silently healed (idempotent, cheap for realistic alias counts). 2. Every executable in `~/.local/bin` bearing our marker whose name is **not** in ENV → deleted (covers remove, covers manual ENV edits, covers the empty-set case). 3. If ≥1 wrapper exists/installed and `$HOME/.local/bin` is absent from `$PATH` → loud `warn` with copy-paste fix (`export PATH="$HOME/.local/bin:$PATH"` + persist to `~/.profile`). Wrappers are still written regardless. 4. Legacy handling (below). No public `sync` subcommand: every subcommand already syncs, so an explicit one adds surface without capability. `POS_SUBCMDS` header stays `create edit remove list show`. ### Existing `ai-aliases.sh`: stop writing entirely — no deprecated shim A shim keeps two activation truths alive, and the sourced-alias-still-shadows-wrapper scenario is precisely the reported failure mode (in interactive bash, aliases take precedence over PATH lookups). The `.sh` artifact must die, not fade. On detecting `SH_FILE`, sync emits a warning block explaining that activation moved to `~/.local/bin/` scripts and that stale sourced aliases shadow them until cleaned. Then: - **If line 1–3 of the file carry our generator marker** (`Auto-generated by pos ai alias`) → auto-remove the file and print remediation for *running* shells: an `unalias ` line with names extracted from the `.sh` contents themselves (the stale file inventories its own definitions — including names no longer in ENV), plus "or simply start a new shell". Auto-remove is safe because (a) the file is regenerable output, not user data, (b) postinstall never shipped the source line, so nothing references it at startup, and (c) the conditional-source idiom (`[ -f ] && source`) tolerates absence even if a user wired it manually. - **If the marker does not match** (foreign/hand-built file) → leave untouched; advise manual review. Never delete files we didn't generate — same policy as wrapper collisions. *[PROVISIONAL nuance]* The auto-remove guard currently checks only the generator-marker header; a user who appended private aliases into our generated file would lose them on upgrade-migration. Accepted risk: the file header says "do not hand-edit", likelihood is low, and the alternative (parsing full-file provenance) buys complexity the requirement doesn't need. Revisit only if a real case appears. ### What carries over unchanged - `ai-aliases.env` format, location, chmod 600, comment conventions — untouched. Previously created aliases migrate with zero data conversion. - `pos config` compatibility: no env-key semantics touched. - All menu flows, name regex, non-tty guard behavior. [DECIDED] ## Decision 3: Edge cases & subcommand semantics ### Name collisions with real binaries — refuse Create-time check order (after existing ENV-duplicate redirect to `edit`): 1. `$HOME/.local/bin/` exists **with** marker → not a collision; sync will overwrite (regeneration path). 2. `$HOME/.local/bin/` exists **without** marker → refuse: `err "File '~/.local/bin/' already exists and was not created by pos ai alias — pick another name"`. Never silently overwrite foreign files. 3. `command -v ` resolves to anything else on PATH (`ls`, `git`, `gcc`, …) → refuse with the conflicting path named. No override flag. Shadowing an arbitrary binary is never a legitimate intent for an *alias* feature, a refusal error costs one rename, and a `--force` surface invites exactly the "surprise factor" this rework is meant to remove. Edit cannot collide (name is the record key); rename remains remove+create (Designer out-of-scope list already excludes renaming). ### Empty result set → wrappers fully retracted With zero ENV entries, sync deletes every marker-bearing wrapper in `~/.local/bin`. `list` prints `Aliases (0):` as today. No empty husks left behind. ### Provider adapter deleted later → runtime failure is already good enough Wrapper execs `pos ai …`; if the adapter vanished, `bin/pos-ai:121` errors: `Unknown provider '

' — available: gemini openrouter`. Actionable, names valid alternatives, zero changes to `pos-ai`. Sync does **not** prune wrappers whose provider directory entry disappeared (ENV is truth for existence; a temporarily missing adapter shouldn't silently eat user config). The provider picker at edit time only offers installed providers, so edit is the natural repair path. ### Subcommand semantics under Option B | Subcommand | Change | |------------|--------| | `list` | Format unchanged; runs after sync so it always reflects disk truth | | `show ` | Adds one line: `Wrapper: ~/.local/bin/` (or `(not installed)` if PATH check failed) | | `create` | Gains collision refusals above; success message replaces "Reload shell: source ~/.bashrc" with `Available immediately: ~/.local/bin/` (+ PATH warning when applicable) | | `edit` | Unchanged flow; on save, sync refreshes the wrapper — change is live on next invocation (this kills the reported bug) | | `remove` | Unchanged confirm(default=n); success message notes the script was deleted from `~/.local/bin`; add hint that running shells may need `hash -r` only if the name still autocompletes stale (rare; bash normally re-scans when a hashed file vanishes) | | menu / `-h` | Help text updated: activation = executable scripts in `~/.local/bin`, no sourcing required | [DECIDED] --- ## Decision 4: Scope fence for Builder ### Approved outcome Alias activation via marker-managed wrapper scripts in `~/.local/bin`, synced against `ai-aliases.env` on every invocation, with legacy `.sh` auto-retirement. ### In-scope components/files | File | Allowed changes | |------|-----------------| | `bin/pos-ai-alias` | Replace `_alias_regen()` with `_wrapper_path()` + `_wrapper_render()` + `_alias_sync()`; keep `_alias_quote_cmd` mechanics verbatim; add `_alias_check_path()`; wire sync into all dispatch entries; collision checks in `_alias_create`; message deltas in create/edit/remove/show/usage; legacy `.sh` retirement block; SH_FILE constant retained solely for migration detection | | `bin/pos-system-uninstall` | Add marker-scan of `~/.local/bin` (grep for `Managed by pos ai alias`) to the discovery list (~line 96 area) and removal pass (~line 261 area), mirroring the existing `pos-ai-hook.sh` pattern — closes uninstall hygiene | | `DOC/POS.md` | `ai alias` section: activation semantics, subcommand table unchanged otherwise (hand-maintained file) | | `DOC/HOWTO.md` / relevant howto | Row/section wording update if it mentions sourcing/reload | | `AGENT_TODO.md` | Move task to Done (dated) in same commit | ### Must NOT change - `bin/pos-ai` — any file byte. - `ai-aliases.env` format, fields, header comments, chmod 600. - `lib/common.sh`, `lib/menu-lib.sh`, `lib/config-ui.sh`. - `postinstall.sh` — the deferred `.bashrc` wiring stays unimplemented by design (obsoleted, not added). - `# POS:` / `# POS_SUBCMDS:` headers (description and subcommand set unchanged → no gen churn beyond none). - Menu structure, step counts, name regex, non-tty guard behavior (Designer spec remains authoritative). - Other categories' tools; entertainment plugins; completions (no flag/subcmd changes). ### Architectural constraints 1. Atomic writes only (mktemp+mv), never in-place truncation of live wrappers. 2. Ownership established exclusively via the line-2 marker string; never delete/overwrite files failing the marker test. 3. All output discipline per Designer spec: tables/results stdout, display/warnings stderr (`log`/`warn`/`err`). 4. Sync must be idempotent and safe to run concurrently-lossy (single-user tool: last write wins, no locking). 5. Wrapper body contains no secrets and no absolute paths except the `pos` lookup by name (PATH-resolved, consistent with old aliases). ### Required verification (adversarial where it matters) 1. `bash -n bin/pos-ai-alias`; `make gen && make check && make lint` ending `0 FAIL, 0 WARN`. 2. **Quoting round-trip through the NEW artifact**: prompts containing `'`, `"`, backtick, `$()`, `%`, `\`, unicode, leading/trailing spaces → create each; execute wrapper under a stubbed `pos` shim on a temp PATH capturing argv; assert `--system` arrives as exactly one intact word and passthrough args (`assist "hi there"`) append correctly. 3. **Staleness kill-test**: create `assist`(gemini) → run wrapper via shim → edit provider→openrouter → run again → argv shows openrouter with **no shell reload** (the regression test for the live failure). 4. Sync idempotency: two consecutive runs → byte-identical artifacts, mtimes stable second run. 5. Orphan retraction: delete an ENV line manually → next `pos ai alias list` removes that wrapper; empty ENV → zero owned wrappers remain. 6. Collision tests: foreign file at `~/.local/bin/` → refused; marker file → refreshed; `command -v` conflict (e.g. `gcc`) → refused with path named. 7. Legacy migration: plant prior-generator-format `ai-aliases.sh` with stale `alias assist=…gemini…` → any subcommand removes it, prints `unalias assist` remediation; plant foreign-content file → untouched, warned. 8. PATH-absent: strip `$HOME/.local/bin` from PATH → loud warn, wrappers still written. 9. Non-tty: `pos ai alias` (menu) still fails cleanly via `menu_guard`. 10. Dead-provider runtime: wrapper pointing at removed adapter produces `pos-ai:121` available-providers error (assert message quality manually once). ### Explicitly out of scope Rename operation; multi-line prompt input; alias import/export; public `sync` subcommand; completion headers; `pos config` integration; systemd/cron integration examples beyond help text. ### Open risks - Users who sourced `ai-aliases.sh` into `.bashrc` manually keep a dead reference — harmless under the conditional-source idiom; warning text covers it. - `~/.local/bin` absent from PATH in exotic shells (non-login ssh without postinstall) — mitigated by persistent warning + fix line. [DECIDED] --- ## Builder-ready step order 1. Core rewrite in `bin/pos-ai-alias`: `_wrapper_path`, `_wrapper_render` (reuse `_alias_quote_cmd`; marker line 2; `set -euo pipefail`; `exec … "$@"`), `_alias_check_path`, `_alias_sync` (render-diff-install, orphan sweep, legacy block). Delete `_alias_regen` body (keep SH_FILE constant for migration). 2. Wire `_alias_sync` into every dispatch entry before subcommand logic. 3. Create-flow collision refusals (marker-aware, `command -v` check) + success-message swap ("Available immediately", drop reload hints everywhere including menu flows). 4. Edit/remove/show/list/usage deltas per D3 table. 5. `bin/pos-system-uninstall`: marker-scan additions in discovery + removal passes. 6. Full verification suite (D4 list) — quoting round-trip and staleness kill-test are the acceptance gates. 7. Docs (`DOC/POS.md`, HOWTO row) + `AGENT_TODO.md` Done entry; conventional commit (`feat:` or `fix:`). Recommended next agent: **Builder** — scope is fully determined; no architectural choices remain. Suggest a Reviewer pass afterward focused on the marker-guard logic (the only place where a bug could delete/overwrite a foreign file). Architect changes: this report only.