From dd69187841f7f87b2b686ad3cccf9a5031a9c4cb Mon Sep 17 00:00:00 2001 From: Will Curran Date: Fri, 31 Jul 2026 15:50:28 -0700 Subject: [PATCH 1/2] fix: the coordinator is not a team member, so stop activating it as one 'claude-team use coordinator' resolved and installed the coordinator profile into the CLAUDE-TEAM block. 'status' then reported "Active team member: Claude Team CLI", and with the coordinator also enabled, ~/.claude/CLAUDE.md held two copies of the same instructions, one in each marker pair. 'launch coordinator' opened a session with a behavior layer as its persona. cmd_list and persona_roster both skip 'coordinator*'. resolve_name did not, and it is the choke point for show, use, and launch. The guard is a separate assert_is_persona rather than a case in resolve_name, called by use and launch and deliberately not by show. Two reasons. Printing the coordinator profile is a legitimate thing to want, and coordinator-prod is the fixture the existing hyphenated-name test uses, so blocking it in resolve_name would fail that test for a reason unrelated to what it tests. It is called after resolve_name, so a name that merely starts with "coordinator" and has no profile still gets "no profile found" rather than this message. Tests cover both directions: use and launch reject both coordinator profiles and write nothing, and show still reads them. Co-Authored-By: Claude Opus 5 --- bin/claude-team | 30 ++++++++++++++++++++++++++++++ tests/run.sh | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/bin/claude-team b/bin/claude-team index 76ff314..50cfe83 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -453,6 +453,34 @@ resolve_name() { echo "$profile" } +# The coordinator is a behavior layer, not a team member. It has its own marker +# block, its own on/prod/off command, and both roster paths skip it: see the +# 'coordinator*' cases in cmd_list and persona_roster. resolve_name did not, so +# 'claude-team use coordinator' resolved, installed the coordinator profile into +# the CLAUDE-TEAM block, and made 'status' report "Active team member: Claude +# Team CLI". With the coordinator also enabled, ~/.claude/CLAUDE.md then held two +# copies of the same instructions, one in each marker pair. +# +# Called by 'use' and 'launch', which treat a name as a persona, and NOT by +# 'show', which only prints a file. Reading the coordinator profile is a +# legitimate thing to want, and blocking it there would also break the test that +# proves resolve_name's character class still accepts a hyphenated name. +# +# Called AFTER resolve_name, so a name that merely starts with "coordinator" and +# has no profile still gets the "no profile found" message rather than this one. +assert_is_persona() { + case "$1" in + coordinator*) + die "'$1' is the coordinator, not a team member, so it has no persona to activate. +Enable it instead with: + claude-team coordinator on (casual) + claude-team coordinator prod (branch enforcement) +To read the profile: + claude-team show $1" + ;; + esac +} + # Title parsing. Profile titles use the form "# Name — Role"; both parts # split at the FIRST em dash so a role may itself contain one. # @@ -669,6 +697,7 @@ cmd_use() { name=$(lowercase "$1") local profile profile=$(resolve_name "$name") + assert_is_persona "$name" # 'use' pins the persona globally, so every future session reads what lands # here. The greeting is dropped: pinned, it makes every new session open with @@ -1172,6 +1201,7 @@ cmd_launch() { name=$(lowercase "$1") shift profile=$(resolve_name "$name") + assert_is_persona "$name" local model task="" branch="" dry_run=false model=$(get_default_model "$name") diff --git a/tests/run.sh b/tests/run.sh index b4ab2d6..a06a861 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -212,6 +212,40 @@ out=$(run_cmd show coordinator-prod) assert_contains "hyphenated name still resolves" "Claude Team CLI" "$out" echo "" +# The coordinator is a behavior layer, not a team member: it has its own marker +# block, its own on/prod/off command, and both roster paths skip it. resolve_name +# did not, so 'claude-team use coordinator' resolved and installed the +# coordinator profile into the CLAUDE-TEAM block. 'status' then reported "Active +# team member: Claude Team CLI", and with the coordinator also enabled the file +# held two copies of the same instructions, one in each marker pair. +echo "the coordinator is not a team member" +assert_exits_nonzero "use rejects the coordinator" "$CLI" use coordinator +assert_exits_nonzero "use rejects the prod coordinator" "$CLI" use coordinator-prod +assert_exits_nonzero "launch rejects the coordinator" "$CLI" launch coordinator --dry-run +out=$(run_cmd use coordinator 2>&1 || true) +assert_contains "the refusal points at the command that does work" "claude-team coordinator on" "$out" + +# Its own HOME, so the assertion holds wherever this section sits in the file. +# Against the shared one it would depend on nothing earlier having installed a +# block, which is true today and is not a property this test should rely on. +COORD_HOME=$(mktemp -d) +mkdir -p "$COORD_HOME/.claude" +CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$COORD_HOME" "$CLI" use coordinator >/dev/null 2>&1 || true +assert_file_lacks "a rejected coordinator writes no persona block" \ + "$COORD_HOME/.claude/CLAUDE.md" "CLAUDE-TEAM:START" +rm -rf "$COORD_HOME" + +# Guard the guard, in the other direction. The check belongs on 'use' and +# 'launch', which activate a name as a persona, and NOT in resolve_name, which +# 'show' also goes through: printing the coordinator profile is a legitimate +# thing to want, and coordinator-prod is the fixture the hyphenated-name test +# above uses, so blocking it there would break that test for an unrelated reason. +out=$(run_cmd show coordinator) +assert_contains "show still reads the coordinator profile" "Active Coordination" "$out" +out=$(run_cmd show coordinator-prod) +assert_contains "show still reads the prod coordinator profile" "Claude Team CLI" "$out" +echo "" + # use — basic injection echo "use" run_cmd use robin >/dev/null From 090c09bd963fc50121a551c0fa22dadc8eba5772 Mon Sep 17 00:00:00 2001 From: Will Curran Date: Fri, 31 Jul 2026 15:50:56 -0700 Subject: [PATCH 2/2] fix: name the personas a sync leaves behind, and delete none of them sync only ever copies. Deleting or renaming a persona in the clone leaves its three installed files behind, and nothing looks at them again. cmd_list and persona_roster read PROFILES_DIR rather than the repo, so a removed persona keeps appearing in 'claude-team list' and its / command keeps resolving, forever. sync now names the leftovers and prints the rm that clears each one. It does not delete them, and that is the decision, not an omission. ~/.claude/commands and ~/.claude/agents also hold files this tool never wrote, and sync has no marker separating its own from a user's, so a prune would be an rm driven by a guess about ownership in the user's global config. That is the one place this tool is careful everywhere else: CLAUDE.md is protected by markers, settings.json by a merge, a foreign pre-commit hook by an outright refusal. Naming the paths costs one command and cannot destroy anything. Three tests assert each of the three files still exists after the report, so a later change to pruning has to be a deliberate one. Co-Authored-By: Claude Opus 5 --- bin/claude-team | 43 +++++++++++++++++++++++++++++++++++++ tests/run.sh | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/bin/claude-team b/bin/claude-team index 50cfe83..d9b72a9 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -1546,6 +1546,46 @@ good surfaces and one stale one. Fix the error above, then rerun 'claude-team sync': it recopies all three and brings them back level." } +# Name the personas that are installed but no longer in the repo. sync only ever +# copies, so deleting or renaming a persona leaves its three installed files +# behind, and nothing else ever looks at them again. The roster is what makes +# that visible: cmd_list and persona_roster read PROFILES_DIR, not the repo, so a +# persona removed from the clone keeps appearing in 'claude-team list' and its +# '/' slash command keeps resolving, forever. +# +# Reported, not deleted, and that is deliberate. ~/.claude/commands and +# ~/.claude/agents also hold files this tool never wrote, from other tools and +# from the user, and sync has no marker that tells its own files apart from +# theirs. A prune here would be an 'rm' driven by a guess about ownership, in the +# user's global config, which is the one place this tool is careful everywhere +# else: CLAUDE.md is protected by markers, settings.json by a merge, a foreign +# pre-commit hook by an outright refusal. Naming the paths costs the user one +# command and cannot destroy anything. +warn_orphaned_personas() { + # The two destination directories are passed in rather than read from + # cmd_sync's locals. Bash scoping would make that work, because a called + # function sees its caller's locals, but it would tie this function to one + # call site invisibly and break under 'set -u' anywhere else. + local repo_profiles="$1" agents_dst="$2" commands_dst="$3" installed name + local orphans=() + for installed in "$PROFILES_DIR"/*.md; do + [[ -f "$installed" ]] || continue + name=$(basename "$installed" .md) + [[ -f "$repo_profiles/$name.md" ]] || orphans+=("$name") + done + # Guarded before any expansion of the array itself: '${orphans[@]}' on an empty + # array is an unbound variable under 'set -u' on Bash before 4.4, and the + # supported floor here is Bash 4. '${#orphans[@]}' is a count and is always safe. + (( ${#orphans[@]} > 0 )) || return 0 + echo "" + echo "$(yellow "!") Installed but no longer in the repo: ${orphans[*]}" + echo "$(dim " sync only copies, so a removed or renamed persona leaves its files behind.")" + echo "$(dim " These still appear in 'claude-team list'. Nothing was deleted. To remove:")" + for name in "${orphans[@]}"; do + echo " $(dim "rm -f $PROFILES_DIR/$name.md $agents_dst/$name.md $commands_dst/$name.md")" + done +} + # Regenerate agents from profiles, then copy all three installed surfaces from # the clone into ~/.claude. A persona exists as three self-contained files # (profile, slash command, subagent), so editing one installed copy leaves the @@ -1591,6 +1631,9 @@ Make sure you are running this from the claude-team-cli repo." cp "$repo_dir/commands"/*.md "$commands_dst/" \ || sync_die "Failed to copy slash commands into $commands_dst." echo "$(green "✓") Slash commands synced to $(dim "$commands_dst")" + # After all three copies land, so the comparison sees the state the user is + # left with rather than the one they started from. + warn_orphaned_personas "$repo_dir/profiles" "$agents_dst" "$commands_dst" say_session_scope echo "" echo "$(dim "Edit profiles in $repo_dir/profiles, then rerun: claude-team sync")" diff --git a/tests/run.sh b/tests/run.sh index a06a861..757077f 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -1583,6 +1583,62 @@ rm -rf "$INSTALL_HOME" "$INSTALL_REPO" echo "" +# sync only ever copies. Deleting or renaming a persona in the clone therefore +# leaves its three installed files behind, and the roster is read from the +# installed profiles rather than from the repo, so the removed persona keeps +# appearing in 'claude-team list' and its '/' command keeps resolving. +# +# Runs from a throwaway copy of the repo, never REPO_DIR, for the reason given +# in the install.sh block above: sync resolves its repo directory from the CLI's +# own path and regenerates agents/ and commands/ there, so running it against the +# real clone would rewrite this repo's own tracked files. +echo "sync reports personas left behind by a delete or rename" + +SYNC_REPO=$(mktemp -d) +cp -R "$REPO_DIR"/profiles "$REPO_DIR"/commands "$REPO_DIR"/agents \ + "$REPO_DIR"/scripts "$REPO_DIR"/bin "$SYNC_REPO/" +SYNC_HOME=$(mktemp -d) +SYNC_CLI="$SYNC_REPO/bin/claude-team" +# HOME only, with no CLAUDE_TEAM_PROFILES: the point of this test is the default +# installed location, which is what sync compares the repo against. +out=$(HOME="$SYNC_HOME" "$SYNC_CLI" sync 2>&1) +assert_not_contains "a clean sync reports nothing left behind" "no longer in the repo" "$out" + +# Guard the guard: if this persona is ever renamed, the removal below becomes a +# no-op and every assertion after it would pass for the wrong reason. +if [[ -f "$SYNC_REPO/profiles/piper.md" ]]; then + ok "the persona to remove exists, so these tests are not vacuous" +else + fail "the persona to remove exists, so these tests are not vacuous (piper renamed: repoint this test)" +fi +rm -f "$SYNC_REPO/profiles/piper.md" "$SYNC_REPO/agents/piper.md" "$SYNC_REPO/commands/piper.md" +out=$(HOME="$SYNC_HOME" "$SYNC_CLI" sync 2>&1) +assert_contains "sync names the persona left behind" "piper" "$out" +assert_contains "sync says why it is still installed" "no longer in the repo" "$out" +assert_contains "sync prints the command that clears it" "rm -f" "$out" + +# Reported, not deleted. sync shares ~/.claude/commands and ~/.claude/agents with +# files it never wrote, and has no marker separating its own from a user's, so it +# must not rm on a guess about ownership. +if [[ -f "$SYNC_HOME/.claude/team/piper.md" ]]; then ok "sync deletes no leftover profile" +else fail "sync deletes no leftover profile (IT WAS DELETED)"; fi +if [[ -f "$SYNC_HOME/.claude/commands/piper.md" ]]; then ok "sync deletes no leftover slash command" +else fail "sync deletes no leftover slash command (IT WAS DELETED)"; fi +if [[ -f "$SYNC_HOME/.claude/agents/piper.md" ]]; then ok "sync deletes no leftover subagent" +else fail "sync deletes no leftover subagent (IT WAS DELETED)"; fi + +# A persona still in the repo must not be reported, or the warning is noise. +# Scoped to the report line, not the whole run: say_session_scope prints +# "slash commands (/akira, /robin, ...)" on every sync, so asserting against all +# of $out would fail on text that has nothing to do with this check. +orphan_report=$(grep "no longer in the repo" <<< "$out" || true) +assert_not_contains "the report names only the removed persona" "robin" "$orphan_report" +# The leftover showing in the roster is the symptom the report exists to explain. +out=$(CLAUDE_TEAM_PROFILES="$SYNC_HOME/.claude/team" HOME="$SYNC_HOME" "$SYNC_CLI" list 2>&1) +assert_contains "the leftover persona still shows in list, which is the symptom" "Piper" "$out" +rm -rf "$SYNC_REPO" "$SYNC_HOME" +echo "" + # ─── Documentation drift ────────────────────────────────────────────────────── # # Five sessions in a row ran an open-ended "review the docs for drift" prompt