From 6f8a90da2636cc90b90e9435eb4a1abacaf1a387 Mon Sep 17 00:00:00 2001 From: Szymon Osiecki Date: Sun, 14 Jun 2026 15:35:57 +0200 Subject: [PATCH 1/4] fix(sh): install_uv - prefer UV_SYSTEM_CERTS / UV_NATIVE_TLS env vars --- .assets/provision/install_uv.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.assets/provision/install_uv.sh b/.assets/provision/install_uv.sh index 03b3b711..14ef3f4e 100755 --- a/.assets/provision/install_uv.sh +++ b/.assets/provision/install_uv.sh @@ -38,7 +38,7 @@ if [ -x "$HOME/.local/bin/uv" ]; then retry_count=0 max_retries=5 while [ $retry_count -le $max_retries ]; do - $HOME/.local/bin/uv self update --system-certs >&2 + UV_SYSTEM_CERTS=true UV_NATIVE_TLS=true "$HOME/.local/bin/uv" self update >&2 [ $? -eq 0 ] && break || true ((retry_count++)) || true echo "retrying... $retry_count/$max_retries" >&2 From 590d1ede697ad5e94ba24a4cc652029012574c9f Mon Sep 17 00:00:00 2001 From: Szymon Osiecki Date: Sun, 14 Jun 2026 15:35:57 +0200 Subject: [PATCH 2/4] chore(ci): switch repo_checks runner to ubuntu-slim --- .github/workflows/repo_checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repo_checks.yml b/.github/workflows/repo_checks.yml index fa811b7d..a947f50d 100644 --- a/.github/workflows/repo_checks.yml +++ b/.github/workflows/repo_checks.yml @@ -6,7 +6,7 @@ on: jobs: preflight_checks: name: "pre-commit hooks" - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: Checkout code From 97f93b42b8e3a7f4a59be7212ff4c68eb41a5744 Mon Sep 17 00:00:00 2001 From: Szymon Osiecki Date: Sun, 14 Jun 2026 15:35:57 +0200 Subject: [PATCH 3/4] chore(lint): centralize prek run via PREK_RUN macro; extract shellcheck config to .shellcheckrc --- .gitignore | 8 ++++++++ .pre-commit-config.yaml | 6 +++--- .shellcheckrc | 14 ++++++++++++++ Makefile | 19 +++++++++++++++---- 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 .shellcheckrc diff --git a/.gitignore b/.gitignore index c44c3ee2..6d45723c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,14 @@ /tmp.*/ /logs/*.log +# local settings +settings.local.json +/.claude/rules/openwolf.md +/.claude/settings.json +/.claude/worktrees/ +/.wolf +/CLAUDE.md + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11313963..d70bca81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,9 +50,9 @@ repos: hooks: - id: shfmt exclude: \.zsh$ - - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.11.0 + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 hooks: - id: shellcheck - args: ["--severity=warning", "--exclude=SC1090,SC2139,SC2148,SC2155,SC2174"] + args: ["--severity=warning"] exclude: \.zsh$ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 00000000..a3cf1507 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,14 @@ +# ShellCheck configuration - shared by pre-commit, VS Code, and CLI. +# Analogous to ruff settings in pyproject.toml. +# +# Note: .shellcheckrc does not support `severity=`; pre-commit passes +# --severity=warning explicitly. For VS Code (bash-ide extension), add +# to user settings: "bashIde.shellcheckArguments": "--severity=warning" + +# Global excludes: +# SC1090 non-constant source - .assets/provision/source.sh sourcing is dynamic +# SC2139 expand at define time - intentional aliases under .assets/config/bash_cfg/ +# SC2148 missing shebang on sourced files - they're not standalone scripts +# SC2155 declare and assign separately - readability cost > theoretical benefit +# SC2174 mkdir mode - the -m flag is rarely the right tool +disable=SC1090,SC2139,SC2148,SC2155,SC2174 diff --git a/Makefile b/Makefile index 31179df2..ad6777c9 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,17 @@ ifdef CA_CUSTOM export NODE_EXTRA_CA_CERTS := $(CA_CUSTOM) endif +# Stage all changes, run prek, then restore previously staged file paths. +# Note: only path-level staging is preserved; partially-staged hunks become fully +# staged after the round-trip. Auto-fixes from hooks land in the working tree. +define PREK_RUN +sf=$$(mktemp); git diff --cached --name-only -z >$$sf; \ +git add --all && prek run $(HOOK) $(1); rc=$$?; \ +git reset -q HEAD; \ +if [ -s $$sf ]; then xargs -0 git add -- <$$sf 2>/dev/null; fi; \ +rm -f $$sf; exit $$rc +endef + .PHONY: help help: ## Show this help message @printf 'Usage: make [target]\n\n' @@ -29,20 +40,20 @@ upgrade: ## Upgrade prek and hooks versions hooks: ## List available pre-commit hook IDs @awk '/- id:/ {print " " $$3}' .pre-commit-config.yaml | sort -u -.PHONY: lint lint-diff lint-all +.PHONY: hooks lint lint-diff lint-all lint: ## Run pre-commit hooks for changed files (HOOK=id to run one hook) @printf "๐Ÿงญ Running pre-commit hooks for changed files...\n\n" - git add --all && prek run $(HOOK) + @$(call PREK_RUN) lint-diff: ## Run pre-commit hooks for files changed in this diff (HOOK=id to run one hook) @printf "๐Ÿงญ Running pre-commit hooks for files changed in this diff...\n\n" @if [ "$$(git branch --show-current)" = "main" ]; then \ printf "โš ๏ธ You are on the main branch. Skipping lint-diff.\n"; \ else \ - git add --all && prek run $(HOOK) --from-ref main --to-ref HEAD; \ + $(call PREK_RUN,--from-ref main --to-ref HEAD); \ fi lint-all: ## Run pre-commit hooks for all files (HOOK=id to run one hook) @printf "๐Ÿงญ Running pre-commit hooks for all files...\n\n" - prek run $(HOOK) --all-files + @$(call PREK_RUN,--all-files) .PHONY: test test-unit test-bats test-pester test: test-unit ## Run all unit tests From 4b99da7dc7293891ef076b4317b2c7db99e9e6e0 Mon Sep 17 00:00:00 2001 From: Szymon Osiecki Date: Sun, 14 Jun 2026 15:35:57 +0200 Subject: [PATCH 4/4] docs(agents): slim AGENTS.md to compound-knowledge index; add ARCHITECTURE.md, design/lessons.md, bash/powershell style rules, second-opinion skill --- .claude/CLAUDE.md | 1 + .claude/rules/bash-style.md | 54 ++++ .claude/rules/powershell-style.md | 71 +++++ .claude/skills/second-opinion/REVIEW-BRIEF.md | 80 +++++ .claude/skills/second-opinion/SKILL.md | 193 +++++++++++ .../second-opinion/scripts/review_brief.py | 299 ++++++++++++++++++ AGENTS.md | 77 +++-- ARCHITECTURE.md | 137 ++++++++ design/lessons.md | 41 +++ 9 files changed, 912 insertions(+), 41 deletions(-) create mode 120000 .claude/CLAUDE.md create mode 100644 .claude/rules/bash-style.md create mode 100644 .claude/rules/powershell-style.md create mode 100644 .claude/skills/second-opinion/REVIEW-BRIEF.md create mode 100644 .claude/skills/second-opinion/SKILL.md create mode 100755 .claude/skills/second-opinion/scripts/review_brief.py create mode 100644 ARCHITECTURE.md create mode 100644 design/lessons.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 00000000..be77ac83 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md new file mode 100644 index 00000000..88b76f87 --- /dev/null +++ b/.claude/rules/bash-style.md @@ -0,0 +1,54 @@ +--- +globs: ["*.sh", "*.bash", "*.bats", "*.zsh"] +--- + +# Bash style + +- Shebang: `#!/usr/bin/env bash` +- Indentation: **2 spaces**; line length: **120 chars max** +- Error handling: `set -euo pipefail` for executable scripts. For files dot-sourced into the user's shell (`.assets/config/bash_cfg/*.sh`, anything installed to `/etc/profile.d/`), drop `-u` - `nounset` breaks shell-init files that source optional vars from the environment. +- Command substitution: `$(...)`, never backticks +- Functions: `snake_case`, private: `_prefixed`; prefer `local` for function-scoped variables +- Variables: `snake_case` locals, `UPPERCASE` constants/env +- Color output: `\e[31;1m` red/error, `\e[32m` green, `\e[92m` bright green, `\e[96m` cyan/info + +## Cross-platform regex + +BSD `grep`/`sed` (macOS) and GNU `grep`/`sed` (Linux) disagree on edge cases. Anything sourced from `.assets/config/bash_cfg/` may run on a macOS host during contributor dev loops. + +- Never use empty-alternative regex: `(|foo|bar)` - BSD silently fails to match the empty alternative. Use `(foo|bar)?` instead. +- Prefer BSD-compatible sed: `sed -En` (not `-rn`), no `\s`/`\w`/`\d` shorthand, no `-P` (perl-regex). +- `grep -E` (ERE) is portable; `grep -P` (PCRE) is GNU-only. + +See `design/lessons.md` for the BSD-grep incident (commit `78b177d`). + +## POSIX guard for `/etc/profile.d/` + +Any file installed into `/etc/profile.d/` is sourced by dash (Debian/Ubuntu `/bin/sh`) during login. dash chokes on bash syntax (`function` keyword, arrays, `[[ ]]`). Start the file with: + +```bash +[ -n "${BASH_VERSION:-}${ZSH_VERSION:-}" ] || return 0 +``` + +Verify with `dash -c '. '`. See `design/lessons.md` (commit `58649ce`). + +## zsh compatibility + +Files under `.assets/config/bash_cfg/` are sourced from both bash and zsh user shells. The `check-zsh-compat` pre-commit hook parses each one under zsh. Common pitfalls: + +- `[[ $arr[@] ]]` - arrays are 1-indexed in zsh, 0-indexed in bash. +- Word splitting on unquoted `$var` - zsh doesn't split by default; explicit `${(s: :)var}` or quote consistently. +- `local` outside a function - bash errors loudly, zsh warns silently. + +## Common patterns + +```bash +# Distro detection +SYS_ID="$(sed -En '/^ID.*(alpine|arch|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" + +# Root check +if [ $EUID -ne 0 ]; then + printf '\e[31;1mRun the script as root.\e[0m\n' >&2 + exit 1 +fi +``` diff --git a/.claude/rules/powershell-style.md b/.claude/rules/powershell-style.md new file mode 100644 index 00000000..68e09916 --- /dev/null +++ b/.claude/rules/powershell-style.md @@ -0,0 +1,71 @@ +--- +globs: ["*.ps1", "*.psm1", "*.psd1"] +--- + +# PowerShell style + +## Formatting + +- Indentation: **4 spaces** +- Brace style: **OTBS** - opening `{` on same line as statement, closing `}` on its own line, block body always on separate lines +- For conditional/loop statements with multiple conditions, all conditions and the opening `{` must be on the same line +- Use ternary `? :` for simple true/false; `switch` with `continue` for range/complex conditions; `if/elseif/else` for distinct paths + +## Naming + +- Functions: `Verb-Noun` PascalCase (approved verbs only - `Get-Verb` for the list) +- Parameters: `PascalCase`; local variables: `camelCase` +- Script-scoped variables: `$script:varName` for state shared across function boundaries (`wsl_setup.ps1` uses `$Script:rel_*` to cache release versions across distro loops) +- Public functions require comment-based help: `.SYNOPSIS`, `.PARAMETER`, `.EXAMPLE` + +## Parameters + +- Use `[Parameter(Mandatory, Position = 0)]` for required positional parameters +- Use `[Alias('s')]` for short parameter aliases (single letter) +- Use `[ValidateSet(...)]` for enums, `[ValidateNotNullOrEmpty()]` for required strings, `[ValidateScript({ ... })]` for complex validation +- Switches are never mandatory +- Use parameter splatting for >3 parameters; cast switches to bool when passing: `-WebDownload ([bool]$WebDownload)` +- Use `[Parameter(ParameterSetName = 'X')]` with `[CmdletBinding(DefaultParameterSetName = 'Y')]` for mutually exclusive parameter groups + +## Error handling + +- Set `$ErrorActionPreference = 'Stop'` in `begin` block +- Use specific exception types in catch blocks before generic catch-all +- Check `$LASTEXITCODE` immediately after external command invocations (`wsl.exe`, `git`, native binaries) +- **Guard native binaries that may not exist on the host.** Cross-platform scripts (Windows host + Linux/WSL guest) cannot assume `ssh`, `git`, `gh`, etc. are on PATH. Use `Get-Command -ErrorAction SilentlyContinue` before invoking, and provide a fallback. See `design/lessons.md` (commit `dfb5943`). + +## Strings + +- Prefer `-f` format operator for simple interpolations: `'{0}: {1}' -f $name, $value` +- Use `[string]::Join()` for complex multi-part strings (output formatting, shell commands) +- Never use `+` operator for string concatenation +- Normalize line endings with `.Replace("\`r\`n", "\`n")` for cross-platform (Windows/Linux) consistency - `wsl.exe` output crosses this boundary +- ANSI colors via escape sequences: `` `e[32m `` (green), `` `e[31;1m `` (bold red), `` `e[0m `` (reset) + +## Collections + +- Use `[System.Collections.Generic.List[T]]` for ordered collections that grow +- Use `[System.Collections.Generic.SortedSet[T]]` for unique items with automatic sorting +- Use `[System.Collections.Generic.HashSet[T]]` for deduplication and O(1) lookups +- Never use non-generic `System.Collections` classes +- Pipe `.Add()` calls to `| Out-Null` to suppress return value +- Use `.ForEach({ ... })` method for pipeline expressions; `foreach` keyword for iteration with `break`/`continue` + +## Output and return + +- Use `Write-Host` for colored/formatted console output (bypasses pipeline) +- Return `$null` explicitly when conditional logic skips operations +- Use `Write-Output -NoEnumerate` when returning empty arrays to prevent pipeline unwrapping `@()` to `$null` + +## WSL-specific (wsl/*.ps1) + +- Use direct `wsl.exe @args` with argument splatting for distribution commands +- Build wsl arguments in `[System.Collections.Generic.List[string]]`, modify in-place for subsequent calls +- Always set `$psi.UseShellExecute = $false` and `$psi.WorkingDirectory = $PWD.Path` for `Process.Start` +- Normalize captured `wsl.exe` output with `.Replace("\`r\`n", "\`n")` - the boundary between Windows and Linux line endings is the most common source of bugs in this layer + +## Module imports + +- Use `Push-Location "$PSScriptRoot/.."` in `begin` block to set directory context +- Import with `Import-Module (Resolve-Path './modules/') -Force` or `Convert-Path` +- Re-export public functions from the module manifest (`.psd1` โ†’ `FunctionsToExport`) diff --git a/.claude/skills/second-opinion/REVIEW-BRIEF.md b/.claude/skills/second-opinion/REVIEW-BRIEF.md new file mode 100644 index 00000000..f94728aa --- /dev/null +++ b/.claude/skills/second-opinion/REVIEW-BRIEF.md @@ -0,0 +1,80 @@ +--- +repo: szymonos/linux-setup-scripts +--- + +# Review brief - linux-setup-scripts + +Curated context for a heterogeneous-model reviewer (`/second-opinion`). +You are reviewing a code diff. Read this brief first, then the diff, then any files needed for context. + +## Project + +Automation scripts for provisioning Linux systems, primarily **WSL** (Windows Subsystem for Linux). + +- **Languages:** Bash 5+ (`.sh`), PowerShell 7.4+ (`.ps1`), Python 3 (pre-commit hooks under `tests/hooks/`). +- **Supported distros:** Fedora/RHEL, Debian/Ubuntu, Arch, OpenSUSE, Alpine. +- **Two execution contexts:** Windows host (`wsl/*.ps1`) drives WSL guests via `wsl.exe`; guest scripts (`.assets/scripts/`, `.assets/provision/`) run inside the distro. + +See `ARCHITECTURE.md` for the full host/guest split, distro detection pattern, pre-commit hook inventory, test layout, and recipes. + +## Focus areas (ordered by importance) + +1. **Correctness** - logic errors, missed edge cases, off-by-one, race conditions, exit-code handling. Distro-detection branches that silently skip a supported distro. +2. **Cross-platform shell portability** - the `.assets/config/bash_cfg/*` files are sourced from both bash and zsh user shells. Anything installed to `/etc/profile.d/` is also sourced by dash. Watch for: + - `function` keyword, bash arrays, `[[ ]]`, `${var,,}` in files that might run under dash. + - Empty-alternative regex `(|foo|bar)` (BSD grep silently fails to match the empty alternative) - use `(foo|bar)?`. + - GNU sed/grep extensions: `-P`, `-r` (use `-E`), `\s`, `\w` - macOS dev loops use BSD tools. + - See `design/lessons.md` for the four documented postmortems (POSIX guard, SSH probe, safe.directory, BSD regex). +3. **Cross-host PowerShell guards** - `wsl/*.ps1` runs on Windows hosts where guest binaries (`ssh`, `gh`, etc.) may not exist. Calling them under `$ErrorActionPreference = 'Stop'` without a `Get-Command -ErrorAction SilentlyContinue` guard aborts orchestration. See `design/lessons.md` (commit `dfb5943`). +4. **Idempotency** - provisioning scripts are re-run during upgrade. Writes to `~/.gitconfig`, `/etc/profile.d/`, and similar must guard with `grep -qFx`, atomic append, or equivalent. Don't append the same line twice. +5. **Error handling** - + - Bash: missing `set -euo pipefail` on executable scripts (note: files sourced into the user's shell or installed to `/etc/profile.d/` should drop `-u`). + - PowerShell: missing `$ErrorActionPreference = 'Stop'` in `begin` block; unchecked `$LASTEXITCODE` after `wsl.exe` or native binary invocations. +6. **WSL boundary** - `wsl.exe` output crosses Windows/Linux line-ending boundaries. Strings captured from inside WSL should be normalized with `.Replace("\`r\`n", "\`n")`. + +## Known patterns - do NOT flag + +These are deliberate. Flagging them is noise - they're documented project decisions: + +- **`prek` instead of `pre-commit`** - `prek` is the pre-commit wrapper used in this project. +- **`--no-verify` in skill instructions** - per-commit hooks are skipped during branch consolidation and validated once via `make lint-diff` after all commits. +- **`--force-with-lease` in skill instructions** - deliberate; the skill rewrites branch history during consolidation. +- **`set -eo pipefail` without `-u` in files sourced into user shells** (under `.assets/config/bash_cfg/`, `/etc/profile.d/`) - intentional. `nounset` breaks shell-init files that source optional env vars. +- **POSIX guard `[ -n "${BASH_VERSION:-}${ZSH_VERSION:-}" ] || return 0` at the top of files installed to `/etc/profile.d/`** - intentional; prevents dash from choking on bash syntax. +- **BSD-compatible regex** (`sed -En`, no `\s`/`\w`/`-P`) in shell files - intentional for macOS dev-loop compatibility. +- **`Write-Host` in PowerShell** - intentional; bypasses the pipeline for colored console output. +- **Symlinked `CLAUDE.md` โ†’ `AGENTS.md`** - single source of truth, two lookup paths. +- **`REVIEW-BRIEF.md` with `repo:` frontmatter tag** - portability mechanism; not stale metadata. +- **`ubuntu-slim` as a GitHub Actions runner** - valid GitHub-hosted runner, used intentionally. +- **OpenWolf files under `.wolf/` and `.claude/rules/openwolf.md`** - gitignored personal context management system, not stale dead code. +- **`disable-model-invocation: true` in some skill frontmatter** - controls whether a skill can be invoked programmatically; deliberate per-skill setting. + +## Output format + +Produce a single markdown response with this structure: + +```text +## Findings + +### F-001 - - : + + +**Suggestion:** + +### F-002 - - : +... +``` + +Severities: + +- **`bug`** - correctness or security defect; the code is wrong. +- **`warning`** - likely issue, needs judgment; the code might be wrong under conditions you can't fully verify. +- **`nit`** - style or clarity; the code works but could be clearer. + +If zero findings, output exactly: `No findings.` + +## Bias-control rules + +- Speculate carefully. If you suspect a bug but can't verify the call site, mark `warning` not `bug`. +- Don't pad with `nit` findings to look productive. Five `nit` items on a 200-line diff is fine; thirty is noise. +- If several findings share the same root cause, consolidate into one finding with multiple `:` references. diff --git a/.claude/skills/second-opinion/SKILL.md b/.claude/skills/second-opinion/SKILL.md new file mode 100644 index 00000000..295fd281 --- /dev/null +++ b/.claude/skills/second-opinion/SKILL.md @@ -0,0 +1,193 @@ +--- +name: second-opinion +description: Heterogeneous-model code review of the current branch's changes. Invokes GitHub Copilot CLI with gpt-5.3-codex to review git diff since merge-base with the repo's trunk branch (or user-specified commit). Reads .claude/skills/second-opinion/REVIEW-BRIEF.md for focused project context. Returns structured findings that Claude reads, summarizes, and acts on. Use when the user types `/second-opinion`, asks for a second opinion on a branch, wants GPT to review the work, or wants an independent review before pushing. +disable-model-invocation: false +--- + +# Second opinion + +Heterogeneous-model author-time review of the current branch. Runs **GitHub Copilot CLI** (`copilot`) with a GPT-family model (default `gpt-5.3-codex`) against the diff since `git merge-base "$TRUNK_REF" HEAD`, where `$TRUNK_REF` is a resolvable git ref for the repo's default branch - either a local branch (`main`) or a remote-tracking ref (`origin/main`), resolved in Phase 1. The reviewer returns structured findings; Claude reads them and acts. + +The bias-control mechanism is **the process boundary itself**. Copilot runs as a separate binary, with a separate model family, returning only text. Claude (the implementer) cannot influence Copilot's review; Copilot cannot edit code. Tool restriction inside Copilot is unnecessary - the architecture enforces the separation. + +## When to use + +- `/second-opinion` - review current branch vs. `git merge-base "$TRUNK_REF" HEAD` (trunk autodetected in Phase 1; works for both local and remote-only checkouts) +- `/second-opinion ` - review since an arbitrary commit hash +- `/second-opinion --model ` - use a different Copilot model +- "Get a second opinion before I push" / "have GPT review this" - same as `/second-opinion` + +## Prerequisites + +`copilot` CLI installed and authenticated. Verify with `command -v copilot >/dev/null && copilot --version`. If missing, surface to the user: install via `https://gh.io/copilot-install` and run `copilot login`. Updates via `copilot update`. + +## Workflow + +### Phase 0 - verify review brief + +Run the bundled check script to verify `REVIEW-BRIEF.md` targets this repo: + +```bash +python3 /scripts/review_brief.py check +``` + +Returns JSON with `match`, `brief_repo`, `current_repo`, `needs_update`. + +- **Match** โ†’ proceed to Phase 1. +- **Mismatch or missing `repo:` tag** โ†’ run discovery and offer a one-time rewrite: + + ```bash + python3 /scripts/review_brief.py discover + ``` + + The discovery output includes detected stacks, context from `CLAUDE.md`/`AGENTS.md`/`README.md`, and the existing brief content. Use this context to rewrite `REVIEW-BRIEF.md` with: + - Updated `repo:` frontmatter tag matching the current repo + - Project description derived from the discovered context + - Focus areas appropriate for the detected tech stack + - Empty "Known patterns - do NOT flag" section (compounding loop will fill it) + - Same output format and bias-control rules (these are repo-agnostic) + + Present the rewrite to the user via `AskUserQuestion` ("Update REVIEW-BRIEF.md for this repo?"). On approval, write the file. On decline, proceed with the existing brief (it may produce noisy or irrelevant findings). + +This phase runs once per repo. After the brief is updated, `check` returns `match: true` on all subsequent runs. + +### Phase 1 - resolve the diff base + +This skill is portable across repos that use `main`, `master`, `trunk`, `prod`, etc. - never assume `main`. Resolve `$TRUNK_REF` to a git ref that's guaranteed to exist locally (a local branch OR a remote-tracking ref - many CI environments only have the trunk at `refs/remotes/origin/`), then merge-base against it: + +```bash +# Detect the trunk name (zero-network first, then fallbacks) +TRUNK_NAME="" +ref="$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" && TRUNK_NAME="${ref#origin/}" +[ -n "$TRUNK_NAME" ] || TRUNK_NAME="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null)" +[ -n "$TRUNK_NAME" ] || for c in main master trunk prod; do + git show-ref --verify --quiet "refs/heads/$c" && TRUNK_NAME="$c" && break + git show-ref --verify --quiet "refs/remotes/origin/$c" && TRUNK_NAME="$c" && break +done +[ -n "$TRUNK_NAME" ] || { echo "Could not resolve trunk; run: git remote set-head origin -a" >&2; exit 1; } + +# Resolve to a usable git ref (prefer local branch, fall back to remote) +if git show-ref --verify --quiet "refs/heads/$TRUNK_NAME"; then + TRUNK_REF="$TRUNK_NAME" +elif git show-ref --verify --quiet "refs/remotes/origin/$TRUNK_NAME"; then + TRUNK_REF="origin/$TRUNK_NAME" +else + echo "Trunk '$TRUNK_NAME' has no local or remote ref; run: git fetch origin $TRUNK_NAME" >&2; exit 1 +fi + +# Default: merge-base with trunk +base="$(git merge-base "$TRUNK_REF" HEAD)" + +# Alternative: a user-specified commit from skill args overrides the default. +# Uncomment and substitute when the caller passed an explicit base ref: +# base="" +``` + +Each fallback is gated on the previous one having actually produced a value - **never collapse the `symbolic-ref` step into a `git symbolic-ref ... | sed ...` pipeline**, because `sed` always exits 0 even when the upstream `git symbolic-ref` failed, so the `||` chain wouldn't trigger. The block always re-resolves `TRUNK_REF` from scratch every time it runs - it does not check for or respect a pre-existing `$TRUNK_REF` in the environment. If a caller has already resolved trunk, the re-resolution returns the same value (so re-running is safe and produces no surprises), but it does *run* every time; it's not skipped. + +If `base == HEAD`, exit early: "Nothing to review - branch is at parity with the base." Don't invoke Copilot. + +**Caller contract: review scope is committed state only.** Uncommitted working-tree changes are invisible to `git diff ..HEAD` and therefore invisible to the reviewer. Callers that need to review uncommitted work (a branch with WIP in the working tree but no commits ahead of the base) must create a throwaway WIP commit *before* invoking this skill, then dispose of it after via their own flow (e.g., `git reset --soft `). This skill will not silently degrade by reviewing the working tree - the process boundary that gives Copilot its bias-control also means it only sees what `git` shows it. + +### Phase 2 - invoke Copilot + +Single Bash call. The prompt tells Copilot to read the brief, run `git diff` itself, and produce findings in the brief's specified format: + +```bash +copilot -p "Read .claude/skills/second-opinion/REVIEW-BRIEF.md, then review the current branch's changes since . Run: git diff ..HEAD to see all changes. Read referenced files for context as needed. Output findings using the format and severities specified in the brief." \ + -s \ + --model gpt-5.3-codex \ + --no-custom-instructions \ + --allow-all-tools +``` + +Flag rationale: + +- **`-s`** (silent) - strips UI chrome, leaves only the agent's response on stdout. Critical for parsing. +- **`--no-custom-instructions`** - skips `AGENTS.md` and `.claude/skills/` auto-loading. The curated `REVIEW-BRIEF.md` is the only context Copilot needs. Loading everything would burn attention budget on irrelevant context. +- **`--allow-all-tools`** - required for non-interactive `-p` mode. Safe here: Copilot's output is text-only back to Claude; any edits Copilot might attempt happen in its process, not Claude's. Even if Copilot wrote a file, Claude would not act on it - Claude only acts on the findings text. +- **`--model gpt-5.3-codex`** - default. Override via skill arg (see model override below). + +If Copilot exits non-zero, capture the error and surface to the user. Don't retry automatically. + +### Phase 3 - parse and act on findings + +Save Copilot's raw output to a temp file and parse it with the bundled script: + +```bash +python3 /scripts/review_brief.py parse /tmp/copilot-review.md +``` + +Returns JSON: `{"findings": [{id, severity, file, line, description, suggestion}, ...], "count": N}`. If count is 0, announce "No findings." and exit. + +#### Output handling + +Two modes, selected by the caller via context (the skill itself doesn't need to know who called it): + +**Interactive mode** (default - standalone `/second-opinion`): + +1. Present a summary table to the user: + + ```text + ## Copilot review (gpt-5.3-codex) - 3 findings + + | ID | Severity | Location | Summary | + |----|----------|----------|---------| + | F-001 | bug | src/main.py:142 | | + | F-002 | warning | lib/utils.sh:88 | | + | F-003 | nit | docs/index.md:23 | | + ``` + +2. Ask via `AskUserQuestion` which to act on. For โ‰ค4 findings, use one question with multiSelect options (`F-001`, `F-002`, ...). For more, present in chunks of 4 or ask "fix all bugs, triage warnings/nits?" first. +3. For each finding the user wants fixed: Claude reads the relevant file, formulates a fix from Copilot's `Suggestion`, applies it via `Edit`. **Do not copy Copilot's patch verbatim** - Copilot suggests direction; Claude writes the actual edit using its own knowledge of the codebase. +4. After edits, run `make lint` to validate. + +**Automated mode** (when invoked by another skill as a pre-push review gate): + +1. Auto-fix findings with `severity: bug` AND a concrete `Suggestion` that maps cleanly to one or two lines of code. No user prompt needed for these - they're obvious corrections. +2. Surface to the user via `AskUserQuestion`: + - Any `bug` finding where the fix is non-obvious or spans multiple files. + - All `warning` findings (by definition, judgment is needed). + - `nit` findings only if the user explicitly opted in (default: skip nits - they're not blockers). +3. After resolution, re-run `make lint` to validate. +4. Return control to the caller. Review-driven fixes are uncommitted working-tree changes - the caller decides how to commit them. + +The caller selects automated mode by passing findings context (e.g., "act on findings per automated mode"). Without that context, the skill defaults to interactive mode. + +## Model override + +Pass `--model ` in the skill args. Claude extracts it and substitutes for `gpt-5.3-codex` in the Copilot invocation. + +Currently available Copilot models (May 2026 - list with `copilot -p "list available models"`): + +| Model ID | Tier | Notes | +| ----------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `gpt-5.3-codex` | standard | **Default** - code-focused, balanced cost | +| `gpt-5.2-codex` | standard | Older codex; use only if 5.3 unavailable | +| `gpt-5.5` | premium | Heavier; use for complex / security-sensitive diffs | +| `gpt-5.4` | standard | General-purpose; less code-specialized than codex variants | +| `gpt-5-mini` | fast/cheap | Quick triage; expect more noise / missed nuance | +| `claude-opus-4-7` | premium | **NOT heterogeneous** - same family as Claude (the implementer); only useful if you specifically want a same-family second context | + +## Anti-patterns + +- **Running `/second-opinion` repeatedly on the same diff.** Wastes Copilot API tokens. If the first run missed something, evolve `REVIEW-BRIEF.md` (add a focus area), don't re-run. +- **Dropping `--no-custom-instructions`.** Copilot would auto-load `AGENTS.md` and every file under `.claude/skills/`. That's hundreds of lines of context unrelated to the actual diff review. +- **Piping the full diff into the `-p` argument.** Copilot's shell access lets it run `git diff` itself - piping risks shell-escape issues and prompt-size limits. Let Copilot run the command. +- **Copying Copilot's patch verbatim.** Copilot's `Suggestion` is a direction; Claude writes the actual edit. Verbatim copies skip Claude's knowledge of the codebase's idiomatic patterns and accepted decisions. +- **Adding `Co-Authored-By: Copilot` to fix commits.** Fixes derived from Copilot's review are Claude's edits informed by Copilot's review - same as a human reviewer's comment. No tooling attribution needed. +- **Running `/second-opinion` after a destructive history rewrite (soft-reset).** Review-driven fixes after a soft-reset need a second reset cycle to re-cut clean commits. Run the review *before* any history rewrite so fixes land in the WIP state and get consolidated for free. + +## Example invocations + +- `/second-opinion` - review against `git merge-base "$TRUNK_REF" HEAD` (trunk autodetected in Phase 1; works for both local and remote-only checkouts) +- `/second-opinion abc1234` - review against an arbitrary commit +- `/second-opinion --model gpt-5.5` - use a heavier model for a security-sensitive branch +- "Get a second opinion before I push" - same as `/second-opinion` + +## Compounding loop + +`REVIEW-BRIEF.md` is **git-tracked** so it compounds across runs. Two triggers update it: + +1. **Copilot keeps flagging an intentional pattern** โ†’ add it to the "do NOT flag" section. +2. **Copilot misses a class of bug repeatedly** โ†’ add the relevant focus area or paired-file mapping. diff --git a/.claude/skills/second-opinion/scripts/review_brief.py b/.claude/skills/second-opinion/scripts/review_brief.py new file mode 100755 index 00000000..0e384fa7 --- /dev/null +++ b/.claude/skills/second-opinion/scripts/review_brief.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Review brief management for the /second-opinion skill. + +Three subcommands: + check - verify REVIEW-BRIEF.md repo tag matches current repo + discover - scan repo for context to generate/update the brief + parse - parse Copilot's raw output into structured JSON findings + +Usage: + python3 scripts/review_brief.py check + python3 scripts/review_brief.py discover + python3 scripts/review_brief.py parse + echo "" | python3 scripts/review_brief.py parse - +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +BRIEF_FILENAME = "REVIEW-BRIEF.md" +CONTEXT_FILES = [ + ("claude_md", "CLAUDE.md"), + ("agents_md", "AGENTS.md"), + ("architecture_md", "ARCHITECTURE.md"), + ("readme", "README.md"), + ("contributing", "CONTRIBUTING.md"), +] +SKIP_DIRS = { + ".git", + "node_modules", + "dist", + "build", + "target", + ".venv", + "venv", + "vendor", + "__pycache__", + "site", +} +MAX_CONTEXT_LINES = 80 + + +def _find_brief(start: Path | None = None) -> Path | None: + """Walk up from start to find REVIEW-BRIEF.md.""" + if start is None: + start = Path(__file__).resolve().parent.parent + brief = start / BRIEF_FILENAME + if brief.exists(): + return brief + for parent in start.parents: + candidate = parent / ".claude" / "skills" / "second-opinion" / BRIEF_FILENAME + if candidate.exists(): + return candidate + return None + + +def _read_frontmatter(path: Path) -> dict[str, str]: + """Extract YAML frontmatter key-value pairs from a markdown file.""" + text = path.read_text(encoding="utf-8", errors="replace") + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + if end == -1: + return {} + fm = text[4:end] + result: dict[str, str] = {} + for line in fm.splitlines(): + if ":" in line: + key, _, value = line.partition(":") + result[key.strip()] = value.strip().strip('"').strip("'") + return result + + +def _get_current_repo() -> str | None: + """Get current repo's nameWithOwner via gh CLI.""" + try: + result = subprocess.run( + ["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + +def _read_head(path: Path, max_lines: int = MAX_CONTEXT_LINES) -> str | None: + """Read up to max_lines from a file, or None if it doesn't exist.""" + if not path.exists(): + return None + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + return "\n".join(lines[:max_lines]) + except OSError: + return None + + +def _detect_stacks(root: Path) -> list[str]: + """Quick stack detection (subset of bootstrap-hooks/detect_stack.py).""" + stacks: list[str] = [] + markers = { + "python": ["pyproject.toml", "requirements.txt", "setup.py"], + "javascript": ["package.json"], + "go": ["go.mod"], + "rust": ["Cargo.toml"], + "java": ["pom.xml", "build.gradle"], + "ruby": ["Gemfile"], + "docker": ["Dockerfile", "docker-compose.yml"], + "terraform": [], + "mkdocs": ["mkdocs.yml", "mkdocs.yaml"], + } + for stack, files in markers.items(): + for f in files: + if (root / f).exists(): + stacks.append(stack) + break + + if any(root.rglob("*.tf")): + stacks.append("terraform") + if any(root.rglob("*.sh")): + stacks.append("shell") + if (root / ".obsidian").exists() or any( + (root / "docs").rglob(".obsidian") if (root / "docs").exists() else [] + ): + stacks.append("obsidian") + if (root / "docs").exists() and any((root / "docs").rglob("*.md")): + stacks.append("markdown-docs") + if (root / ".github" / "workflows").exists(): + stacks.append("github-actions") + if (root / ".gitlab-ci.yml").exists(): + stacks.append("gitlab-ci") + + return sorted(set(stacks)) + + +# -- Subcommands -------------------------------------------------------------- + + +def cmd_check(args: argparse.Namespace) -> int: + """Check if REVIEW-BRIEF.md repo tag matches current repo.""" + root = Path(args.repo_root).resolve() + brief_path = _find_brief(root / ".claude" / "skills" / "second-opinion") + + result: dict = { + "brief_exists": brief_path is not None and brief_path.exists(), + "brief_repo": None, + "current_repo": None, + "match": False, + "needs_update": False, + } + + if not result["brief_exists"]: + result["needs_update"] = True + result["reason"] = "REVIEW-BRIEF.md not found" + json.dump(result, sys.stdout, indent=2) + print() + return 1 + + fm = _read_frontmatter(brief_path) + result["brief_repo"] = fm.get("repo") + result["current_repo"] = _get_current_repo() + + if result["brief_repo"] is None: + result["needs_update"] = True + result["reason"] = "No repo: tag in REVIEW-BRIEF.md frontmatter" + elif result["current_repo"] is None: + result["needs_update"] = False + result["reason"] = "Could not determine current repo (gh CLI unavailable?)" + elif result["brief_repo"] == result["current_repo"]: + result["match"] = True + else: + result["needs_update"] = True + brief = result["brief_repo"] + current = result["current_repo"] + result["reason"] = ( + f"Repo mismatch: brief targets '{brief}', current is '{current}'" + ) + + json.dump(result, sys.stdout, indent=2) + print() + return 0 if result["match"] else 1 + + +def cmd_discover(args: argparse.Namespace) -> int: + """Scan repo for context to generate/update the brief.""" + root = Path(args.repo_root).resolve() + + context_files: dict[str, str | None] = {} + for key, filename in CONTEXT_FILES: + context_files[key] = _read_head(root / filename) + + stacks = _detect_stacks(root) + current_repo = _get_current_repo() + + brief_path = _find_brief(root / ".claude" / "skills" / "second-opinion") + existing_brief = None + if brief_path and brief_path.exists(): + existing_brief = brief_path.read_text(encoding="utf-8", errors="replace") + + result = { + "repo": current_repo, + "root": str(root), + "stacks": stacks, + "context_files": {k: v for k, v in context_files.items() if v is not None}, + "existing_brief": existing_brief, + } + + json.dump(result, sys.stdout, indent=2) + print() + return 0 + + +def cmd_parse(args: argparse.Namespace) -> int: + """Parse Copilot's raw markdown output into structured JSON findings.""" + if args.input == "-": + raw = sys.stdin.read() + else: + raw = Path(args.input).read_text(encoding="utf-8", errors="replace") + + if "No findings." in raw and "### F-" not in raw: + json.dump({"findings": [], "count": 0}, sys.stdout, indent=2) + print() + return 0 + + finding_re = re.compile( + r"###\s+(F-\d+)\s*-\s*(bug|warning|nit)\s*-\s*(.+?)(?::(\d+))?\s*\n" + r"(.*?)(?=\n###\s+F-|\n##\s|\Z)", + re.DOTALL, + ) + + findings: list[dict] = [] + for m in finding_re.finditer(raw): + body = m.group(5).strip() + + suggestion = None + suggestion_match = re.search( + r"\*\*Suggestion:\*\*\s*(.+?)(?=\n\n|\n###|\Z)", body, re.DOTALL + ) + if suggestion_match: + suggestion = suggestion_match.group(1).strip() + description = body[: suggestion_match.start()].strip() + else: + description = body + + finding: dict = { + "id": m.group(1), + "severity": m.group(2), + "file": m.group(3).strip(), + "description": description, + } + if m.group(4): + finding["line"] = int(m.group(4)) + if suggestion: + finding["suggestion"] = suggestion + + findings.append(finding) + + json.dump({"findings": findings, "count": len(findings)}, sys.stdout, indent=2) + print() + return 0 + + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Review brief management") + parser.add_argument( + "--repo-root", default=".", help="Repository root (default: cwd)" + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("check", help="Verify REVIEW-BRIEF.md repo tag matches current repo") + sub.add_parser( + "discover", help="Scan repo for context to generate/update the brief" + ) + + parse_p = sub.add_parser("parse", help="Parse Copilot output into JSON findings") + parse_p.add_argument("input", help="Path to raw output file, or '-' for stdin") + + args = parser.parse_args() + + commands = { + "check": cmd_check, + "discover": cmd_discover, + "parse": cmd_parse, + } + return commands[args.command](args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AGENTS.md b/AGENTS.md index c5f30144..b1ff5814 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,58 +2,53 @@ Automation scripts for provisioning Linux systems, primarily **WSL** (Windows Subsystem for Linux). -- **Languages**: Bash 5.0+ (`.sh`), PowerShell 7.4+ (`.ps1`) -- **Supported distros**: Fedora/RHEL, Debian/Ubuntu, Arch, OpenSUSE, Alpine -- **Targets**: WSL (primary), VMs/bare-metal/distroboxes (secondary) +- **Languages:** Bash 5.0+ (`.sh`), PowerShell 7.4+ (`.ps1`) +- **Supported distros:** Fedora/RHEL, Debian/Ubuntu, Arch, OpenSUSE, Alpine +- **Targets:** WSL (primary), VMs / bare-metal / distroboxes (secondary) -## Key Entry Points +## Compound knowledge -- `wsl/wsl_setup.ps1` - main orchestration script, runs on Windows host, calls `.assets/provision/` scripts inside WSL -- `.assets/scripts/linux_setup.sh` - provisioning from Linux host (bare-metal, VMs, WSL) -- `.assets/provision/install_*.sh` - individual tool installers (most require root) -- `.assets/provision/setup_*.sh` - configuration scripts (typically run as user) -- `.assets/provision/source.sh` - shared functions (dotsourced by other scripts) +Read on demand, not upfront: -## Tooling +| Layer | When to read | What it answers | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | --------------------------- | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Adding a new installer, alias module, or WSL operation; touching pre-commit hooks or tests | How things connect | +| [`design/lessons.md`](design/lessons.md) | Before "fixing" a guard, retry, or odd-looking conditional that has a commit hash next to it | What went wrong before | +| [`.claude/rules/bash-style.md`](.claude/rules/bash-style.md) | Editing `*.sh` / `*.bash` / `*.bats` / `*.zsh` | Shell conventions + gotchas | +| [`.claude/rules/powershell-style.md`](.claude/rules/powershell-style.md) | Editing `*.ps1` / `*.psm1` / `*.psd1` | PowerShell conventions | + +Skip these for typo fixes, doc-only edits, and conversational questions. -- **GitHub**: `gh` CLI for PR management, issue tracking, etc. -- **Pre-commit runner**: `prek` (not `pre-commit`) -- **PowerShell**: 7.4+ - use `pwsh` command (not `powershell`) +## Key Entry Points -## Before Committing +- `wsl/wsl_setup.ps1` - main orchestration script, runs on the Windows host, calls `.assets/provision/` scripts inside WSL via `wsl.exe` +- `.assets/scripts/linux_setup.sh` - provisioning from a Linux host (bare-metal, VMs, WSL guest) +- `.assets/provision/install_*.sh` - individual tool installers (most require root) +- `.assets/provision/setup_*.sh` / `setup_*.ps1` - configuration scripts (typically run as user) +- `.assets/provision/source.sh` - shared functions, dot-sourced by other scripts -Run `make lint` and fix any failures. Pre-commit hooks are configured in `.pre-commit-config.yaml`. +`ARCHITECTURE.md` ยง 1 has the full host-vs-guest split. -## Bash Style (`.sh`) +## Tooling -- Shebang: `#!/usr/bin/env bash` -- Indentation: **2 spaces** -- Line length: **120 chars max** -- Error handling: `set -euo pipefail` -- Command substitution: `$(...)`, never backticks -- Functions: `snake_case`, private: `_prefixed` -- Variables: `snake_case` locals, `UPPERCASE` constants/env -- PowerShell local variables use `camelCase` (see below) -- Color output: `\e[31;1m` red/error, `\e[32m` green, `\e[92m` bright green, `\e[96m` cyan/info +- **GitHub:** `gh` CLI for PR / issue / release operations +- **Pre-commit runner:** `prek` (**not** `pre-commit`) +- **PowerShell:** 7.4+ - use `pwsh` (not `powershell`) +- **Search:** `rg` (ripgrep) instead of `grep`, `fd` instead of `find` - faster, respects `.gitignore` by default -### Common Bash Patterns +## Common Commands ```bash -# Distro detection -SYS_ID="$(sed -En '/^ID.*(alpine|arch|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" - -# Root check -if [ $EUID -ne 0 ]; then - printf '\e[31;1mRun the script as root.\e[0m\n' >&2 - exit 1 -fi +make lint # Run pre-commit hooks on changed files - use before every commit +make lint-all # Run all hooks on all files (slow) +make lint HOOK= # Run a single hook - seconds instead of minutes +make hooks # List hook IDs +make test-unit # bats + Pester, fast, no Docker - agent-runnable +make help # All targets ``` -## PowerShell Style (`.ps1`) +**Run `make lint` and fix failures before every commit.** Pre-commit hooks are configured in `.pre-commit-config.yaml` (`ARCHITECTURE.md` ยง 3 lists each one and why it exists). + +## Global Renames and Pattern Changes -- Indentation: **4 spaces** -- Brace style: **OTBS** (opening `{` on same line, closing `}` on own line, block body always on separate lines) -- Functions: `Verb-Noun` PascalCase (approved verbs only) -- Parameters: `PascalCase`; local variables: `camelCase` -- Public functions require comment-based help (`.SYNOPSIS`, `.PARAMETER`, `.EXAMPLE`) -- `wsl_setup.ps1` uses `$Script:rel_*` variables to cache release versions across distro loops +Before fixing a pattern globally, run `rg ` or `git grep ` first to find **all** occurrences - don't start editing until the full scope is known. For bulk renames across many files, use `sed -i` rather than editing one at a time. Verify with another grep afterwards. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..ec543de3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,137 @@ +# Architecture + +Single source of truth for how this repo is laid out, where the cross-platform/cross-shell seams are, and how to extend it. Read on demand from `AGENTS.md`. + +## 1. Host vs guest split + +The repo has two execution contexts. Knowing which side a script runs on is the first thing to check before editing. + +| Side | Path | Runs on | Invoked by | +| ------------------- | -------------------------------- | ------------------------ | --------------------------------------------- | +| Windows host (PS) | `wsl/*.ps1` | Windows PowerShell 7.4+ | User directly, from Windows | +| Guest provisioner | `.assets/scripts/linux_setup.sh` | Linux/WSL (root) | User on bare-metal, or `wsl_setup.ps1` | +| Guest installers | `.assets/provision/install_*.sh` | Linux/WSL (root) | `linux_setup.sh`, `wsl_setup.ps1` via wsl.exe | +| Guest config (user) | `.assets/provision/setup_*.sh` | Linux/WSL (user) | `linux_setup.sh`, `wsl_setup.ps1` via wsl.exe | +| Guest config (PS) | `.assets/provision/setup_*.ps1` | Linux pwsh (user) | `pwsh_setup.ps1` after pwsh install | +| Shared functions | `.assets/provision/source.sh` | dot-sourced (no shebang) | other `.sh` provision scripts | +| Trigger one-shots | `.assets/trigger/*.{sh,ps1}` | either, varies | Manual / scheduled | + +**Crossing the boundary.** `wsl_setup.ps1` reaches into the guest by: + +1. Copying repo files into the distro under `~/source/repos/szymonos/linux-setup-scripts/` (or the equivalent via `wsl_files_copy.ps1`). +2. Invoking `wsl.exe -d -u --