diff --git a/.claude/.claude-plugin/CHANGELOG.md b/.claude/.claude-plugin/CHANGELOG.md index 0c1ffb4..03d1691 100644 --- a/.claude/.claude-plugin/CHANGELOG.md +++ b/.claude/.claude-plugin/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to the `orchestrator` plugin are documented in this file. Fo [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versions track `plugin.json` / `marketplace.json`. +## [0.2.2] - 2026-07-17 + +### Changed +- **Stopped vendoring the runtime harness (issue #134, reverting issue #128).** `/orchestrator:setup`/ + `/orchestrator:sync` no longer copy the plugin's own `agents/`, `commands/`, `hooks/`, `scripts/`, + `skills/` into a consumer repo's local `.claude/` — those now resolve at runtime via + `${CLAUDE_PLUGIN_ROOT}`, so **the plugin must stay enabled for everyday sessions**, not just to run + setup/sync. Nothing kept an unmanaged vendored copy updated, and `resolve-roots.sh` deliberately makes + a repo-tracked `.claude/scripts` layout win over `${CLAUDE_PLUGIN_ROOT}`, so a stale vendored copy + permanently shadowed a fresh plugin install (the reDeploy incident that prompted this issue). The + `.claude/.orchestrator-vendor` marker and the vendor copy machinery are removed. +- **`pr-loop.service` → v2**: `ExecStart` now resolves `loop-daemon.sh` at unit-start time — prefers a + repo-tracked `.claude/scripts/loop-daemon.sh` (self-hosting), else the newest + `~/.claude/plugins/cache/*/orchestrator/*/scripts/loop-daemon.sh` — instead of assuming a vendored + repo-local copy. +- **`.claude/settings.json` template**: hook commands now resolve via + `${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/...` instead of a hardcoded + `$CLAUDE_PROJECT_DIR/.claude/scripts/...` path, matching the no-longer-vendored layout. + +### Added +- **`/orchestrator:sync` detects and warns about stale/unmanaged local copies** of the + now-unvendored `agents/commands/hooks/scripts/skills` directories instead of silently deleting or + restamping them — flags byte-identical leftovers as safe to delete, diverging copies as a possible + deliberate override, and calls out a leftover `.claude/.orchestrator-vendor` marker. + +### Notes for downstream installs +- If your repo onboarded while vendoring was active (between #128 and #134), run `/orchestrator:sync` + to get the stale-vendor report, then see `docs/MIGRATION.md` → "If you onboarded between issues #128 + and #134" for cleanup steps. **Keep the plugin enabled** after cleaning up. +- **Migration caveat:** deleting/refreshing files on disk is not enough for a repo with the loop already + armed — a running `pr-loop`/`claude-rc` systemd unit holds its OLD script in memory until its unit + restarts: `systemctl --user restart pr-loop-.service claude-rc-.service`. + ## [0.2.1] - 2026-07-15 Everything shipped since 0.2.0. Headline: the driver now enforces a one-shot contract (no turn diff --git a/.claude/.claude-plugin/marketplace.json b/.claude/.claude-plugin/marketplace.json index d8674e3..78f76eb 100644 --- a/.claude/.claude-plugin/marketplace.json +++ b/.claude/.claude-plugin/marketplace.json @@ -4,13 +4,13 @@ "name": "Roberto Cano" }, "description": "reCode: multi-agent orchestration harness — fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.", - "version": "0.2.1", + "version": "0.2.2", "plugins": [ { "name": "orchestrator", "source": "./", "description": "reCode: multi-agent orchestration harness — fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.", - "version": "0.2.1", + "version": "0.2.2", "author": { "name": "Roberto Cano" } diff --git a/.claude/.claude-plugin/plugin.json b/.claude/.claude-plugin/plugin.json index e9d36c4..8f250d6 100644 --- a/.claude/.claude-plugin/plugin.json +++ b/.claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "orchestrator", - "version": "0.2.1", + "version": "0.2.2", "description": "reCode: multi-agent orchestration harness — fan out sub-tasks to isolated worktree implementers, gate them, and route results through reviewers.", "author": { "name": "Roberto Cano" }, "commands": [ diff --git a/.claude/.orchestrator-vendor b/.claude/.orchestrator-vendor deleted file mode 100644 index 498068d..0000000 --- a/.claude/.orchestrator-vendor +++ /dev/null @@ -1,23 +0,0 @@ -# @orchestrator-managed runtime-vendor v1 -# -# Version stamp for the vendored runtime harness (issue #128). `/orchestrator:setup`'s -# scaffold.sh copies this plugin's own `agents/`, `commands/`, `hooks/`, `scripts/`, and -# `skills/` subtrees wholesale into the consumer repo's `.claude/`, then writes this file -# alongside them at `.claude/.orchestrator-vendor` as the single top-level marker that -# governs the WHOLE vendored tree as one unit (not one marker per file). `/orchestrator:sync` -# re-vendors the tree the same way when this marker is behind what the plugin ships, using -# the same never-downgrade / flag-local-edits-as-conflict rules as every other managed file -# — see `.claude/skills/setup/templates/MANIFEST.md`. -# -# Once vendored, a consumer session reads agents/commands/hooks/scripts/skills straight out -# of the local `.claude/` tree — the plugin only needs to be ENABLED to run -# `/orchestrator:setup` or `/orchestrator:sync` (the install/update channel), not for -# everyday runtime sessions. That's what removes the plugin's session-start load cost from -# time-sensitive paths like headless `claude remote-control` spawns. -# -# EXCEPTION: `.claude/scripts/arm-loop.sh` is excluded from this wholesale copy. It already -# has its OWN dedicated managed-file row (canonical shipped copy: `templates/arm-loop.sh`, -# substituted with placeholders at `arm-loop.sh` ARM time) which can legitimately differ -# from this plugin repo's own live self-hosting copy of `.claude/scripts/arm-loop.sh`. -# Vendoring it a second time via this whole-tree copy would create two divergent sources of -# truth for the same destination path — see scaffold.sh/sync.sh's runtime-vendor sections. diff --git a/.claude/scripts/loop-census.test.sh b/.claude/scripts/loop-census.test.sh index 1620995..25d9acf 100644 --- a/.claude/scripts/loop-census.test.sh +++ b/.claude/scripts/loop-census.test.sh @@ -217,6 +217,15 @@ cat > "$scripts_dir/pr-ci-fix.sh" <<'EOF' printf '10\tfeat/issue-10-a\tbuild\tsha10\n' printf '11\tfeat/issue-11-a\tbuild\tsha11\n' EOF +# Commit this swap into fixture1's git history — fixture1 is reused for the +# main_dirty checks much further below, which assume the fixture is otherwise +# clean; leaving this rewrite uncommitted made `git status --porcelain` show +# a real ` M .claude/scripts/pr-ci-fix.sh` line for the rest of the fixture's +# life, permanently tripping main_dirty=yes regardless of which exclusion was +# actually under test (unrelated pre-existing gap, not part of what's being +# tested here). +git -C "$fixture" add .claude/scripts/pr-ci-fix.sh +git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q -m "swap in ci_fix_prs stub (test fixture)" outCiFix="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" check "ci_fix_prs=2 counted when pr-ci-fix.sh reports two candidates" bash -c 'printf "%s\n" "$1" | grep -qx "ci_fix_prs=2"' _ "$outCiFix" diff --git a/.claude/scripts/sync-managed-files.test.sh b/.claude/scripts/sync-managed-files.test.sh new file mode 100755 index 0000000..03bcbb2 --- /dev/null +++ b/.claude/scripts/sync-managed-files.test.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# sync-managed-files.test.sh — offline smoke test for scaffold.sh's / sync.sh's +# managed-file ladder AND the stale-vendor detect-and-warn behavior (issue #134). +# +# Issue #134 stopped vendoring the plugin's own runtime harness (`agents/`, `commands/`, +# `hooks/`, `scripts/`, `skills/`) into consumer repos (issue #128's model) — consumer +# sessions now read those straight from the plugin cache (`${CLAUDE_PLUGIN_ROOT}`), and +# `.claude/{agents,commands,hooks,scripts,skills}` are no longer created/managed by +# scaffold.sh/sync.sh at all. This file replaces vendor-runtime.test.sh (which tested the +# now-removed vendoring copy machinery) and asserts: +# 1. fresh scaffold -> the 4 MANAGED_FILES rows are created (feature-fanout.js, +# pr-loop.service, claude-rc.service, arm-loop.sh), settings.json is created with no +# enabledPlugins/extraKnownMarketplaces, and NO .claude/{agents,commands,hooks,skills} +# are created, NO .claude/.orchestrator-vendor marker is written, and .claude/scripts/ +# contains ONLY arm-loop.sh (not the plugin's other generic scripts). +# 2. re-running scaffold -> idempotent (kept/up to date, no errors). +# 3. sync against an unchanged plugin root -> managed files "up to date"; stale-vendor +# detection reports "none found" (no local vendor leftovers to warn about). +# 4. scaffold.sh restamp (v1 -> v2) of a managed file (feature-fanout.js) actually lands +# the new content. +# 5. sync.sh restamp (v1 -> v2), marker-only bump -> restamps cleanly. +# 6. sync.sh conflict: installed managed file has local edits, behind shipped version -> +# flagged conflict, left untouched. +# 7. never-downgrade: scaffold.sh and sync.sh both leave a target whose managed-file +# marker is newer than shipped completely alone. +# 8. stale-vendor detection: a consumer with LEFTOVER local .claude/scripts (and a legacy +# `.claude/.orchestrator-vendor` marker) from the old #128 vendoring model gets a +# "stale-vendor" warning (not a silent restamp, not a delete) — content identical to +# the plugin's own copy is flagged "safe to delete"; content that diverges is flagged +# as a possible deliberate local override ("conflict"), and either way the local +# directory is left completely untouched by sync.sh. The legacy marker file and the +# systemd-restart migration caveat are both surfaced. +# 9. self-hosting: sync.sh running from INSIDE the target's own .claude (plugin_root == +# target_root/.claude, as when this plugin's own repo syncs against itself) never +# flags its own canonical agents/commands/hooks/scripts/skills as stale-vendor +# leftovers — no leftover/conflict line, no "verify it's not a leftover" phrasing, no +# migration caveat — even when a stray legacy marker is also present. +# 10. stray legacy marker with no stale vendor directories present (consumer manually +# deleted the vendored dirs but left `.claude/.orchestrator-vendor` behind) still +# gets a detect-and-warn line calling out the leftover marker file — it is not +# silently ignored just because no directories triggered the main check — and the +# marker is left on disk untouched. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/sync-managed-files.test.sh +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +scaffold_sh="$repo_root/.claude/skills/setup/scaffold.sh" +sync_sh="$repo_root/.claude/skills/sync/sync.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/sync-managed-files-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 +} +check_output() { + # $1 = desc, $2 = haystack, $3 = needle (grep -F) + local desc="$1" haystack="$2" needle="$3" + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc (expected to find: $needle)" + fi +} +check_no_output() { + # $1 = desc, $2 = haystack, $3 = needle that must NOT appear (grep -F) + local desc="$1" haystack="$2" needle="$3" + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + fail=1 + echo "FAIL - $desc (did not expect to find: $needle)" + else + ok=$((ok + 1)) + echo "ok - $desc" + fi +} +build_plugin_fixture() { + # $1 = destination dir for a trimmed "plugin root" fixture. Copies only the entries + # scaffold.sh/sync.sh actually read from a plugin root — agents/, commands/, hooks/, + # scripts/, skills/ (skills/ carries scaffold.sh, sync.sh, and templates/) — instead of + # the whole live .claude/ tree, which would otherwise drag in .claude/worktrees/ (large), + # .claude/state/, and settings.local.json into every fixture. + local dest="$1" entry + mkdir -p "$dest" + for entry in agents commands hooks scripts skills; do + cp -a "$repo_root/.claude/$entry" "$dest/$entry" + done +} + +# --------------------------------------------------------------------------- +# Scenario 1: fresh scaffold — managed files only, no vendoring. +# --------------------------------------------------------------------------- +t1="$work/consumer1" +mkdir -p "$t1" +out1="$(bash "$scaffold_sh" "$t1" 2>&1)" +scaffold1_rc=$? +check "s1: scaffold exits 0" test "$scaffold1_rc" -eq 0 +check "s1: feature-fanout.js created" test -f "$t1/.claude/workflows/feature-fanout.js" +check "s1: pr-loop.service created" test -f "$t1/.claude/systemd/pr-loop.service" +check "s1: claude-rc.service created" test -f "$t1/.claude/systemd/claude-rc.service" +check "s1: arm-loop.sh created" test -f "$t1/.claude/scripts/arm-loop.sh" +check "s1: arm-loop.sh executable bit preserved" test -x "$t1/.claude/scripts/arm-loop.sh" +check "s1: settings.json created" test -f "$t1/.claude/settings.json" +check "s1: settings.json has no enabledPlugins key" \ + node -e "process.exit('enabledPlugins' in require(process.argv[1]) ? 1 : 0)" "$t1/.claude/settings.json" +check "s1: settings.json has no extraKnownMarketplaces key" \ + node -e "process.exit('extraKnownMarketplaces' in require(process.argv[1]) ? 1 : 0)" "$t1/.claude/settings.json" +check "s1: NOT vendored — .claude/agents absent" test ! -e "$t1/.claude/agents" +check "s1: NOT vendored — .claude/commands absent" test ! -e "$t1/.claude/commands" +check "s1: NOT vendored — .claude/hooks absent" test ! -e "$t1/.claude/hooks" +check "s1: NOT vendored — .claude/skills absent" test ! -e "$t1/.claude/skills" +check "s1: NOT vendored — .claude/.orchestrator-vendor absent" test ! -e "$t1/.claude/.orchestrator-vendor" +check "s1: .claude/scripts contains ONLY the managed arm-loop.sh (no gate.sh etc.)" \ + bash -c '[ "$(ls -1 "$1")" = "arm-loop.sh" ]' _ "$t1/.claude/scripts" + +# --------------------------------------------------------------------------- +# Scenario 2: re-running scaffold is idempotent. +# --------------------------------------------------------------------------- +out2="$(bash "$scaffold_sh" "$t1" 2>&1)" +check_output "s2: re-run keeps settings.json (user-owned)" "$out2" "kept: consumer runtime settings" +check_output "s2: re-run reports arm-loop.sh up to date" "$out2" "up to date: managed file (.claude/scripts/arm-loop.sh)" + +# --------------------------------------------------------------------------- +# Scenario 3: sync against an unchanged plugin root -> up to date, no stale-vendor. +# --------------------------------------------------------------------------- +out3="$(bash "$sync_sh" "$t1" 2>&1)" +rc3=$? +check "s3: sync exits 0" test "$rc3" -eq 0 +check_output "s3: feature-fanout.js up to date" "$out3" "up to date: .claude/workflows/feature-fanout.js" +check_output "s3: stale-vendor none found" "$out3" "stale-vendor: none found" + +# base_v = the version this checkout's template actually ships today (read dynamically — +# see sync.sh's "single source of truth" comment for why nothing here hardcodes v1/v2). +base_v="$(grep -F -- '@orchestrator-managed feature-fanout v' \ + "$repo_root/.claude/skills/setup/templates/feature-fanout.js" | head -1 | grep -o '[0-9]\+$')" +next_v=$((base_v + 1)) +future_v=99 + +# --------------------------------------------------------------------------- +# Scenario 4: scaffold.sh restamp (base_v -> next_v) of a managed file lands new content. +# --------------------------------------------------------------------------- +t4="$work/consumer4" +mkdir -p "$t4" +bash "$scaffold_sh" "$t4" >/dev/null 2>&1 + +plugin_v2="$work/plugin-v2" +build_plugin_fixture "$plugin_v2" +sed -i "s/@orchestrator-managed feature-fanout v$base_v/@orchestrator-managed feature-fanout v$next_v/" \ + "$plugin_v2/skills/setup/templates/feature-fanout.js" +printf '\n// bumped content\n' >> "$plugin_v2/skills/setup/templates/feature-fanout.js" + +out4="$(bash "$plugin_v2/skills/setup/scaffold.sh" "$t4" 2>&1)" +check_output "s4: restamp reported base_v -> next_v" "$out4" \ + "restamped: managed file (.claude/workflows/feature-fanout.js) v$base_v -> v$next_v" +check "s4: feature-fanout.js content actually refreshed (not stale)" \ + grep -q -- "bumped content" "$t4/.claude/workflows/feature-fanout.js" + +# --------------------------------------------------------------------------- +# Scenario 5: sync.sh restamp, marker-only bump -> restamps cleanly. +# --------------------------------------------------------------------------- +plugin_v2b="$work/plugin-v2b" +build_plugin_fixture "$plugin_v2b" +sed -i "s/@orchestrator-managed feature-fanout v$base_v/@orchestrator-managed feature-fanout v$next_v/" \ + "$plugin_v2b/skills/setup/templates/feature-fanout.js" +t5="$work/consumer5" +mkdir -p "$t5" +bash "$scaffold_sh" "$t5" >/dev/null 2>&1 +out5="$(bash "$plugin_v2b/skills/sync/sync.sh" "$t5" 2>&1)" +check_output "s5: marker-only bump restamps cleanly" "$out5" \ + "restamped: .claude/workflows/feature-fanout.js v$base_v -> v$next_v" + +# --------------------------------------------------------------------------- +# Scenario 6: sync.sh conflict — local edit + behind shipped version. +# --------------------------------------------------------------------------- +t6="$work/consumer6" +mkdir -p "$t6" +bash "$scaffold_sh" "$t6" >/dev/null 2>&1 +echo "// local hack" >> "$t6/.claude/workflows/feature-fanout.js" +out6="$(bash "$plugin_v2b/skills/sync/sync.sh" "$t6" 2>&1)" +check_output "s6: local edit + behind flags conflict" "$out6" \ + "conflict: .claude/workflows/feature-fanout.js is v$base_v (behind v$next_v) AND has local edits" +check "s6: local edit persists — file untouched by sync" \ + grep -qF -- "// local hack" "$t6/.claude/workflows/feature-fanout.js" + +# --------------------------------------------------------------------------- +# Scenario 7: never-downgrade — a marker newer than shipped is left alone by both scripts. +# --------------------------------------------------------------------------- +t7="$work/consumer7" +mkdir -p "$t7" +bash "$scaffold_sh" "$t7" >/dev/null 2>&1 +sed -i "s/@orchestrator-managed feature-fanout v$base_v/@orchestrator-managed feature-fanout v$future_v/" \ + "$t7/.claude/workflows/feature-fanout.js" +snapshot7="$work/snapshot7" +cp -a "$t7/.claude/workflows/feature-fanout.js" "$snapshot7" +out7a="$(bash "$scaffold_sh" "$t7" 2>&1)" +check_output "s7: scaffold keeps a newer-than-shipped marker" "$out7a" \ + "is v$future_v, newer than this installer's v$base_v" +out7b="$(bash "$sync_sh" "$t7" 2>&1)" +check_output "s7: sync keeps a newer-than-shipped marker" "$out7b" \ + "is v$future_v, newer than this plugin's v$base_v" +check "s7: feature-fanout.js byte-identical after both runs (no downgrade)" \ + diff -q "$snapshot7" "$t7/.claude/workflows/feature-fanout.js" + +# --------------------------------------------------------------------------- +# Scenario 8: stale-vendor detection (issue #134) — leftover from the old #128 model. +# --------------------------------------------------------------------------- +plugin_fixture="$work/plugin-fixture" +build_plugin_fixture "$plugin_fixture" + +# 8a. Local .claude/scripts identical to the plugin's own -> "safe to delete", no marker. +t8a="$work/consumer8a" +mkdir -p "$t8a" +bash "$scaffold_sh" "$t8a" >/dev/null 2>&1 +cp -a "$plugin_fixture/scripts/." "$t8a/.claude/scripts/" # re-vendor scripts/ by hand, simulating a stale #128-era install +out8a="$(bash "$plugin_fixture/skills/sync/sync.sh" "$t8a" 2>&1)" +check_output "s8a: identical local scripts/ flagged stale + safe to delete" "$out8a" \ + "stale-vendor: .claude/scripts — matches the plugin's shipped copy byte-for-byte" +check_output "s8a: safe-to-delete wording present" "$out8a" "safe to delete" +check "s8a: local scripts/ left completely untouched (sync never deletes)" \ + test -f "$t8a/.claude/scripts/gate.sh" + +# 8b. Local .claude/scripts diverges from the plugin's own -> "conflict" (possible override). +t8b="$work/consumer8b" +mkdir -p "$t8b" +bash "$scaffold_sh" "$t8b" >/dev/null 2>&1 +cp -a "$plugin_fixture/scripts/." "$t8b/.claude/scripts/" +echo "# local override" >> "$t8b/.claude/scripts/gate.sh" +out8b="$(bash "$plugin_fixture/skills/sync/sync.sh" "$t8b" 2>&1)" +check_output "s8b: diverging local scripts/ flagged as a possible override" "$out8b" \ + "stale-vendor conflict: .claude/scripts — diverges from the plugin's shipped copy" +check "s8b: local override persists — untouched by sync" \ + grep -qF -- "# local override" "$t8b/.claude/scripts/gate.sh" + +# 8c. Legacy .orchestrator-vendor marker present -> extra legacy-marker + migration-caveat lines. +t8c="$work/consumer8c" +mkdir -p "$t8c" +bash "$scaffold_sh" "$t8c" >/dev/null 2>&1 +cp -a "$plugin_fixture/scripts/." "$t8c/.claude/scripts/" +printf '# @orchestrator-managed runtime-vendor v1\n' > "$t8c/.claude/.orchestrator-vendor" +out8c="$(bash "$plugin_fixture/skills/sync/sync.sh" "$t8c" 2>&1)" +check_output "s8c: legacy vendor marker called out" "$out8c" \ + "stale-vendor: .claude/.orchestrator-vendor — legacy vendor marker" +check_output "s8c: migration caveat (systemd restart) surfaced" "$out8c" "MIGRATION CAVEAT" +check_output "s8c: migration caveat mentions restarting the unit" "$out8c" "systemctl --user restart" + +# 8d. No local vendored dirs at all -> "none found", no false positives. +out8d="$(bash "$plugin_fixture/skills/sync/sync.sh" "$t1" 2>&1)" +check_output "s8d: no local vendor leftovers -> none found" "$out8d" "stale-vendor: none found" + +# --------------------------------------------------------------------------- +# Scenario 9: self-hosting — sync.sh running from INSIDE the target's own .claude +# (plugin_root == target_root/.claude) must never flag the plugin's own canonical +# agents/commands/hooks/scripts/skills as stale-vendor leftovers (issue #134 review +# finding: this used to warn on the plugin repo's own tree and tell the operator to +# reconcile/delete it, plus print the systemd migration caveat — both wrong in self-host +# mode). Arrange the fixture so sync.sh's own script_dir/../.. resolves back to the +# target root: copy the plugin fixture straight into $t9/.claude so +# $t9/.claude/skills/sync/sync.sh's plugin_root is $t9/.claude itself. +# --------------------------------------------------------------------------- +t9="$work/consumer9" +mkdir -p "$t9/.claude" +for entry in agents commands hooks scripts skills; do + cp -a "$plugin_fixture/$entry" "$t9/.claude/$entry" +done + +# 9a. Self-hosting, no legacy marker -> completely silent: no stale-vendor leftover/ +# conflict lines, no migration caveat, just "none found". +out9a="$(bash "$t9/.claude/skills/sync/sync.sh" "$t9" 2>&1)" +rc9a=$? +check "s9a: self-hosting sync exits 0" test "$rc9a" -eq 0 +check_output "s9a: self-hosting reports none found" "$out9a" "stale-vendor: none found" +check_no_output "s9a: no stale-vendor leftover warning for the plugin's own agents dir" "$out9a" "stale-vendor: .claude/agents" +check_no_output "s9a: no stale-vendor leftover warning for the plugin's own scripts dir" "$out9a" "stale-vendor: .claude/scripts" +check_no_output "s9a: no stale-vendor conflict warning" "$out9a" "stale-vendor conflict" +check_no_output "s9a: no 'verify it's not just a leftover' phrasing" "$out9a" "verify it's not just a leftover" +check_no_output "s9a: no migration caveat" "$out9a" "MIGRATION CAVEAT" +check_no_output "s9a: no reconcile-and-remove-marker instruction" "$out9a" "reconciled the flagged directories" + +# 9b. Self-hosting WITH a stray legacy marker present -> still completely silent (the +# self-hosting short-circuit takes priority over the stray-marker check from scenario 10). +printf '# @orchestrator-managed runtime-vendor v1\n' > "$t9/.claude/.orchestrator-vendor" +out9b="$(bash "$t9/.claude/skills/sync/sync.sh" "$t9" 2>&1)" +check_output "s9b: self-hosting + stray marker still reports none found" "$out9b" "stale-vendor: none found" +check_no_output "s9b: self-hosting + stray marker emits no legacy-marker warning" "$out9b" "legacy vendor marker" +check_no_output "s9b: self-hosting + stray marker emits no migration caveat" "$out9b" "MIGRATION CAVEAT" +rm -f "$t9/.claude/.orchestrator-vendor" + +# --------------------------------------------------------------------------- +# Scenario 10: stray legacy marker with NO stale vendor directories present (a consumer +# who deleted the vendored dirs by hand but left `.claude/.orchestrator-vendor` behind). +# Previously this got no warning at all since the legacy-marker check only ran when a +# stale directory was also found; it must now warn about the stray marker on its own, +# while never deleting it and never printing the full-leftover migration caveat (that +# caveat is reserved for when there's actually something to migrate away from). +# --------------------------------------------------------------------------- +t10="$work/consumer10" +mkdir -p "$t10" +bash "$scaffold_sh" "$t10" >/dev/null 2>&1 +printf '# @orchestrator-managed runtime-vendor v1\n' > "$t10/.claude/.orchestrator-vendor" +out10="$(bash "$sync_sh" "$t10" 2>&1)" +rc10=$? +check "s10: sync exits 0" test "$rc10" -eq 0 +check_output "s10: stray marker warns even with no stale dirs present" "$out10" \ + "legacy vendor marker from the old #128 model found, but no stale vendor directories are present" +check_output "s10: still reports none found for the directories themselves" "$out10" "stale-vendor: none found" +check_no_output "s10: no migration caveat for a bare stray marker (nothing to migrate)" "$out10" "MIGRATION CAVEAT" +check "s10: marker file left untouched — sync never deletes it" test -f "$t10/.claude/.orchestrator-vendor" + +echo +if [ "$fail" -ne 0 ]; then + echo "sync-managed-files.test.sh: FAILED" + exit 1 +fi +echo "sync-managed-files.test.sh: all $ok checks passed" diff --git a/.claude/scripts/vendor-runtime.test.sh b/.claude/scripts/vendor-runtime.test.sh deleted file mode 100755 index d2f01ae..0000000 --- a/.claude/scripts/vendor-runtime.test.sh +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env bash -# vendor-runtime.test.sh — offline smoke test for the runtime harness vendor -# (issue #128): scaffold.sh vendoring agents/commands/hooks/scripts/skills -# wholesale into a consumer .claude/, gated by the single top-level marker -# .claude/.orchestrator-vendor, and sync.sh's behind/up-to-date/conflict -# ladder for that same tree. Asserts: -# 1. fresh scaffold -> vendor tree created, marker stamped, arm-loop.sh -# NOT duplicated by the tree copy (still only the managed-file copy), -# settings.json created with no enabledPlugins/extraKnownMarketplaces. -# 2. re-running scaffold -> idempotent ("up to date" / "kept", no errors). -# 3. sync against an unchanged plugin root -> "up to date". -# 4. scaffold.sh restamp (v1 -> v2) with a REAL content change inside a -# vendored subdirectory (skills/setup/) -> proves the copy is idempotent -# AND refreshing: no duplicate nests (.claude/skills/setup/setup, -# .claude/skills/sync/sync — the reported bug) and the subdir file's -# content actually lands at the new version instead of staying stale. -# arm-loop.sh's own managed copy must also survive untouched. -# 5. sync.sh restamp (v1 -> v2), marker-only bump with no other content -# change -> restamped cleanly (no false conflict) AND no nesting. -# 6. sync against a plugin root whose marker was bumped WITH a real -# content change -> conflict (installed has no local edits, but the -# script can't tell a legitimate upstream change from a hand-edit — -# same conservative behavior as every other managed row); the -# installed file must NOT have picked up the new upstream bytes. -# 7. sync with a genuine local edit to a vendored file (same version) -> -# conflict, and the local edit marker is still readable back out of -# the file afterward (proves it was genuinely left untouched, not -# just that the log line said so). -# 8. sync with a local edit ONLY to arm-loop.sh -> the runtime-vendor row -# stays "up to date" (excluded from its diff), while arm-loop.sh's OWN -# managed row reports the conflict — proves the two mechanisms don't -# double-manage the same file; the hand edit is still present in -# arm-loop.sh afterward. -# 9. never-downgrade: a target marker newer than the plugin's shipped -# version (v99) is left byte-for-byte untouched by scaffold.sh. -# 10. never-downgrade: same, for sync.sh. -# -# Exit 0 on success, non-zero if any assertion fails. Runnable bare: -# bash .claude/scripts/vendor-runtime.test.sh -set -uo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -scaffold_sh="$repo_root/.claude/skills/setup/scaffold.sh" -sync_sh="$repo_root/.claude/skills/sync/sync.sh" - -work="$(mktemp -d "${TMPDIR:-/tmp}/vendor-runtime-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 -} -check_output() { - # $1 = desc, $2 = haystack, $3 = needle (grep -F) - local desc="$1" haystack="$2" needle="$3" - if printf '%s' "$haystack" | grep -qF -- "$needle"; then - ok=$((ok + 1)) - echo "ok - $desc" - else - fail=1 - echo "FAIL - $desc (expected to find: $needle)" - fi -} -trees_identical() { - # $1, $2 = two directory trees. True iff a recursive diff finds no differences at all - # (used by the never-downgrade scenarios to prove a "kept, newer" verdict really left - # the whole vendored tree byte-for-byte alone, not just logged the right line). - diff -rq "$1" "$2" >/dev/null 2>&1 -} -build_plugin_fixture() { - # $1 = destination dir for a trimmed "plugin root" fixture. Copies only the entries - # scaffold.sh/sync.sh's vendor step actually reads from a plugin root — agents/, - # commands/, hooks/, scripts/, skills/, .orchestrator-vendor — instead of the whole live - # .claude/ tree, which would otherwise drag in .claude/worktrees/ (large), - # .claude/state/, and settings.local.json into every fixture. - local dest="$1" entry - mkdir -p "$dest" - for entry in agents commands hooks scripts skills .orchestrator-vendor; do - cp -a "$repo_root/.claude/$entry" "$dest/$entry" - done -} - -# --------------------------------------------------------------------------- -# Scenario 1: fresh scaffold. -# --------------------------------------------------------------------------- -t1="$work/consumer1" -mkdir -p "$t1" -out1="$(bash "$scaffold_sh" "$t1" 2>&1)" -scaffold1_rc=$? -check "s1: scaffold exits 0" test "$scaffold1_rc" -eq 0 -check "s1: marker file created" test -f "$t1/.claude/.orchestrator-vendor" -check "s1: agents/ vendored" test -f "$t1/.claude/agents/orchestrator.md" -check "s1: commands/ vendored" test -d "$t1/.claude/commands" -check "s1: hooks/ vendored" test -f "$t1/.claude/hooks/hooks.json" -check "s1: skills/ vendored (includes scaffold.sh itself)" test -f "$t1/.claude/skills/setup/scaffold.sh" -check "s1: scripts/ vendored (e.g. gate.sh)" test -f "$t1/.claude/scripts/gate.sh" -check "s1: gate.sh executable bit preserved" test -x "$t1/.claude/scripts/gate.sh" -check "s1: arm-loop.sh present via its OWN managed row" test -f "$t1/.claude/scripts/arm-loop.sh" -check "s1: settings.json created" test -f "$t1/.claude/settings.json" -check "s1: settings.json has no enabledPlugins key" \ - node -e "process.exit('enabledPlugins' in require(process.argv[1]) ? 1 : 0)" "$t1/.claude/settings.json" -check "s1: settings.json has no extraKnownMarketplaces key" \ - node -e "process.exit('extraKnownMarketplaces' in require(process.argv[1]) ? 1 : 0)" "$t1/.claude/settings.json" -check_output "s1: vendor row reported created/up-to-date" "$out1" "runtime harness vendor" - -# --------------------------------------------------------------------------- -# Scenario 2: re-running scaffold is idempotent. -# --------------------------------------------------------------------------- -out2="$(bash "$scaffold_sh" "$t1" 2>&1)" -check_output "s2: re-run reports vendor up to date" "$out2" "up to date: runtime harness vendor" -check_output "s2: re-run keeps settings.json (user-owned)" "$out2" "kept: consumer runtime settings" -check "s2: still no nested skills/setup/setup after idempotent re-run" test ! -e "$t1/.claude/skills/setup/setup" -check "s2: still no nested skills/sync/sync after idempotent re-run" test ! -e "$t1/.claude/skills/sync/sync" - -# --------------------------------------------------------------------------- -# Scenario 3: sync against an unchanged plugin root -> up to date. -# --------------------------------------------------------------------------- -out3="$(bash "$sync_sh" "$t1" 2>&1)" -rc3=$? -check "s3: sync exits 0" test "$rc3" -eq 0 -check_output "s3: vendor row up to date" "$out3" "up to date: .claude/{agents,commands,hooks,scripts,skills}" - -# --------------------------------------------------------------------------- -# Scenario 4: scaffold.sh restamp (v1 -> v2) with a REAL content change inside -# a vendored subdirectory -> no nesting, content genuinely refreshed. This is -# the direct regression test for the reported bug: the old `cp -a "$entry" -# "$dst/$base"` form nests the source dir INTO an already-existing destination -# (producing .claude/skills/setup/setup) instead of refreshing it in place, so -# the top-level file never actually picks up the new content. -# --------------------------------------------------------------------------- -t4="$work/consumer4" -mkdir -p "$t4" -bash "$scaffold_sh" "$t4" >/dev/null 2>&1 - -plugin_v2content="$work/plugin-v2-content" -build_plugin_fixture "$plugin_v2content" -sed -i 's/@orchestrator-managed runtime-vendor v1/@orchestrator-managed runtime-vendor v2/' \ - "$plugin_v2content/.orchestrator-vendor" -printf '\n\n' >> "$plugin_v2content/skills/setup/SKILL.md" - -out4="$(bash "$plugin_v2content/skills/setup/scaffold.sh" "$t4" 2>&1)" -check_output "s4: restamp reported v1 -> v2" "$out4" "restamped: runtime harness vendor" -check "s4: no nested .claude/skills/setup/setup (nesting bug)" test ! -e "$t4/.claude/skills/setup/setup" -check "s4: no nested .claude/skills/sync/sync (nesting bug)" test ! -e "$t4/.claude/skills/sync/sync" -check "s4: skills/setup/SKILL.md content actually refreshed to v2 (not stale)" \ - grep -q -- "v2 skill content" "$t4/.claude/skills/setup/SKILL.md" -check "s4: arm-loop.sh not vendored/overwritten by the tree copy" \ - grep -q -- "@orchestrator-managed arm-loop v" "$t4/.claude/scripts/arm-loop.sh" - -# --------------------------------------------------------------------------- -# Scenario 5: sync.sh restamp, marker-only bump with NO other content change -# -> restamps cleanly (no false conflict) AND without nesting. -# --------------------------------------------------------------------------- -plugin_v2="$work/plugin-v2" -build_plugin_fixture "$plugin_v2" -sed -i 's/@orchestrator-managed runtime-vendor v1/@orchestrator-managed runtime-vendor v2/' \ - "$plugin_v2/.orchestrator-vendor" -t5="$work/consumer5" -mkdir -p "$t5" -bash "$scaffold_sh" "$t5" >/dev/null 2>&1 -out5="$(bash "$plugin_v2/skills/sync/sync.sh" "$t5" 2>&1)" -check_output "s5: marker-only bump restamps cleanly" "$out5" "restamped: .claude/{agents,commands,hooks,scripts,skills} v1 -> v2" -check "s5: no nested .claude/skills/setup/setup after sync restamp" test ! -e "$t5/.claude/skills/setup/setup" -check "s5: no nested .claude/skills/sync/sync after sync restamp" test ! -e "$t5/.claude/skills/sync/sync" - -# --------------------------------------------------------------------------- -# Scenario 6: plugin marker bumped WITH a real content change -> conflict -# (can't distinguish a legitimate upstream change from a local hand-edit). -# The installed file must NOT have picked up the new upstream bytes. -# --------------------------------------------------------------------------- -plugin_v3="$work/plugin-v3" -build_plugin_fixture "$plugin_v3" -sed -i 's/@orchestrator-managed runtime-vendor v1/@orchestrator-managed runtime-vendor v2/' \ - "$plugin_v3/.orchestrator-vendor" -echo "" >> "$plugin_v3/agents/orchestrator.md" -t6="$work/consumer6" -mkdir -p "$t6" -bash "$scaffold_sh" "$t6" >/dev/null 2>&1 -out6="$(bash "$plugin_v3/skills/sync/sync.sh" "$t6" 2>&1)" -check_output "s6: content-changing bump flags conflict, not a silent restamp" "$out6" \ - "conflict: .claude/{agents,commands,hooks,scripts,skills} is v1 (behind v2) AND has local edits" -check "s6: installed file did not silently pick up the new upstream bytes" \ - bash -c '! grep -qF -- "" "$1"' _ "$t6/.claude/agents/orchestrator.md" - -# --------------------------------------------------------------------------- -# Scenario 7: genuine local edit, same version -> conflict, and the edit is -# still readable back out of the file afterward (proves the tree was -# genuinely left untouched, not just that the log line said so). -# --------------------------------------------------------------------------- -t7="$work/consumer7" -mkdir -p "$t7" -bash "$scaffold_sh" "$t7" >/dev/null 2>&1 -echo "" >> "$t7/.claude/agents/orchestrator.md" -out7="$(bash "$sync_sh" "$t7" 2>&1)" -check_output "s7: local edit at same version flags conflict" "$out7" \ - "conflict: .claude/{agents,commands,hooks,scripts,skills} is marked v1 but content diverges" -check "s7: local marker persists — file genuinely untouched by sync" \ - grep -qF -- "" "$t7/.claude/agents/orchestrator.md" - -# --------------------------------------------------------------------------- -# Scenario 8: local edit ONLY to arm-loop.sh doesn't leak into the vendor row, -# and the hand edit is still present afterward (neither mechanism touched it). -# --------------------------------------------------------------------------- -t8="$work/consumer8" -mkdir -p "$t8" -bash "$scaffold_sh" "$t8" >/dev/null 2>&1 -echo "# hand edit" >> "$t8/.claude/scripts/arm-loop.sh" -out8="$(bash "$sync_sh" "$t8" 2>&1)" -check_output "s8: arm-loop.sh's OWN managed row flags the conflict" "$out8" \ - "conflict: .claude/scripts/arm-loop.sh is marked" -check_output "s8: vendor row unaffected by the arm-loop.sh edit" "$out8" \ - "up to date: .claude/{agents,commands,hooks,scripts,skills}" -check "s8: hand edit persists in arm-loop.sh — untouched by either mechanism" \ - grep -qF -- "# hand edit" "$t8/.claude/scripts/arm-loop.sh" - -# --------------------------------------------------------------------------- -# Scenario 9: never-downgrade — scaffold.sh must not touch a target whose -# vendor marker is newer (v99) than what this installer ships. -# --------------------------------------------------------------------------- -t9="$work/consumer9" -mkdir -p "$t9" -bash "$scaffold_sh" "$t9" >/dev/null 2>&1 -sed -i 's/@orchestrator-managed runtime-vendor v1/@orchestrator-managed runtime-vendor v99/' \ - "$t9/.claude/.orchestrator-vendor" -snapshot9="$work/snapshot9" -cp -a "$t9/.claude" "$snapshot9" -out9="$(bash "$scaffold_sh" "$t9" 2>&1)" -check_output "s9: scaffold keeps a newer-than-shipped marker" "$out9" \ - "is v99, newer than this installer's v1" -check "s9: vendored tree byte-identical after run (no downgrade)" \ - trees_identical "$snapshot9" "$t9/.claude" - -# --------------------------------------------------------------------------- -# Scenario 10: never-downgrade — sync.sh must not touch a target whose vendor -# marker is newer (v99) than what this plugin ships. -# --------------------------------------------------------------------------- -t10="$work/consumer10" -mkdir -p "$t10" -bash "$scaffold_sh" "$t10" >/dev/null 2>&1 -sed -i 's/@orchestrator-managed runtime-vendor v1/@orchestrator-managed runtime-vendor v99/' \ - "$t10/.claude/.orchestrator-vendor" -snapshot10="$work/snapshot10" -cp -a "$t10/.claude" "$snapshot10" -out10="$(bash "$sync_sh" "$t10" 2>&1)" -check_output "s10: sync keeps a newer-than-shipped marker" "$out10" \ - "is v99, newer than this plugin's v1" -check "s10: vendored tree byte-identical after run (no downgrade)" \ - trees_identical "$snapshot10" "$t10/.claude" - -echo -if [ "$fail" -ne 0 ]; then - echo "vendor-runtime.test.sh: FAILED" - exit 1 -fi -echo "vendor-runtime.test.sh: all $ok checks passed" diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index ffb7561..c6b6eb6 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -13,13 +13,14 @@ version-controlled inside the consumer's own repo: the project-specific adapter, workflow file, and GitHub Actions YAML. This skill's job is to scaffold exactly that non-distributable residue, on top of the interview below. -**Also (issue #128): this skill vendors the plugin's own runtime harness** — `agents/`, `commands/`, `hooks/`, -`scripts/`, `skills/` — wholesale into the consumer's local `.claude/`, plus a `.claude/settings.json` that -wires the runtime hooks locally. A plugin loads (and pays its load cost) on every session start while it's -enabled, no matter how its hooks are wired, so the only way to remove that cost from the runtime path is to -stop depending on the plugin being loaded at all once setup is done. **The `orchestrator` plugin only needs to -stay enabled to RUN `/orchestrator:setup`/`/orchestrator:sync`** (the install/update channel) — not for -everyday sessions. See step 4 below. +**This skill does NOT vendor the plugin's runtime harness into the consumer repo** (issue #134 reverted issue +#128's vendoring model): `agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` are read straight from the +plugin cache (`${CLAUDE_PLUGIN_ROOT}`), never copied into the consumer's local `.claude/`. **The `orchestrator` +plugin must stay enabled for everyday sessions**, not just to run `/orchestrator:setup`/`/orchestrator:sync` — +its own `hooks/hooks.json` (and, if wired, this repo's own `.claude/settings.json` `hooks` block, see step 4) +only resolve while it's loaded. This also creates a `.claude/settings.json` from a template (step 4) that +wires the same hooks locally via the same `${CLAUDE_PLUGIN_ROOT}` resolution, with a local-`.claude/` fallback +for the self-hosting case. Be conversational but efficient. Use the `AskUserQuestion` tool for discrete choices; ask for free-text (names, paths, shell commands) in plain prose. **Never invent values** — if you don't know a command or path, @@ -100,29 +101,21 @@ idempotently: the same way as `feature-fanout.js` (own `@orchestrator-managed vN` marker, re-stamped on upgrade). These carry `__WORKDIR__`/`__REPO_SLUG__`/etc. placeholders that `arm-loop.sh` substitutes at ARM time, not at scaffold time — scaffolding them here does NOT install or start anything. See step 9 below for arming. -- **`.claude/{agents,commands,hooks,scripts,skills}/`** (issue #128) — the runtime harness itself, vendored - wholesale from the plugin root, managed as ONE unit via a single top-level marker file - (`.claude/.orchestrator-vendor`, `@orchestrator-managed runtime-vendor vN`), restamped/re-vendored on the - same behind/never-downgrade ladder as every other managed row. **`.claude/scripts/arm-loop.sh` is excluded** - from this copy — it's already managed by its own row above with a different canonical source, so - double-vendoring it would create two disagreeing sources of truth for the same file. Once this tree is - vendored, the plugin's own `agents/commands/hooks/scripts/skills` are no longer on the runtime critical - path — a session reads the local `.claude/` copies instead, whether or not the plugin is enabled. - **`.claude/settings.json`** — **user-owned, created only if absent.** Wires the runtime hooks (`PostToolUse` lint + log-worker-tool, `Stop` test_affected, `PreToolUse` guard-git-add) to - `$CLAUDE_PROJECT_DIR/.claude/scripts/...`, plus baseline `permissions`/`sandbox`. Deliberately carries no - `enabledPlugins`/`extraKnownMarketplaces` — keep those only in a settings.json you maintain yourself while - installing/updating the plugin (e.g. the block from Step 1 of `docs/GETTING_STARTED.md`), not in the - runtime file, which must keep working with the plugin disabled. **Single-owner rule (issue #129):** the - plugin's `hooks/hooks.json` and this file's `hooks` block fire the SAME hooks — while both are active - (plugin enabled AND this file wired), every gate runs TWICE per turn (the expensive `Stop` `test_affected` - is the costly one). Only one may own the hooks at a time; this file is the intended steady-state owner, - the plugin's copy is only needed transiently to run `/orchestrator:setup`/`/orchestrator:sync`. **If you - already have a `settings.json`** (likely, since you needed `enabledPlugins` to install the plugin in the - first place), scaffold.sh reports it "kept" and leaves it completely untouched — merge the four hooks above - and the `permissions`/`sandbox` blocks from `.claude/skills/setup/templates/settings.json` into your - existing file by hand. Either way, **drop `enabledPlugins`/`extraKnownMarketplaces` for `orchestrator@recode` - as part of finishing this setup run** (see step 11) — don't leave both registrations active "for later." + `${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/...` (issue #134 — this plugin no longer vendors + `agents/commands/hooks/scripts/skills` locally, so the fallback resolves to the plugin cache whenever the + plugin is enabled, and only degrades to the local `.claude/` copy for the self-hosting case), plus baseline + `permissions`/`sandbox`. Deliberately carries no `enabledPlugins`/`extraKnownMarketplaces` — keep those only + in a settings.json you maintain yourself while installing/updating the plugin (e.g. the block from Step 1 of + `docs/GETTING_STARTED.md`). **Double-hook caveat (issue #129):** the plugin's `hooks/hooks.json` and this + file's `hooks` block fire the SAME hooks — since the plugin must now stay enabled for this file's hooks to + resolve at all, both fire on every turn (double gate execution; harmless but wasteful, notably the `Stop` + `test_affected` check). Tell the user they can drop this file's `hooks` block and rely solely on the + plugin's `hooks/hooks.json` if that matters to them. **If you already have a `settings.json`** (likely, + since you needed `enabledPlugins` to install the plugin in the first place), scaffold.sh reports it "kept" + and leaves it completely untouched — merge the four hooks above and the `permissions`/`sandbox` blocks from + `.claude/skills/setup/templates/settings.json` into your existing file by hand. - `.github/workflows/gates.yml` + `.github/actions/setup/action.yml` — the CI gate. Created if absent, left untouched if present. - `.gitignore` entries (append-if-missing, never duplicated): `.env`, `.env.*`, `!.env.example`, @@ -133,11 +126,10 @@ Report the script's per-file summary (created / kept / restamped / up to date / write the interview answers into `.claude/gates.json` (validate with `node -e "require('./.claude/gates.json')"`) and fill `CLAUDE.md` from its template sections (What this project is / Stack & layout mirroring the module map / Conventions / Merge policy mirroring `gates.json` / Don'ts) — propose both files and get an explicit "yes" -before writing. Beyond that, **you (the interview) should not hand-edit `.claude/settings.json` or any vendored -agent/script/hook** — `scaffold.sh` already handled those mechanically (settings.json created-if-absent, -the runtime harness vendored); only the adapter and `CLAUDE.md` need YOUR project-specific answers written in. -If scaffold.sh reported settings.json "kept" because one already existed, tell the user to merge the runtime -hooks in by hand (see step 4 above) — don't do it for them silently. +before writing. Beyond that, **you (the interview) should not hand-edit `.claude/settings.json`** — +`scaffold.sh` already handled it mechanically (created-if-absent); only the adapter and `CLAUDE.md` need YOUR +project-specific answers written in. If scaffold.sh reported settings.json "kept" because one already existed, +tell the user to merge the runtime hooks in by hand (see above) — don't do it for them silently. ## 5. Gitignore verification `scaffold.sh` already appended the required entries in step 4. Spot-check with `git check-ignore ` for @@ -254,11 +246,11 @@ gitignore entries, labels created, bot status, CI status, loop armed?, hardened? points in one line each: **label an issue `module:*` to queue it; approve the bot's PR to ship it.** Finish with an ordered checklist of everything only the human can complete, e.g.: - add `GH_BOT_TOKEN` to `.env` / add the bot as a write collaborator (if step 7 flagged it), -- **disable the `orchestrator` plugin for everyday sessions now that `.claude/settings.json` owns the hooks - locally** — drop (or comment out) `enabledPlugins`/`extraKnownMarketplaces` for `orchestrator@recode` - wherever you set them (step 1's block). Until you do, the plugin's `hooks/hooks.json` and your local - `.claude/settings.json` both fire on every turn — double gate execution (issue #129). Re-enable only when - you next need to run `/orchestrator:setup` or `/orchestrator:sync`. +- **keep the `orchestrator` plugin enabled** (issue #134 — this repo no longer vendors the runtime harness + locally, so agents/commands/hooks/scripts/skills only resolve while the plugin is loaded). If you'd rather + avoid the double-hook-execution caveat above (issue #129), drop the `hooks` block from `.claude/settings.json` + instead of disabling the plugin — disabling the plugin breaks everyday sessions now, not just + `/orchestrator:setup`/`/orchestrator:sync`. - set branch protection / required status checks (if wanted), - OS-level isolation from `docs/HARDENING.md` Step 2 (sudo / VM / WSL interop) if hardening, - if the daemon path was chosen: run `bash .claude/scripts/arm-loop.sh` in a real terminal outside Claude diff --git a/.claude/skills/setup/scaffold.sh b/.claude/skills/setup/scaffold.sh index 7dd8b6f..87f0808 100755 --- a/.claude/skills/setup/scaffold.sh +++ b/.claude/skills/setup/scaffold.sh @@ -110,106 +110,18 @@ for entry in "${MANAGED_FILES[@]}"; do fi done -# --- 2b. runtime harness vendor: keep the consumer session-start critical path OFF the -# plugin (issue #128) ------------------------------------------------------- -# A Claude Code plugin loads (and pays its load cost, ~18s for this one) on EVERY session -# start if it's enabled, regardless of how its hooks are wired — so the only way to remove -# that cost from the consumer's runtime path is to stop depending on the plugin being loaded -# at all once setup is done. This copies the plugin's own runtime subtrees — `agents/`, -# `commands/`, `hooks/`, `scripts/`, `skills/` — wholesale from the plugin root into the -# consumer's local `.claude/`, so a session reads them straight off disk with the plugin -# disabled. The plugin is then only needed to RUN `/orchestrator:setup`/`/orchestrator:sync` -# (the install/update channel), not for everyday sessions. -# -# Managed as ONE unit (not one marker per file, since the whole tree moves together): a -# single top-level version stamp file, `.claude/.orchestrator-vendor`, gates the copy using -# the exact same managed_version_of marker-ladder as the MANAGED_FILES loop above. -# -# EXCEPTION: `.claude/scripts/arm-loop.sh` is skipped by this copy — it already has its own -# dedicated row in MANAGED_FILES above (canonical shipped copy: templates/arm-loop.sh, which -# gets `__WORKDIR__`-style placeholders substituted at ARM time and can legitimately differ -# from THIS plugin repo's own live self-hosting copy of arm-loop.sh). Vendoring it a second -# time here would create two divergent sources of truth for the same destination path. -VENDOR_DIRS=(agents commands hooks scripts skills) -VENDOR_MARKER_PREFIX="@orchestrator-managed runtime-vendor v" -plugin_root="$(cd "$script_dir/../.." && pwd)" -vendor_marker_src="$plugin_root/.orchestrator-vendor" -vendor_marker_dst="$target_root/.claude/.orchestrator-vendor" -vendor_label="runtime harness vendor (.claude/{agents,commands,hooks,scripts,skills})" - -copy_vendor_dirs() { - # Prune-then-copy of each VENDOR_DIRS subtree's CONTENTS (not the subtree itself) from - # the plugin root into target_root/.claude, preserving executable bits (cp -a). This must - # be idempotent AND pruning: a plain `cp -a src dst` when dst already EXISTS nests the - # source dir inside it (src becomes dst/src) instead of refreshing it in place — that's - # exactly the bug this replaced (a restamp/upgrade run used to leave duplicate nests like - # .claude/skills/setup/setup and never actually update changed files). Removing the - # destination subtree first and then copying the source's CONTENTS (`src/.` -> `dst/`) - # both fixes the nesting and prunes files removed upstream, so a later `diff -rq` never - # trips on stale leftovers. - # - # EXCEPTION: `.claude/scripts/arm-loop.sh` must survive this — it already has its own - # dedicated MANAGED_FILES row (canonical template: templates/arm-loop.sh) and must NOT be - # vendored/overwritten from this plugin repo's own live copy. Back it up before pruning - # `scripts/`, then restore it (or remove whatever the plugin copy dropped in its place if - # there was nothing to restore) after the copy. - local d arm_backup="" - for d in "${VENDOR_DIRS[@]}"; do - [ -d "$plugin_root/$d" ] || continue - if [ "$d" = "scripts" ] && [ -f "$target_root/.claude/scripts/arm-loop.sh" ]; then - arm_backup="$(mktemp "${TMPDIR:-/tmp}/arm-loop.sh.XXXXXX")" - cp -a "$target_root/.claude/scripts/arm-loop.sh" "$arm_backup" - fi - rm -rf "$target_root/.claude/$d" - mkdir -p "$target_root/.claude/$d" - cp -a "$plugin_root/$d/." "$target_root/.claude/$d/" - if [ "$d" = "scripts" ]; then - if [ -n "$arm_backup" ]; then - cp -a "$arm_backup" "$target_root/.claude/scripts/arm-loop.sh" - rm -f "$arm_backup" - arm_backup="" - else - rm -f "$target_root/.claude/scripts/arm-loop.sh" - fi - fi - done - cp "$vendor_marker_src" "$vendor_marker_dst" -} - -vendor_shipped_version="$(managed_version_of "$vendor_marker_src" "$VENDOR_MARKER_PREFIX")" -if [ -z "$vendor_shipped_version" ]; then - echo " error: $vendor_label — shipped marker $vendor_marker_src has no valid @orchestrator-managed marker; plugin install looks broken" >&2 -elif [ "$plugin_root" = "$target_root/.claude" ]; then - # Running the already-vendored copy of scaffold.sh directly (CLAUDE_PLUGIN_ROOT unset, so - # the ${CLAUDE_PLUGIN_ROOT:-.claude} fallback resolved to this repo's own .claude) — there - # is no distinct plugin root to vendor FROM, so there's nothing safe to do here. - echo " kept: $vendor_label — running from an already-vendored copy, no distinct plugin root to vendor from; enable the plugin and re-run to pull updates" -else - vendor_existing_version="$(managed_version_of "$vendor_marker_dst" "$VENDOR_MARKER_PREFIX")" - if [ ! -f "$vendor_marker_dst" ]; then - copy_vendor_dirs - echo " created: $vendor_label at v$vendor_shipped_version" - elif [ -z "$vendor_existing_version" ]; then - copy_vendor_dirs - echo " restamped: $vendor_label — no marker found, now v$vendor_shipped_version" - elif [ "$vendor_existing_version" -lt "$vendor_shipped_version" ]; then - copy_vendor_dirs - echo " restamped: $vendor_label v$vendor_existing_version -> v$vendor_shipped_version" - elif [ "$vendor_existing_version" -eq "$vendor_shipped_version" ]; then - echo " up to date: $vendor_label already v$vendor_shipped_version" - else - echo " kept: $vendor_label is v$vendor_existing_version, newer than this installer's v$vendor_shipped_version — left untouched" - fi -fi - -# --- 2c. consumer settings.json: user-owned from birth, create only if absent ------- +# --- 2b. consumer settings.json: user-owned from birth, create only if absent ------- # Unlike gates.json/CLAUDE.md (also user-owned), this template must NEVER be silently # considered "just another user-owned file" without comment: it carries the runtime hook -# wiring (PostToolUse lint + log-worker-tool, Stop test_affected, PreToolUse guard-git-add) -# that used to depend on the plugin's hooks/hooks.json (which only fires while the plugin is -# loaded). If the consumer already has a settings.json (likely — it may carry their own -# enabledPlugins/extraKnownMarketplaces while installing/updating), scaffold.sh leaves it -# completely untouched; SKILL.md instructs merging the runtime hooks in by hand in that case. +# wiring (PostToolUse lint + log-worker-tool, Stop test_affected, PreToolUse guard-git-add), +# pointed at `${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/...` (issue #134 — +# this repo no longer vendors `agents/commands/hooks/scripts/skills` into consumer repos, so +# the plugin must stay ENABLED for both its own `hooks/hooks.json` and this file's hooks to +# resolve; the `${CLAUDE_PLUGIN_ROOT:-...}` fallback only degrades to the local `.claude/` +# copy for the self-hosting case, where there is no distinct plugin install). If the consumer +# already has a settings.json (likely — it may carry their own enabledPlugins/ +# extraKnownMarketplaces while installing/updating), scaffold.sh leaves it completely +# untouched; SKILL.md instructs merging the runtime hooks in by hand in that case. copy_if_absent "$templates_dir/settings.json" "$target_root/.claude/settings.json" "consumer runtime settings (.claude/settings.json)" # --- 3. CI templates: create only if absent ---------------------------------------- diff --git a/.claude/skills/setup/templates/CLAUDE.md b/.claude/skills/setup/templates/CLAUDE.md index 5c4a854..3162133 100644 --- a/.claude/skills/setup/templates/CLAUDE.md +++ b/.claude/skills/setup/templates/CLAUDE.md @@ -21,10 +21,12 @@ This repo is set up for orchestrated multi-agent development. See `docs/USAGE.md`. - **Agents:** `.claude/agents/` — orchestrator, implementer (worktree-isolated), reviewer, test-runner. - **Adapter:** `.claude/gates.json` — module map, gate commands, model routing. **This is the file to keep current.** -- **Gates run via** `.claude/scripts/gate.sh ` and the hooks in `.claude/settings.json`. Keep the - `orchestrator` plugin **disabled** outside of running `/orchestrator:setup`/`/orchestrator:sync` — its own - `hooks/hooks.json` registers the same hooks, and if both the plugin and this file are active at once every - gate (notably the `Stop` `test_affected` check) runs twice per turn. +- **Gates run via** `gate.sh ` and the hooks in `.claude/settings.json`. Keep the `orchestrator` plugin + **enabled** for everyday sessions — this repo does not vendor a local copy of `agents/commands/hooks/scripts/skills` + (issue #134), so both the generic agents and this file's hook commands resolve through + `${CLAUDE_PLUGIN_ROOT}`. Note the plugin's own `hooks/hooks.json` registers the same hooks as this file's + `hooks` block — with both active every gate (notably the `Stop` `test_affected` check) runs twice per turn; + drop this file's `hooks` block if you'd rather rely solely on the plugin's. - **Workflow:** `.claude/workflows/feature-fanout.js` for deterministic fan-out. ### Module boundaries (hard rule) diff --git a/.claude/skills/setup/templates/MANIFEST.md b/.claude/skills/setup/templates/MANIFEST.md index 9aa5b1a..5834111 100644 --- a/.claude/skills/setup/templates/MANIFEST.md +++ b/.claude/skills/setup/templates/MANIFEST.md @@ -15,9 +15,7 @@ materializes them into the consumer repo on first run. | `arm-loop.sh` | `.claude/scripts/arm-loop.sh` | managed | (issue #102) installs both systemd units above (with placeholders substituted for THIS checkout) + `loginctl enable-linger` + starts the remote-control tmux session. MUST be run in a real terminal outside Claude Code (sandbox caveat — see `docs/HARDENING.md`). Marker `@orchestrator-managed arm-loop vN`; copied with the executable bit preserved. | | `gates.yml` | `.github/workflows/gates.yml` | ci | created only if absent; never overwritten | | `action.yml` | `.github/actions/setup/action.yml` | ci | created only if absent; never overwritten | -| *(live plugin tree, not a template file)* `agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` | `.claude/agents/`, `.claude/commands/`, `.claude/hooks/`, `.claude/scripts/`, `.claude/skills/` | managed (whole-tree, issue #128) | Vendored wholesale from the plugin ROOT (not from `templates/` — this is the only vendored row that copies the plugin's own live directories) so consumer sessions read the runtime harness off local disk without the plugin loaded. Gated as ONE unit by the top-level marker `.claude/.orchestrator-vendor` (`@orchestrator-managed runtime-vendor vN`), re-stamped/re-vendored on the same behind/never-downgrade ladder as every other managed row. **Exception:** `.claude/scripts/arm-loop.sh` is skipped by this copy — it is already its own row below with a different canonical source (`templates/arm-loop.sh`), and double-managing the same destination path from two pristine sources would make the two mechanisms disagree about what "up to date" means for that one file. | -| `.orchestrator-vendor` | `.claude/.orchestrator-vendor` | managed (marker only) | The single version-stamp file that governs the whole-tree row above. Copied last, after the tree copy succeeds, so a failure mid-copy never leaves a stamped-but-partial vendor tree. | -| `settings.json` | `.claude/settings.json` | user | created only if absent; never overwritten. Wires the runtime hooks (`PostToolUse` lint + log-worker-tool, `Stop` test_affected, `PreToolUse` guard-git-add) to `$CLAUDE_PROJECT_DIR/.claude/scripts/...`, plus baseline `permissions`/`sandbox`. Deliberately carries **no** `enabledPlugins`/`extraKnownMarketplaces` — those belong only in a settings.json the user maintains themselves while installing/updating the plugin. If a settings.json already exists, setup leaves it untouched and the setup SKILL instructs merging the runtime hooks in by hand. | +| `settings.json` | `.claude/settings.json` | user | created only if absent; never overwritten. Wires the runtime hooks (`PostToolUse` lint + log-worker-tool, `Stop` test_affected, `PreToolUse` guard-git-add) to `${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/...`, plus baseline `permissions`/`sandbox`. Deliberately carries **no** `enabledPlugins`/`extraKnownMarketplaces` — those belong only in a settings.json the user maintains themselves while installing/updating the plugin. If a settings.json already exists, setup leaves it untouched and the setup SKILL instructs merging the runtime hooks in by hand. | The three `managed` rows added by issue #102 (`pr-loop.service`, `claude-rc.service`, `arm-loop.sh`) follow exactly the same ownership class and marker convention as `feature-fanout.js` — `scaffold.sh` @@ -26,12 +24,34 @@ behind, `sync.sh` re-stamps them going forward, and neither ever touches a copy locally diverged from the last pristine version it was stamped from (that's a `conflict`, left for a human — see `.claude/skills/sync/SKILL.md`). -The runtime-vendor row added by issue #128 reuses this exact same marker ladder, just applied to a -directory-tree copy instead of a single `cp` — see `.claude/scripts/arm-loop.sh` above for why it's -carved out as an exception rather than folded into the tree copy. Once vendored, the `orchestrator` -plugin only needs to stay **enabled** to run `/orchestrator:setup`/`/orchestrator:sync` (the -install/update channel) — not for everyday sessions, which is what removes the plugin's session-start -load cost from time-sensitive paths like headless `claude remote-control` spawns. +## Stopped vendoring the runtime harness (issue #134) +Issue #128 used to vendor the plugin's own `agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` +subtrees wholesale into a consumer's `.claude/`, gated by a single top-level marker +`.claude/.orchestrator-vendor`, so a session could read them off local disk with the plugin disabled. +That model was reverted by issue #134: consumer repos no longer carry local copies of these +directories at all. `agents/commands/hooks/scripts/skills` are read straight from the plugin cache +(`${CLAUDE_PLUGIN_ROOT}`) instead — **the `orchestrator` plugin must now stay enabled for everyday +sessions**, not just to run `/orchestrator:setup`/`/orchestrator:sync`. Reasoning: nothing kept the +old vendored copy updated outside of `/orchestrator:sync`, and `resolve-roots.sh` deliberately makes a +repo-tracked `.claude/scripts` layout win over `${CLAUDE_PLUGIN_ROOT}` (correct for the self-hosting +and worktree-gate-run cases) — so a consumer who forgot to `sync` regularly ended up permanently +shadowing a fresh plugin install with a stale vendored copy (see the reDeploy incident that prompted +#134). + +`sync.sh` does **not** delete a repo's leftover vendored copy from before this change (a local copy +might be a deliberate override, not just staleness) — it only **detects and warns**: for each of +`agents/commands/hooks/scripts/skills` still present locally, it diffs against the plugin's own +shipped copy and reports either `stale-vendor: ... safe to delete` (content identical — just shadowing +the plugin cache) or `stale-vendor conflict: ...` (content diverges — treat as a possible deliberate +override and review the diff before deleting). It also calls out a leftover +`.claude/.orchestrator-vendor` marker file specifically. See `.claude/skills/sync/sync.sh`'s +`detect_stale_vendor_copies` and `.claude/skills/sync/SKILL.md`. + +**Migration caveat:** deleting/refreshing files on disk is not enough for a repo with the loop already +armed — a running `pr-loop`/`claude-rc` systemd unit holds its OLD script **in memory** until its unit +restarts. After cleaning up a stale vendored copy, restart the units: +`systemctl --user restart pr-loop-.service claude-rc-.service` (cf. the +2026-07-16 reCode deploy-lag incident, issue #131). See `.claude/skills/setup/scaffold.sh` for the implementation, and `.claude/skills/setup/SKILL.md` for the full onboarding flow this scaffold step is one part of. diff --git a/.claude/skills/setup/templates/pr-loop.service b/.claude/skills/setup/templates/pr-loop.service index 05d704e..138eab7 100644 --- a/.claude/skills/setup/templates/pr-loop.service +++ b/.claude/skills/setup/templates/pr-loop.service @@ -1,4 +1,4 @@ -# @orchestrator-managed pr-loop-service v1 +# @orchestrator-managed pr-loop-service v2 # systemd (user) unit TEMPLATE (issue #102). Installed into # ~/.config/systemd/user/pr-loop-.service by # `.claude/scripts/arm-loop.sh`, which substitutes the __PLACEHOLDER__ tokens @@ -10,6 +10,21 @@ # template instead (then re-run arm-loop.sh), or fork it under a different # name if you want a permanently custom copy. Re-run `/orchestrator:sync` to # pick up plugin updates to this template before re-arming. +# +# ExecStart resolution (issue #134): this repo no longer vendors `.claude/scripts/` into +# consumer repos, so `loop-daemon.sh` normally lives ONLY in the plugin cache, whose exact +# on-disk path (`~/.claude/plugins/cache////scripts/`) isn't +# knowable at ARM time (arm-loop.sh runs in a plain terminal outside Claude Code, where +# `${CLAUDE_PLUGIN_ROOT}` — a Claude-Code-session-only env var — is never set). ExecStart is +# therefore a small resolver, evaluated fresh on every unit (re)start, not a single baked +# path: (1) prefer `__WORKDIR__/.claude/scripts/loop-daemon.sh` if present — the +# self-hosting / legitimate-repo-tracked-copy case (see resolve-roots.sh); (2) else fall +# back to the newest `scripts/loop-daemon.sh` found under any installed plugin cache +# (`~/.claude/plugins/cache/*/orchestrator/*/scripts/loop-daemon.sh`, most-recently-modified +# wins, matching how `/plugin marketplace update` refreshes the cache in place). A future +# improvement would teach arm-loop.sh to resolve+bake the exact plugin cache path via a new +# placeholder instead of this runtime glob — tracked as a follow-up, not done here because +# arm-loop.sh is a separately-owned managed file (see MANIFEST.md). [Unit] Description=Cron-less autonomous PR loop daemon (__REPO_SLUG__) After=network-online.target @@ -20,7 +35,7 @@ Type=simple WorkingDirectory=__WORKDIR__ __GATES_ENV__ Environment=PATH=__PATH__ -ExecStart=/usr/bin/env bash __WORKDIR__/.claude/scripts/loop-daemon.sh +ExecStart=/usr/bin/env bash -c 'd="__WORKDIR__/.claude/scripts/loop-daemon.sh"; if [ ! -f "$d" ]; then d="$(ls -1t "$HOME"/.claude/plugins/cache/*/orchestrator/*/scripts/loop-daemon.sh 2>/dev/null | head -1)"; fi; if [ -z "$d" ] || [ ! -f "$d" ]; then echo "pr-loop.service: could not find loop-daemon.sh — neither __WORKDIR__/.claude/scripts/loop-daemon.sh nor a plugin cache copy exist; run /orchestrator:setup or enable the orchestrator plugin, then re-run arm-loop.sh" >&2; exit 1; fi; exec bash "$d"' Restart=always RestartSec=15 StandardOutput=journal diff --git a/.claude/skills/setup/templates/settings.json b/.claude/skills/setup/templates/settings.json index a2c62d2..a0efec8 100644 --- a/.claude/skills/setup/templates/settings.json +++ b/.claude/skills/setup/templates/settings.json @@ -1,5 +1,5 @@ { - "_README": "Generic harness settings. Hooks call .claude/scripts/gate.sh (commands live in .claude/gates.json) — you usually edit gates.json, not this file. The 'allow' list pre-approves the agent command surface so PARALLEL workers never block on permission prompts (an unapproved command in one subagent stalls the fan-out) and so the harness never has to persist a grant into this tracked file mid-run. Build/lint/test are covered generically via the gate.sh wildcard; if your agents run package-manager/build tools DIRECTLY (not via gate.sh), add those prefixes too — e.g. 'Bash(pnpm -r:*)', 'Bash(forge test:*)', 'Bash(cargo test:*)', 'Bash(go test:*)'. NOTE: the harness OWNS this file at runtime — it may rewrite the working-tree copy with its own session grant list, so the COMMITTED version is the source of truth and hand-edits won't persist mid-session. To commit a clean version despite that rewrite, stage via the git index: `sha=$(git hash-object -w .claude/settings.json) && git update-index --cacheinfo 100644,$sha,.claude/settings.json`. See docs/USAGE.md and the project's notes on concurrent config writes (anthropics/claude-code#29217). NOTE (issue #128): this file is deliberately created ONLY if you don't already have one, and it deliberately does NOT set enabledPlugins/extraKnownMarketplaces — those belong only in a settings.json you maintain yourself while installing/updating the orchestrator plugin, never in the runtime file that must work with the plugin disabled.", + "_README": "Generic harness settings. Hooks call gate.sh (commands live in .claude/gates.json) — you usually edit gates.json, not this file. The 'allow' list pre-approves the agent command surface so PARALLEL workers never block on permission prompts (an unapproved command in one subagent stalls the fan-out) and so the harness never has to persist a grant into this tracked file mid-run. Build/lint/test are covered generically via the gate.sh wildcard; if your agents run package-manager/build tools DIRECTLY (not via gate.sh), add those prefixes too — e.g. 'Bash(pnpm -r:*)', 'Bash(forge test:*)', 'Bash(cargo test:*)', 'Bash(go test:*)'. NOTE: the harness OWNS this file at runtime — it may rewrite the working-tree copy with its own session grant list, so the COMMITTED version is the source of truth and hand-edits won't persist mid-session. To commit a clean version despite that rewrite, stage via the git index: `sha=$(git hash-object -w .claude/settings.json) && git update-index --cacheinfo 100644,$sha,.claude/settings.json`. See docs/USAGE.md and the project's notes on concurrent config writes (anthropics/claude-code#29217). NOTE (issue #134): this file is deliberately created ONLY if you don't already have one, and it deliberately does NOT set enabledPlugins/extraKnownMarketplaces — those belong only in a settings.json you maintain yourself while installing/updating the orchestrator plugin. This plugin no longer vendors `agents/commands/hooks/scripts/skills` into your repo (issue #134, reverting #128's vendoring), so hook commands below resolve via `${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/...` — the plugin cache when the plugin is enabled (the normal case; keep it enabled for everyday sessions now), falling back to a local `.claude/scripts/` only for the self-hosting case where there is no distinct plugin install. CAVEAT (issue #129): the plugin's own `hooks/hooks.json` fires the SAME hooks whenever the plugin is enabled — since the plugin must now stay enabled for this file's hooks to resolve at all, both fire on every turn (double gate execution, harmless but wasteful). If that matters to you, drop the `hooks` block below and rely solely on the plugin's `hooks/hooks.json`.", "hooks": { "PostToolUse": [ { @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" lint" + "command": "bash \"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/gate.sh\" lint" } ] }, @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/log-worker-tool.sh\"" + "command": "bash \"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/log-worker-tool.sh\"" } ] } @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" test_affected" + "command": "bash \"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/gate.sh\" test_affected" } ] } @@ -37,7 +37,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/guard-git-add.py\"" + "command": "python3 \"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/guard-git-add.py\"" } ] } diff --git a/.claude/skills/sync/SKILL.md b/.claude/skills/sync/SKILL.md index 91e0349..e2864e2 100644 --- a/.claude/skills/sync/SKILL.md +++ b/.claude/skills/sync/SKILL.md @@ -12,28 +12,44 @@ upgrade reach into repos that already onboarded, without a human re-running the ## Ownership model (reuse the setup MANIFEST — do not invent a new scheme) See `.claude/skills/setup/templates/MANIFEST.md` for the authoritative ownership classes. Sync only acts on -the **managed** rows — today: `feature-fanout.js` -> `.claude/workflows/feature-fanout.js`, (issue #102) +the **managed** rows — today: `feature-fanout.js` -> `.claude/workflows/feature-fanout.js`, and (issue #102) the cron-less loop daemon's systemd unit templates + installer: `pr-loop.service` -> `.claude/systemd/pr-loop.service`, `claude-rc.service` -> `.claude/systemd/claude-rc.service`, -and `arm-loop.sh` -> `.claude/scripts/arm-loop.sh`, and (issue #128) **the vendored runtime harness tree**: -`agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` -> `.claude/agents/`, `.claude/commands/`, -`.claude/hooks/`, `.claude/scripts/`, `.claude/skills/`, gated as ONE unit by a single top-level marker file, -`.claude/.orchestrator-vendor`. All of these are reconciled by the exact same marker-version ladder below — -the loop-daemon files and the runtime-vendor tree are ordinary managed rows, not a special case. It is -designed so adding a new managed file later is a one-line addition to `sync.sh`'s managed-file table, not a -rewrite. The one deliberate exception: `.claude/scripts/arm-loop.sh` is excluded from the runtime-vendor -tree's copy/diff because it's already its own row with a different canonical source — see the comment on -`VENDOR_DIRS` in `sync.sh`. +and `arm-loop.sh` -> `.claude/scripts/arm-loop.sh`. All of these are reconciled by the exact same +marker-version ladder below. It is designed so adding a new managed file later is a one-line addition to +`sync.sh`'s managed-file table, not a rewrite. Re-stamping the loop-daemon templates only updates the checked-in files in the repo — it never touches an already-installed unit under `~/.config/systemd/user/` or restarts a running daemon. Tell the user to re-run `bash .claude/scripts/arm-loop.sh` (in a real terminal, per the sandbox caveat) after a restamp if they want the installed units to pick up the change. -**The runtime-vendor tree is what lets the `orchestrator` plugin stay disabled between updates** (issue #128) -— once vendored, a session reads agents/commands/hooks/scripts/skills off local disk, so the plugin only -needs to be enabled to run this very skill (or `/orchestrator:setup`). Re-run this skill any time after -updating the plugin marketplace listing to pull the latest runtime harness in. +**This plugin does NOT vendor `agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` into the consumer repo** +(issue #134 reverted issue #128's vendoring model) — those are read straight from the plugin cache +(`${CLAUDE_PLUGIN_ROOT}`), so **the `orchestrator` plugin must stay enabled for everyday sessions**, not just +to run this skill or `/orchestrator:setup`. Sync instead **detects and warns** about local leftovers of the +old vendored tree (e.g. a repo onboarded before #134, or one that legitimately kept a local override) — see +"Stale-vendor detection" below. It never deletes or restamps those directories itself. + +### Stale-vendor detection (issue #134) +For each of `agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` still present locally under `.claude/`, +`sync.sh`'s `detect_stale_vendor_copies` diffs it against the plugin's own shipped copy (excluding +`arm-loop.sh`, separately managed above) and reports one of: +- `stale-vendor: ... matches the plugin's shipped copy ... safe to delete` — a leftover from before #134 that + now only shadows the plugin cache (`resolve-roots.sh` deliberately makes a repo-tracked `.claude/scripts` + layout win over `${CLAUDE_PLUGIN_ROOT}`, which is exactly the failure mode that motivated #134 — see the + reDeploy incident it references). Sync does **not** delete it; tell the user it's safe to. +- `stale-vendor conflict: ... diverges from the plugin's shipped copy` — treat this as a **possible deliberate + local override**, not assumed-safe-to-delete. Show the user the diff (`diff -rq `) + and let them decide whether it's a stale relic or a fix worth upstreaming (same "It's a generic improvement + → upstream it" guidance as `docs/MIGRATION.md`). +- A leftover `.claude/.orchestrator-vendor` marker file (the old #128 vendor-version stamp) is called out + separately, once you've reconciled the flagged directories above. +- **Migration caveat, always repeat when any stale-vendor line appears:** deleting/refreshing files on disk is + not enough for a repo whose loop is already armed — a running `pr-loop`/`claude-rc` systemd unit holds its + OLD script **in memory** until its unit restarts. Tell the user to run + `systemctl --user restart pr-loop-.service claude-rc-.service` after cleaning up (cf. + the 2026-07-16 reCode deploy-lag incident, issue #131). Sync **never** touches user-owned files, under any circumstance: - `.claude/gates.json` @@ -82,6 +98,10 @@ version-marked managed files, not the "create if absent" ci files. normal per-file verdicts that still exit 0. Surface `error` lines to the user prominently and suggest reinstalling/updating the plugin rather than treating it as something to fix in the consumer repo. + Separately (not a per-file managed-row verdict), it also prints `stale-vendor:` / `stale-vendor conflict:` + lines — see "Stale-vendor detection" above — for any leftover local copy of the pre-#134 vendored tree, + or `stale-vendor: none found` if there is none. + 3. **Report a diff summary.** Relay the script's per-file summary verbatim to the user (it's already in the created/up-to-date/restamped/conflict/kept/skipped/error vocabulary above). Call out clearly which line, if any, changed on disk (`restamped`) versus which are informational only, and call out `error` lines as a @@ -102,7 +122,9 @@ version-marked managed files, not the "create if absent" ci files. 5. **Hand off.** Summarize: which managed files were checked, which were restamped, which need a human merge decision (and what you did about it, if anything), and which are already current. Remind the user that user-owned files (`gates.json`, `CLAUDE.md`, `settings.local.json`, `.claude/state/`) are never - touched by sync — those stay exactly as the human left them. + touched by sync — those stay exactly as the human left them. If any `stale-vendor` line was printed, + surface it prominently (don't bury it in the managed-file summary) along with the migration caveat about + restarting the armed systemd units after the human cleans up a stale local copy. ## Reference - `.claude/skills/setup/templates/MANIFEST.md` — the template -> destination map and ownership classes this diff --git a/.claude/skills/sync/sync.sh b/.claude/skills/sync/sync.sh index b6a74b5..657d688 100755 --- a/.claude/skills/sync/sync.sh +++ b/.claude/skills/sync/sync.sh @@ -70,28 +70,28 @@ MANAGED_FILES=( "arm-loop.sh|.claude/scripts/arm-loop.sh|@orchestrator-managed arm-loop v" ) -# --- runtime harness vendor: ONE managed unit, not a row in MANAGED_FILES (issue #128) -- -# Unlike everything in MANAGED_FILES above (a single template file -> single destination -# file), the runtime harness vendor is a whole-TREE copy — the plugin's own `agents/`, -# `commands/`, `hooks/`, `scripts/`, `skills/` subtrees, copied wholesale from the plugin -# root (not from templates_dir) into the consumer's `.claude/`. It is gated by ONE -# top-level marker file, `.claude/.orchestrator-vendor`, reusing the exact same -# managed_version_of ladder as MANAGED_FILES, just applied to a directory-tree copy -# instead of a single `cp`. See scaffold.sh's matching "runtime harness vendor" section -# and `.claude/skills/setup/templates/MANIFEST.md` for the full rationale. -# -# EXCEPTION: `.claude/scripts/arm-loop.sh` is excluded from this tree copy/diff — it is -# already a MANAGED_FILES row above with its own canonical template (`templates/arm-loop.sh`, -# placeholder-substituted at ARM time), which can legitimately differ from this plugin -# repo's own live self-hosting copy of arm-loop.sh. Double-managing the same destination -# path from two different pristine sources would make the two mechanisms disagree about -# what "up to date" even means for that one file. -VENDOR_DIRS=(agents commands hooks scripts skills) -VENDOR_MARKER_PREFIX="@orchestrator-managed runtime-vendor v" +# --- stale-vendor detection (issue #134) -------------------------------------------- +# Prior to issue #134, this plugin vendored its own runtime subtrees — `agents/`, +# `commands/`, `hooks/`, `scripts/`, `skills/` — wholesale into a consumer's local +# `.claude/` (issue #128), gated by a single marker file `.claude/.orchestrator-vendor`. +# That model was reverted: consumer repos no longer carry local copies of these +# directories at all — agents/commands/hooks/scripts/skills are read straight from the +# plugin cache (`${CLAUDE_PLUGIN_ROOT}`), which stays the single source of truth. Sync +# does NOT delete anything on a consumer's behalf (a local copy might be a deliberate, +# legitimate override, not just stale leftovers) — it only DETECTS AND WARNS, see +# `detect_stale_vendor_copies` below. This also covers a repo that was vendored under +# the old #128 model and never cleaned up (e.g. reDeploy, the motivating case for #134). +STALE_VENDOR_DIRS=(agents commands hooks scripts skills) +LEGACY_VENDOR_MARKER_REL=".claude/.orchestrator-vendor" plugin_root="$(cd "$script_dir/../.." && pwd)" -vendor_marker_src="$plugin_root/.orchestrator-vendor" -vendor_marker_dst="$target_root/.claude/.orchestrator-vendor" -vendor_dest_rel=".claude/{agents,commands,hooks,scripts,skills}" + +# self-hosting: this plugin's OWN repo running sync.sh against itself, i.e. plugin_root +# resolves to $target_root/.claude. In that case .claude/{agents,commands,hooks,scripts, +# skills} ARE the plugin's own legitimately-tracked canonical source directories, not +# vendored copies of anything — stale-vendor detection must stay completely silent (no +# warning, no migration caveat) rather than flag the plugin's own tree as a leftover. +self_hosting=0 +[ "$plugin_root" = "$target_root/.claude" ] && self_hosting=1 # --- user-owned files: NEVER written by sync, only reported for visibility --------- USER_OWNED_FILES=( @@ -156,58 +156,51 @@ has_local_edits() { [ "$a" != "$b" ] } -has_local_edits_vendor() { - # Directory-tree analog of has_local_edits, used only for the runtime harness vendor - # (issue #128): recursively diffs each VENDOR_DIRS subtree between the installed - # .claude/ and the plugin root's own .claude/, excluding arm-loop.sh (separately - # managed — see the EXCEPTION comment on VENDOR_DIRS above). Any residual difference — - # including an extra locally-added file — counts as a local edit, same conservative - # philosophy as has_local_edits. - local installed_claude="$1" plugin_claude="$2" d - for d in "${VENDOR_DIRS[@]}"; do - [ -d "$plugin_claude/$d" ] || continue - if ! diff -rq -x arm-loop.sh "$plugin_claude/$d" "$installed_claude/$d" >/dev/null 2>&1; then - return 0 - fi - done - return 1 -} - -copy_vendor_dirs() { - # Prune-then-copy of each VENDOR_DIRS subtree's CONTENTS (not the subtree itself) from - # the plugin root into $target_root/.claude, preserving executable bits (cp -a). Mirrors - # scaffold.sh's copy_vendor_dirs exactly — see that function's comment for why a plain - # `cp -a src dst` on an already-existing dst nests instead of refreshing (the bug this - # replaced: a restamp used to leave duplicate nests like .claude/skills/sync/sync and - # never actually update changed files), and why removing the destination subtree first - # then copying the source's CONTENTS (`src/.` -> `dst/`) both fixes the nesting and prunes - # files removed upstream. - # - # EXCEPTION: `.claude/scripts/arm-loop.sh` must survive this — it already has its own - # dedicated MANAGED_FILES row and must NOT be vendored/overwritten from this plugin - # repo's own live copy. Back it up before pruning `scripts/`, then restore it (or remove - # whatever the plugin copy dropped in its place if there was nothing to restore) after. - local d arm_backup="" - for d in "${VENDOR_DIRS[@]}"; do - [ -d "$plugin_root/$d" ] || continue - if [ "$d" = "scripts" ] && [ -f "$target_root/.claude/scripts/arm-loop.sh" ]; then - arm_backup="$(mktemp "${TMPDIR:-/tmp}/arm-loop.sh.XXXXXX")" - cp -a "$target_root/.claude/scripts/arm-loop.sh" "$arm_backup" - fi - rm -rf "$target_root/.claude/$d" - mkdir -p "$target_root/.claude/$d" - cp -a "$plugin_root/$d/." "$target_root/.claude/$d/" +detect_stale_vendor_copies() { + # Issue #134: detect (never delete) local copies of the directories this plugin used to + # vendor (issue #128). For each STALE_VENDOR_DIRS entry present under $target_root/.claude, + # classify it: + # - identical to the plugin's own shipped copy (diff -rq, excluding arm-loop.sh — see + # the MANAGED_FILES row for why that one file is separately managed and can + # legitimately differ) -> "stale", almost certainly safe to delete: it just shadows + # the plugin cache (resolve-roots.sh deliberately makes a repo-tracked + # `.claude/scripts` layout win over `${CLAUDE_PLUGIN_ROOT}`, so a stale copy silently + # wins over a freshly-updated plugin install). + # - diverges from the plugin's shipped copy -> "conflict", flagged as a POSSIBLE + # deliberate local override rather than assumed-safe-to-delete — same conservative + # philosophy as has_local_edits: a false "conflict" costs a human a glance at a diff, + # a false "safe to delete" can silently destroy someone's local fix. + # Self-hosting short-circuit: when this plugin's own repo runs sync.sh against itself + # ($self_hosting=1, computed above from plugin_root/target_root), .claude/{agents, + # commands,hooks,scripts,skills} ARE the plugin's own canonical source, not vendored + # copies of anything — skip the whole detect-and-warn body and report "none found" so + # self-hosted runs never flag (or suggest deleting) the plugin's own tree. + # Returns 0 if any stale directory was found (so the caller can print the migration + # caveat once), 1 if none were found (including the self-hosting short-circuit). + local d dst extra any_found=0 + if [ "$self_hosting" -eq 1 ]; then + return 1 + fi + for d in "${STALE_VENDOR_DIRS[@]}"; do + dst="$target_root/.claude/$d" + [ -d "$dst" ] || continue if [ "$d" = "scripts" ]; then - if [ -n "$arm_backup" ]; then - cp -a "$arm_backup" "$target_root/.claude/scripts/arm-loop.sh" - rm -f "$arm_backup" - arm_backup="" - else - rm -f "$target_root/.claude/scripts/arm-loop.sh" - fi + # .claude/scripts/arm-loop.sh is its OWN managed row (see MANAGED_FILES) and is + # expected to exist here even with vendoring stopped — only flag scripts/ as a + # stale-vendor leftover if it holds anything ELSE. + extra="$(find "$dst" -mindepth 1 -maxdepth 1 ! -name arm-loop.sh -print -quit 2>/dev/null)" + [ -n "$extra" ] || continue + fi + any_found=1 + if [ ! -d "$plugin_root/$d" ]; then + echo " stale-vendor: .claude/$d — present locally; this repo no longer vendors the runtime harness (issue #134) — verify it's not just a leftover from an old vendored install before relying on it" + elif diff -rq -x arm-loop.sh "$plugin_root/$d" "$dst" >/dev/null 2>&1; then + echo " stale-vendor: .claude/$d — matches the plugin's shipped copy byte-for-byte; this repo no longer vendors the runtime harness (issue #134), so this local copy only shadows the plugin cache — safe to delete (sync will not delete it for you)" + else + echo " stale-vendor conflict: .claude/$d — diverges from the plugin's shipped copy — this MAY be a deliberate local override rather than a stale leftover; review the diff yourself (e.g. diff -rq \"$plugin_root/$d\" \"$dst\") before deciding whether to delete it — sync will never delete it for you" fi done - cp "$vendor_marker_src" "$vendor_marker_dst" + return $((1 - any_found)) } # --- 1. managed files: compare marker version + content, act per the ladder below -- @@ -278,51 +271,27 @@ for entry in "${MANAGED_FILES[@]}"; do fi done -# --- 1b. runtime harness vendor: same ladder, applied to a whole tree via ONE marker --- -if [ ! -d "$plugin_root/agents" ]; then - echo " error: $vendor_dest_rel — no shipped plugin root at $plugin_root; plugin install looks broken" >&2 - had_broken_install=1 -elif [ "$plugin_root" = "$target_root/.claude" ]; then - # Running the vendored copy of sync.sh directly (CLAUDE_PLUGIN_ROOT unset, so the - # ${CLAUDE_PLUGIN_ROOT:-.claude} fallback resolved to this repo's own .claude) — there is - # no distinct plugin root to re-vendor FROM. - echo " kept: $vendor_dest_rel — running from an already-vendored copy, no distinct plugin root to re-vendor from; enable the plugin and re-run to pull updates" +# --- 1b. stale-vendor detection: never restamp/delete, only detect + warn (issue #134) -- +if detect_stale_vendor_copies; then + legacy_marker="$target_root/$LEGACY_VENDOR_MARKER_REL" + if [ -f "$legacy_marker" ]; then + echo " stale-vendor: $LEGACY_VENDOR_MARKER_REL — legacy vendor marker from the old #128 model found; this repo is a leftover of a vendored install" + fi + echo " stale-vendor: found local .claude/{agents,commands,hooks,scripts,skills} copies — this plugin stopped vendoring these (issue #134); once you've reconciled the flagged directories above (deleted the safe-to-delete ones, kept/upstreamed any genuine local override), also remove $LEGACY_VENDOR_MARKER_REL if present" + echo " stale-vendor: MIGRATION CAVEAT — a running pr-loop/claude-rc systemd unit holds its OLD script in memory until its unit restarts; refreshing/deleting files on disk is not enough. After cleaning up, run: systemctl --user restart pr-loop-.service claude-rc-.service (cf. the 2026-07-16 reCode deploy-lag incident, issue #131)" +elif [ "$self_hosting" -eq 1 ]; then + # Self-hosting: stay completely silent — no stale-vendor line, no stray-marker check, no + # migration caveat. This is the plugin's own tree; there is nothing to reconcile. + echo " stale-vendor: none found — .claude/{agents,commands,hooks,scripts,skills} are not vendored locally (as expected; served from \${CLAUDE_PLUGIN_ROOT})" else - vendor_shipped_version="$(managed_version_of "$vendor_marker_src" "$VENDOR_MARKER_PREFIX")" - if ! is_sane_version "$vendor_shipped_version"; then - echo " error: $vendor_dest_rel — shipped marker $vendor_marker_src has no valid @orchestrator-managed marker (got \"$vendor_shipped_version\"); plugin install looks broken" >&2 - had_broken_install=1 - elif [ ! -f "$vendor_marker_dst" ]; then - echo " missing: $vendor_dest_rel — not present; run /orchestrator:setup to create it" - else - vendor_installed_version="$(managed_version_of "$vendor_marker_dst" "$VENDOR_MARKER_PREFIX")" - if [ -z "$vendor_installed_version" ]; then - vendor_installed_version=0 - elif ! is_sane_version "$vendor_installed_version"; then - echo " conflict: $vendor_dest_rel has a malformed or out-of-range version marker (\"$vendor_installed_version\") — needs-merge, left untouched" - vendor_installed_version="" - fi - - if [ -n "$vendor_installed_version" ]; then - if [ "$vendor_installed_version" -gt "$vendor_shipped_version" ]; then - echo " kept: $vendor_dest_rel is v$vendor_installed_version, newer than this plugin's v$vendor_shipped_version — left untouched" - elif [ "$vendor_installed_version" -eq "$vendor_shipped_version" ]; then - if has_local_edits_vendor "$target_root/.claude" "$plugin_root"; then - echo " conflict: $vendor_dest_rel is marked v$vendor_installed_version but content diverges from the pristine v$vendor_shipped_version tree — needs-merge, left untouched" - else - echo " up to date: $vendor_dest_rel already v$vendor_shipped_version" - fi - else - # vendor_installed_version < vendor_shipped_version - if has_local_edits_vendor "$target_root/.claude" "$plugin_root"; then - echo " conflict: $vendor_dest_rel is v$vendor_installed_version (behind v$vendor_shipped_version) AND has local edits — needs-merge, left untouched" - else - copy_vendor_dirs - echo " restamped: $vendor_dest_rel v$vendor_installed_version -> v$vendor_shipped_version" - fi - fi - fi + # No stale vendor DIRECTORIES were found, but a consumer may have deleted those by hand + # and left a stray legacy marker file behind — warn about that leftover too (detect and + # warn only; sync never deletes it for the operator). + legacy_marker="$target_root/$LEGACY_VENDOR_MARKER_REL" + if [ -f "$legacy_marker" ]; then + echo " stale-vendor: $LEGACY_VENDOR_MARKER_REL — legacy vendor marker from the old #128 model found, but no stale vendor directories are present; remove this stray marker file" fi + echo " stale-vendor: none found — .claude/{agents,commands,hooks,scripts,skills} are not vendored locally (as expected; served from \${CLAUDE_PLUGIN_ROOT})" fi # --- 2. user-owned files: report only, never write ---------------------------------- diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d2f4325..1ec6de7 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -48,25 +48,27 @@ Then, in Claude Code, inside **your own project**: (`/plugin` alone opens an interactive picker if you'd rather browse marketplaces/plugins than type the commands above.) -Enabling the plugin here is what lets you run `/orchestrator:setup` in Step 2 — that step also **vendors the -plugin's own runtime harness into your repo's local `.claude/`**, so afterward the plugin only needs to stay -enabled to run `/orchestrator:setup`/`/orchestrator:sync` again later (the install/update channel), not for -day-to-day sessions. See Step 2. +Enabling the plugin here is what lets you run `/orchestrator:setup` in Step 2, and — unlike an earlier version +of this template (issue #128) — **keep the plugin enabled afterward too**: this repo does not vendor a local +copy of its own runtime harness into your repo (issue #134), so `agents/`, `commands/`, `hooks/`, `scripts/`, +`skills/` are read straight from the plugin cache on every session, not just during +`/orchestrator:setup`/`/orchestrator:sync`. See Step 2. ## Step 2 — Onboard: run `/orchestrator:setup` With the plugin enabled, run: ``` /orchestrator:setup ``` -It interviews you, then writes the files a plugin **cannot** carry into your repo, and **vendors the plugin's -runtime harness — `agents/`, `commands/`, `hooks/`, `scripts/`, `skills/` — wholesale into your local -`.claude/`** so a session reads them off disk without the plugin loaded (issue #128: an enabled plugin loads, -and pays its ~18s load cost, on every session start regardless of how its hooks are wired — vendoring is what -removes that cost from anything time-sensitive, e.g. headless `claude remote-control` spawns). It also creates -a **`.claude/settings.json`** from a template *if you don't already have one*, wiring the runtime hooks to -`$CLAUDE_PROJECT_DIR/.claude/scripts/...` instead of the plugin's `hooks/hooks.json` (which only fires while -the plugin is loaded). **Once this is done, the plugin only needs to stay enabled to run -`/orchestrator:setup`/`/orchestrator:sync`** — feel free to disable it between updates. What the interview +It interviews you, then writes the files a plugin **cannot** carry into your repo — it does **not** copy the +plugin's own `agents/commands/hooks/scripts/skills` into your repo (issue #134: an earlier version of this +template vendored those wholesale into your local `.claude/` so a session could read them off disk with the +plugin disabled, issue #128 — that model was reverted because nothing kept an unmanaged vendored copy updated, +and a stale local copy permanently shadows a fresh plugin install; see `resolve-roots.sh`). It also creates a +**`.claude/settings.json`** from a template *if you don't already have one*, wiring the runtime hooks to +`${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/...` — the plugin cache while the plugin is +enabled, falling back to a local `.claude/scripts/` only if you're self-hosting this template directly (no +distinct plugin install). **Keep the `orchestrator` plugin enabled for everyday sessions** — its own +`hooks/hooks.json` and, unless you drop the block, this file's `hooks` both need it loaded. What the interview collects and scaffolds: - **`.claude/gates.json`** (the adapter — the *only* file that makes the generic agents work on YOUR stack). Set: @@ -109,11 +111,11 @@ Each should run the right command (or say "not configured — skipping"). > `Stop` hook and every agent's "done". ### Choosing `test_affected` per stack -`test_affected` runs on the `Stop` hook — wired by the `.claude/settings.json` `/orchestrator:setup` scaffolds -(Step 2 above; it calls `$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh test_affected`), not the plugin's -`hooks/hooks.json` (that only fires while the plugin is loaded) — after every change, so it should be *fast* -— ideally only the tests touched by the diff. But "test only what changed" isn't free in every stack. Sensible -options: +`test_affected` runs on the `Stop` hook — wired both by the plugin's own `hooks/hooks.json` and by the +`.claude/settings.json` `/orchestrator:setup` scaffolds (Step 2 above; it calls +`${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/scripts/gate.sh test_affected`) — after every change, so it +should be *fast* — ideally only the tests touched by the diff. But "test only what changed" isn't free in every +stack. Sensible options: | Stack | Cheap `test_affected` | Notes | |---|---|---| diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 275e2e0..59ba0ef 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -26,6 +26,23 @@ The project was renamed **reCode**. What this means for an existing install: existing downstream install keeps working untouched; renaming the marketplace alias to `recode` is optional tidiness, not a requirement. +## If you onboarded between issues #128 and #134 (vendored runtime harness) +For a window, `/orchestrator:setup`/`/orchestrator:sync` vendored the plugin's own +`agents/commands/hooks/scripts/skills` wholesale into your `.claude/` (issue #128), gated by a +`.claude/.orchestrator-vendor` marker file, so a session could read them off disk with the plugin disabled. +That model was reverted (issue #134) for exactly the reason this whole document exists: nothing kept an +unmanaged copy updated, and a stale local copy permanently shadows a fresh plugin install (`resolve-roots.sh` +deliberately makes a repo-tracked `.claude/scripts` layout win over `${CLAUDE_PLUGIN_ROOT}`). If your repo went +through that window, treat it exactly like the hand-copied case below — the same "What to delete" / "Before +you delete: check for local patches" guidance applies, and `.claude/.orchestrator-vendor` is one more file to +delete alongside the vendored directories. Running `/orchestrator:sync` will flag any leftover copy for you +(`stale-vendor: ... safe to delete` vs. `stale-vendor conflict: ...` if it diverges from the plugin's shipped +copy) instead of silently deleting or restamping it — see `.claude/skills/sync/SKILL.md`. **Keep the plugin +enabled after cleaning up** — issue #134 also means the plugin must stay enabled for everyday sessions now, not +just to run setup/sync. **Migration caveat:** if you've already armed the loop, deleting files on disk is not +enough — a running `pr-loop`/`claude-rc` systemd unit holds its OLD script in memory until its unit restarts: +`systemctl --user restart pr-loop-.service claude-rc-.service`. + ## What to delete Remove the copied harness that the plugin now carries — it's generic, not project-specific, and staying on a frozen copy means you never get fixes/improvements: diff --git a/docs/USAGE.md b/docs/USAGE.md index b849c5b..1c534c7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -436,26 +436,37 @@ marketplace listing and let Claude Code update the installed plugin: /plugin marketplace update recode ``` Then re-stamp the files `/orchestrator:setup` scaffolded into **your** repo (`gates.json`, `CLAUDE.md`, the -fan-out workflow, the CI gate workflow, and — issue #128 — the vendored runtime harness: `agents/`, -`commands/`, `hooks/`, `scripts/`, `skills/`) so they pick up any changes shipped in the update: +fan-out workflow, the loop-daemon systemd unit templates + `arm-loop.sh`, the CI gate workflow) so they pick +up any changes shipped in the update: ``` /orchestrator:sync ``` This compares the version markers `/orchestrator:setup` already scaffolded against what the current plugin ships and re-stamps anything behind — flagging local edits instead of clobbering them (see -`.claude/skills/sync/SKILL.md`) — so an update refreshes managed files (e.g. `feature-fanout.js`, or the whole -vendored runtime harness as one unit via its `.claude/.orchestrator-vendor` marker) without re-running the -whole interview, and never touches your own `gates.json`/`CLAUDE.md`/`settings.json` (those are created once -and left alone on every re-run). - -**The plugin only needs to be *enabled* to run `/orchestrator:setup`/`/orchestrator:sync`** — once the runtime -harness is vendored, everyday sessions read `agents/commands/hooks/scripts/skills` straight out of your local -`.claude/`, so the plugin doesn't need to load at session start at all. This matters because a loaded plugin -pays its load cost (~18s for this one) on **every** session start regardless of how its hooks are wired; for -anything time-sensitive — e.g. `claude remote-control` spawning a headless driver under a ~20-30s spawn-ack -window — that cost can be the difference between the parent seeing the child come up and declaring it dead -(see issue #128). Re-enable the plugin (or just leave it enabled — it's harmless, only slower) whenever you -next want to run setup/sync for an update. +`.claude/skills/sync/SKILL.md`) — so an update refreshes managed files (e.g. `feature-fanout.js`) without +re-running the whole interview, and never touches your own `gates.json`/`CLAUDE.md`/`settings.json` (those are +created once and left alone on every re-run). It also detects and warns about local leftovers from an old +vendored install (issue #134, see below) instead of silently deleting or restamping them. + +**The `orchestrator` plugin must stay *enabled* for everyday sessions**, not just to run +`/orchestrator:setup`/`/orchestrator:sync` — this repo does **not** vendor a local copy of `agents/`, +`commands/`, `hooks/`, `scripts/`, `skills/` into your `.claude/` (issue #134 reverted issue #128's vendoring +model). `agents/commands/hooks/scripts/skills` are read straight from the plugin cache +(`${CLAUDE_PLUGIN_ROOT}`) on every session; disabling the plugin between updates now breaks everyday sessions, +not just setup/sync. (Vendoring was reverted because nothing kept an unmanaged vendored copy updated outside +`/orchestrator:sync`, and `resolve-roots.sh` deliberately makes a repo-tracked `.claude/scripts` layout win +over `${CLAUDE_PLUGIN_ROOT}` — correct for self-hosting/worktree gate runs, but it meant a stale vendored copy +permanently shadowed a fresh plugin install; see the reDeploy incident that prompted #134.) + +**If your repo was onboarded before #134 and still carries a local `.claude/{agents,commands,hooks,scripts,skills}` +copy** (and possibly a `.claude/.orchestrator-vendor` marker), `/orchestrator:sync` flags it — +`stale-vendor: ... safe to delete` if it's byte-identical to the plugin's shipped copy (just shadowing the +plugin cache), or `stale-vendor conflict: ...` if it diverges (review before deleting — it may be a deliberate +local override). Sync never deletes it for you. **Migration caveat:** if you've already armed the loop, +deleting/refreshing files on disk is not enough — a running `pr-loop`/`claude-rc` systemd unit holds its OLD +script in memory until its unit restarts, so restart both after cleaning up: +`systemctl --user restart pr-loop-.service claude-rc-.service` (cf. the 2026-07-16 +reCode deploy-lag incident, issue #131). > **Maintainer note: bump `plugin.json`'s `version` on every real change.** `/plugin marketplace update` only > re-fetches plugin content when the plugin's version string actually changes (`.claude/.claude-plugin/plugin.json`