Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/attest-hook-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@smooai/smooth': patch
---

smooth-agent: attest-push-hint missed `cd <repo> && 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 <path> push` is
recognised (the flag's value was eating the `push` token).
10 changes: 10 additions & 0 deletions .changeset/selective-pr-ci.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
86 changes: 78 additions & 8 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -101,35 +148,38 @@ 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`
# would weaponize them (and every new lint a stable Rust bump adds).
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:
Expand All @@ -138,15 +188,15 @@ 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
report-format: junit
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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion claude-plugins/smooth-agent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
34 changes: 32 additions & 2 deletions claude-plugins/smooth-agent/hooks/attest-push-hint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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) <arg>` 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 <path> push` — explicit, beats everything
# 2. the LAST `cd <path>` 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.
Expand Down
58 changes: 48 additions & 10 deletions scripts/sync-versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`;
Expand Down
Loading