From d1755289382ebae8b9f34375318fb9a5407f9bb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 19:45:17 +0000 Subject: [PATCH 1/2] fix: close three data-loss paths found by the audit, and the doc claims that were false Three auditors worked independently and all three found the same meta-pattern: a fix applied to one instance of a bug without checking its siblings. Every finding below was reproduced before the fix and is now covered by a test that fails against the previous code. session done deleted gitignored files. The earlier fix replaced two git diff calls with git status --porcelain, which sees untracked files but not ignored ones, and git worktree remove deletes those. A .env holding the only copy of a credential was destroyed at exit 0. The class survived the fix; only the instance closed. The guard now passes --ignored, and exempts .claude-session, which this command created and is meant to remove. Without that exemption the guard blocked every close, which the suite caught immediately. Damaged marker pairs in ~/.claude/CLAUDE.md destroyed user content. Both block_install and block_remove find the block by scanning for START then END, and with END missing they deleted everything from START to end of file. That file is one the tool tells users to hand-edit, so a lost marker is an ordinary accident, and it took a personal runbook with it at exit 0. Both writers now refuse when the markers are unbalanced or duplicated, rather than guessing which of the user's lines belong inside the block. A literal pipe in a branch name or plan slug silently corrupted the index. It is stored as a pipe-delimited table read back with awk -F'|', git accepts a pipe in a ref name, and "sprint-42 | scoped" is an ordinary slug. The tool printed a success line, then status reported none, done could not close the row, and session done deleted the worktree while the row stayed active forever. Both write sites now reject the character, which is cheaper than making every reader robust to a broken row. session start validates before creating the worktree, so a rejection leaves no orphan. Separately, session done now says so when its rewrite matched no row, since silence there is what turned a corrupt row into a success message. Test infrastructure: the branch section ran against the ambient working directory and asserted the repo's own name, so it failed in a differently-named clone and failed ten ways outside a git repo, both of which happen to contributors using this tool's own worktree sessions. It now builds a fixture like every other git-touching section. A mktemp -d nested inside a string leaked one directory on every run. Nothing asserted the generated description: field, which Claude Code reads to choose a subagent, so collapsing all seventeen to one string passed 209 tests; a content assertion now catches it, because regenerate-and-diff structurally cannot. Documentation: gtm.md is a published document that sold "Ten specialists" and "Bash 3.2+" while the product has seventeen and hard-exits below Bash 4, so a macOS reader following it hit a fatal error on first run. plugin.json hardcoded "Seventeen", the same drift just fixed in the tests; the count is now simply gone, since a number that is not written cannot go stale. The test count in README and ROADMAP said 175 against an actual 209. 223 tests, up from 209. shellcheck clean. Suite now passes from a renamed clone and from outside a git repo, and leaks no temp directories. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- .claude-plugin/plugin.json | 2 +- README.md | 2 +- ROADMAP.md | 2 +- bin/claude-team | 74 +++++++++++++++++- gtm.md | 10 +-- tests/run.sh | 151 ++++++++++++++++++++++++++++++++++++- 6 files changed, 227 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 20948c7..2e20bc0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "claude-team", - "description": "Seventeen named specialist personas for Claude Code: session-scoped /name switching, delegation subagents with model tiers, a persona session launcher, and coordinator workflows with branch hygiene.", + "description": "Named specialist personas for Claude Code: session-scoped /name switching, delegation subagents with model tiers, a persona session launcher, and coordinator workflows with branch hygiene.", "version": "2.0.0", "author": { "name": "Code Katz" } } diff --git a/README.md b/README.md index 38cd106..369fdd6 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ The `0.4` through `0.7` numbers used during development are retired. v1 is the f - `claude-team sync` propagates a profile edit to all three installed copies - `claude-team install-hook` registers session context on the clone install path - Plain technical English standard for the six coding specialists ([WRITING.md](WRITING.md)) -- 175-test suite covering the CLI commands, both coordinator modes, and the install path +- 223-test suite covering the CLI commands, both coordinator modes, and the install path ### v1.0 diff --git a/ROADMAP.md b/ROADMAP.md index 4714177..14a73ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ v1 delivered the roster and the coordinator. v2 closes the gaps that made the te - [x] Slash commands generated from profiles, so a new persona means writing one file instead of three - [x] Session handoff briefing: every persona defines a Handoff Brief, the coordinator asks for one at a switch, and it now reaches all four delivery surfaces including the `/name` slash commands - [x] Parallel session prompts carry a Context field, so a session starts from what was already decided instead of re-deriving it -- [x] 175-test suite covering the CLI commands, both coordinator modes, and the install path +- [x] 223-test suite covering the CLI commands, both coordinator modes, and the install path ### Shipped — v1.0 diff --git a/bin/claude-team b/bin/claude-team index 457b0d8..37ad4c4 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -298,10 +298,34 @@ get_active() { # destroy the user's file. The lock covers the whole read-modify-write. Two # sessions writing different blocks of the same file otherwise lose one of # them, or each fail to see the other's marker and append a second copy. +# Refuse to rewrite a file whose marker pair is damaged. block_install and +# block_remove both locate the block by scanning for START and END; if END is +# missing, both delete everything from START to end of file, which is the user's +# own content on a file they are told to hand-edit. Reproduced: a CLAUDE.md with +# a lost END marker lost a personal runbook to 'claude-team use', exit 0. +# +# Substring matching is why a bare count is the right check: a line merely +# mentioning the marker inside the user's prose is indistinguishable from the +# marker itself, so two STARTs is equally unsafe to rewrite blind. +block_assert_sane() { + local file="$1" start="$2" end="$3" label="$4" starts ends + [[ -f "$file" ]] || return 0 + starts=$(grep -cF "$start" "$file" || true) + ends=$(grep -cF "$end" "$file" || true) + [[ "$starts" == "$ends" && "$starts" -le 1 ]] && return 0 + die "The $label markers in $file are damaged: $starts start, $ends end (expected one of each, or none). +Refusing to rewrite, because repairing this automatically would guess at which +of your own lines belong inside the block. Fix the markers by hand, or delete +the block entirely, then rerun. The markers are: + $start + $end" +} + block_install() { local file="$1" start="$2" end="$3" content="$4" lock_acquire "$file" touch "$file" + block_assert_sane "$file" "$start" "$end" "block" local tmp tmp=$(tmp_beside "$file") if grep -qF "$start" "$file"; then @@ -324,6 +348,7 @@ block_install() { block_remove() { local file="$1" start="$2" end="$3" lock_acquire "$file" + block_assert_sane "$file" "$start" "$end" "block" local tmp tmp=$(tmp_beside "$file") awk -v s="$start" -v e="$end" ' @@ -616,6 +641,20 @@ get_active_branch() { # ─── Branch commands ────────────────────────────────────────────────────────── +# The branch index is a pipe-delimited table read back with awk -F'|', so a +# literal '|' in any field shifts every column after it. Nothing rejected one, +# and git itself accepts a '|' in a ref name, so 'branch start feat/a|b' or a +# plan slug like "sprint-42 | scoped" printed a success line and then wrote a +# row the tool could never read back: status reported none, done could not close +# it, and the row stayed active forever. Rejecting at the two write sites is +# cheaper and safer than making every reader robust to a broken row. +assert_index_safe() { + local value="$1" label="$2" + [[ "$value" != *"|"* ]] || die "The $label may not contain '|': $value +The branch index is a pipe-delimited table, so that character would corrupt the +row and the entry could not be read back. Use a different separator." +} + cmd_branch_start() { [[ $# -ge 1 ]] || die "Usage: claude-team branch start [--plan ]" local branch_name="$1" @@ -649,6 +688,10 @@ Run 'claude-team branch done' or 'claude-team branch abandon' before starting a ensure_branches_index + assert_index_safe "$branch_name" "branch name" + assert_index_safe "$plan_slug" "plan slug" + assert_index_safe "$project" "project name" + local today today=$(date '+%Y-%m-%d') printf '| %s | %s | %s | %s | active | |\n' \ @@ -948,6 +991,11 @@ cmd_session_start() { esac done + # Fail before creating a worktree, not after: a rejected row here would + # otherwise leave an unregistered worktree on disk. + assert_index_safe "$branch_name" "branch name" + assert_index_safe "$plan_slug" "plan slug" + local repo_root project worktree_path repo_root=$(git rev-parse --show-toplevel 2>/dev/null) \ || die "Not in a git repo. Run this from your project's root directory." @@ -1057,13 +1105,23 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no # that had not been git added yet was deleted below with no warning and a # zero exit. A file you have written but not staged is the most ordinary # state in a working session, so that was the common case, not an edge one. + # --ignored is not optional. Plain --porcelain omits ignored files, and + # 'git worktree remove' deletes them, so without it a .env holding the only + # copy of a credential was destroyed with a zero exit. A file is gitignored + # because it is generated or because it is secret, and the second kind exists + # nowhere else. + # .claude-session is excluded because this command created it: session start + # writes the marker and adds it to info/exclude, so it shows up as ignored in + # every worktree and would otherwise block every close. It is the one ignored + # path the tool owns and is meant to remove. local dirty - dirty=$(git -C "$worktree_path" status --porcelain 2>/dev/null) + dirty=$(git -C "$worktree_path" status --porcelain --ignored 2>/dev/null \ + | grep -v '^!! \.claude-session$' || true) if [[ -n "$dirty" ]]; then echo "$(red "⚠") Worktree still has uncommitted work:" >&2 printf '%s\n' "$dirty" | sed 's/^/ /' >&2 echo " Commit it, stash it, or delete the files, then close the session." >&2 - echo " $(dim "Untracked files count: they are not in any commit and would be lost.")" >&2 + echo " $(dim "'??' is untracked and '!!' is ignored. Neither is in any commit.")" >&2 exit 1 fi @@ -1092,8 +1150,20 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no print } ' "$BRANCHES_INDEX" > "$tmp" || die "Failed to rewrite the branch index: $BRANCHES_INDEX" + # An unchanged file means the awk matched no row, so the session is about to + # be closed while the index still calls it active. Say so: silence here is + # what turns a malformed row into a success message and a permanent ghost + # entry. The worktree removal below is still what the user asked for. + # An 'if', not 'cmp && assign': the files differ in the normal case, so the + # && list would return non-zero and set -e would kill the function here. + local index_unchanged=false + if cmp -s "$tmp" "$BRANCHES_INDEX"; then index_unchanged=true; fi tmp_commit "$tmp" "$BRANCHES_INDEX" lock_release + if [[ "$index_unchanged" == true ]]; then + echo "$(yellow "!") No active index row matched '$branch' for '$project', so nothing was marked merged." >&2 + echo " $(dim "Closing the worktree anyway. Check: claude-team branch list")" >&2 + fi # Remove the worktree (git requires this from outside it). No --force # fallback: git refuses to remove a worktree holding modified or untracked diff --git a/gtm.md b/gtm.md index 1f55ff0..89b57d1 100644 --- a/gtm.md +++ b/gtm.md @@ -21,7 +21,7 @@ claude-team-cli is not a prompt library. It's not a collection of system prompts It's a named, opinionated specialist who shows up with domain expertise, asks the questions a senior practitioner would ask, and pushes back when something's off. Ten of them, covering the full product development lifecycle from discovery to launch. -**One-line positioning:** *Your AI development team. Ten specialists, one CLI, zero meetings.* +**One-line positioning:** *Your AI development team. Seventeen specialists, one CLI, zero meetings.* ### Differentiation Hook @@ -206,7 +206,7 @@ The scenario is absurd enough to be memorable and complex enough to be real. ### Meet My Claude Dev Team -**Ten specialists. One CLI. Zero meetings.** +**Seventeen specialists. One CLI. Zero meetings.** --- @@ -308,7 +308,7 @@ Switch mid-session without restarting: /morgan /alex /robin /toni /quinn ``` -Works on macOS and Linux. Requires Bash 3.2+ and Claude Code. +Works on macOS and Linux. Requires Bash 4+ and Claude Code. macOS ships Bash 3.2, so run `brew install bash` first. **Bonus:** Two companion skills — [claude-devlog-skill](https://github.com/code-katz/claude-devlog-skill) and [claude-roadmap-skill](https://github.com/code-katz/claude-roadmap-skill) — give the team persistent memory. The devlog captures architectural decisions across sessions. The roadmap tracks priorities and records every reprioritization. Install both with two commands and your team doesn't just think differently — it *remembers* differently. More on these in a later post. @@ -335,7 +335,7 @@ Same product, ten different specialists, and the tools that make sure none of it ### Post Body (paste directly into LinkedIn) ``` -I built a 10-person AI dev team inside Claude Code. +I built a 17-person AI dev team inside Claude Code. Not prompt templates. A CLI that gives Claude named specialist personas — senior consultants with domain @@ -413,7 +413,7 @@ What would you automate first? ``` Repos: -Team CLI (11 specialist personas): +Team CLI (17 specialist personas): github.com/code-katz/claude-team-cli Companion skills: diff --git a/tests/run.sh b/tests/run.sh index 525ec10..0096b46 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -309,7 +309,8 @@ echo "" # it hardcodes the real PROFILES_DIR, and this needs a missing one instead. echo "profiles directory entirely missing" -NO_PROFILES="$(mktemp -d)/does-not-exist" +NO_PROFILES_BASE=$(mktemp -d) +NO_PROFILES="$NO_PROFILES_BASE/does-not-exist" NOPROF_HOME=$(mktemp -d) if CLAUDE_TEAM_PROFILES="$NO_PROFILES" HOME="$NOPROF_HOME" "$CLI" list >/dev/null 2>&1; then fail "list fails safely when the profiles dir is missing" @@ -333,7 +334,7 @@ else fi assert_file_lacks "a failed use writes nothing to CLAUDE.md when the profiles dir is missing" \ "$NOPROF_HOME/.claude/CLAUDE.md" "CLAUDE-TEAM:START" -rm -rf "$NOPROF_HOME" +rm -rf "$NOPROF_HOME" "$NO_PROFILES_BASE" echo "" # content preservation: use/reset and coordinator on/off must round-trip the @@ -407,6 +408,20 @@ echo "branch commands" BRANCHES_INDEX="$TEST_HOME/.claude/branches/INDEX.md" +# Own fixture, like every other git-touching section. This block used to run +# against the ambient CWD and assert the literal string "claude-team-cli" as the +# project name, so it failed in a differently-named clone and failed ten ways +# when the suite was run from outside a git repo at all. Both are ordinary: +# 'claude-team session start' puts contributors in a worktree named for the +# branch, not the repo. +BRANCH_REPO=$(mktemp -d) +BRANCH_PROJECT=$(basename "$BRANCH_REPO") +git init -q "$BRANCH_REPO" +git -C "$BRANCH_REPO" commit -q --allow-empty -m init +run_cmd() { + (cd "$BRANCH_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" "$@") +} + # status warns when no index exists out=$(run_cmd branch status 2>&1) assert_contains "branch status warns when no active branch" "none|No branch|no active|No active" "$out" @@ -415,7 +430,7 @@ assert_contains "branch status warns when no active branch" "none|No branch|no a run_cmd branch start feat/test-branch >/dev/null assert_file_has "branch start creates index" "$BRANCHES_INDEX" "Branch Index" assert_file_has "branch start writes branch name" "$BRANCHES_INDEX" "feat/test-branch" -assert_file_has "branch start writes project name" "$BRANCHES_INDEX" "claude-team-cli" +assert_file_has "branch start writes project name" "$BRANCHES_INDEX" "$BRANCH_PROJECT" assert_file_has "branch start writes active status" "$BRANCHES_INDEX" "active" # status shows active branch @@ -423,7 +438,11 @@ out=$(run_cmd branch status) assert_contains "branch status shows active branch" "feat/test-branch" "$out" # start blocked when active already exists -assert_exits_nonzero "branch start blocked when active exists" "$CLI" branch start feat/duplicate +if (cd "$BRANCH_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch start feat/duplicate >/dev/null 2>&1); then + fail "branch start blocked when active exists" +else + ok "branch start blocked when active exists" +fi # done marks merged out=$(run_cmd branch "done" 2>&1) @@ -484,6 +503,12 @@ rm -f "$_foreign_hook_src" rm -rf "$GUARD_REPO" echo "" +# The branch section overrode run_cmd with a fixture-scoped version; restore it. +run_cmd() { + CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" "$@" +} +rm -rf "$BRANCH_REPO" + # session commands echo "session commands" @@ -618,6 +643,41 @@ if [[ -d "$STAGED_WT" ]]; then ok "worktree survives a refused session done (sta else fail "worktree survives a refused session done (staged new file)"; fi rm -rf "$STAGED_REPO" +# Gitignored file. The first fix for this bug used plain --porcelain, which +# omits ignored files, and 'git worktree remove' deletes them. A .env is +# gitignored precisely because it is secret, so it exists nowhere else. The fix +# moved the bug from untracked to ignored rather than closing the class. +IGN_REPO=$(mktemp -d) +git init -q "$IGN_REPO" +printf '.env\n' > "$IGN_REPO/.gitignore" +git -C "$IGN_REPO" add .gitignore +git -C "$IGN_REPO" commit -q -m init +(cd "$IGN_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/ignored >/dev/null 2>&1) +IGN_WT="$SESSION_WORKTREES/$(basename "$IGN_REPO")/feat-ignored" +printf 'SECRET_DB_PASSWORD=hunter2\n' > "$IGN_WT/.env" +if (cd "$IGN_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1); then + fail "session done refuses a gitignored file" +else + ok "session done refuses a gitignored file" +fi +if [[ -f "$IGN_WT/.env" ]]; then ok "a gitignored secret survives a refused session done" +else fail "a gitignored secret survives a refused session done (THE FILE WAS DELETED)"; fi +rm -rf "$IGN_REPO" + +# The tool's own .claude-session marker is gitignored in every worktree, so the +# --ignored guard must exempt it or no session could ever be closed. +CLEAN_REPO=$(mktemp -d) +git init -q "$CLEAN_REPO" +git -C "$CLEAN_REPO" commit -q --allow-empty -m init +(cd "$CLEAN_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/clean >/dev/null 2>&1) +CLEAN_WT="$SESSION_WORKTREES/$(basename "$CLEAN_REPO")/feat-clean" +if (cd "$CLEAN_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1); then + ok "a clean worktree still closes despite its ignored marker" +else + fail "a clean worktree still closes despite its ignored marker" +fi +rm -rf "$CLEAN_REPO" + # Untracked new file. This is the case that lost data: 'git diff' and 'git diff # --cached' are both blind to a file that was never added, so it passed the # guard, and the '|| ... --force' fallback then overrode git's own refusal to @@ -646,6 +706,72 @@ assert_contains "the refusal names the untracked file" "untracked.txt" "$out" rm -rf "$UNTRACKED_REPO" echo "" +# A '|' in any indexed field shifts every awk column after it, so the tool wrote +# rows it could never read back: status said none, done could not close them, +# and session done printed success while the row stayed active forever. git +# itself accepts '|' in a ref name, and "sprint-42 | scoped" is an ordinary plan +# slug, so this needed no adversary. +echo "index delimiter safety" +PIPE_REPO=$(mktemp -d) +git init -q "$PIPE_REPO" +git -C "$PIPE_REPO" commit -q --allow-empty -m init +if (cd "$PIPE_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch start 'feat/a|b' >/dev/null 2>&1); then + fail "branch start rejects a pipe in the branch name" +else + ok "branch start rejects a pipe in the branch name" +fi +if (cd "$PIPE_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch start feat/ok --plan 'sprint | scoped' >/dev/null 2>&1); then + fail "branch start rejects a pipe in the plan slug" +else + ok "branch start rejects a pipe in the plan slug" +fi +if (cd "$PIPE_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start 'feat/c|d' >/dev/null 2>&1); then + fail "session start rejects a pipe in the branch name" +else + ok "session start rejects a pipe in the branch name" +fi +# The rejection must happen before any worktree is created. +if [[ -z "$(find "$TEST_HOME/.claude/worktrees" -name 'feat-c*' 2>/dev/null)" ]]; then + ok "a rejected session name leaves no orphan worktree" +else fail "a rejected session name leaves no orphan worktree"; fi +# A registered branch must still be readable back, which is what pipes broke. +(cd "$PIPE_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch start feat/readable >/dev/null 2>&1) +out=$( (cd "$PIPE_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch status) 2>&1) +assert_contains "a registered branch reads back from the index" "feat/readable" "$out" +rm -rf "$PIPE_REPO" +echo "" + +# CLAUDE.md is a file the user is told to hand-edit, so its markers get damaged. +# block_install and block_remove both locate the block by scanning for START +# then END; with END missing they deleted everything from START to end of file, +# which is the user's own content, at exit 0. +echo "damaged marker blocks" +DMG_HOME=$(mktemp -d) +mkdir -p "$DMG_HOME/.claude" +printf '\nstale block, END lost\n# my runbook\n- step one\n' > "$DMG_HOME/.claude/CLAUDE.md" +if CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$DMG_HOME" "$CLI" use akira >/dev/null 2>&1; then + fail "use refuses a file with an unbalanced marker pair" +else + ok "use refuses a file with an unbalanced marker pair" +fi +assert_file_has "user content below a damaged block survives" "$DMG_HOME/.claude/CLAUDE.md" "step one" +if CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$DMG_HOME" "$CLI" reset >/dev/null 2>&1; then + fail "reset refuses a file with an unbalanced marker pair" +else + ok "reset refuses a file with an unbalanced marker pair" +fi +assert_file_has "user content survives a refused reset" "$DMG_HOME/.claude/CLAUDE.md" "step one" +# Two STARTs is equally unsafe: matching is a substring test, so a marker quoted +# in the user's own prose is indistinguishable from the real one. +printf '\nA\n\n\nB\n\n' > "$DMG_HOME/.claude/CLAUDE.md" +if CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$DMG_HOME" "$CLI" use akira >/dev/null 2>&1; then + fail "use refuses a file with duplicated marker pairs" +else + ok "use refuses a file with duplicated marker pairs" +fi +rm -rf "$DMG_HOME" +echo "" + # Shared-state concurrency. ~/.claude/branches/INDEX.md and ~/.claude/CLAUDE.md # are shared across every session on the machine, and parallel sessions are the # product's headline feature, so concurrent access is the designed-for case. @@ -855,6 +981,23 @@ agent_count=${#agent_files[@]} [[ -e "${agent_files[0]}" ]] || agent_count=0 if [[ "$agent_count" == "$PERSONA_COUNT" ]]; then ok "every persona has a generated subagent" else fail "every persona has a generated subagent (expected $PERSONA_COUNT, got $agent_count)"; fi +# description: is the field Claude Code reads to pick a subagent for automatic +# delegation. Collapsing it to one identical string across all seventeen would +# silently disable meaningful delegation, and a regenerate-and-diff check cannot +# see it: the generator would agree with itself. Only a content assertion can. +descdrift="" +while IFS=$'\t' read -r slug display _role; do + grep -q "^description: $display," "$REPO_DIR/agents/$slug.md" || descdrift="$descdrift $slug" +done < <( + for _pf in "$REPO_DIR"/profiles/*.md; do + _sl=$(basename "$_pf" .md) + case "$_sl" in coordinator*) continue ;; esac + _ti=$(grep -m1 '^# ' "$_pf" | sed 's/^# //') + printf '%s\t%s\t%s\n' "$_sl" "${_ti%% —*}" "${_ti#*— }" + done +) +if [[ -z "$descdrift" ]]; then ok "each subagent description names its own persona" +else fail "each subagent description names its own persona (wrong:$descdrift)"; fi assert_contains "akira agent carries model tier" "model: claude-fable-5" "$(cat "$REPO_DIR/agents/akira.md")" assert_contains "iris agent carries model tier" "model: claude-opus-4-8" "$(cat "$REPO_DIR/agents/iris.md")" assert_contains "agents marked as generated" "GENERATED from profiles" "$(cat "$REPO_DIR/agents/robin.md")" From 39ba39d369cf6738f38b29a526c0fc60c48839b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 23:17:44 +0000 Subject: [PATCH 2/2] fix: stop redefining run_cmd, which CI's shellcheck rejects The branch section needed a git fixture, and I gave it one by redefining run_cmd mid-file and restoring the original afterwards. CI's shellcheck raises SC2218 on that ("this function is only defined later"), because once a name has several definitions every earlier call becomes ambiguous about which body it reaches. The same ambiguity applies to a human reader, so the error is fair. Replaced with a separate run_branch helper defined once, beside run_cmd, at the top of the file. Nine calls inside the branch section point at it. One definition per name now. Worth recording: local shellcheck 0.11.0 reports zero SC2218 on the exact file CI rejected, verified by linting that committed blob directly. The check is present in 0.11.0 and fires on a simple synthetic case, so the two versions disagree specifically about redefinition. Every "lint clean" claim I made today rested on the local run, and this is the case that proves the local run is not a proxy for the gate. 223 tests, unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- tests/run.sh | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 0096b46..bb6467b 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -42,6 +42,14 @@ run_cmd() { CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" "$@" } +# The branch section needs a git repo to work in. A separate helper rather than +# a mid-file redefinition of run_cmd: redefining a function trips shellcheck +# (SC2218) and makes every earlier call ambiguous to a human reader too. +# $BRANCH_REPO is set by that section before anything calls this. +run_branch() { + (cd "$BRANCH_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" "$@") +} + # ─── Assertion helpers ─────────────────────────────────────────────────────── ok() { PASS=$((PASS + 1)); printf " \033[32m✓\033[0m %s\n" "$1"; } @@ -418,23 +426,20 @@ BRANCH_REPO=$(mktemp -d) BRANCH_PROJECT=$(basename "$BRANCH_REPO") git init -q "$BRANCH_REPO" git -C "$BRANCH_REPO" commit -q --allow-empty -m init -run_cmd() { - (cd "$BRANCH_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" "$@") -} # status warns when no index exists -out=$(run_cmd branch status 2>&1) +out=$(run_branch branch status 2>&1) assert_contains "branch status warns when no active branch" "none|No branch|no active|No active" "$out" # start registers a branch -run_cmd branch start feat/test-branch >/dev/null +run_branch branch start feat/test-branch >/dev/null assert_file_has "branch start creates index" "$BRANCHES_INDEX" "Branch Index" assert_file_has "branch start writes branch name" "$BRANCHES_INDEX" "feat/test-branch" assert_file_has "branch start writes project name" "$BRANCHES_INDEX" "$BRANCH_PROJECT" assert_file_has "branch start writes active status" "$BRANCHES_INDEX" "active" # status shows active branch -out=$(run_cmd branch status) +out=$(run_branch branch status) assert_contains "branch status shows active branch" "feat/test-branch" "$out" # start blocked when active already exists @@ -445,26 +450,26 @@ else fi # done marks merged -out=$(run_cmd branch "done" 2>&1) +out=$(run_branch branch "done" 2>&1) assert_contains "branch done output mentions merged" "merged" "$out" assert_file_has "branch done updates status in index" "$BRANCHES_INDEX" "merged" # status after done shows none -out=$(run_cmd branch status 2>&1) +out=$(run_branch branch status 2>&1) assert_contains "branch status after done shows none" "none|No branch|no active|No active" "$out" # start with --plan links a plan slug -run_cmd branch start feat/with-plan --plan some-plan-slug >/dev/null +run_branch branch start feat/with-plan --plan some-plan-slug >/dev/null assert_file_has "branch start --plan writes plan slug" "$BRANCHES_INDEX" "some-plan-slug" # abandon marks abandoned -out=$(run_cmd branch abandon 2>&1) +out=$(run_branch branch abandon 2>&1) assert_contains "branch abandon output mentions abandoned" "abandoned" "$out" assert_file_has "branch abandon updates status in index" "$BRANCHES_INDEX" "abandoned" # list shows full table -run_cmd branch start feat/listable >/dev/null -out=$(run_cmd branch list 2>&1) +run_branch branch start feat/listable >/dev/null +out=$(run_branch branch list 2>&1) assert_contains "branch list shows header" "Branch Index" "$out" assert_contains "branch list shows branch entry" "feat/listable" "$out" @@ -503,10 +508,6 @@ rm -f "$_foreign_hook_src" rm -rf "$GUARD_REPO" echo "" -# The branch section overrode run_cmd with a fixture-scoped version; restore it. -run_cmd() { - CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" "$@" -} rm -rf "$BRANCH_REPO" # session commands