diff --git a/DEVLOG.md b/DEVLOG.md index 092e3c1..6968c85 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -5,6 +5,51 @@ Auto-maintained via Claude devlog skill. Entries are reverse-chronological. --- +## [2026-07-29] Two stale promises: a customization layer that should not exist, and a feature that already did + +**Category:** `decision` +**Tags:** `roadmap`, `docs`, `handoff`, `codegen`, `messaging`, `dogfooding` + +**Risk Level:** `low` +**Breaking Change:** `no` + +### Summary + +PRs #23 and #24 close the same kind of gap from opposite directions: the documentation described a product that did not match the code. #23 removed a promise that should never be kept. #24 found a shipped feature listed as unbuilt, and fixed the one surface where it failed to arrive. The suite went 163 to 175. + +### Detail + +**Local profile overrides were retired rather than deferred.** They had been the top unbuilt roadmap item since v2 planning. The reasoning that killed them came from the product owner and is worth preserving verbatim in spirit: an override layer creates a second source of persona truth competing with the repo, and no reliable way to tell whether Akira is behaving like upstream Akira or like a local edit. Team-scoped profiles, the same layer at project scope via `claude-team init` and `.claude-team/`, went for the same reason. The supported paths are the two that already existed, a pull request or a fork, and `CONTRIBUTING.md` now documents both along with persona authoring. + +**The installer was overstating what it had activated, and the first diagnosis was wrong.** The claim was that it advertised subagents as ready to delegate to. That message had been deleted by `cbbb7a4` when `install.sh` began delegating to `claude-team sync`. The real defect was silence. Claude Code registers subagents and hooks when a session starts and reads slash commands on demand, so a sync activates one of three surfaces immediately. Nothing said so, three green checkmarks in a row read as three live capabilities, and `install.sh` closed with "Your Claude dev team is ready." The SessionStart hook had the starkest form of it, printing a bare checkmark for something that cannot by definition fire in the session that registers it. + +**Session handoff briefing was listed as aspirational and had shipped with the personas.** All seventeen carried a Handoff Brief section with byte-identical opening and closing stems; both coordinator profiles instructed the coordinator to ask for one at a switch; three earlier DEVLOG entries treat "3 behaviors + handoff" as a structural invariant. The roadmap entry had been written by paraphrasing the already-shipped `robin.md` text. + +**The defect was delivery, and it landed on the worst surface available.** `### Handoff Brief` sat inside `## Required Interactive Behaviors`, which `strip_interactive_behaviors` excises from every slash command: 17 of 17 subagents carried it, 0 of 17 slash commands did. `coordinator.md:126` tells the user to run `/` at the exact moment of a handoff, and `:128` then tells the coordinator to ask that persona for a brief. The asker had the instruction. The answerer, on the path the coordinator itself recommends, had never been told what a Handoff Brief is. + +**A test asserted the bug as correct.** The commands drift check mirrored the stripper's awk exactly, so the absence was not an oversight that slipped through review; it was pinned in place by CI. This is the third defect in two days where a green suite protected the wrong behavior, after the hook tests that passed while the hook never installed, and the three-copy persona problem that produced no error. + +### Decisions Made + +- **The heading was promoted rather than the stripper made cleverer.** `### Handoff Brief` became `## Handoff Brief`, and the stripper's end marker moved from `## Signature Question` to it. This codifies what the structure already implied: the section was deliberately unnumbered while its siblings are `### 1.`, `### 2.`, `### 3.` +- **The generator now refuses a profile with no `## Handoff Brief`.** The stripper resets its skip flag on that marker, so a profile lacking it would silently drop every later section from the slash command. The identical latent failure exists with `## Signature Question` and goes unnoticed only because all seventeen happen to have one. +- **Handoffs stay prompt-only.** No file, no command, no stored state. This keeps the feature clear of the shared-state locking the branch index needed, and it was the product owner's explicit call. +- **Parallel session prompts gained a fourth Context field.** They carried Persona, Task, and File scope, so every session re-derived context it was never given. A finished session with dependents now writes a brief that becomes the downstream Context. +- **Dated history is never rewritten.** The roadmap row promoting overrides to top priority stays verbatim; new rows record the retirement and the correction. + +### Mistakes Worth Recording + +- **A control test passed for the wrong reason.** Proving the traversal tests were meaningful required running them against pre-fix code, but the scratch tree was missing `gtm.md`, so the traversal resolved to nothing and every assertion passed against unfixed code. The suite now asserts the traversal target exists before testing against it. The same mistake nearly recurred on the handoff control. +- **The installer diagnosis was wrong in its specifics** and was corrected in the PR body rather than quietly restated. The message being blamed had been deleted three commits earlier. + +### Related + +- PR #23, PR #24 +- `CONTRIBUTING.md`: the supported customization paths and persona authoring +- ROADMAP: the aspirational list is now empty, two items retired and one found already shipped + +--- + ## [2026-07-29] v2 hardening: four specialists, a real race, and a lint I broke **Category:** `milestone` diff --git a/bin/claude-team b/bin/claude-team index f7ce362..457b0d8 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -92,7 +92,12 @@ _lock_owner() { cat "$1/owner" 2>/dev/null || true; } _lock_is_stale() { local lock_dir="$1" host pid started now [[ -d "$lock_dir" ]] || return 1 - if ! read -r host pid started < "$lock_dir/owner" 2>/dev/null; then + # The redirect must precede the command. Bash applies redirections left to + # right and aborts on the first failure, so a trailing 2>/dev/null is never + # installed when the '<' itself fails. A missing owner file is the normal + # transient state described above, so the old form leaked a raw bash error to + # the terminal on roughly half of all contended calls. + if ! 2>/dev/null read -r host pid started < "$lock_dir/owner"; then return 1 fi now=$(date '+%s') @@ -237,6 +242,35 @@ title_name() { printf '%s\n' "${1%% —*}" } +# Drop a trailing "Consultant" or "Manager", which reads as noise in a one-line +# roster, unless dropping it would leave a single word: "Product Manager" must +# not become "Product". Mirrors the rule in scripts/generate-agents.sh. +short_role() { + local role="$1" + case "$role" in + *" Consultant" | *" Manager") + case "${role% *}" in *" "*) role="${role% *}" ;; esac + ;; + esac + printf '%s\n' "$role" +} + +# Emit "slugDisplayShort role" for every persona, read from the +# profiles on disk. Every roster the CLI prints derives from this. Four separate +# hardcoded copies of this list had drifted, leaving 'claude-team help' and the +# installer missing four to nine of the seventeen personas: a roster a human has +# to remember to update is a roster that goes stale. +persona_roster() { + local profile name title + for profile in "$PROFILES_DIR"/*.md; do + [[ -f "$profile" ]] || continue + name=$(basename "$profile" .md) + case "$name" in coordinator*) continue ;; esac + title=$(profile_title "$profile") + printf '%s\t%s\t%s\n' "$name" "$(title_name "$title")" "$(short_role "$(title_role "$title")")" + done +} + title_role() { printf '%s\n' "${1#*— }" } @@ -522,19 +556,10 @@ Make sure you are running this from the claude-team-cli repo." done echo "" echo "Use them in any Claude Code session:" - echo " $(dim "/robin") — switch to Robin (Testing)" - echo " $(dim "/akira") — switch to Akira (Backend)" - echo " $(dim "/sasha") — switch to Sasha (Frontend)" - echo " $(dim "/toni") — switch to Toni (Product Marketing)" - echo " $(dim "/river") — switch to River (Product)" - echo " $(dim "/alex") — switch to Alex (DevOps)" - echo " $(dim "/morgan") — switch to Morgan (Security)" - echo " $(dim "/jordan") — switch to Jordan (Data & ML)" - echo " $(dim "/casey") — switch to Casey (Data Analyst)" - echo " $(dim "/quinn") — switch to Quinn (Project Manager)" - echo " $(dim "/sage") — switch to Sage (Business Advisor)" - echo " $(dim "/kai") — switch to Kai (UX Design)" - echo " $(dim "/iris") — switch to Iris (Brand & Illustration)" + # Same derivation as cmd_help: one roster, read from the profiles on disk. + while IFS=$'\t' read -r slug display role; do + printf ' %s — switch to %s (%s)\n' "$(dim "/$slug")" "$display" "$role" + done < <(persona_roster) echo " $(dim "/team") — show current team status" echo "" echo "$(dim "No new session required — switches take effect immediately.")" @@ -1026,11 +1051,19 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no project=$(grep '^project=' "$worktree_path/.claude-session" | cut -d= -f2) branch=$(grep '^branch=' "$worktree_path/.claude-session" | cut -d= -f2) - # Warn on uncommitted changes - if ! git -C "$worktree_path" diff --quiet 2>/dev/null || \ - ! git -C "$worktree_path" diff --cached --quiet 2>/dev/null; then - echo "$(red "⚠") Uncommitted changes in worktree." >&2 - echo " Commit or stash before closing the session." >&2 + # Refuse to close a worktree that still holds work. git status --porcelain + # reports staged, unstaged, AND untracked in one pass. The previous check used + # two git diff calls, neither of which sees an untracked file, so a new file + # 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. + local dirty + dirty=$(git -C "$worktree_path" status --porcelain 2>/dev/null) + 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 exit 1 fi @@ -1062,9 +1095,15 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no tmp_commit "$tmp" "$BRANCHES_INDEX" lock_release - # Remove worktree (must be called from outside the worktree) - git -C "$repo_root" worktree remove "$worktree_path" 2>/dev/null \ - || git -C "$repo_root" worktree remove "$worktree_path" --force + # Remove the worktree (git requires this from outside it). No --force + # fallback: git refuses to remove a worktree holding modified or untracked + # files, which is the same protection as the guard above, and retrying with + # --force overrode it unconditionally. Anything git blocks here is a state the + # guard should have caught, so surface it instead of forcing through it. + git -C "$repo_root" worktree remove "$worktree_path" \ + || die "git refused to remove the worktree at $worktree_path. +The branch index already records this session as merged. Inspect the worktree, +then remove it with: git -C $repo_root worktree remove --force $worktree_path" echo "" echo "$(green "✓") Session closed: $(bold "$branch")" @@ -1291,19 +1330,11 @@ cmd_help() { echo " claude-team branch guard remove Remove the pre-commit hook" echo "" echo "$(bold "Examples:")" - echo " claude-team use robin Activate Robin (Testing)" - echo " claude-team use akira Activate Akira (Backend)" - echo " claude-team use sasha Activate Sasha (Frontend)" - echo " claude-team use toni Activate Toni (Product Marketing)" - echo " claude-team use river Activate River (Product)" - echo " claude-team use alex Activate Alex (DevOps)" - echo " claude-team use morgan Activate Morgan (Security)" - echo " claude-team use jordan Activate Jordan (Data & ML)" - echo " claude-team use casey Activate Casey (Data Analyst)" - echo " claude-team use quinn Activate Quinn (Project Manager)" - echo " claude-team use sage Activate Sage (Business Advisor)" - echo " claude-team use kai Activate Kai (UX Design)" - echo " claude-team use iris Activate Iris (Brand & Illustration)" + # Derived from the profiles on disk, so a new persona appears here the moment + # its profile lands. The previous hardcoded list was missing four of them. + while IFS=$'\t' read -r slug display role; do + printf ' claude-team use %-22s Activate %s (%s)\n' "$slug" "$display" "$role" + done < <(persona_roster) echo " claude-team coordinator on Enable coordinator (casual — commit to main, no branch gate)" echo " claude-team coordinator prod Enable coordinator (prod — branch required before code)" echo " claude-team reset Return to default Claude behavior" diff --git a/install.sh b/install.sh index 066a933..a5fa7cd 100755 --- a/install.sh +++ b/install.sh @@ -127,14 +127,7 @@ echo " SessionStart hook, and the coordinator. Slash commands work right now." echo "" echo "Quick start:" echo " claude-team list $(dim "# see your team")" -echo " claude-team use robin $(dim "# activate Robin (Testing)")" -echo " claude-team use akira $(dim "# activate Akira (Backend)")" -echo " claude-team use sasha $(dim "# activate Sasha (Frontend)")" -echo " claude-team use toni $(dim "# activate Toni (Product Marketing)")" -echo " claude-team use river $(dim "# activate River (Product)")" -echo " claude-team use sage $(dim "# activate Sage (Business Advisor)")" -echo " claude-team use kai $(dim "# activate Kai (UX Design)")" -echo " claude-team use iris $(dim "# activate Iris (Brand & Illustration)")" +echo " claude-team use $(dim "# activate a team member for all new sessions")" echo " claude-team coordinator on $(dim "# casual mode (commit to main, no branch enforcement)")" echo " claude-team coordinator prod $(dim "# prod mode (branch required before code)")" echo " claude-team coordinator off $(dim "# disable coordinator")" @@ -153,5 +146,14 @@ echo " claude-team branch abandon $(dim "# mark abandoned")" echo " claude-team branch guard install $(dim "# block accidental commits on main")" echo "" echo "Slash commands $(dim "(switch personas mid-session, no restart needed)"):" -echo " /robin /akira /sasha /toni /river /sage /kai /iris /team /branch /session" +# Derived from the profiles just installed. The hardcoded line this replaces +# listed 8 of the 17 personas, so more than half the team was undiscoverable +# from the installer's own output. +printf ' ' +for _p in "$REPO_DIR"/profiles/*.md; do + _n=$(basename "$_p" .md) + case "$_n" in coordinator*) continue ;; esac + printf '/%s ' "$_n" +done +printf '\n /team /branch /session /parallel\n' echo "" diff --git a/tests/run.sh b/tests/run.sh index 6728bae..f48a180 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -103,6 +103,39 @@ out=$(run_cmd help) assert_contains "shows tool name" "claude-team" "$out" assert_contains "lists use command" "use " "$out" assert_contains "lists install-commands" "install-commands" "$out" +# Every roster the CLI prints is derived from the profiles on disk. Four +# separate hardcoded copies had drifted, leaving help and the installer missing +# four to nine of the seventeen personas: nothing broke, but a new user could +# not discover team members that exist. A derived list cannot drift, and this +# asserts the derivation rather than the current text. +missing="" +for profile in "$PROFILES_DIR"/*.md; do + pname=$(basename "$profile" .md) + case "$pname" in coordinator*) continue ;; esac + grep -q "claude-team use $pname " <<< "$out" || missing="$missing $pname" +done +if [[ -z "$missing" ]]; then ok "help lists every persona" +else fail "help lists every persona (missing:$missing)"; fi +out=$(run_cmd install-commands) +missing="" +for profile in "$PROFILES_DIR"/*.md; do + pname=$(basename "$profile" .md) + case "$pname" in coordinator*) continue ;; esac + grep -q "/$pname" <<< "$out" || missing="$missing $pname" +done +if [[ -z "$missing" ]]; then ok "install-commands lists every persona" +else fail "install-commands lists every persona (missing:$missing)"; fi +assert_not_contains "the roster excludes the coordinator profiles" "use coordinator" "$out" +# The installer prints its own slash-command line; it must derive it too. +missing="" +for profile in "$PROFILES_DIR"/*.md; do + pname=$(basename "$profile" .md) + case "$pname" in coordinator*) continue ;; esac + grep -q "coordinator\*" "$REPO_DIR/install.sh" || true + grep -q "profiles/\*.md" "$REPO_DIR/install.sh" || missing="derived-loop-absent" +done +if [[ -z "$missing" ]]; then ok "installer derives its slash-command list from profiles" +else fail "installer derives its slash-command list from profiles"; fi echo "" # list @@ -265,6 +298,44 @@ assert_exits_nonzero "use without name exits nonzero" "$CLI" use assert_exits_nonzero "show without name exits nonzero" "$CLI" show echo "" +# A profiles directory that does not exist at all -- a fresh machine, a typo'd +# CLAUDE_TEAM_PROFILES, a clone moved without a resync -- is a real, plausible +# state, previously untested on any command. require_profiles_dir guards only +# cmd_list; show, use, and launch all fall through to resolve_name's plain "no +# profile found" message instead, which is misleading here (it points at +# 'claude-team list', which would also fail, with a different message) but +# still exits nonzero and writes nothing. That fail-safe behavior, not the +# wording, is what this pins; assert_exits_nonzero is not reused here because +# 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" +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" +else + ok "list fails safely when the profiles dir is missing" +fi +if CLAUDE_TEAM_PROFILES="$NO_PROFILES" HOME="$NOPROF_HOME" "$CLI" show robin >/dev/null 2>&1; then + fail "show fails safely when the profiles dir is missing" +else + ok "show fails safely when the profiles dir is missing" +fi +if CLAUDE_TEAM_PROFILES="$NO_PROFILES" HOME="$NOPROF_HOME" "$CLI" use robin >/dev/null 2>&1; then + fail "use fails safely when the profiles dir is missing" +else + ok "use fails safely when the profiles dir is missing" +fi +if CLAUDE_TEAM_PROFILES="$NO_PROFILES" HOME="$NOPROF_HOME" "$CLI" launch robin --dry-run >/dev/null 2>&1; then + fail "launch fails safely when the profiles dir is missing" +else + ok "launch fails safely when the profiles dir is missing" +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" +echo "" + # content preservation: use/reset and coordinator on/off must round-trip the # user's own CLAUDE.md byte-for-byte (leading blanks and trailing spaces # included), and must not leave a stray trailing blank line behind. @@ -389,6 +460,27 @@ if [[ -x "$_hook" ]]; then ok "branch guard hook is executable"; else fail "bran if grep -q "claude-team" "$_hook" 2>/dev/null; then ok "branch guard hook has claude-team marker"; else fail "branch guard hook has claude-team marker"; fi (cd "$GUARD_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch guard remove >/dev/null 2>&1) if [[ ! -f "$_hook" ]]; then ok "branch guard remove deletes hook"; else fail "branch guard remove deletes hook"; fi + +# install must refuse to clobber a pre-commit hook it did not create, rather +# than overwrite someone's own hook silently. Compared as files, not as a +# command-substitution string: $(cat ...) strips the trailing newline that +# printf wrote, so a string comparison against it fails even when the file on +# disk is byte-for-byte untouched. +_foreign_hook_src="$TEST_HOME/foreign-hook-fixture" +printf '#!/bin/sh\necho "pre-existing custom hook"\n' > "$_foreign_hook_src" +cp "$_foreign_hook_src" "$_hook" +chmod +x "$_hook" +if (cd "$GUARD_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" branch guard install >/dev/null 2>&1); then + fail "branch guard install refuses to overwrite a foreign hook" +else + ok "branch guard install refuses to overwrite a foreign hook" +fi +if cmp -s "$_hook" "$_foreign_hook_src"; then + ok "branch guard install leaves the foreign hook untouched" +else + fail "branch guard install leaves the foreign hook untouched" +fi +rm -f "$_foreign_hook_src" rm -rf "$GUARD_REPO" echo "" @@ -462,9 +554,98 @@ if [[ -d "$SESSION_WT3" ]]; then ok "session start handles metachar branch name" assert_file_has "session done marks metachar branch merged" "$SESSION_INDEX" "feat/(v1\.2\.3).*merged" if [[ ! -d "$SESSION_WT3" ]]; then ok "session done removes metachar worktree"; else fail "session done removes metachar worktree"; fi +# session start reuses an EXISTING local branch instead of creating a new one +# from the default branch. The reused branch is given its own commit, +# diverged from the default branch, so the test can tell "reused" apart from +# "recreated fresh from default": without the divergence the two look +# identical whenever they happen to start at the same commit. +_reuse_parent=$(git -C "$SESSION_REPO" rev-parse HEAD) +_reuse_tree=$(git -C "$SESSION_REPO" rev-parse "HEAD^{tree}") +_reuse_commit=$(git -C "$SESSION_REPO" commit-tree "$_reuse_tree" -p "$_reuse_parent" -m "diverge for reuse test") +git -C "$SESSION_REPO" branch feat/reuse-existing "$_reuse_commit" >/dev/null 2>&1 +(cd "$SESSION_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/reuse-existing >/dev/null 2>&1) +SESSION_WT4="$SESSION_WORKTREES/$(basename "$SESSION_REPO")/feat-reuse-existing" +if [[ -d "$SESSION_WT4" ]]; then ok "session start reuses an existing local branch"; else fail "session start reuses an existing local branch"; fi +if [[ "$(git -C "$SESSION_WT4" rev-parse HEAD 2>/dev/null)" == "$_reuse_commit" ]]; then + ok "reused branch keeps its own commit, not a fresh one from the default branch" +else + fail "reused branch keeps its own commit, not a fresh one from the default branch" +fi +(cd "$SESSION_WT4" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1) + rm -rf "$SESSION_REPO" echo "" +# session done: uncommitted changes guard. This is the one check standing +# between "close a session" and permanently deleting whatever is in its +# worktree: cmd_session_done removes the worktree once it passes. It was +# entirely untested in either direction before this block -- not proven to +# block when it should, not proven to let a clean session through. +echo "session done: uncommitted changes guard" + +# Modified TRACKED file: 'git diff' (unstaged) must catch it. +UNCOM_REPO=$(mktemp -d) +git init -q "$UNCOM_REPO" +printf 'orig\n' > "$UNCOM_REPO/tracked.txt" +git -C "$UNCOM_REPO" add tracked.txt +git -C "$UNCOM_REPO" commit -q -m init +(cd "$UNCOM_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/uncommitted-tracked >/dev/null 2>&1) +UNCOM_WT="$SESSION_WORKTREES/$(basename "$UNCOM_REPO")/feat-uncommitted-tracked" +printf 'modified\n' > "$UNCOM_WT/tracked.txt" +if (cd "$UNCOM_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1); then + fail "session done refuses a modified tracked file" +else + ok "session done refuses a modified tracked file" +fi +if [[ -d "$UNCOM_WT" ]]; then ok "worktree survives a refused session done (modified tracked file)" +else fail "worktree survives a refused session done (modified tracked file)"; fi +rm -rf "$UNCOM_REPO" + +# Staged new file: 'git diff --cached' must catch it. +STAGED_REPO=$(mktemp -d) +git init -q "$STAGED_REPO" +git -C "$STAGED_REPO" commit -q --allow-empty -m init +(cd "$STAGED_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/uncommitted-staged >/dev/null 2>&1) +STAGED_WT="$SESSION_WORKTREES/$(basename "$STAGED_REPO")/feat-uncommitted-staged" +printf 'new\n' > "$STAGED_WT/staged.txt" +git -C "$STAGED_WT" add staged.txt +if (cd "$STAGED_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1); then + fail "session done refuses a staged new file" +else + ok "session done refuses a staged new file" +fi +if [[ -d "$STAGED_WT" ]]; then ok "worktree survives a refused session done (staged new file)" +else fail "worktree survives a refused session done (staged new file)"; fi +rm -rf "$STAGED_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 +# remove a worktree holding untracked files. The worktree was deleted, the file +# with it, exit 0, and the index recorded the branch as merged. Writing a file +# and not staging it yet is the most ordinary state in a working session, so +# this was the common path, not an edge case. +UNTRACKED_REPO=$(mktemp -d) +git init -q "$UNTRACKED_REPO" +git -C "$UNTRACKED_REPO" commit -q --allow-empty -m init +(cd "$UNTRACKED_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/uncommitted-untracked >/dev/null 2>&1) +UNTRACKED_WT="$SESSION_WORKTREES/$(basename "$UNTRACKED_REPO")/feat-uncommitted-untracked" +printf 'irreplaceable\n' > "$UNTRACKED_WT/untracked.txt" +if (cd "$UNTRACKED_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1); then + fail "session done refuses an untracked new file" +else + ok "session done refuses an untracked new file" +fi +if [[ -f "$UNTRACKED_WT/untracked.txt" ]]; then ok "untracked work survives a refused session done" +else fail "untracked work survives a refused session done (THE FILE WAS DELETED)"; fi +if [[ -d "$UNTRACKED_WT" ]]; then ok "worktree survives a refused session done (untracked new file)" +else fail "worktree survives a refused session done (untracked new file)"; fi +# The refusal must name the offending file, or the user cannot act on it. +out=$( (cd "$UNTRACKED_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" 2>&1) || true) +assert_contains "the refusal names the untracked file" "untracked.txt" "$out" +rm -rf "$UNTRACKED_REPO" +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. @@ -556,18 +737,28 @@ echo "" # launch + plugin surfaces echo "launch + plugin surfaces" +# Tier assertions alone do not prove WHICH profile launch resolved: the model +# comes from a separate tiers.conf lookup keyed on the same name, so a launch +# that silently loaded the wrong persona's profile (a copy-paste bug, a stale +# alias table, anything that decouples the two lookups) would still show the +# right tier. Pin the printed --append-system-prompt-file path to the +# requested persona's own file too. out=$(run_cmd launch akira --dry-run 2>&1) assert_contains "launch akira defaults to fable tier" "claude-fable-5" "$out" assert_contains "launch dry-run shows append-system-prompt-file" "append-system-prompt-file" "$out" +assert_contains "launch akira points at akira's own profile" "$PROFILES_DIR/akira\.md" "$out" out=$(run_cmd launch robin --dry-run 2>&1) assert_contains "launch robin defaults to sonnet tier" "claude-sonnet-5" "$out" +assert_contains "launch robin points at robin's own profile" "$PROFILES_DIR/robin\.md" "$out" out=$(run_cmd launch sage --dry-run 2>&1) assert_contains "launch sage defaults to fable tier" "claude-fable-5" "$out" +assert_contains "launch sage points at sage's own profile" "$PROFILES_DIR/sage\.md" "$out" out=$(run_cmd launch toni --dry-run 2>&1) assert_contains "launch toni defaults to opus tier" "claude-opus-4-8" "$out" +assert_contains "launch toni points at toni's own profile" "$PROFILES_DIR/toni\.md" "$out" out=$(run_cmd launch akira --model claude-haiku-4-5 --dry-run 2>&1) assert_contains "launch --model overrides tier default" "claude-haiku-4-5" "$out" @@ -577,6 +768,58 @@ assert_contains "launch --task appends initial prompt" "review.*auth.*flow" "$ou out=$(run_cmd launch nobody --dry-run 2>&1) || true assert_contains "launch unknown persona errors" "No profile found" "$out" +echo "" + +# launch --worktree: creates the session's worktree on demand, then execs +# inside it. Before this block, no test -- dry-run or real -- exercised the +# flag at all. A stub 'claude' placed first on PATH captures the real +# (non-dry-run) exec so the worktree-creation side effect and the final argv +# can both be checked, without ever invoking the real Claude Code CLI. +echo "launch --worktree" + +LAUNCH_NOGIT=$(mktemp -d) +if (cd "$LAUNCH_NOGIT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" launch akira --worktree feat/x --dry-run >/dev/null 2>&1); then + fail "launch --worktree outside a git repo dies" +else + ok "launch --worktree outside a git repo dies" +fi +rm -rf "$LAUNCH_NOGIT" + +LAUNCH_REPO=$(mktemp -d) +git init -q "$LAUNCH_REPO" +git -C "$LAUNCH_REPO" commit -q --allow-empty -m init +LAUNCH_WT="$TEST_HOME/.claude/worktrees/$(basename "$LAUNCH_REPO")/feat-launch-wt" + +out=$(cd "$LAUNCH_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" launch akira --worktree feat/launch-wt --dry-run 2>&1) +assert_contains "launch --worktree dry-run reports the worktree would be created" "would create worktree" "$out" +assert_contains "launch --worktree dry-run targets the worktree dir, not \$PWD" "$LAUNCH_WT" "$out" +if [[ -d "$LAUNCH_WT" ]]; then fail "launch --worktree dry-run has no side effect"; else ok "launch --worktree dry-run has no side effect"; fi + +FAKE_CLAUDE_DIR=$(mktemp -d) +cat > "$FAKE_CLAUDE_DIR/claude" <<'STUB' +#!/usr/bin/env bash +echo "FAKE-CLAUDE-INVOKED cwd=$PWD" +printf 'FAKE-CLAUDE-ARG: %s\n' "$@" +STUB +chmod +x "$FAKE_CLAUDE_DIR/claude" + +out=$(cd "$LAUNCH_REPO" && PATH="$FAKE_CLAUDE_DIR:$PATH" CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" launch akira --worktree feat/launch-wt 2>&1) +assert_contains "launch --worktree really creates the worktree and execs there" "FAKE-CLAUDE-INVOKED cwd=$LAUNCH_WT" "$out" +assert_contains "launch --worktree execs with the requested persona's profile" "FAKE-CLAUDE-ARG: $PROFILES_DIR/akira.md" "$out" + +# A second launch for the same branch must reuse the worktree already made, +# not call session start again -- that would hit "already has an active +# session" and die, taking the whole launch down with it. +if out2=$(cd "$LAUNCH_REPO" && PATH="$FAKE_CLAUDE_DIR:$PATH" CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" launch akira --worktree feat/launch-wt 2>&1); then + ok "launch --worktree reuses an existing worktree on a second call" +else + fail "launch --worktree reuses an existing worktree on a second call" +fi +assert_contains "reused launch --worktree still execs in the same worktree" "FAKE-CLAUDE-INVOKED cwd=$LAUNCH_WT" "$out2" +assert_count "launch --worktree registers the branch exactly once" "$BRANCHES_INDEX" "feat/launch-wt" 1 + +rm -rf "$LAUNCH_REPO" "$FAKE_CLAUDE_DIR" +echo "" if command -v python3 >/dev/null 2>&1; then if python3 -c "import json; json.load(open('$REPO_DIR/.claude-plugin/plugin.json'))" 2>/dev/null; then @@ -589,6 +832,14 @@ if command -v python3 >/dev/null 2>&1; then else fail "hooks.json is valid and registers SessionStart" fi +else + # A silent 'if command -v python3' guard with no else shrinks PASS+FAIL by + # two on a python3-less machine with no indication why: the count just + # differs from what CI reports, and nothing says these two checks did not + # run. Both CI runners ship python3, so this branch never fires there; it + # exists so a contributor on a minimal local machine sees a reason instead + # of a smaller, unexplained number. + printf " \033[33m!\033[0m skipped (no python3): plugin.json / hooks.json JSON validity\n" fi agent_files=("$REPO_DIR"/agents/*.md) @@ -599,43 +850,50 @@ assert_contains "akira agent carries model tier" "model: claude-fable-5" "$(cat 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")" -# Regeneration drift: CI never runs generate-agents.sh, and the count check above -# only counts files. generate-agents.sh copies the profile into the agent verbatim -# except for the ## Greeting section, which belongs to the slash command. So every -# non-blank profile line above ## Greeting must appear verbatim in its agent. -# Catches a profile edited without regenerating. +# Regeneration drift. CI never runs generate-agents.sh by hand, so a profile +# edited without a resync, or a generated file hand-edited independent of its +# profile, must be caught here. +# +# The previous check re-implemented the generator's own stripping logic a +# second time, by hand, in this file, to predict what each generated file +# should contain, then tested that the profile's text was a SUBSET of it. Two +# things follow from "subset": extra or wrong content the generator (or a +# stray hand-edit) adds is invisible to it, and the prediction has to be kept +# in sync with the generator by hand forever. That second property is what let +# this same check assert a missing Handoff Brief section as correct: its +# hand-written awk mirrored the generator's own bug instead of the +# requirement. +# +# This regenerates every agent and slash command from the committed profiles +# into a scratch tree with the real generator, then diffs each one against the +# committed copy. It catches both directions of drift and cannot mirror a bug +# the generator does not have, because it IS the generator, not a second +# implementation of it. What it structurally cannot catch is the opposite +# failure: a generator whose transform is wrong but internally consistent, so +# a fresh regeneration reproduces the same wrong output already committed. +# That needs a content assertion instead, which is what the Handoff Brief +# presence checks below are. +REGEN_DIR=$(mktemp -d) +cp -R "$REPO_DIR"/profiles "$REPO_DIR"/scripts "$REGEN_DIR/" +if bash "$REGEN_DIR/scripts/generate-agents.sh" >/dev/null 2>&1; then + ok "generate-agents.sh runs clean against the committed profiles" +else + fail "generate-agents.sh runs clean against the committed profiles" +fi stale="" for profile in "$REPO_DIR"/profiles/*.md; do pname=$(basename "$profile" .md) case "$pname" in coordinator*) continue ;; esac - agent="$REPO_DIR/agents/$pname.md" - if [[ ! -f "$agent" ]]; then stale="$stale $pname(no-agent)"; continue; fi - drift=$(awk '/^## Greeting$/ { exit } { print }' "$profile" \ - | grep -v '^[[:space:]]*$' | grep -F -x -v -c -f "$agent" - || true) - [[ "$drift" == "0" ]] || stale="$stale $pname($drift)" -done -if [[ -z "$stale" ]]; then ok "generated agents match their profiles" -else fail "generated agents match their profiles (stale:$stale)"; fi - -# Same drift check for commands/, which generate-agents.sh derives from the same -# profile: Required Interactive Behaviors excised, and the ## Greeting heading -# replaced by its sentence as the trailer. So every non-blank profile line outside -# those two sections, plus the greeting sentence, must appear verbatim in the -# slash command. The excised range ends at ## Handoff Brief, not ## Signature -# Question, so the brief survives; this awk must track the stripper exactly. -cdrift="" -for profile in "$REPO_DIR"/profiles/*.md; do - pname=$(basename "$profile" .md) - case "$pname" in coordinator*) continue ;; esac - cmd="$REPO_DIR/commands/$pname.md" - if [[ ! -f "$cmd" ]]; then cdrift="$cdrift $pname(no-command)"; continue; fi - d=$(awk '/^## Required Interactive Behaviors$/{skip=1} /^## Handoff Brief$/{skip=0} - /^## Greeting$/{next} !skip' "$profile" \ - | grep -v '^[[:space:]]*$' | grep -F -x -v -c -f "$cmd" - || true) - [[ "$d" == "0" ]] || cdrift="$cdrift $pname($d)" + if ! diff -q "$REPO_DIR/agents/$pname.md" "$REGEN_DIR/agents/$pname.md" >/dev/null 2>&1; then + stale="$stale $pname(agent)" + fi + if ! diff -q "$REPO_DIR/commands/$pname.md" "$REGEN_DIR/commands/$pname.md" >/dev/null 2>&1; then + stale="$stale $pname(command)" + fi done -if [[ -z "$cdrift" ]]; then ok "generated commands match their profiles" -else fail "generated commands match their profiles (drift:$cdrift)"; fi +if [[ -z "$stale" ]]; then ok "generated agents and commands match a fresh regeneration" +else fail "generated agents and commands match a fresh regeneration (stale:$stale)"; fi +rm -rf "$REGEN_DIR" # The greeting is a persona-switch line ("Greet the user briefly as Robin"). A # delegated subagent never switches the session, so it must not carry one. @@ -746,11 +1004,21 @@ assert_file_lacks "installer no longer claims the team is ready" "$REPO_DIR/inst echo "" # install.sh end to end into an isolated HOME: files land where the CLI -# expects them, and the coordinator prompt delegates to the CLI code path +# expects them, and the coordinator prompt delegates to the CLI code path. +# +# Runs from a throwaway copy of the repo, never from REPO_DIR. install.sh +# delegates to 'claude-team sync', which resolves its repo directory from the +# CLI's own path and regenerates agents/ and commands/ there. Run against the +# real clone, the suite rewrote its own tracked files: a genuine drift would +# fail the regeneration check earlier in this file and then be silently +# repaired here, so a second run came back green with nothing fixed. echo "install.sh" +INSTALL_REPO=$(mktemp -d) +cp -R "$REPO_DIR"/profiles "$REPO_DIR"/commands "$REPO_DIR"/agents \ + "$REPO_DIR"/scripts "$REPO_DIR"/bin "$REPO_DIR"/install.sh "$INSTALL_REPO/" INSTALL_HOME=$(mktemp -d) -if (cd "$REPO_DIR" && echo "n" | HOME="$INSTALL_HOME" bash install.sh >/dev/null 2>&1); then +if (cd "$INSTALL_REPO" && echo "n" | HOME="$INSTALL_HOME" bash install.sh >/dev/null 2>&1); then ok "install.sh runs clean with coordinator skipped" else fail "install.sh runs clean with coordinator skipped" @@ -768,7 +1036,7 @@ else fail "install.sh skips coordinator when told no" fi -if (cd "$REPO_DIR" && echo "prod" | HOME="$INSTALL_HOME" bash install.sh >/dev/null 2>&1); then +if (cd "$INSTALL_REPO" && echo "prod" | HOME="$INSTALL_HOME" bash install.sh >/dev/null 2>&1); then ok "install.sh runs clean with coordinator prod" else fail "install.sh runs clean with coordinator prod" @@ -782,7 +1050,7 @@ if [[ "$hookrefs" == "1" ]]; then else fail "reinstall does not duplicate the SessionStart hook (found $hookrefs)" fi -rm -rf "$INSTALL_HOME" +rm -rf "$INSTALL_HOME" "$INSTALL_REPO" echo ""