yosh 0.1.2

A POSIX-compliant shell implemented in Rust
Documentation
# TODO

## Job Control: Known Limitations

- [ ] `disown` builtin — not implemented (non-POSIX extension)
- [ ] `suspend` builtin — not implemented
- [ ] Terminal state save/restore (tcgetattr/tcsetattr) — jobs that modify terminal settings may leave terminal in bad state
- [ ] Pipeline command display in `jobs` output uses placeholder format — improve to reconstruct shell syntax

## History: Known Limitations

- [ ] `HISTCONTROL` colon-separated values — bash supports `ignoredups:ignorespace` but current implementation only accepts single values like `ignoreboth` (`src/interactive/history.rs`)
- [ ] `history.save()` silently ignores write errors — disk-full or permission errors are swallowed (`src/interactive/history.rs`)
- [ ] `suggest()` linear scan performance — iterates all history entries on each keystroke; acceptable for HISTSIZE ≤ 500, may need caching or indexing for larger histories (`src/interactive/history.rs`)

## Future: Interactive Mode Enhancements

- [ ] `ENV` tilde expansion PTY test — `ENV=~/foo` tilde expansion is only exercised on interactive startup; add PTY test to verify `~` and `~user` cases (`tests/pty_interactive.rs`)
- [ ] Multiline editing — visual multiline editing with cursor movement across lines
- [ ] `set -o interactive` flag management
- [ ] Interactive-specific trap behavior — SIGTERM/SIGQUIT ignored by default
- [ ] `CLICOLOR=0` support in `should_colorize()` — disable colors even on TTY when `CLICOLOR=0` is set; many CLI tools support this alongside `NO_COLOR` (`src/main.rs`)
- [ ] Bash-style prompt escapes — `\w` (working directory), `\u` (username), `\h` (hostname), etc.
- [ ] History expansion — `!!` (last command), `!n` (by number)
- [ ] Right-aligned prompt (`PS1_RIGHT`) — starship-style right-side prompt display based on terminal width (`src/interactive/line_editor.rs`)
- [ ] Pre-prompt hook timeout — protect against slow `pre_prompt` plugins blocking prompt display; consider timeout or async approach (`src/plugin/mod.rs`)
- [ ] Prompt segment API — structured segment registration for multiple plugins to contribute prompt sections without PS1 conflicts (`src/plugin/`, `crates/yosh-plugin-sdk/`)
- [ ] Ctrl+C / empty-Enter type distinction — both return `Ok(Some(""))` from `read_line`; introduce a dedicated variant for clearer intent (`src/interactive/line_editor.rs`, `src/interactive/mod.rs`)
- [ ] Parse status edge-case tests — `||` continuation, `for...do` incomplete, nested structures, unterminated here-document (`tests/interactive.rs`)
- [ ] Tab completion: `CompletionUI`/`FuzzySearchUI` filtered/total display — both UIs show `N/N` instead of `filtered/total` because original count is not tracked (`src/interactive/completion.rs`, `src/interactive/fuzzy_search.rs`)
- [ ] Tab completion: unify `read_line` and `read_line_with_completion` — `read_line` is now only used by tests; consider merging into a single method (`src/interactive/line_editor.rs`)
- [ ] Syntax highlighting: color palette customization — allow users to override colors via environment variables like `YOSH_COLOR_KEYWORD=blue` (`src/interactive/highlight.rs`)
- [ ] Syntax highlighting: double-quote `$` expansion uses inline scanning — deeply nested cases like `"$(foo "$(bar)")"` may highlight incorrectly; consider mode-stack approach (`src/interactive/highlight.rs`)
- [ ] Syntax highlighting: `redraw()` ANSI optimization — currently calls `reset_style()` on every style change; could reduce escape sequences with diff-based rendering (`src/interactive/line_editor.rs`)
- [ ] Emacs keybindings: `~/.inputrc` config file — Keymap struct is separated for future configurability but no config file reading is implemented (`src/interactive/keymap.rs`)
- [ ] Emacs keybindings: undo group boundary on space — spec says space triggers undo group boundary but implementation defers boundary to next non-space char; undo granularity is slightly coarser than readline (`src/interactive/line_editor.rs`)
- [ ] Emacs keybindings: PTY E2E tests — kill/yank round-trip, undo, word movement, numeric arg scenarios not covered by PTY tests (`tests/pty_interactive.rs`)
- [ ] PTY tests: remaining `thread::sleep` after send — autosuggest/tab completion/syntax highlight/`set -m` tests still rely on 50–200ms fixed waits for UI render or child startup (not raw-mode races); if CI flakiness appears on those paths, migrate them to condition-based waits similar to `wait_for_raw_mode` (`tests/pty_interactive.rs`)

## Future: Plugin System Enhancements

- [ ] Runtime plugin load/unload — builtin commands `plugin load <path>` / `plugin unload <name>` for dynamic management
- [ ] SemVer API version management — replace single `YOSH_PLUGIN_API_VERSION` check with semver range compatibility (`crates/yosh-plugin-api/`)
- [ ] SDK `export!` macro `unsafe` lint — `#[allow(unsafe_attr_outside_unsafe)]` workaround in generated code; clean up when macro hygiene improves (`crates/yosh-plugin-sdk/src/lib.rs`)
- [ ] Sandbox: warn on unknown capability strings in `plugins.toml` — currently `capabilities_from_strs` silently ignores typos like `"typo:read"`; should log warning in `load_from_config` (`src/plugin/config.rs`, `src/plugin/mod.rs`)
- [ ] Sandbox: `CAP_ALL` manual sync risk — when adding new capabilities, `CAP_ALL` must be manually updated; consider deriving it from a list or using a test to verify completeness (`crates/yosh-plugin-api/src/lib.rs`)
- [ ] `yosh-plugin sync`/`install`: suggest `YOSH_GITHUB_TOKEN` when GitHub API rate limit (60 req/hour) is hit without auth (`crates/yosh-plugin-manager/src/github.rs`, `crates/yosh-plugin-manager/src/install.rs`)
- [ ] `yosh-plugin install`: tilde expansion for local paths — `~/my-plugin.dylib` not supported because `canonicalize()` doesn't expand `~`; consider reusing `config::expand_tilde_path` before canonicalization (`crates/yosh-plugin-manager/src/install.rs`)
- [ ] `yosh-plugin sync --prune`: remove empty plugin directories after deleting binaries (`crates/yosh-plugin-manager/src/sync.rs`)
- [ ] Workspace default package: `cargo test` without `-p` or `--workspace` may not find yosh tests — document in CLAUDE.md or set `default-members` in workspace config (`Cargo.toml`)
- [ ] `yosh-plugin update`: version replacement uses naive `String::replacen` which may target wrong plugin if two share the same version — consider using `toml_edit` for TOML-preserving edits (`crates/yosh-plugin-manager/src/main.rs`)
- [ ] `yosh-plugin update` help: add `#[arg(value_name = "PLUGIN")]` to show `[PLUGIN]` instead of `[NAME]` in help output (`crates/yosh-plugin-manager/src/main.rs`)
- [ ] `verify.rs` reads entire file into memory for SHA-256 — use streaming `Digest::update()` for large binaries (`crates/yosh-plugin-manager/src/verify.rs`)
- [ ] `GitHubClient` public API error type — `find_asset_url`, `latest_version`, `download` still return `Result<_, String>`; promote internal `GitHubApiError` to a public error type so callers can match on structured variants instead of string messages (`crates/yosh-plugin-manager/src/github.rs`)
- [ ] Integration tests: add checksum mismatch re-download test and partial failure (404) test per spec (`crates/yosh-plugin-manager/tests/`)

## Future: Code Quality Improvements

- [ ] `JobTable::update_status` per-process status tracking — currently overwrites the overall `job.status` on each child exit; if per-process status tracking (e.g., `$PIPESTATUS` array) is needed in the future, the `Job` struct will need a `Vec<(Pid, JobStatus)>` field instead of a single `status` (`src/env/jobs.rs`)
- [ ] `skip_balanced_*` unterminated input tests — `skip_balanced_parens`, `skip_balanced_braces`, `skip_balanced_double_parens` all return `bytes.len()` on unterminated input but none have tests for this behavior (`src/expand/mod.rs`)
- [ ] `find_in_path` vs `lookup_in_path` — `find_in_path` returns `Option<PathBuf>` (exec-only); `lookup_in_path` returns 3-state `PathLookup` for 126/127 distinction. Consider making `find_in_path` a thin wrapper over `lookup_in_path` to remove the near-duplicate directory walk (`src/exec/command.rs`)
- [ ] `exec_regular_builtin` "internal error" guards for `wait` / `fg`/`bg`/`jobs` / `command` are growing — consider factoring "Executor-requiring builtins" into an explicit classification or dispatch table instead of per-name guards (`src/builtin/mod.rs`)
- [ ] `render_verbose` Function arm has no unit test — `command -V <function>` branch exercised only through E2E; add a focused unit test in `src/builtin/command.rs` tests module
- [ ] `preview_command` has no direct unit tests — only exercised via E2E; add focused tests for compound-command / unexpandable-word fallback and pipeline first-command extraction (`src/exec/mod.rs`)
- [ ] `JobSpecError::Ambiguous` fully qualified at 3 call sites in `src/exec/mod.rs` (builtin_wait/fg/bg) — add a module-level `use crate::env::jobs::JobSpecError;` for readability
- [ ] `highlight_scanner.rs` `KEYWORDS` duplicates POSIX §2.4 list — `src/interactive/highlight_scanner.rs:66-69` defines its own copy of the 16 reserved words, separate from the canonical `crate::lexer::reserved::RESERVED_WORDS`. Consolidate once the contextual subsets (`COMMAND_POSITION_KEYWORDS` includes `"time"`, command-position restoration logic) are re-expressed in terms of the canonical list (`src/interactive/highlight_scanner.rs`)
- [ ] `cargo fmt --check -- <path>` misreads edition — rustfmt 1.8.0 / Rust 1.94.1 fails to parse let-chain syntax as edition 2024 when invoked with explicit file paths despite `Cargo.toml` specifying `edition = "2024"`, producing spurious fmt errors. Workaround: invoke `rustfmt --edition 2024 --check <path>` directly. Revisit when upstream rustfmt catches up.
- [ ] `parse_compound_list` non-empty regression tests are incomplete — only `nonempty_if_parses_ok` exists in `src/parser/mod.rs`. Add parallel `nonempty_while_parses_ok` / `nonempty_until_parses_ok` / `nonempty_for_parses_ok` / `nonempty_brace_group_parses_ok` / `nonempty_subshell_parses_ok` so future refactors cannot accidentally over-reject any individual context.
- [ ] LINENO update allocates a `String` per command — `exec_simple_command` / `exec_compound_command` call `cmd.line.to_string()` and go through `VarStore::set`. For tight loops this is ~500μs per 10k commands. If benchmarks ever show pressure, add `ShellEnv.exec.current_lineno: usize` and intercept `$LINENO` in `expand::param` to read that field directly, bypassing the alloc + HashMap write (`src/exec/simple.rs`, `src/exec/compound.rs`, `src/expand/param.rs`).
- [ ] `first_simple_cmd` duplicates `parse_first_simple` — both walk `Parser::new(src).parse_program()` to pull the first `SimpleCommand`, but with different APIs (`into_iter().next()` vs `[0]` indexing) and different panic messages. Consolidate or delete one (`src/parser/mod.rs`).
- [ ] Test helpers `first_simple_cmd` / `first_compound_cmd` use bare `unwrap()` — swap to `expect("program should contain …")` so future test failures pinpoint the step that produced `None` (`src/parser/mod.rs`).
- [ ] Extract `try_parse_assignment` value-construction walker into a private helper — the ~25-line match loop plus its 21-line doc comment dominates `try_parse_assignment` and will be swapped wholesale when sub-project 4 replaces `prev_was_literal` with escape metadata. A helper like `fn build_assignment_value_parts(after_eq: &str, remaining_parts: &[WordPart]) -> Vec<WordPart>` would make the doc comment a rustdoc `///`, keep `try_parse_assignment` focused on name/value splitting, and localize sub-project 4's diff (`src/parser/mod.rs`).
- [ ] `try_parse_assignment` `other.clone()` deep-copies CommandSub — the non-Literal branch clones each remaining `WordPart`, which for `$(...)` substitutions clones the embedded `Program`. Same inefficiency as the prior `extend_from_slice`, so not a regression, but consider consuming `Word` (take ownership) or draining `word.parts` to avoid the copy (`src/parser/mod.rs`).
- [ ] `parser::tests` duplicated `use ast::ParamExpr;` — three `assignment_rhs_param_*` tests each declare `use ast::ParamExpr;` inline. Hoist to the existing `use ast::{AndOrOp, CaseTerminator, CompoundCommandKind, RedirectKind, SeparatorOp, WordPart};` line at the top of `mod tests` for consistency (`src/parser/mod.rs`).
- [ ] `expand_assignment_builtin_args` FQN inconsistency — `src/exec/simple.rs:25-31` uses full paths (`crate::env::ShellEnv`, `crate::parser::ast::Word`, `crate::expand::expand_word_to_string`, `crate::expand::expand_words`) mixed with inline `use crate::parser::Parser; use crate::parser::ast::Assignment;`, while `Assignment`, `Word`, and `expand_words` are already imported at module level. Hoist `ShellEnv` and `Parser` to the module preamble and drop the redundant `crate::` prefixes for symmetry (`src/exec/simple.rs`).
- [ ] `expand_assignment_builtin_args` string round-trip — helper builds `"NAME=value"` strings that the builtin re-parses with `find('=')`. Lossless today, but couples the helper shape to the legacy builtin API. When a future refactor touches `builtin_export`/`builtin_readonly` signatures, consider passing `Vec<(String, Option<String>)>` directly to skip the round-trip (`src/exec/simple.rs`, `src/builtin/special.rs`).
- [ ] `parse_for_reserved_word_*_rejected` assertion OR clause is too broad — the sub-project-5 parser tests use `msg.contains("reserved word") || msg.contains("not a valid")`, but the `"not a valid"` side is never reached for inputs like `if`/`in` because those tokens pass `is_valid_name`. Tighten to `msg.contains("reserved word")` only so the assertion clearly pins the new Rule-5 rejection path (`src/parser/mod.rs`).
- [ ] `src/signal.rs` remaining hardcoded signal literals in tests — `test_signal_name_to_number_int` / `_sigint` / `_case_insensitive` / `_term` / `_kill`, `test_signal_number_to_name_2` / `_15` / `_9` still assert against raw numbers (1, 2, 9, 15). Numbers happen to be portable Linux/macOS, but the file now mixes `libc::SIG*` and raw literals. Replace with `libc::SIG*` for consistency (`src/signal.rs:381-421`). Code-review follow-up from 2026-04-20 signal-table fix.
- [ ] `FD_TEST_LOCK.lock().unwrap()` lock-poisoning cascade — if a fd-test panics while holding the lock, subsequent fd tests cascade-fail with `PoisonError`. Switch to `.lock().unwrap_or_else(|e| e.into_inner())` to keep failures local to the original panicking test (`src/exec/redirect.rs:258,262,297,329`). Code-review follow-up from 2026-04-20 redirect self-heal.
- [ ] `RedirectState::apply` doc comment does not mention `save=false` fd leak on partial failure — spec §C declares this out of scope, but the code comment only says "rollback is a no-op" without flagging that opened fds from successful redirects preceding the failing one are still leaked. Add a sentence to the doc comment (`src/exec/redirect.rs:32-44`). Code-review follow-up from 2026-04-20 redirect self-heal.
- [ ] macOS CI job — Task 1 (SIGNAL_TABLE libc-const fix) corrects a bug that only manifests on macOS. Current CI only runs on Linux, so the regression test for the fix is not actually exercising the bug pre-fix. Add a GitHub Actions macOS runner to `cargo test` on every push so future signal-numbering regressions are caught. Spec cross-cutting concern from 2026-04-20 signal-table design.
- [ ] `test_signal_table_matches_libc_constants` / `test_handled_signals_match_libc_constants` duplicate a `match name => libc::SIG*` table — extract a private helper `fn name_to_libc(name: &str) -> Option<i32>` so adding a new entry to `SIGNAL_TABLE` only requires updating one arm, not both tests (`src/signal.rs:456-520`). Code-review Minor follow-up from 2026-04-20 signal-table fix.
- [ ] `tests/signals.rs` parallel-load flakes — `test_trap_int_execution`, `test_trap_reset`, `test_subshell_trap_reset`, `test_kill_dash_s`, `test_kill_dash_signal_name` reliably fail when run as part of `cargo test` (full suite, parallel) but pass 15/15 under `cargo test --test signals` alone. Observed twice on 2026-04-20. Root cause uninvestigated — likely pgid / signal delivery race between parallel yosh subprocesses spawned by `yosh_exec` / `yosh_exec_timeout`. Either serialize within the test file (mutex around shell spawns) or set `#![cfg_attr(test, serial_test::serial)]` on the offending tests.

## Future: E2E Test Expansion

- [ ] Builtin test POSIX_REF values could use more specific section numbers (e.g., `2.14.3` instead of `2.14 Special Built-In Utilities`)
- [ ] `fd_close.sh` test only checks exit code, not actual fd close effect
- [ ] Extend chapter-by-chapter POSIX coverage beyond XCU Chapter 2 — once the Chapter 2 coverage matrix stabilizes, add systematic E2E coverage for Chapter 4 Utilities (all shell-relevant builtins: special + regular, with option/edge-case matrices) and Chapter 8 Environment Variables. Reuse the `POSIX_REF`/`XFAIL` harness established for Chapter 2.
- [ ] Deepen Chapter 2 POSIX coverage to normative-requirement granularity — after the hybrid (representative + thin-section) coverage lands, enumerate every shall/must/should clause in XCU Chapter 2 and add one E2E test per normative requirement (est. +100–200 tests). Use `XFAIL` liberally to register gaps; the goal is to make each normative clause individually traceable to a test ID.
- [ ] `tilde_rhs_user_form.sh` documents absence of `EXPECT_OUTPUT` — the test omits `EXPECT_OUTPUT` because `~root` resolution is platform-dependent and verifies correctness in-script via `case`. Add a one-line comment explaining this so future contributors do not misread the omission as an oversight (`e2e/posix_spec/2_06_01_tilde_expansion/tilde_rhs_user_form.sh`).
- [ ] `tilde_rhs_command_prefix.sh` depends on external `sh -c` — the test uses `sh -c 'echo "$PREFIXED"'` to verify command-prefix assignment expansion, which cross-checks the external `sh` rather than yosh alone. If CI flakes arise on minimal Alpine/busybox environments, switch to a yosh-internal verification path (e.g., a builtin that echoes an env var) (`e2e/posix_spec/2_06_01_tilde_expansion/tilde_rhs_command_prefix.sh`).
- [ ] `readwrite_bidirectional.sh` description and name overstate body — test only exercises `exec 3<>file; exec 3<&-` (open-then-close smoke). Rename to `readwrite_opens_fd.sh` and reword DESCRIPTION to "N<>file opens the file without error" so readers don't expect an actual round-trip assertion (`e2e/posix_spec/2_07_redirection/readwrite_bidirectional.sh`).
- [ ] `readwrite_basic.sh` and `readwrite_param_expansion.sh` are near-duplicates — both do `echo X 1<>"$f"; cat "$f"`. Differentiate the parameter-expansion variant by embedding `$TEST_TMPDIR` directly in the redirect target (e.g. `echo roundtrip 1<>"${TEST_TMPDIR}/rw_pe_direct"`) so the redirect-target word-expansion code path is pinned, not the outer assignment (`e2e/posix_spec/2_07_redirection/`).
- [ ] `dup_input_*.sh` missing unquoted-fd variant — only `cat <&"$fd"` (quoted) is exercised. Add a `dup_input_unquoted_fd.sh` covering `fd=3; cat <&$fd` so future changes to word-expansion in redirect contexts cannot regress silently (`e2e/posix_spec/2_07_redirection/`).
- [ ] E2E test defensive `$TEST_TMPDIR` check — add `: "${TEST_TMPDIR:?TEST_TMPDIR not set}"` to the top of tests that rely on it, so standalone invocations fail with a clear error instead of writing to `/rw_basic`-style root paths (`e2e/posix_spec/2_07_redirection/`, `e2e/posix_spec/2_14_13_times/`).
- [ ] `times` operand rejection test missing — POSIX §2.14.13 says `times` takes no operands. Add `times_rejects_operand.sh` verifying non-zero exit (and `yosh:` stderr prefix) for `times foo` (`e2e/posix_spec/2_14_13_times/`).
- [ ] §2.7.6 `>&` (DupOutput) dedicated E2E tests — analogous to the §2.7.5 suite in `e2e/posix_spec/2_07_redirection/dup_input_*.sh`. Current coverage via `e2e/redirection/stderr_to_stdout.sh` (legacy dir, no `POSIX_REF`) is incidental. Add `dup_output_basic`, `dup_output_param_expansion`, `dup_output_bad_fd`, `dup_output_close` mirroring the DupInput suite.
- [ ] Rule 7/10 weak-intent tests need failure-signature comments — `rule07_not_at_word_position.sh`, `rule10_reserved_after_cmd_is_arg.sh`, `rule10_reserved_after_pipe_in_cmdpos.sh` pass but don't obviously document what a regression failure would look like. Add inline `# NOTE:` comments (mirroring the pattern in `rule10_reserved_quoted_not_recognized.sh`) explaining the expected observable vs. the buggy observable (`e2e/posix_spec/2_10_shell_grammar/`).
- [ ] §2.10.1 backslash-newline line-continuation test missing — a cheap and unambiguous §2.10.1 lexical test (`echo a\<newline>b` → `ab`) was omitted from the representative 3-test set. Add `line_continuation.sh` under `e2e/posix_spec/2_10_1_lexical/` when normative-granularity coverage expands.
- [ ] POSIX_REF format contract is undocumented — the current convention mixes `2.10.2 Rule N - <Name>`, `2.10.2 Rule N - <Name> (<discriminator>)`, `2.10 Shell Grammar - <Topic>`, and `2.X.Y <Section Name>` forms. A tooling-oriented grep like `grep -E 'POSIX_REF: 2\.10\.2 Rule'` will miss the topic-form entries (e.g. `terminator_semicolon_equals_newline.sh`). Document acceptable shapes in CLAUDE.md or an `e2e/README.md` once a second exceptional case appears.
- [ ] Rule 9 taxonomy needs a disambiguation note — the label reuses "Rule 9" across `Body of function`, `Body of compound_command`, and `Body of compound_list (<ctx>)` forms. The first is literal POSIX Rule 9; the other two generalize to the same grammar class. Add a single sentence to `CLAUDE.md` or `e2e/README.md` documenting that "Rule 9" is shorthand for compound_command/compound_list body violations across all contexts.
- [ ] E2E counterpart for `x=foo:\~/bin` escape case missing — `assignment_rhs_backslash_tilde_after_colon_stays_literal` pins the behavior at the AST level, but no E2E exercises the full parser→expander pipeline. Add `tilde_mixed_backslash_after_colon.sh` under `e2e/posix_spec/2_06_01_tilde_expansion/` asserting `x=foo:\~/bin; echo "$x"` outputs `foo:~/bin` literally.
- [ ] Double-quote escape coverage gap — only `e2e/quoting/backslash_in_double_quotes.sh` (`"\$HOME"` form) exercises the sub-project-4 `EscapedLiteral` switch inside double quotes. Manual checks confirm `"\\\\"`, `"\""`, and `` "\`" `` still round-trip correctly, but no E2E pins them. Add compound `backslash_dq_special_chars.sh` asserting the four POSIX-recognized double-quote escapes (`\$`, `\\`, `\"`, `` \` ``) each render to a single character (`e2e/quoting/` or `e2e/posix_spec/2_02_01_escape_character/`).
- [ ] `assignment_rhs_param_then_escaped_tilde_stays_literal` assertion is loose — the parser-unit test added in sub-project 4 Task 3 uses `!any(matches!(p, Tilde(_)))`, which catches the user-visible bug but wouldn't detect shape regressions (e.g., `/bin` being dropped from the value). Tighten to `assert_eq!(parts, vec![Parameter(var), lit(":"), EscapedLiteral("~"), lit("/bin")])` mirroring the sibling `assignment_rhs_param_then_tilde_no_colon_stays_literal` test style (`src/parser/mod.rs`).
- [ ] Full E2E suite occasional transient failures — one observed run showed `2 failed + 4 timedout` out of 374 while a back-to-back re-run was `374/374` clean. Specific tests that flaked were not captured. Next time flakes recur, save the full run via `./e2e/run_tests.sh 2>&1 | tee /tmp/e2e.log` and grep `\[FAIL\]|\[TIMEOUT\]` to identify them; determine whether the flakes concentrate in PTY-sensitive paths (expected per CLAUDE.md) or leak into non-PTY tests (would warrant isolation work).

## Future: Release Skill Enhancements

- [ ] `phase_push` remote tag upsert — currently only checks local tag existence; if the same tag already exists on origin, `git push origin <tag>` rejects. Add `git ls-remote --exit-code --tags origin <tag>` check before pushing (`.claude/skills/release/scripts/release.sh`)
- [ ] `test_plugin/Cargo.toml` version lag risk — `tests/plugins/test_plugin` is a workspace member but not in the `phase_bump` manifests list (not publishable). Currently safe because it depends on workspace crates only via `path =`; breaks if it ever adds `version = "..."` pins (`.claude/skills/release/scripts/release.sh`)
- [ ] `CRATES` array comment — `yosh-plugin-manager` has no dependency on `yosh-plugin-api`/`yosh-plugin-sdk`, so its position in `api → sdk → manager → yosh` is convention, not dependency-ordered. Add a comment clarifying this (`.claude/skills/release/scripts/release.sh`)
- [ ] `phase_publish` root-crate branch — the `if [[ "$crate" == "yosh" ]]` special case (bare `cargo publish` for root vs `cargo publish -p` for members) can be simplified to uniform `cmd=(cargo publish -p "$crate")` since cargo accepts `-p` on root crates too (`.claude/skills/release/scripts/release.sh`)