From 941b7b44ae57b5e108970d3ab9579c9195d8733e Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:44:26 +0200 Subject: [PATCH] fix(harness): split plugin root from consumer project root in all scripts (closes #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts derived one root via '../..' from BASH_SOURCE, which conflates two different roots and breaks every script when run from the plugin install cache (the cache strips the .claude/ prefix, so ../.. escapes the plugin). New shared resolve-roots.sh (sourced by all 10 scripts — one implementation): - script_dir: this script's own dir — where SIBLINGS live in both layouts; all cross-script calls (bot-gh.sh etc.) now use it. - root: consumer project root — repo-tracked layout (/.claude/scripts) wins so worktree gate runs keep anchoring to the worktree, else CLAUDE_PROJECT_DIR, else git toplevel of cwd, else cwd. Never fails (log-event.sh's never-block contract). cockpit.sh: agents ship with the plugin, so the routing panel falls back to the plugin-layout agents/ dir when the project has no .claude/agents; also replaced 2 literal NUL bytes (Map-key separator in embedded JS) with \u0000 so the file is text again and grep-able. smoke-fanout.sh stages resolve-roots.sh beside gate.sh (the smoke caught the missing-sibling case immediately). Verified: self gates green incl. fan-out smoke; cache-layout repro from the issue passes end-to-end (notify-poll reaches bot-gh.sh; gate.sh runs the consumer's gates from cwd=/ via CLAUDE_PROJECT_DIR and via git fallback; cockpit renders with plugin agents; log-event rc=0 writing consumer state). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NTZEsfFT4mrZpv8CY67Frd --- .claude/scripts/bot-gh.sh | 4 +++- .claude/scripts/cockpit.sh | 16 +++++++++++----- .claude/scripts/gate.sh | 9 +++++---- .claude/scripts/log-event.sh | 6 ++++-- .claude/scripts/merge-ready.sh | 6 ++++-- .claude/scripts/notify-poll.sh | 6 ++++-- .claude/scripts/pr-feedback.sh | 6 ++++-- .claude/scripts/prepare-pr.sh | 5 +++-- .claude/scripts/resolve-roots.sh | 31 +++++++++++++++++++++++++++++++ .claude/scripts/seed-issues.sh | 4 +++- .claude/scripts/worktree.sh | 5 +++-- .claude/self/smoke-fanout.sh | 2 ++ 12 files changed, 77 insertions(+), 23 deletions(-) create mode 100644 .claude/scripts/resolve-roots.sh diff --git a/.claude/scripts/bot-gh.sh b/.claude/scripts/bot-gh.sh index 3c1be7b..cb071a2 100755 --- a/.claude/scripts/bot-gh.sh +++ b/.claude/scripts/bot-gh.sh @@ -22,7 +22,9 @@ # Usage: .claude/scripts/bot-gh.sh pr create --title "..." --body "..." set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" if [ -f "$root/.env" ]; then set -a # shellcheck disable=SC1091 diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh index 6f01be1..128cd24 100755 --- a/.claude/scripts/cockpit.sh +++ b/.claude/scripts/cockpit.sh @@ -35,9 +35,15 @@ # generated artifact). Pass a second positional arg to write elsewhere. set -uo pipefail -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -root="$(cd "$script_dir/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" self="$script_dir/cockpit.sh" +# Agent definitions ship with the PLUGIN, not the consumer repo: prefer the +# project's own .claude/agents (repo/worktree layout, or a consumer override), +# else the plugin-cache layout where agents/ sits beside scripts/. +agents_dir="$root/.claude/agents" +[ -d "$agents_dir" ] || agents_dir="$script_dir/../agents" # --------------------------------------------------------------------------- # Hidden seam: the blocking-graph parser as its own subcommand, so it has @@ -169,7 +175,7 @@ node -e ' out.push({ role, model, description }); } fs.writeFileSync(process.argv[2], JSON.stringify(out)); -' "$root/.claude/agents" "$tmpdir/agents.json" +' "$agents_dir" "$tmpdir/agents.json" # ---- adapter (review lenses/skills, budget) ------------------------------------ node -e ' @@ -310,11 +316,11 @@ function phaseBadge(phase) { } } function renderLiveProgress() { - const latest = new Map(); // "roletask" -> event + const latest = new Map(); // "role\u0000task" -> event for (const ev of events) { const role = ev.role != null ? String(ev.role) : ""; const task = ev.task != null ? String(ev.task) : ""; - const key = role + "" + task; + const key = role + "\u0000" + task; latest.set(key, ev); // later lines overwrite earlier ones for the same key } const workers = [...latest.values()]; diff --git a/.claude/scripts/gate.sh b/.claude/scripts/gate.sh index 8f1a3c4..8c6e87f 100755 --- a/.claude/scripts/gate.sh +++ b/.claude/scripts/gate.sh @@ -6,10 +6,11 @@ set -uo pipefail key="${1:?usage: gate.sh }" -# Repo root is two levels up from this script (/.claude/scripts/gate.sh) — -# robust whether or not we're nested inside another git repo. -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -root="$(cd "$script_dir/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer +# project (repo-tracked /.claude/scripts layout wins — robust in worktrees — +# else CLAUDE_PROJECT_DIR/git-toplevel/cwd for the plugin-cache layout). +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" # Dependency-freshness preflight (pnpm-gated: no-ops unless the repo uses pnpm). # pnpm copies the resolved lockfile to node_modules/.pnpm/lock.yaml on every diff --git a/.claude/scripts/log-event.sh b/.claude/scripts/log-event.sh index 693602e..0bb52db 100755 --- a/.claude/scripts/log-event.sh +++ b/.claude/scripts/log-event.sh @@ -49,8 +49,10 @@ while [ $# -gt 0 ]; do shift || break done -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || exit 0 -root="$(cd "$script_dir/../.." 2>/dev/null && pwd)" || exit 0 +# Two-root derivation (issue #63) — resolve-roots.sh never fails, matching this +# script's never-block contract. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/resolve-roots.sh" 2>/dev/null || exit 0 events_file="${CLAUDE_EVENTS_FILE:-$root/.claude/state/events.jsonl}" max_lines="${EVENTS_MAX_LINES:-2000}" diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh index 9af9fda..ec6498c 100644 --- a/.claude/scripts/merge-ready.sh +++ b/.claude/scripts/merge-ready.sh @@ -26,9 +26,11 @@ set -euo pipefail export PATH="$HOME/.local/bin:$PATH" -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" # Route EVERY gh call (list/view/merge) through the bot identity (see bot-gh.sh). -gh() { bash "$root/.claude/scripts/bot-gh.sh" "$@"; } +gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" owner="${MERGE_APPROVER:-${repo%%/*}}" # the approver whose APPROVED review authorizes a merge gates="$root/.claude/gates.json" diff --git a/.claude/scripts/notify-poll.sh b/.claude/scripts/notify-poll.sh index 133afc8..8fc783b 100755 --- a/.claude/scripts/notify-poll.sh +++ b/.claude/scripts/notify-poll.sh @@ -11,10 +11,12 @@ # Repo is derived from the current git remote; override with $1 (owner/repo). set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" # Route EVERY gh call through the bot identity (see bot-gh.sh). Defined before the # first gh use below so the repo-derivation call already runs as the bot. -gh() { bash "$root/.claude/scripts/bot-gh.sh" "$@"; } +gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" owner="${MERGE_APPROVER:-${repo%%/*}}" # the human whose APPROVED review gates a merge state_dir="$root/.claude/state" # add .claude/state/ to .gitignore diff --git a/.claude/scripts/pr-feedback.sh b/.claude/scripts/pr-feedback.sh index 67acac9..5159fba 100644 --- a/.claude/scripts/pr-feedback.sh +++ b/.claude/scripts/pr-feedback.sh @@ -15,9 +15,11 @@ # Invoke as `bash .claude/scripts/pr-feedback.sh` (pre-approve that exact command). set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" # Route EVERY gh call through the bot identity (see bot-gh.sh). -gh() { bash "$root/.claude/scripts/bot-gh.sh" "$@"; } +gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" bot="${BOT_LOGIN:-robercano-ghbot}" marker="" diff --git a/.claude/scripts/prepare-pr.sh b/.claude/scripts/prepare-pr.sh index 0a0f760..d3d03a9 100755 --- a/.claude/scripts/prepare-pr.sh +++ b/.claude/scripts/prepare-pr.sh @@ -43,8 +43,9 @@ case "$pr" in ''|*[!0-9]*) echo "prepare-pr: PR number must be numeric (got '$pr')" >&2; exit 2 ;; esac -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -root="$(cd "$script_dir/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" cd "$root" gates="$root/.claude/gates.json" diff --git a/.claude/scripts/resolve-roots.sh b/.claude/scripts/resolve-roots.sh new file mode 100644 index 0000000..fdbdb76 --- /dev/null +++ b/.claude/scripts/resolve-roots.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# resolve-roots.sh — the ONE implementation of the two-root derivation +# (issue #63). SOURCE this from a sibling script; do not execute it. +# +# There are two distinct roots, and the plugin install cache broke scripts +# that conflated them with a single `../..` hop: +# +# script_dir — where SIBLING scripts live (bot-gh.sh, gate.sh, …). This is +# simply this file's own directory, correct in BOTH layouts: +# repo/worktree checkout: /.claude/scripts/ +# plugin install cache: ~/.claude/plugins/cache////scripts/ +# (the cache strips the .claude/ prefix, so `../..` lands outside the plugin). +# +# root — the CONSUMER PROJECT root, where .env (GH_BOT_TOKEN), gates.json, +# .claude/state/, worktrees, and the git repo live. Resolution order: +# 1. repo-tracked layout (/.claude/scripts) → . This must win over +# CLAUDE_PROJECT_DIR: worktree implementers run the WORKTREE's copy of +# gate.sh and need the worktree as root, while the harness env var +# points at the main checkout. +# 2. $CLAUDE_PROJECT_DIR — set by the harness; the only reliable signal +# when running from the plugin cache. +# 3. git toplevel of the cwd, then cwd — callers invoke these scripts +# from the consumer repo, so this is the right last resort. +# +# Never exits/fails (log-event.sh sources this and must never block a worker): +# every step has a fallback. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +case "$script_dir" in + */.claude/scripts) root="${script_dir%/.claude/scripts}" ;; + *) root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" ;; +esac diff --git a/.claude/scripts/seed-issues.sh b/.claude/scripts/seed-issues.sh index d13d103..ccdfc6b 100755 --- a/.claude/scripts/seed-issues.sh +++ b/.claude/scripts/seed-issues.sh @@ -29,7 +29,9 @@ command -v gh >/dev/null 2>&1 || { echo "gh not on PATH (try: export PATH=\"\$ command -v node >/dev/null 2>&1 || { echo "node not on PATH"; exit 1; } gh auth status >/dev/null 2>&1 || { echo "Not authenticated. Run: gh auth login"; exit 1; } -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" gates="$root/.claude/gates.json" REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)" echo "Seeding issues into ${REPO:-}" diff --git a/.claude/scripts/worktree.sh b/.claude/scripts/worktree.sh index a7d916f..72fa589 100755 --- a/.claude/scripts/worktree.sh +++ b/.claude/scripts/worktree.sh @@ -19,8 +19,9 @@ case "$phase" in *) echo "worktree.sh: phase must be 'setup' or 'teardown' (got '$phase')"; exit 2 ;; esac -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -root="$(cd "$script_dir/../.." && pwd)" +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" # Which adapter to read. Defaults to the project adapter; set GATES_FILE to run a # different one (e.g. GATES_FILE=.claude/self/gates.json). Relative paths resolve # from the repo root. diff --git a/.claude/self/smoke-fanout.sh b/.claude/self/smoke-fanout.sh index 71f828c..3c4ab95 100644 --- a/.claude/self/smoke-fanout.sh +++ b/.claude/self/smoke-fanout.sh @@ -47,6 +47,8 @@ mkdir -p "$repo/.claude/scripts" cp -R "$fixture/src" "$fixture/test" "$repo/" || fail "copy fixture sources" cp "$fixture/gates.json" "$repo/.claude/gates.json" || fail "copy adapter" cp "$root/.claude/scripts/gate.sh" "$repo/.claude/scripts/gate.sh" || fail "copy gate.sh" +# gate.sh sources its sibling resolve-roots.sh (issue #63) — stage it alongside. +cp "$root/.claude/scripts/resolve-roots.sh" "$repo/.claude/scripts/resolve-roots.sh" || fail "copy resolve-roots.sh" G init -q -b main . || fail "git init" G add -- .claude src test G commit -qm "fixture: initial state" || fail "initial commit"