From 1cc4c49d73b723f02231de38e9827d0fb429e09c Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:56:08 +0200 Subject: [PATCH] feat(harness): add /orchestrator:feedback consumer-to-inbox skill (issue #177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a five-second feedback-capture skill for rollout testing: run /orchestrator:feedback "" from a consumer repo (reDeploy/reDeFi) and it autofills the origin label, installed plugin version, and body template, then files a triage-inbox issue (no milestone, no planned) in robercano/reCode via plain gh — the one deliberate owner-identity gh call in this plugin, since it IS the owner's own feedback. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/feedback.test.sh | 296 ++++++++++++++++++++++++++++ .claude/skills/feedback/SKILL.md | 78 ++++++++ .claude/skills/feedback/feedback.sh | 219 ++++++++++++++++++++ docs/USAGE.md | 24 +++ 4 files changed, 617 insertions(+) create mode 100644 .claude/scripts/feedback.test.sh create mode 100644 .claude/skills/feedback/SKILL.md create mode 100644 .claude/skills/feedback/feedback.sh diff --git a/.claude/scripts/feedback.test.sh b/.claude/scripts/feedback.test.sh new file mode 100644 index 0000000..9dc1457 --- /dev/null +++ b/.claude/scripts/feedback.test.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# feedback.test.sh — offline smoke test for .claude/skills/feedback/feedback.sh +# (issue #177). Builds throwaway temp git repo fixtures (mktemp -d, git init, +# an origin remote set per-scenario, the REAL feedback.sh + resolve-roots.sh +# copied alongside a fixture-local plugin.json — mirrors release.test.sh's/ +# worktree-cleanup.test.sh's fixture pattern) and exercises `feedback.sh +# --dry-run` (the primary, fully offline path) plus an overridable +# $FEEDBACK_GH_BIN stub (for the non-dry-run "no partial issue" / "degrade +# without labels" paths) — NEVER the real network. +# +# Exercises: +# 1. remote -> label mapping: a "redeploy"-ish origin gets from:redeploy, a +# "redefi"-ish origin gets from:redefi, neither still files (feedback +# only, no from:* label), case-insensitively. +# 2. installed-plugin-version resolution: CLAUDE_PLUGIN_ROOT's manifest wins +# when set; falls back to /.claude/.claude-plugin/plugin.json when +# it isn't; degrades to the literal "unknown" on a corrupt manifest +# rather than crashing. +# 3. consumer-repo guard, all three conditions: no origin remote at all; an +# origin that resolves to robercano/reCode itself; and no resolvable +# plugin manifest anywhere — each is a clean non-zero exit with a clear +# stderr message and NO gh call attempted (checked via the stub log +# staying empty). +# 4. body-template assembly: Observed/Expected/Severity-suggestion sections, +# the description and severity text, the origin repo + plugin version +# trailer, and the "(not specified)" severity fallback when none is +# given. +# 5. no-partial-issue-on-guard-failure: a guard failure makes zero gh +# invocations (verified against a logging $FEEDBACK_GH_BIN stub even +# though --dry-run alone already proves no network call is made). +# 6. real (non-dry-run) path via the $FEEDBACK_GH_BIN stub: label-create +# calls fire best-effort before issue-create; a failing issue-create +# retries exactly once without --label flags and still succeeds +# (missing-label degrade), with a warning on stderr. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/feedback.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +feedback_src="$script_dir/../skills/feedback/feedback.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/feedback-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# new_fixture NAME ORIGIN_URL [PLUGIN_VERSION] +# Builds / as a throwaway git repo: real feedback.sh + +# resolve-roots.sh under .claude/scripts/ (feedback.sh sources its sibling +# resolve-roots.sh via a fixed ../../scripts/ relative path, so it must sit +# at .claude/scripts/ regardless of where feedback.sh itself is invoked from +# in the fixture), an origin remote (skip entirely if ORIGIN_URL is the +# literal string "NONE"), and — unless PLUGIN_VERSION is the literal string +# "NOPLUGIN" — a local .claude/.claude-plugin/plugin.json fallback manifest +# stamped with PLUGIN_VERSION (default "9.9.9"). Prints the fixture root. +new_fixture() { + local name="$1" origin="$2" version="${3:-9.9.9}" + local repo="$work/$name" + mkdir -p "$repo/.claude/scripts" + git init -q -b main "$repo" + git -C "$repo" config user.email "test@example.com" + git -C "$repo" config user.name "Test" + + # feedback.sh actually lives under .claude/skills/feedback/ in the real + # tree (its resolve-roots.sh reference is ../../scripts/resolve-roots.sh), + # so mirror that exact relative layout in the fixture too. + mkdir -p "$repo/.claude/skills/feedback" + cp "$feedback_src" "$repo/.claude/skills/feedback/feedback.sh" + cp "$resolve_roots_src" "$repo/.claude/scripts/resolve-roots.sh" + chmod +x "$repo/.claude/skills/feedback/feedback.sh" + + if [ "$version" != "NOPLUGIN" ]; then + mkdir -p "$repo/.claude/.claude-plugin" + if [ "$version" = "CORRUPT" ]; then + printf '{ not valid json' > "$repo/.claude/.claude-plugin/plugin.json" + else + printf '{"name":"orchestrator","version":"%s"}\n' "$version" > "$repo/.claude/.claude-plugin/plugin.json" + fi + fi + + if [ "$origin" != "NONE" ]; then + git -C "$repo" remote add origin "$origin" + fi + + echo "seed" > "$repo/seed.txt" + git -C "$repo" add -A + git -C "$repo" commit -q -m "seed fixture" + + printf '%s' "$repo" +} + +run_feedback() { + # $1 = repo dir, rest = args to feedback.sh + local repo="$1"; shift + ( cd "$repo" && env -u CLAUDE_PLUGIN_ROOT bash "$repo/.claude/skills/feedback/feedback.sh" "$@" ) +} + +run_feedback_with_plugin_root() { + # $1 = repo dir, $2 = CLAUDE_PLUGIN_ROOT dir, rest = args to feedback.sh + local repo="$1" plugin_root="$2"; shift 2 + ( cd "$repo" && CLAUDE_PLUGIN_ROOT="$plugin_root" bash "$repo/.claude/skills/feedback/feedback.sh" "$@" ) +} + +install_stub_gh() { + # $1 = repo dir, $2 = log file, $3 = "succeed" | "fail-with-labels" | + # "fail-always". Installs a logging stub gh binary in the fixture and + # returns the path via stdout (feedback.sh is invoked with + # FEEDBACK_GH_BIN=, never $PATH). + local repo="$1" log="$2" mode="$3" + local stub="$repo/stub-gh.sh" + cat > "$stub" <> "\$log_file" +printf '\n' >> "\$log_file" +mode="$mode" +case "\$1" in + label) + exit 0 + ;; + issue) + case "\$2" in + create) + if printf '%s\n' "\$*" | grep -q -- '--label' && [ "\$mode" = "fail-with-labels" ]; then + echo "stub-gh: simulated failure — labels rejected" >&2 + exit 1 + fi + if [ "\$mode" = "fail-always" ]; then + echo "stub-gh: simulated hard failure" >&2 + exit 1 + fi + echo "https://github.com/robercano/reCode/issues/999" + ;; + *) + echo "stub-gh: unexpected issue subcommand: \$*" >&2 + exit 1 + ;; + esac + ;; + *) + echo "stub-gh: unexpected command: \$*" >&2 + exit 1 + ;; +esac +STUB + chmod +x "$stub" + printf '%s' "$stub" +} + +# ============================================================================= +# Scenario 1: remote -> label mapping (all via --dry-run, zero network). +# ============================================================================= +repo1="$(new_fixture repo1 "git@github.com:acme/reDeploy-fork.git")" +out1="$(run_feedback "$repo1" "widget renders wrong" --dry-run 2>&1)" +rc1=$? +check "scenario 1: redeploy-ish origin exits 0" [ "$rc1" -eq 0 ] +check "scenario 1: redeploy-ish origin gets from:redeploy" bash -c 'printf "%s" "$1" | grep -q -- "--label from:redeploy"' _ "$out1" +check "scenario 1: redeploy-ish origin also keeps plain feedback label" bash -c 'printf "%s" "$1" | grep -q -- "--label feedback"' _ "$out1" + +repo2="$(new_fixture repo2 "https://github.com/acme/myReDeFi-app.git")" +out2="$(run_feedback "$repo2" "gas estimate off" --dry-run 2>&1)" +rc2=$? +check "scenario 1: redefi-ish origin exits 0" [ "$rc2" -eq 0 ] +check "scenario 1: redefi-ish origin gets from:redefi" bash -c 'printf "%s" "$1" | grep -q -- "--label from:redefi"' _ "$out2" +check "scenario 1: redefi-ish origin does NOT also get from:redeploy" bash -c '! printf "%s" "$1" | grep -q -- "--label from:redeploy"' _ "$out2" + +repo3="$(new_fixture repo3 "git@github.com:acme/unrelated-project.git")" +out3="$(run_feedback "$repo3" "unrelated feedback" --dry-run 2>&1)" +rc3=$? +check "scenario 1: unrelated origin still exits 0 (files anyway)" [ "$rc3" -eq 0 ] +check "scenario 1: unrelated origin gets plain feedback label" bash -c 'printf "%s" "$1" | grep -q -- "--label feedback"' _ "$out3" +check "scenario 1: unrelated origin gets NO from:* label" bash -c '! printf "%s" "$1" | grep -q -- "--label from:"' _ "$out3" +check "scenario 1: unrelated origin is still noted in the body" bash -c 'printf "%s" "$1" | grep -q "Origin repo: acme/unrelated-project"' _ "$out3" + +# ============================================================================= +# Scenario 2: installed-plugin-version resolution + fallback + degrade. +# ============================================================================= +repo4="$(new_fixture repo4 "git@github.com:acme/reDeploy.git" "3.4.5")" +out4="$(run_feedback "$repo4" "version check" --dry-run 2>&1)" +check "scenario 2: local .claude/.claude-plugin/plugin.json fallback picked up" bash -c 'printf "%s" "$1" | grep -q "Installed plugin version: 3.4.5"' _ "$out4" + +plugin_root_dir="$work/plugin-cache-root" +mkdir -p "$plugin_root_dir/.claude-plugin" +printf '{"name":"orchestrator","version":"7.7.7"}\n' > "$plugin_root_dir/.claude-plugin/plugin.json" +out5="$(run_feedback_with_plugin_root "$repo4" "$plugin_root_dir" "version check via plugin root" --dry-run 2>&1)" +check "scenario 2: CLAUDE_PLUGIN_ROOT manifest wins over the local fallback" bash -c 'printf "%s" "$1" | grep -q "Installed plugin version: 7.7.7"' _ "$out5" + +repo5="$(new_fixture repo5 "git@github.com:acme/reDeploy.git" "CORRUPT")" +out6="$(run_feedback "$repo5" "corrupt manifest" --dry-run 2>&1)" +check "scenario 2: corrupt local manifest degrades to 'unknown' rather than crashing" bash -c 'printf "%s" "$1" | grep -q "Installed plugin version: unknown"' _ "$out6" +check "scenario 2: corrupt manifest still exits 0 (version degrade must not block filing)" [ $? -eq 0 ] + +# ============================================================================= +# Scenario 3: consumer-repo guard — all three conditions, each a clean +# non-zero exit with NO gh call ever attempted (verified against the stub +# log staying empty for the FULL guard-failure set, not just --dry-run). +# ============================================================================= +repo6="$(new_fixture repo6 "NONE" "9.9.9")" +gh_log6="$work/gh-log-6.log"; : > "$gh_log6" +stub6="$(install_stub_gh "$repo6" "$gh_log6" succeed)" +out7="$(cd "$repo6" && env -u CLAUDE_PLUGIN_ROOT FEEDBACK_GH_BIN="$stub6" FEEDBACK_TEST_GH_LOG="$gh_log6" bash "$repo6/.claude/skills/feedback/feedback.sh" "no origin at all" 2>&1)" +rc7=$? +check "scenario 3: no origin remote -> non-zero exit" [ "$rc7" -ne 0 ] +check "scenario 3: no origin remote -> clear error message" bash -c 'printf "%s" "$1" | grep -qi "no .origin. git remote"' _ "$out7" +check "scenario 3: no origin remote -> zero gh calls made (no partial issue)" bash -c '[ ! -s "$1" ]' _ "$gh_log6" + +repo7="$(new_fixture repo7 "git@github.com:robercano/reCode.git" "9.9.9")" +gh_log7="$work/gh-log-7.log"; : > "$gh_log7" +stub7="$(install_stub_gh "$repo7" "$gh_log7" succeed)" +out8="$(cd "$repo7" && env -u CLAUDE_PLUGIN_ROOT FEEDBACK_GH_BIN="$stub7" FEEDBACK_TEST_GH_LOG="$gh_log7" bash "$repo7/.claude/skills/feedback/feedback.sh" "running from reCode itself" 2>&1)" +rc8=$? +check "scenario 3: origin is robercano/reCode itself -> non-zero exit" [ "$rc8" -ne 0 ] +check "scenario 3: refuses with a clear reCode-itself message" bash -c 'printf "%s" "$1" | grep -qi "robercano/reCode itself"' _ "$out8" +check "scenario 3: origin-is-reCode -> zero gh calls made" bash -c '[ ! -s "$1" ]' _ "$gh_log7" + +repo8="$(new_fixture repo8 "git@github.com:acme/reDeploy.git" "NOPLUGIN")" +gh_log8="$work/gh-log-8.log"; : > "$gh_log8" +stub8="$(install_stub_gh "$repo8" "$gh_log8" succeed)" +out9="$(cd "$repo8" && env -u CLAUDE_PLUGIN_ROOT FEEDBACK_GH_BIN="$stub8" FEEDBACK_TEST_GH_LOG="$gh_log8" bash "$repo8/.claude/skills/feedback/feedback.sh" "plugin not installed" 2>&1)" +rc9=$? +check "scenario 3: no resolvable plugin manifest -> non-zero exit" [ "$rc9" -ne 0 ] +check "scenario 3: unresolvable manifest -> clear error message" bash -c 'printf "%s" "$1" | grep -qi "orchestrator plugin"' _ "$out9" +check "scenario 3: unresolvable manifest -> zero gh calls made" bash -c '[ ! -s "$1" ]' _ "$gh_log8" + +# ============================================================================= +# Scenario 4: body-template assembly — sections, description, severity +# fallback, and explicit severity text. +# ============================================================================= +repo9="$(new_fixture repo9 "git@github.com:acme/reDeFi.git" "1.2.3")" +out10="$(run_feedback "$repo9" "loop stalls on empty queue" --dry-run 2>&1)" +check "scenario 4: body has an Observed section with the description" bash -c ' + printf "%s" "$1" | grep -q "## Observed" && + printf "%s" "$1" | grep -q "loop stalls on empty queue" +' _ "$out10" +check "scenario 4: body has an Expected section" bash -c 'printf "%s" "$1" | grep -q "## Expected"' _ "$out10" +check "scenario 4: body has a Severity suggestion section" bash -c 'printf "%s" "$1" | grep -q "## Severity suggestion"' _ "$out10" +check "scenario 4: no severity given -> '(not specified)' fallback" bash -c 'printf "%s" "$1" | grep -q "(not specified)"' _ "$out10" + +out11="$(run_feedback "$repo9" "another one" --severity "high" --dry-run 2>&1)" +check "scenario 4: explicit severity text appears verbatim" bash -c 'printf "%s" "$1" | grep -q "^high$"' _ "$out11" + +# ============================================================================= +# Scenario 5 (real, non-dry-run path via $FEEDBACK_GH_BIN): label-create +# fires best-effort, and a failing labeled issue-create retries exactly once +# without --label and still succeeds (missing-label degrade). +# ============================================================================= +repo10="$(new_fixture repo10 "git@github.com:acme/reDeploy.git" "9.9.9")" +gh_log10="$work/gh-log-10.log"; : > "$gh_log10" +stub10="$(install_stub_gh "$repo10" "$gh_log10" fail-with-labels)" +out12="$(cd "$repo10" && env -u CLAUDE_PLUGIN_ROOT FEEDBACK_GH_BIN="$stub10" FEEDBACK_TEST_GH_LOG="$gh_log10" bash "$repo10/.claude/skills/feedback/feedback.sh" "labels rejected case" 2>&1)" +rc12=$? +check "scenario 5: overall call still succeeds after the label-degrade retry" [ "$rc12" -eq 0 ] +check "scenario 5: label create calls were attempted (best-effort)" bash -c 'grep -q "^label create feedback" "$1" && grep -q "^label create from:redeploy" "$1"' _ "$gh_log10" +check "scenario 5: first issue-create attempt carried --label flags" bash -c 'grep -q "^issue create .*--label feedback" "$1"' _ "$gh_log10" +check "scenario 5: a second issue-create attempt (retry) has NO --label flags" bash -c ' + grep "^issue create" "$1" | grep -qv -- "--label" +' _ "$gh_log10" +check "scenario 5: warns on stderr about the label degrade" bash -c 'printf "%s" "$1" | grep -qi "without labels"' _ "$out12" +check "scenario 5: still prints the created issue URL" bash -c 'printf "%s" "$1" | grep -q "https://github.com/robercano/reCode/issues/999"' _ "$out12" + +repo11="$(new_fixture repo11 "git@github.com:acme/reDeploy.git" "9.9.9")" +gh_log11="$work/gh-log-11.log"; : > "$gh_log11" +stub11="$(install_stub_gh "$repo11" "$gh_log11" fail-always)" +out13="$(cd "$repo11" && env -u CLAUDE_PLUGIN_ROOT FEEDBACK_GH_BIN="$stub11" FEEDBACK_TEST_GH_LOG="$gh_log11" bash "$repo11/.claude/skills/feedback/feedback.sh" "hard failure case" 2>&1)" +rc13=$? +check "scenario 5b: a hard gh failure (both attempts fail) exits non-zero" [ "$rc13" -ne 0 ] +check "scenario 5b: reports the final failure clearly" bash -c 'printf "%s" "$1" | grep -qi "could not file the feedback issue"' _ "$out13" +check "scenario 5b: retried exactly once without labels before giving up" bash -c ' + n=$(grep -c "^issue create" "$1") + [ "$n" -eq 2 ] +' _ "$gh_log11" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "feedback.test.sh: PASS ($ok checks)" + exit 0 +else + echo "feedback.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/skills/feedback/SKILL.md b/.claude/skills/feedback/SKILL.md new file mode 100644 index 0000000..7a6e77b --- /dev/null +++ b/.claude/skills/feedback/SKILL.md @@ -0,0 +1,78 @@ +--- +name: feedback +description: Capture rollout feedback from a CONSUMER repo (reDeploy, reDeFi, or any other project with the orchestrator plugin installed) as a five-second act — /orchestrator:feedback "" — and file it as a triage-inbox issue in the PLUGIN repo (robercano/reCode). Autofills the origin repo, installed plugin version, and a body template; only asks for the description and an optional severity. Use this whenever the user runs `/orchestrator:feedback`, or asks to "file feedback", "report a bug in the orchestrator", or "note this for reCode" while working in a consumer repo. +--- + +You are running **feedback capture** — the five-second consumer-side act of noting something wrong or +worth improving in the orchestrator plugin, without breaking the owner's flow while they're inside a +consumer repo (reDeFi, reDeploy, ...) during rollout testing (issue #177). This skill deliberately does +almost nothing itself: nearly all of the logic is deterministic and lives in +`.claude/skills/feedback/feedback.sh` (or `${CLAUDE_PLUGIN_ROOT}/skills/feedback/feedback.sh` when running +from the installed plugin cache) — you gather the two things only a human can supply, then call it. + +## Identity note (read this — it is the one deliberate exception in this plugin) +Every other skill in this repo routes ALL `gh` calls through +`bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh` so PRs land bot-authored and the owner can approve +them. **This skill is the one deliberate exception.** `feedback.sh` calls **plain `gh`** — the owner's own +ambient identity — because the issue being filed genuinely IS the owner's own feedback (they are the one +running this, from inside a consumer repo, during their own rollout testing). There is no "the owner can't +approve their own PR" problem here to route around: this only ever files an *issue*, not a PR, and it is +correct for it to be owner-authored. Do not route this one call through bot-gh.sh. + +## Flow + +1. **Gather the two inputs.** Ask for: + - the one-line description (required) — what was observed, said concisely enough to fit a `/orchestrator:feedback "..."` invocation. If the user already supplied it as the command argument, use that directly; don't re-ask. + - a severity suggestion (optional) — if the user doesn't volunteer one, it's fine to skip; the helper + fills in "(not specified)" and the owner can triage severity later. Don't stall the five-second goal + interrogating for it. + Everything else (origin label, plugin version, milestone, labels) is fully automatic — never ask about + those. + +2. **Run the helper for real** (no `--dry-run` — that flag exists for `feedback.test.sh`'s offline smoke + test, not for actual use): + ``` + bash ${CLAUDE_PLUGIN_ROOT:-.claude}/skills/feedback/feedback.sh "" [--severity ""] + ``` + Run it from the consumer repo's working directory (or any subdirectory of it) — it resolves the repo + root itself via `resolve-roots.sh`, the same two-root derivation `bot-gh.sh` uses. + +3. **Read the result.** + - **Success** — the helper prints the created issue's URL on stdout. Tell the user it's filed, share the + URL, and mention which label(s) landed on it (see "What gets autofilled" below). + - **Guard failure** (exit 1, clear one-line message on stderr) — this means one of three concrete + conditions wasn't met: (a) no resolvable `origin` git remote, (b) origin IS robercano/reCode itself + (this skill files INTO reCode; it does not run FROM it), or (c) the orchestrator plugin's manifest + isn't resolvable (neither `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json` nor + `/.claude/.claude-plugin/plugin.json` exists — i.e. the plugin doesn't look installed here). In + every one of these cases the helper creates **no issue at all** — relay the exact stderr message to the + user; don't retry with a workaround, since a guard failure means the five-second assumption + (consumer repo, plugin installed) genuinely doesn't hold here. + - **Label degrade warning** (exit 0, but a warning on stderr) — the issue still filed successfully, just + without one or more labels attaching cleanly. Relay this too, so the user knows to label it by hand if + they care. + +## What gets autofilled (so you can explain it if asked) +- **Origin label** — derived from the consumer repo's `origin` git remote: a case-insensitive `redeploy` + substring match adds `from:redeploy`, `redefi` adds `from:redefi`. Neither match still files the issue + (labelled just `feedback`) — the raw origin repo slug is always noted in the body regardless, so nothing + about where the feedback came from is ever lost even without a matching from:* label. +- **Installed plugin version** — read from whichever plugin manifest resolved during the guard check; + degrades to the literal string `unknown` (never blocks filing) if the file exists but its `version` field + can't be parsed. +- **Body template** — `## Observed` (the description), `## Expected` (left as a fill-in-if-different + placeholder), `## Severity suggestion` (what was gathered, or "(not specified)"), plus a trailer noting the + origin repo and plugin version. +- **Labels** — `feedback` + whichever `from:*` label matched. Deliberately **no milestone, no `planned` + label** — this lands in reCode's census-invisible triage inbox (see `docs/USAGE.md` → "Release cycle" → + the rollout & feedback companion) until the owner triages it, exactly like the auto-filed + `Rollout & feedback: vX.Y.Z` issue `release.sh` files after every cut. + +## Reference +- `.claude/skills/feedback/feedback.sh` — the offline-where-possible implementation; the one network call + it makes (label create + issue create) uses plain `gh`, see the identity note above. +- `.claude/scripts/feedback.test.sh` — the standalone, offline smoke test (remote→label mapping, version + resolution + fallback + "unknown" degrade, all three consumer-repo guard conditions, body-template + assembly, and the no-partial-issue-on-guard-failure / degrade-without-labels-on-create-failure paths) — + exercised via `feedback.sh --dry-run` and an overridable `$FEEDBACK_GH_BIN` stub, never the real network. +- `docs/USAGE.md` → "Release cycle" → the `/orchestrator:feedback` subsection for the user-facing summary. diff --git a/.claude/skills/feedback/feedback.sh b/.claude/skills/feedback/feedback.sh new file mode 100644 index 0000000..212e49e --- /dev/null +++ b/.claude/skills/feedback/feedback.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# feedback.sh — helper for the /orchestrator:feedback skill (issue #177). +# +# Runs in a CONSUMER repo (e.g. reDeploy, reDeFi) and files a triage-inbox +# issue in the PLUGIN repo (robercano/reCode). This is the ONE deliberate spot +# in this plugin that shells out to PLAIN `gh` instead of bot-gh.sh: the issue +# being filed genuinely IS the owner's own feedback (they are the one running +# this from inside a consumer repo during rollout testing), so there is no +# "bot needs to open a PR the owner can approve" problem bot-gh.sh exists to +# solve — see bot-gh.sh's own header for that rationale, which does not apply +# here. `gh` runs with whatever identity is already logged in on the machine +# (the owner's), by design. +# +# Usage: +# feedback.sh "" [--severity SEV] [--dry-run] +# feedback.sh --description "<...>" [--severity SEV] [--dry-run] +# +# --dry-run performs every PURE step (consumer-repo guard, origin->label +# mapping, plugin-version resolution, body-template assembly) for real, then +# PRINTS the exact `gh issue create` invocation it would run — including the +# fully assembled body — instead of executing it. No network call of any +# kind happens under --dry-run (mirrors release.sh's --dry-run boundary). +# +# The actual `gh` calls (both non-dry-run label-create and issue-create) are +# additionally routed through an overridable command, $FEEDBACK_GH_BIN +# (defaults to plain `gh`), so a test can point this at a logging stub +# instead of the real network binary without needing --dry-run. +# +# Consumer-repo guard (concrete definition — ALL three must hold, checked in +# order, first failure wins, and NO gh call is ever made if any fails, dry-run +# or not): +# 1. the current repo has a resolvable GitHub `origin` remote, +# 2. that origin is NOT robercano/reCode itself (this skill files INTO +# reCode, it does not run FROM it), +# 3. the orchestrator plugin's manifest is resolvable — either +# ${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json (the normal consumer +# case: plugin installed from the marketplace cache) or, as a fallback, +# /.claude/.claude-plugin/plugin.json (a local-clone install, +# or this repo's own self-hosting layout). +# Any failure degrades gracefully: a clear one-line error on stderr, exit +# non-zero, and no issue (partial or otherwise) is ever created. +# +# Origin -> label mapping (best-effort, never blocks filing): +# the origin owner/repo slug is matched case-insensitively for the +# substring "redeploy" -> `from:redeploy`, or "redefi" -> `from:redefi`. If +# neither matches, NO from:* label is applied — the issue still files with +# just `feedback`, and the raw origin repo slug is always noted in the body +# regardless of whether a from:* label could be derived from it, so nothing +# is silently lost. +# +# Installed plugin version: read from whichever manifest resolved above, +# case degrading to the string "unknown" (never crashing) if the file exists +# but its `version` field can't be parsed. +# +# Missing-label degrade: label creation (`gh label create ... --force`) is +# always best-effort (`|| true`) before filing. If the `gh issue create` call +# itself then fails (e.g. because a label genuinely doesn't exist and this +# gh/token combination can't create it), this retries EXACTLY ONCE with no +# --label flags at all, so a label problem degrades to "filed without +# labels" (with a loud warning to add them by hand) rather than losing the +# issue entirely. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../scripts/resolve-roots.sh +. "$script_dir/../../scripts/resolve-roots.sh" + +GH_BIN="${FEEDBACK_GH_BIN:-gh}" +TARGET_REPO="${FEEDBACK_TARGET_REPO:-robercano/reCode}" + +DESCRIPTION="" +SEVERITY="" +DRY_RUN=0 + +while [ $# -gt 0 ]; do + case "$1" in + --description) DESCRIPTION="${2:?--description requires a value}"; shift 2 ;; + --severity) SEVERITY="${2:?--severity requires a value}"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -*) + echo "feedback.sh: unknown flag '$1'" >&2 + exit 2 + ;; + *) + if [ -z "$DESCRIPTION" ]; then + DESCRIPTION="$1" + shift + else + echo "feedback.sh: unexpected extra argument '$1'" >&2 + exit 2 + fi + ;; + esac +done + +if [ -z "$DESCRIPTION" ]; then + echo "feedback.sh: a one-line description is required (positional argument or --description)" >&2 + exit 2 +fi + +# --- consumer-repo guard, step 1: resolvable origin remote ------------------- +origin_url="$(git -C "$root" remote get-url origin 2>/dev/null || true)" +if [ -z "$origin_url" ]; then + echo "feedback.sh: no 'origin' git remote found in $root — /orchestrator:feedback must be run from a CONSUMER repo checkout with an origin remote, not a repo with no remote configured." >&2 + exit 1 +fi + +origin_owner_repo="" +case "$origin_url" in + git@github.com:*) origin_owner_repo="${origin_url#git@github.com:}"; origin_owner_repo="${origin_owner_repo%.git}" ;; + ssh://git@github.com/*) origin_owner_repo="${origin_url#ssh://git@github.com/}"; origin_owner_repo="${origin_owner_repo%.git}" ;; + https://github.com/*) origin_owner_repo="${origin_url#https://github.com/}"; origin_owner_repo="${origin_owner_repo%.git}" ;; +esac + +if [ -z "$origin_owner_repo" ]; then + echo "feedback.sh: could not parse a GitHub owner/repo out of origin remote '$origin_url' — only github.com remotes are supported." >&2 + exit 1 +fi + +origin_lower="$(printf '%s' "$origin_owner_repo" | tr '[:upper:]' '[:lower:]')" + +# --- consumer-repo guard, step 2: must NOT be robercano/reCode itself -------- +if [ "$origin_lower" = "robercano/recode" ]; then + echo "feedback.sh: refusing to run inside robercano/reCode itself — /orchestrator:feedback captures a CONSUMER's feedback and files it INTO reCode; run it from the consumer repo (reDeploy/reDeFi/etc), not from reCode's own checkout." >&2 + exit 1 +fi + +# --- consumer-repo guard, step 3: the plugin's manifest must be resolvable --- +plugin_manifest="" +if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.json" ]; then + plugin_manifest="$CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.json" +elif [ -f "$root/.claude/.claude-plugin/plugin.json" ]; then + plugin_manifest="$root/.claude/.claude-plugin/plugin.json" +fi + +if [ -z "$plugin_manifest" ]; then + echo "feedback.sh: could not find the orchestrator plugin's manifest (checked \${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json and $root/.claude/.claude-plugin/plugin.json) — is the orchestrator plugin installed in this repo?" >&2 + exit 1 +fi + +# --- installed plugin version (degrade to "unknown", never crash) ----------- +plugin_version="$(node -e ' + try { + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(j.version || "unknown")); + } catch (e) { + process.stdout.write("unknown"); + } +' "$plugin_manifest" 2>/dev/null || true)" +[ -n "$plugin_version" ] || plugin_version="unknown" + +# --- origin -> from:* label mapping (best-effort; empty = no from:* label) --- +origin_label="" +case "$origin_lower" in + *redeploy*) origin_label="from:redeploy" ;; + *redefi*) origin_label="from:redefi" ;; +esac + +# --- body template ------------------------------------------------------------ +severity_line="${SEVERITY:-(not specified)}" +title="Feedback: $DESCRIPTION" +body="## Observed +$DESCRIPTION + +## Expected +(fill in if different from observed) + +## Severity suggestion +$severity_line + +--- +Origin repo: $origin_owner_repo +Installed plugin version: $plugin_version +Filed via \`/orchestrator:feedback\` (issue #177)." + +labels=(feedback) +[ -n "$origin_label" ] && labels+=("$origin_label") + +label_args=() +for lbl in "${labels[@]}"; do + label_args+=(--label "$lbl") +done + +if [ "$DRY_RUN" -eq 1 ]; then + echo "feedback.sh: [dry-run] would run: $GH_BIN issue create --repo $TARGET_REPO --title \"$title\" ${label_args[*]}" + echo "feedback.sh: [dry-run] body would be:" + printf '%s\n' "$body" + echo "feedback.sh: [dry-run] complete — no gh/network call performed" + exit 0 +fi + +# ============================================================================= +# Everything below performs a REAL gh call (or the overridable $GH_BIN stand- +# in) and is never reached under --dry-run. +# ============================================================================= + +# Idempotent label creation, best-effort. Owner's own real gh (not the bot's +# older gh, which issue #169 found silently no-ops `gh label create` — this +# path never touches bot-gh.sh, so that caveat does not apply here), so plain +# `gh label create ... --force` is fine. +for lbl in "${labels[@]}"; do + "$GH_BIN" label create "$lbl" --description "Feedback filed via /orchestrator:feedback" --force >/dev/null 2>&1 || true +done + +if issue_out="$("$GH_BIN" issue create --repo "$TARGET_REPO" --title "$title" --body "$body" "${label_args[@]}" 2>&1)"; then + printf '%s\n' "$issue_out" + exit 0 +fi + +echo "feedback.sh: warning — issue create with labels failed, retrying once without labels: $issue_out" >&2 +if issue_out="$("$GH_BIN" issue create --repo "$TARGET_REPO" --title "$title" --body "$body" 2>&1)"; then + printf '%s\n' "$issue_out" + echo "feedback.sh: warning — filed without labels; add ${labels[*]} by hand." >&2 + exit 0 +fi + +echo "feedback.sh: error — could not file the feedback issue: $issue_out" >&2 +exit 1 diff --git a/docs/USAGE.md b/docs/USAGE.md index 4ca4294..4de2108 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -537,6 +537,30 @@ micro-milestone containing just that one bug issue, label it `planned`, let the cut the patch release through the exact same `Release vX.Y.Z+1` → `release.sh` machinery above (its "Blocked by" list will just be the one bug issue). +**`/orchestrator:feedback` — the five-second consumer→inbox capture path (issue #177).** The rollout & +feedback companion above assumes someone remembers to go file something; `/orchestrator:feedback "" [--severity SEV]` is the low-friction complement — run it from INSIDE a consumer repo +(reDeploy, reDeFi, ...) while the owner is actually using the plugin there, and it lands the same triage-inbox +issue in `robercano/reCode` without breaking flow. It asks for only two things — the description, and an +optional severity suggestion — and autofills everything else: +- **origin label** — derived from the consumer repo's `origin` git remote (case-insensitive `redeploy` → + `from:redeploy`, `redefi` → `from:redefi`; neither match still files the issue labelled just `feedback`, + with the raw origin repo slug always noted in the body regardless), +- **installed plugin version** — read from the plugin cache manifest + (`${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`, falling back to a local-clone install's + `.claude/.claude-plugin/plugin.json`; degrades to `unknown` rather than crashing), +- **body template** — `Observed` / `Expected` / `Severity suggestion` sections plus an origin-repo + + plugin-version trailer, +- **labels** — `feedback` + whichever `from:*` label matched. Same as the rollout companion issue, + deliberately **no milestone, no `planned` label** — it lands in the same census-invisible triage inbox + above, for the owner to triage later. + +Implemented as `.claude/skills/feedback/SKILL.md` + `.claude/skills/feedback/feedback.sh` — see the skill +for the full consumer-repo guard definition (no origin remote / origin is reCode itself / plugin not +installed here all degrade to a clear error and file nothing, never a partial issue) and the one deliberate +place in this whole plugin that calls **plain `gh`** instead of `bot-gh.sh`: the issue being filed genuinely +IS the owner's own feedback, so there is no bot-authorship-for-approval problem to route around. + ## Roadmap (`docs/ROADMAP.md`, issue #175) **`docs/ROADMAP.md` is GENERATED — never hand-edit it.** The single source of truth is GitHub itself (open