diff --git a/.changeset/attest-hook-cwd.md b/.changeset/attest-hook-cwd.md new file mode 100644 index 000000000..143b19184 --- /dev/null +++ b/.changeset/attest-hook-cwd.md @@ -0,0 +1,9 @@ +--- +'@smooai/smooth': patch +--- + +smooth-agent: attest-push-hint missed `cd && git push` (the common shape) + +Resolved the repo from the session's `.cwd`, which is never where the command +runs — agents `cd` inline. Also fixed push-detection so `git -C push` is +recognised (the flag's value was eating the `push` token). diff --git a/.changeset/selective-pr-ci.md b/.changeset/selective-pr-ci.md new file mode 100644 index 000000000..2d375c28d --- /dev/null +++ b/.changeset/selective-pr-ci.md @@ -0,0 +1,10 @@ +--- +'@smooai/smooth': patch +--- + +CI: path-filter PR checks so a non-Rust change stops compiling the workspace twice + +`Rust checks` (ubuntu + windows) and `Web checks` ran unconditionally, so a +bash/JSON/doc-only PR burned ~45 min of runner time — Windows bills at 2x — to +validate a shell script. Steps are now gated on `dorny/paths-filter`; the jobs +still run and report so branch protection is unaffected. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 03fc3e073..299a355bd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ "name": "smooth-agent", "source": "./claude-plugins/smooth-agent", "description": "Run a Big Smooth that drives Claude Code worker sessions over tmux, coordinate agents over th-mail, drive the org's Smooth Operator, and track work in pearls. Provides the /smooth command + a windows-build-box skill (throwaway Windows EC2 over SSM). Ships the shared SmooAI repo guardrail hooks (worktree enforcement, th-over-curl nudges, pearls-label reminder, pearl-store read-only-wedge guard).", - "version": "0.4.0", + "version": "0.26.5", "category": "orchestration", "keywords": ["orchestration", "tmux", "multi-agent", "th-mail", "pearls", "smooth-operator"], "author": { diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index feb415016..e48515763 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -15,6 +15,11 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' +# dorny/paths-filter queries a PR's changed files through the API. +permissions: + contents: read + pull-requests: read + jobs: rust: # Pearl th-a165b4: Windows joins the test matrix so cross-platform @@ -60,31 +65,73 @@ jobs: steps: - uses: actions/checkout@v4 + # th-6578ee: a bash/JSON/doc-only PR was compiling the whole Rust + # workspace on TWO platforms (Windows bills at 2x) plus a full web + # build — ~45 min of runner time to validate a shell script. + # + # The filter gates STEPS, not the job. The job must still run and + # report: branch protection requires these checks, and a workflow- + # level `paths:` would make them never appear at all, blocking the PR + # forever. Same reason the `push:` trigger exists (pearl th-e392d9). + # + # PR events only. On push-to-main and merge_group there is no + # reliable base to diff against, and those are exactly the moments a + # skipped check would be expensive to be wrong about — so they run + # everything. + - name: Detect changes + id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@v3 + with: + filters: | + rust: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'rustfmt.toml' + - 'go/**' + - 'scripts/build-smooth-dolt.sh' + - '.github/workflows/pr-checks.yml' + + # One boolean instead of repeating the compound condition on a dozen + # steps. `shell: bash` because the Windows leg defaults to pwsh, + # where `>> $GITHUB_OUTPUT` is `$env:GITHUB_OUTPUT`. + - name: Gate + id: gate + shell: bash + run: echo "run=${{ github.event_name != 'pull_request' || steps.filter.outputs.rust == 'true' }}" >>"$GITHUB_OUTPUT" + - name: Install system dependencies - if: runner.os == 'Linux' + if: steps.gate.outputs.run == 'true' && runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libcap-ng-dev libicu-dev pkg-config protobuf-compiler # No apt on Windows — protoc is the only native build-time tool the # workspace needs there (tonic-build in smooth-scribe et al.). - name: Install protoc - if: runner.os == 'Windows' + if: steps.gate.outputs.run == 'true' && runner.os == 'Windows' uses: arduino/setup-protoc@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: dtolnay/rust-toolchain@stable + if: steps.gate.outputs.run == 'true' with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + if: steps.gate.outputs.run == 'true' - uses: pnpm/action-setup@v4 + if: steps.gate.outputs.run == 'true' - uses: actions/setup-node@v4 + if: steps.gate.outputs.run == 'true' with: node-version: 22 - name: Build web UI + if: steps.gate.outputs.run == 'true' shell: bash run: | cd crates/smooth-web/web @@ -101,23 +148,24 @@ jobs: # TCP transport is pearl th-5f35a5) — but the binary must keep # compiling so it can't silently regress with another unix-only call. - name: Build smooth-dolt (embedded Dolt for pearl memory tests) - if: runner.os == 'Linux' + if: steps.gate.outputs.run == 'true' && runner.os == 'Linux' run: bash scripts/build-smooth-dolt.sh - name: Build smooth-dolt (Windows — CGO via mingw, gms_pure_go drops ICU) - if: runner.os == 'Windows' + if: steps.gate.outputs.run == 'true' && runner.os == 'Windows' shell: bash run: cd go/smooth-dolt && CGO_ENABLED=1 go build -tags gms_pure_go -o smooth-dolt.exe . - name: Install nextest + if: steps.gate.outputs.run == 'true' uses: taiki-e/install-action@nextest - name: Format check - if: runner.os == 'Linux' + if: steps.gate.outputs.run == 'true' && runner.os == 'Linux' run: cargo fmt --check - name: Clippy - if: runner.os == 'Linux' + if: steps.gate.outputs.run == 'true' && runner.os == 'Linux' # Honor the lint levels declared in [workspace.lints]: clippy::all # (deny) + unused_must_use/unsafe_code fail the build; pedantic and # nursery are advisory `warn` by design, so a blanket `-D warnings` @@ -125,11 +173,13 @@ jobs: run: cargo clippy - name: Build examples (needed by cross-process file-lock test, pearl th-9799fa) + if: steps.gate.outputs.run == 'true' run: cargo build --examples --workspace # bash on both legs so the filter expression quotes predictably # (pwsh would need its own escaping for the `&` and parentheses). - name: Test + if: steps.gate.outputs.run == 'true' shell: bash run: cargo nextest run --profile ci ${{ matrix.test_args }} env: @@ -138,7 +188,7 @@ jobs: # One report per PR — publishing from both legs would double every # entry in the check summary. - name: Publish test report - if: always() && runner.os == 'Linux' + if: always() && steps.gate.outputs.run == 'true' && runner.os == 'Linux' uses: ctrf-io/github-test-reporter@v1 with: report-path: target/nextest/ci/junit.xml @@ -146,7 +196,7 @@ jobs: continue-on-error: true - name: Build - if: runner.os == 'Linux' + if: steps.gate.outputs.run == 'true' && runner.os == 'Linux' run: cargo build --release -p smooai-smooth-cli web: @@ -156,14 +206,34 @@ jobs: steps: - uses: actions/checkout@v4 + # Same step-gating rationale as the rust job above: the job must keep + # reporting for branch protection, so only the work is skipped. + - name: Detect changes + id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@v3 + with: + filters: | + web: + - 'crates/smooth-web/web/**' + - '.github/workflows/pr-checks.yml' + + - name: Gate + id: gate + run: echo "run=${{ github.event_name != 'pull_request' || steps.filter.outputs.web == 'true' }}" >>"$GITHUB_OUTPUT" + - uses: pnpm/action-setup@v4 + if: steps.gate.outputs.run == 'true' - uses: actions/setup-node@v4 + if: steps.gate.outputs.run == 'true' with: node-version: 22 - name: Install + if: steps.gate.outputs.run == 'true' run: cd crates/smooth-web/web && pnpm install --no-frozen-lockfile - name: Build + if: steps.gate.outputs.run == 'true' run: cd crates/smooth-web/web && pnpm build diff --git a/claude-plugins/smooth-agent/.claude-plugin/plugin.json b/claude-plugins/smooth-agent/.claude-plugin/plugin.json index ada40e48b..93ecfe321 100644 --- a/claude-plugins/smooth-agent/.claude-plugin/plugin.json +++ b/claude-plugins/smooth-agent/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "smooth-agent", - "version": "0.4.0", + "version": "0.26.5", "description": "Big Smooth orchestration for Claude Code: tmux-supervised worker sessions that run in parallel, coordinate over th-mail, and track work in pearls. Auto-onboards every Claude Code session to the th-mail bus at startup, then nudges it to self-rename to a task-meaningful handle. Provides the /smooth command and a windows-build-box skill (throwaway Windows EC2 over SSM). Also ships the shared SmooAI repo guardrails as hooks (worktree enforcement, th-over-curl nudges, pearls-label reminder, pearl-store read-only-wedge guard) so the SmooAI repos stop hand-copying .claude/hooks.", "author": { "name": "SmooAI", diff --git a/claude-plugins/smooth-agent/hooks/attest-push-hint.sh b/claude-plugins/smooth-agent/hooks/attest-push-hint.sh index 761729929..06cdf485e 100755 --- a/claude-plugins/smooth-agent/hooks/attest-push-hint.sh +++ b/claude-plugins/smooth-agent/hooks/attest-push-hint.sh @@ -30,12 +30,42 @@ echo "$CMD" | grep -q 'attest:ack' && exit 0 # Only care about an actual `git push` that publishes commits. Skip the forms that # publish nothing a CI run would check: tag/branch deletion, --dry-run, and # `th pearls push` (a Dolt sync, not git). -echo "$CMD" | grep -qE '(^|[;&|]|\s)git\s+(-[^ ]+\s+)*push(\s|$)' || exit 0 +# The `(-C|--git-dir|--work-tree) ` alternative is load-bearing: those flags +# take a VALUE, so a naive `(-[^ ]+\s+)*push` lets the value eat the `push` token +# and `git -C /some/repo push` reads as "not a push". Caught in testing 2026-08-06. +echo "$CMD" | grep -qE '(^|[;&|]|\s)git\s+((-C|--git-dir|--work-tree)[[:space:]=]+[^ ]+\s+|-[^ ]+\s+)*push(\s|$)' || exit 0 echo "$CMD" | grep -qE '(--delete|--dry-run|-d\s|--tags)' && exit 0 +# WHERE the push will actually happen. `.cwd` is the SESSION's directory, which is +# not where the command runs: a session sits in one repo for its whole life, so +# agents write `cd /path/to/other-repo && git push`. Resolving from `.cwd` alone +# silently missed exactly that — the common case — and the hook shipped that way +# (verified 2026-08-06: real `cd …/smooai && git push` from a smooth session +# exited 0, while the same push with cwd=smooai correctly asked). +# +# Precedence, last wins, mirroring how the shell would actually end up: +# 1. `git -C push` — explicit, beats everything +# 2. the LAST `cd ` in the chain +# 3. `.cwd` +resolve_dir() { + local base target + base=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null) + + target=$(echo "$CMD" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+([^[:space:];&|]+).*/\1/p' | tail -1) + [[ -z "$target" ]] && target=$(echo "$CMD" | sed -nE 's/.*(^|[;&|][[:space:]]*)cd[[:space:]]+([^[:space:];&|]+).*/\2/p' | tail -1) + + # Strip quotes an agent may have wrapped a path in, and expand a leading ~. + target=${target%\"}; target=${target#\"}; target=${target%\'}; target=${target#\'} + [[ "$target" == "~"* ]] && target="$HOME${target#\~}" + + [[ -z "$target" ]] && { echo "$base"; return; } + [[ "$target" == /* ]] && { echo "$target"; return; } + echo "$base/$target" +} + # Repo-agnostic by design: the hook fires only where the convention exists, so # copying scripts/ci/ into another repo turns this on there with no extra wiring. -ROOT=$(cd "$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null)" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null) +ROOT=$(cd "$(resolve_dir)" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null) [[ -n "$ROOT" && -x "$ROOT/scripts/ci/attest.sh" ]] || exit 0 # What could be credited here, straight from the directory — no list to keep in sync. diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index 4ee610f34..5fcbb8948 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -28,7 +28,38 @@ if (!version) { process.exit(1); } +// The Claude Code plugin ships this repo's hooks (worktree enforcement, +// th-over-curl, attest-push-hint). Its manifests carry their OWN version, and +// `claude plugin update` only reinstalls when it sees a version DELTA — so a +// plugin change that ships without a bump is a change nobody can install. +// +// That is not hypothetical: smooth-agent sat installed at 0.2.0 for 26 days +// (2026-07-11 → 2026-08-06) while this repo shipped 20+ versions. Two hooks +// merged in that window simply never ran on anyone's machine, and the one that +// prompted this — attest-push-hint — was reported as "live" three separate +// times while doing nothing. +// +// Riding the changeset version removes the judgment call entirely: every +// release bumps the plugin, so an installed copy is always comparable to the +// published one. Over-bumping (plugin version moves when only Rust changed) is +// harmless — `claude plugin update` just reinstalls the same hooks. +// +// plugin.json and the marketplace entry MUST agree; `claude plugin tag` +// validates that, so both are updated here together. +const pluginVersion = (label) => ({ + path: label, + apply(content) { + const pattern = /("version"\s*:\s*")([^"]+)(")/; + if (!pattern.test(content)) { + throw new Error(`"version" not found in ${label}`); + } + return content.replace(pattern, `$1${version}$3`); + }, +}); + const updates = [ + pluginVersion("claude-plugins/smooth-agent/.claude-plugin/plugin.json"), + pluginVersion(".claude-plugin/marketplace.json"), { path: "Cargo.toml", apply(content) { @@ -118,19 +149,26 @@ const updates = [ // smooth-operator repo — their locked versions track that repo, NOT // the workspace version. Bumping a lock entry to a workspace version // that source never published breaks `cargo` resolution under - // `--locked` ("= "*" locked to 0.23.0 … candidate 1.23.1"). Three - // package names, matched exactly: - // - `smooai-smooth-operator-core` (crates.io engine) - // - `smooai-smooth-operator-server` (git dep) - // - `smooai-smooth-operator` (git dep, the svc crate) - // The Cargo.toml dep skip (above) keys on `git =`, but lock entries - // carry `source` on a separate line outside this 2-line match, so - // here we skip by exact name. Pearl th-1ee32b (the lock twin the - // git-dep Cargo.toml fix in #260 left behind). + // `--locked` ("= "*" locked to 0.23.0 … candidate 1.23.1"). Pearl + // th-1ee32b (the lock twin the git-dep Cargo.toml fix in #260 left + // behind). + // + // Matched by PREFIX, not by an exact list. It used to be + // `(-core|-server)?`, which silently missed + // `smooai-smooth-operator-adapter-memory` and + // `smooai-smooth-operator-ingestion` once those joined the dep graph + // — so every `version:sync` rewrote two git-dep entries and every + // lock-touching PR carried the churn (pearl th-16ff70, "flips + // 0.23.x↔1.23.x on every local build"). + // + // A hand-maintained allowlist cannot track a dependency graph that + // grows upstream. The prefix is safe because NO crate in this + // workspace is named `smooai-smooth-operator*` — the engine lives in + // its own repo (see CLAUDE.md §1); `ls crates/` is the check. const pattern = /(name = "smooai-smooth-[^"]+"\nversion = ")([^"]+)(")/g; return content.replace(pattern, (match, pre, _ver, post) => { - if (/name = "smooai-smooth-operator(-core|-server)?"\n/.test(pre)) { + if (/name = "smooai-smooth-operator[^"]*"\n/.test(pre)) { return match; } return `${pre}${version}${post}`;