From 065c5f008fc79dbe65bef0122ae3680c81b193e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:05:40 +0000 Subject: [PATCH 1/6] docs: renumber to v2.0, retire the pre-release 0.x versions Version numbering had drifted across three files: plugin.json said 0.7.0, README said "v0.6 (current)", and ROADMAP said "Shipping Now - v0.6". None of them agreed, and 0.x undersold a tool with 135 tests, a plugin manifest, and a documented install path people already follow. Normalises on 2.0.0 as current, with everything before it presented as v1. The break lands where the substance breaks: v0.4 through v0.6 built the roster, the coordinator, and branch hygiene, while v0.7 carried a behavioural breaking change (persona slash commands stopped writing ~/.claude/CLAUDE.md) that today's three merges complete. A major bump is the honest home for that. The result reads in one pass: v1 built the team, v2 made the team dependable. No v1.0.0 release is fabricated. The repository has no git tags, so v1 is presented as the first generation of the tool rather than a dated release, and the README states plainly that the 0.x numbers were development numbering. Also corrects two stale claims in ROADMAP.md. The "82-test suite" line had been wrong since early July (the DEVLOG v0.7 entry already recorded 98) and the count is now 135. Two v0.6 checkboxes were unchecked but visibly done: the README refactor, and ROADMAP.md itself. Per-version test counts are removed, since one count against current is the only number that will not rot. Local profile overrides is promoted from "exploring" to top priority. Sync fixed the symptom, an edit landing in one installed copy out of three. It did not fix the cause: the only place to customise a persona is a tracked file the next git pull overwrites. The 2026-04-09 ROADMAP revision-history row still reads "v0.6 shipping" and is left verbatim. A dated changelog entry is not rewritten to match a later renumber; the new row beneath it records the change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- .claude-plugin/plugin.json | 2 +- README.md | 49 +++++++++++++++++++++++--------------- ROADMAP.md | 38 +++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bddc30a..20948c7 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.", - "version": "0.7.0", + "version": "2.0.0", "author": { "name": "Code Katz" } } diff --git a/README.md b/README.md index a27e204..cf8db5e 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,26 @@ Four specialists for card and board game projects. Reiner designs the systems, C --- -**What's New — v0.7** +**What's New in v2.0** + +v1 gave you the team. v2 makes the team dependable: every session gets its own persona, every edit reaches every copy, and every install path ends up with the same working setup. + +**One persona edit, every copy updated** — `claude-team sync` regenerates the delegation subagents and reinstalls all three installed persona files in one step. Before this, editing a profile updated one of three copies and left the rest stale with no warning, so `/akira` and `claude-team use akira` could quietly disagree about who Akira is. + +**Session context on every install path** — the SessionStart hook now registers when you install from a clone, not only through the plugin system. If the clone moves, `claude-team install-hook` re-points it. + +**Plain technical English from the coding six** — Akira, Sasha, Robin, Alex, Morgan, and Jordan write to a fourteen-rule standard adapted from the plain-language principles of ASD-STE100. Named actors, one instruction per sentence, no filler, and no loss of precision. See [WRITING.md](WRITING.md). **Session-scoped personas** — the `/akira`-style commands no longer touch global state, so parallel sessions each keep their own persona with no cross-talk. `claude-team use` still exists for pinning a global default and now says so out loud. **`claude-team launch `** — open a dedicated Claude Code session with the persona baked in as system prompt, on its tier model (Fable 5 for the deep-reasoning personas, Opus 4.8 for consulting and craft, Sonnet 5 for implementation), optionally inside an isolated worktree: `claude-team launch akira --task "design the battles API" --worktree session/1-akira-battles`. -**Delegation subagents** — seventeen generated agents let any session hand work to a persona ("have Robin review this diff") without switching. Regenerate from profiles with `scripts/generate-agents.sh`. +**Delegation subagents** — seventeen generated agents let any session hand work to a persona ("have Robin review this diff") without switching. Regenerate from profiles with `claude-team sync`. **Plugin packaging** — install everything (commands, agents, hooks, CLI on PATH) via the plugin system; `install.sh` remains for manual setups. **Worktree-isolated `/parallel`** — session plans create a git worktree per session and never switch branches; the coordination session merges in dependency order. -**What's New — v0.6** - -**[Parallel sessions](#coordinator-proactive-team-check-ins)** — run independent work streams simultaneously, each with a dedicated team member, scoped task, and explicit file boundary. No merge conflicts. No context bleed. - -**[Casual and production workflow modes](#coordinator-proactive-team-check-ins)** — casual mode (default) commits straight to main with no branch enforcement; production mode adds branch gates, worktrees, and MR/PR flow as an explicit opt-in. - --- ## Who This Is For @@ -880,28 +882,37 @@ See [ROADMAP.md](ROADMAP.md) for the living roadmap with current priorities and For the full version history, see [DEVLOG.md](DEVLOG.md). -### v0.4 +The `0.4` through `0.7` numbers used during development are retired. v1 is the first generation of the tool. v2 is what ships today. -**Sage (Business Advisor)** — business formation, financial ops, legal awareness, fundraising literacy, compliance basics. Clear professional-advice boundary: Sage flags exactly when to consult a licensed attorney, CPA, or financial advisor. Expanded the team to eleven specialists. +### v2.0 (current) -### v0.5 +**A team you can rely on across sessions, installs, and edits** -**Kai (UX Design & Visual Art)** — wireframes, HTML/CSS mockups, visual design, AI image generation via Hugging Face MCP (FLUX.1, Qwen-Image). Clear boundary with Sasha: Kai designs the visual target, Sasha implements it in production code. Expanded the team to twelve specialists. +- Session-scoped personas: parallel sessions each keep their own specialist, with no cross-talk +- `claude-team launch ` opens a dedicated session with the persona as system prompt, on its tier model +- Seventeen delegation subagents, so any session can hand work to a specialist without switching +- Plugin packaging: commands, agents, hooks, and the CLI installed in one step +- Worktree-isolated `/parallel`, so session plans never switch branches in a shared checkout +- `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)) +- 135-test suite covering the CLI commands, both coordinator modes, and the install path -### v0.6 (current) +### v1.0 -**Two-mode coordinator + branch hygiene infrastructure** +**The specialist roster, the coordinator, and branch hygiene** -- Casual mode (default): no branch enforcement — commit directly to `main` -- Prod mode (opt-in): branch required before any code, worktree isolation, MR/PR workflow -- `/prod-mode` and `/casual-mode` slash commands +- **Sage (Business Advisor):** business formation, financial ops, legal awareness, fundraising literacy, compliance basics. Clear professional-advice boundary: Sage flags exactly when to consult a licensed attorney, CPA, or financial advisor. +- **Kai (UX Design & Visual Art):** wireframes, HTML/CSS mockups, visual design, AI image generation via Hugging Face MCP (FLUX.1, Qwen-Image). Clear boundary with Sasha: Kai designs the visual target, Sasha implements it in production code. +- **Two-mode coordinator:** casual mode (default) applies no branch enforcement, so you commit directly to `main`. Prod mode is an opt-in that requires a branch before any code, with worktree isolation and an MR/PR workflow. +- `/prod-mode` and `/casual-mode` slash commands for mid-session toggling - `claude-team branch` and `claude-team session` command families -- 82-test suite covering all CLI commands and both coordinator modes +- Parallel sessions: independent work streams, each with a dedicated team member, scoped task, and explicit file boundary ### Later — Exploring with the Community If any of these would change how you use the tool, [open an issue](https://github.com/code-katz/claude-team-cli/issues). -- **Local profile overrides:** `~/.claude/team/local/` for per-user customizations without forking the repo +- **Local profile overrides:** `~/.claude/team/local/` for per-user customizations without forking the repo. This is the next thing we want to build. `claude-team sync` made persona edits stick across all three copies, but the edit still lives in tracked files that the next `git pull` overwrites. Overrides are what turn "customize a persona" into something you only have to do once. - **Team-scoped profiles:** `claude-team init` creates a `.claude-team/` config in a project repo, shared across the dev team - **Session handoff briefing:** when switching team members mid-task, the coordinator generates a structured briefing so the incoming specialist doesn't start cold diff --git a/ROADMAP.md b/ROADMAP.md index af1d6bc..cd7b6c3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,11 +7,25 @@ ## Current Roadmap -### Shipping Now — v0.6 +### Shipping Now — v2.0 -**Two-mode coordinator + branch hygiene infrastructure** +**A team you can rely on across sessions, installs, and edits** -The coordinator is now mode-aware. Casual mode (default) routes team members and suggests operating modes without enforcing branch discipline — designed for personal projects and anyone committing directly to main. Prod mode is an explicit opt-in for teams that need branch registration, worktree isolation, and a full MR/PR workflow. +v1 delivered the roster and the coordinator. v2 closes the gaps that made the team unreliable in daily use: personas leaking between parallel sessions, a profile edit reaching one installed copy out of three, and session context missing entirely for anyone who installed from a clone instead of the plugin system. + +- [x] Session-scoped personas: the `/name` commands no longer write global state, so parallel sessions never overwrite each other +- [x] `claude-team launch ` — a dedicated session with the persona as system prompt, on its tier model, optionally in an isolated worktree +- [x] Seventeen delegation subagents generated from `profiles/`, so any session can hand work to a specialist without switching +- [x] Plugin packaging: commands, agents, hooks, and the CLI installed in one step +- [x] Worktree-isolated `/parallel`: session plans create a worktree per session and never switch branches in a shared checkout +- [x] `claude-team sync` — one command propagates a profile edit to all three installed copies +- [x] `claude-team install-hook` — the SessionStart hook now registers on the `install.sh` path, not only the plugin path +- [x] Plain technical English standard for the six coding specialists, documented in [WRITING.md](WRITING.md) +- [x] 135-test suite covering the CLI commands, both coordinator modes, and the install path + +### Shipped — v1.0 + +**The specialist roster, the coordinator, and branch hygiene** - [x] `claude-team coordinator on` — casual mode, no branch enforcement - [x] `claude-team coordinator prod` — prod mode, branch required before any code @@ -19,24 +33,27 @@ The coordinator is now mode-aware. Casual mode (default) routes team members and - [x] `claude-team status` shows active mode: `on (casual)` or `on (prod)` - [x] Branch hygiene infrastructure: `branch start`, `done`, `abandon`, `status`, `list`, `guard` - [x] Session/worktree isolation: `session start`, `done`, `status`, `list` -- [x] 82-test suite covering all CLI commands and both coordinator modes -- [ ] README refactor — casual/prod modes clearly explained; branch hygiene section framed as prod-only -- [ ] ROADMAP.md — this file +- [x] Test suite covering all CLI commands and both coordinator modes +- [x] README refactor: casual/prod modes clearly explained, branch hygiene framed as prod-only +- [x] ROADMAP.md — this file ### Near-Term -- README getting-started path optimized for casual users -- Expanded test coverage for mode-specific coordinator behavior +**Local profile overrides** is the highest-value unbuilt item on this list. Details in the section below. + +Also queued: generating the `/name` slash command for a new persona. `claude-team sync` copies slash commands but cannot generate one, because `commands/` is hand-maintained by design. Adding a persona still means copying an existing command file and swapping the body in. --- ## Aspirational — Looking for Feedback -These are directions we're exploring. If any of them would change how you use the tool, [open an issue](https://github.com/code-katz/claude-team-cli/issues) — real usage feedback shapes what gets built next. +These are directions we're exploring. If any of them would change how you use the tool, [open an issue](https://github.com/code-katz/claude-team-cli/issues). Real usage feedback shapes what gets built next. -**Local profile overrides** +**Local profile overrides (top priority)** A `~/.claude/team/local/` directory for per-user customizations without forking the repo. Override any persona's behavior for your specific workflow without touching the upstream source. +This moved from "exploring" to "next thing we want to build" during v2. `claude-team sync` fixed the symptom, which was an edit landing in one installed copy out of three. It did not fix the cause: the only place to customize a persona is a tracked file that the next `git pull` overwrites. Until overrides exist, "make Akira work the way my team works" is something you have to redo after every update. Customizing the team is the whole point of the product, so this is the gap worth closing first. + **Team-scoped profiles** `claude-team init` creates a `.claude-team/` config at the project root, so team conventions are shared across developers on the same repo. Consistent team member behaviors without everyone managing their own `~/.claude/team/` independently. @@ -50,3 +67,4 @@ When switching team members mid-task, the coordinator generates a structured bri | Date | Type | Description | |---|---|---| | 2026-04-09 | snapshot | Initial ROADMAP.md — v0.6 shipping, aspirational backlog identified | +| 2026-07-29 | snapshot | Renumbered to v2.0 current, v1.0 shipped; pre-release `0.x` numbers retired. Corrected test count to 135. Local profile overrides promoted to top priority. | From bec6b01d5a04c0a4d92384f8e662122179fe0298 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:11:17 +0000 Subject: [PATCH 2/6] feat: generate slash commands from profiles commands/.md was hand-maintained while being about 95 percent derivable from profiles/.md. It drifted twice: commit 6d9bb89 "sync commands/ for six personas", and again during the plain-English work. The drift test already contained the transformation as a checker, so this turns that checker into a generator. scripts/generate-agents.sh now writes both agents/.md and commands/.md. Extending it rather than adding a sibling script is deliberate: bin/claude-team sync and install.sh both invoke this one script, so a sibling would never run on either path and claude-team sync would install a slash command lagging the profile edit that triggered it. Each profile gains a ## Greeting section holding the one line the persona says on switch. That was the only part of a command file not derivable from the profile. The generator aborts with a named error when a profile lacks it, so a new persona cannot ship a command ending in a bare separator. ## Greeting is excised from the generated subagent, which is invoked rather than switched to. Verified byte-identical against the previous hand-maintained files. Eleven of seventeen match exactly. Six differ by exactly one deleted blank line, and those six are precisely the files 6d9bb89 hand-synced, so this normalises that drift. agents/ is unchanged from HEAD, which proves ## Greeting does not leak. The hand-maintained drift checker becomes "generated commands match their profiles", mirroring the agents test, plus a new guard that the greeting stays out of the subagents. The sync test now seeds its marker in the profile rather than the generated command, since regeneration would overwrite it; that proves profile to generated command to installed command end to end. 136 tests, up from 135. README and ROADMAP are corrected where they described commands/ as hand-maintained. The DEVLOG entries that say so are dated records of what was true then and are left as written. Known follow-up, flagged during generation and not yet fixed: cmd_use cats the whole profile into ~/.claude/CLAUDE.md, so a globally pinned persona now also carries its greeting and would greet on every new session. The fix belongs in bin/claude-team, which another change is holding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- README.md | 8 ++-- ROADMAP.md | 5 +- commands/akira.md | 1 - commands/alex.md | 1 - commands/jordan.md | 1 - commands/morgan.md | 1 - commands/robin.md | 1 - commands/sasha.md | 1 - profiles/akira.md | 4 ++ profiles/alex.md | 4 ++ profiles/casey.md | 4 ++ profiles/cornelius.md | 4 ++ profiles/ernie.md | 4 ++ profiles/iris.md | 4 ++ profiles/jordan.md | 4 ++ profiles/kai.md | 4 ++ profiles/morgan.md | 4 ++ profiles/piper.md | 4 ++ profiles/quinn.md | 4 ++ profiles/reiner.md | 4 ++ profiles/river.md | 4 ++ profiles/robin.md | 4 ++ profiles/sage.md | 4 ++ profiles/sasha.md | 4 ++ profiles/toni.md | 4 ++ scripts/generate-agents.sh | 97 +++++++++++++++++++++++++++++++++----- tests/run.sh | 51 +++++++++++++------- 27 files changed, 194 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index cf8db5e..270b3d2 100644 --- a/README.md +++ b/README.md @@ -802,11 +802,13 @@ Each persona is installed as three self-contained files, and `profiles/` is the |---|---|---| | `~/.claude/team/.md` | `claude-team show`, `claude-team use` | copied from `profiles/` | | `~/.claude/agents/.md` | delegation ("have robin review this diff") | regenerated from `profiles/` | -| `~/.claude/commands/.md` | the `/` slash command | copied from `commands/` | +| `~/.claude/commands/.md` | the `/` slash command | regenerated from `profiles/` | -Editing the installed profile updates the first and silently leaves the other two stale, with nothing to warn you. `sync` regenerates the subagent and reinstalls all three. +Editing the installed profile updates the first and silently leaves the other two stale, with nothing to warn you. `sync` regenerates both derived files and reinstalls all three. -The slash command is the one piece `sync` cannot generate, since `commands/` is hand-maintained: it is the profile with the Required Interactive Behaviors section excised, wrapped in a switch preamble. For a brand-new persona, copy `commands/robin.md` to `commands/yourname.md` and swap the body in. The test suite fails if a slash command drifts from its profile. +Both `agents/` and `commands/` are generated by `scripts/generate-agents.sh`, which `sync` runs for you. Never edit them by hand; the next sync overwrites your change. The test suite fails if either drifts from its profile. + +A profile needs a `## Greeting` section, holding the one line the persona says when you switch to it with `/`. The generator refuses to run without it, so a new persona cannot ship a slash command that ends in a bare separator. `## Greeting` is excised from the delegation subagent, which is invoked rather than switched to and has nobody to greet. --- diff --git a/ROADMAP.md b/ROADMAP.md index cd7b6c3..666c126 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,8 @@ v1 delivered the roster and the coordinator. v2 closes the gaps that made the te - [x] `claude-team sync` — one command propagates a profile edit to all three installed copies - [x] `claude-team install-hook` — the SessionStart hook now registers on the `install.sh` path, not only the plugin path - [x] Plain technical English standard for the six coding specialists, documented in [WRITING.md](WRITING.md) -- [x] 135-test suite covering the CLI commands, both coordinator modes, and the install path +- [x] Slash commands generated from profiles, so a new persona means writing one file instead of three +- [x] 136-test suite covering the CLI commands, both coordinator modes, and the install path ### Shipped — v1.0 @@ -41,7 +42,7 @@ v1 delivered the roster and the coordinator. v2 closes the gaps that made the te **Local profile overrides** is the highest-value unbuilt item on this list. Details in the section below. -Also queued: generating the `/name` slash command for a new persona. `claude-team sync` copies slash commands but cannot generate one, because `commands/` is hand-maintained by design. Adding a persona still means copying an existing command file and swapping the body in. +Slash command generation shipped in v2.0 and is no longer queued. `scripts/generate-agents.sh` now writes both the delegation subagent and the `/name` slash command from the profile, so adding a persona means writing one profile. --- diff --git a/commands/akira.md b/commands/akira.md index 37eb075..567b83f 100644 --- a/commands/akira.md +++ b/commands/akira.md @@ -85,7 +85,6 @@ They apply to your prose: explanations, review comments, commit messages, PR des Clarity is not dilution. These rules shorten your sentences; they never lower your precision. Keep naming the protocol, the consistency guarantee, and the exact failure mode. - ## Signature Question > "What are the consistency requirements here, and who should never have access to this data?" diff --git a/commands/alex.md b/commands/alex.md index ed3172b..4d7df67 100644 --- a/commands/alex.md +++ b/commands/alex.md @@ -84,7 +84,6 @@ They apply to your prose: explanations, review comments, commit messages, PR des Clarity is not dilution. These rules shorten your sentences; they never lower your precision. Keep naming the tool, the pipeline stage, and the exact rollback step. - ## Signature Question > "If this server dies right now, how exactly does it rebuild itself without human intervention?" diff --git a/commands/jordan.md b/commands/jordan.md index 6da512e..e72c679 100644 --- a/commands/jordan.md +++ b/commands/jordan.md @@ -83,7 +83,6 @@ They apply to your prose: explanations, review comments, commit messages, PR des Clarity is not dilution. These rules shorten your sentences; they never lower your precision. Keep naming the data format, the schema field, and the exact drift signal. - ## Signature Question > "How are we monitoring data quality here, and what happens when the upstream schema inevitably changes?" diff --git a/commands/morgan.md b/commands/morgan.md index 866e9d5..c244764 100644 --- a/commands/morgan.md +++ b/commands/morgan.md @@ -83,7 +83,6 @@ They apply to your prose: explanations, review comments, commit messages, PR des Clarity is not dilution. These rules shorten your sentences; they never lower your precision. Keep naming the CVE, the STRIDE category, and the exact attack path. - ## Signature Question > "What is the absolute worst thing an attacker could do if they compromised this specific service account?" diff --git a/commands/robin.md b/commands/robin.md index 32253b8..3865517 100644 --- a/commands/robin.md +++ b/commands/robin.md @@ -83,7 +83,6 @@ They apply to your prose: explanations, review comments, commit messages, PR des Clarity is not dilution. These rules shorten your sentences; they never lower your precision. Keep naming the test pattern, the tolerated flake rate, and the exact assertion that fails. - ## Signature Question > "What's the failure mode we haven't considered yet, and could an attacker exploit it?" diff --git a/commands/sasha.md b/commands/sasha.md index 2fa347c..6bff633 100644 --- a/commands/sasha.md +++ b/commands/sasha.md @@ -87,7 +87,6 @@ They apply to your prose: explanations, review comments, commit messages, PR des Clarity is not dilution. These rules shorten your sentences; they never lower your precision. Keep naming the WCAG success criterion, the ARIA attribute, and the exact browser behavior. - ## Signature Question > "How does this behave for a keyboard-only user, and could this expose sensitive data to an attacker?" diff --git a/profiles/akira.md b/profiles/akira.md index e300830..2f5d16c 100644 --- a/profiles/akira.md +++ b/profiles/akira.md @@ -93,3 +93,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What are the consistency requirements here, and who should never have access to this data?" + +## Greeting + +Greet the user briefly as Akira and confirm you're now active. Ask what they're building or what architectural challenge they're facing. diff --git a/profiles/alex.md b/profiles/alex.md index 9435c31..6274ae5 100644 --- a/profiles/alex.md +++ b/profiles/alex.md @@ -92,3 +92,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "If this server dies right now, how exactly does it rebuild itself without human intervention?" + +## Greeting + +Greet the user briefly as Alex and confirm you're now active. Ask what infrastructure or deployment challenge they're facing. diff --git a/profiles/casey.md b/profiles/casey.md index 8024f49..6d8b0ac 100644 --- a/profiles/casey.md +++ b/profiles/casey.md @@ -72,3 +72,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "Who is the audience, what is the single thing you need them to know, and what do you need them to do with that information?" + +## Greeting + +Greet the user briefly as Casey and confirm you're now active. Ask what dashboard or analytics challenge they're working on, and whether they've completed exploratory analysis or are still in discovery. diff --git a/profiles/cornelius.md b/profiles/cornelius.md index cd5ee3f..a8a07ea 100644 --- a/profiles/cornelius.md +++ b/profiles/cornelius.md @@ -68,3 +68,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "Is this what actually happened, and why did it matter to the outcome?" + +## Greeting + +Greet the user briefly as Cornelius and confirm you're now active. Ask what battle, unit, or claim they need checked. diff --git a/profiles/ernie.md b/profiles/ernie.md index a5f724c..0abbe2a 100644 --- a/profiles/ernie.md +++ b/profiles/ernie.md @@ -68,3 +68,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "Is this true, and does it make the reader feel why it mattered?" + +## Greeting + +Greet the user briefly as Ernie and confirm you're now active. Ask what mission, card, or scene needs words. diff --git a/profiles/iris.md b/profiles/iris.md index 1c65c90..0712c24 100644 --- a/profiles/iris.md +++ b/profiles/iris.md @@ -89,3 +89,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "Does this still read at sixteen pixels in a single color, and does it look like it came from the same hand as everything else in the set?" + +## Greeting + +Greet the user briefly as Iris and confirm you're now active. Ask what asset they need and where it will be used smallest. diff --git a/profiles/jordan.md b/profiles/jordan.md index cfa039b..5db2f28 100644 --- a/profiles/jordan.md +++ b/profiles/jordan.md @@ -91,3 +91,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "How are we monitoring data quality here, and what happens when the upstream schema inevitably changes?" + +## Greeting + +Greet the user briefly as Jordan and confirm you're now active. Ask what data pipeline, ML system, or analytics challenge they're working on. diff --git a/profiles/kai.md b/profiles/kai.md index 9ad2fc0..013caa9 100644 --- a/profiles/kai.md +++ b/profiles/kai.md @@ -84,3 +84,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What does this screen look like at the size the user will actually see it, and does the visual hierarchy guide their eye to the right thing first?" + +## Greeting + +Greet the user briefly as Kai and confirm you're now active. Ask what screen or visual they're working on. diff --git a/profiles/morgan.md b/profiles/morgan.md index 8efa0e8..17a8c76 100644 --- a/profiles/morgan.md +++ b/profiles/morgan.md @@ -91,3 +91,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What is the absolute worst thing an attacker could do if they compromised this specific service account?" + +## Greeting + +Greet the user briefly as Morgan and confirm you're now active. Ask what system or architecture they need a security review on. diff --git a/profiles/piper.md b/profiles/piper.md index 4d7cec5..b26f386 100644 --- a/profiles/piper.md +++ b/profiles/piper.md @@ -71,3 +71,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "How do I break this, and is it still fun when I can't?" + +## Greeting + +Greet the user briefly as Piper and confirm you're now active. Ask what scenario or ruleset they want played and broken. diff --git a/profiles/quinn.md b/profiles/quinn.md index 4379a89..2026d3e 100644 --- a/profiles/quinn.md +++ b/profiles/quinn.md @@ -79,3 +79,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "Who owns this, when is it due, and what's blocking it?" + +## Greeting + +Greet the user briefly as Quinn and confirm you're now active. Ask what they need tracked, planned, or unblocked. diff --git a/profiles/reiner.md b/profiles/reiner.md index 94cd393..1d40556 100644 --- a/profiles/reiner.md +++ b/profiles/reiner.md @@ -69,3 +69,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What decision is the player actually making here, and is it interesting?" + +## Greeting + +Greet the user briefly as Reiner and confirm you're now active. Ask what game, mechanic, or scenario they're designing. diff --git a/profiles/river.md b/profiles/river.md index 1cd6fab..aa492a3 100644 --- a/profiles/river.md +++ b/profiles/river.md @@ -70,3 +70,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What specific user problem does this solve, and how will we know we've solved it?" + +## Greeting + +Greet the user briefly as River and confirm you're now active. Ask what problem they're trying to frame or what they're planning. diff --git a/profiles/robin.md b/profiles/robin.md index 7ff5c6b..5603eb8 100644 --- a/profiles/robin.md +++ b/profiles/robin.md @@ -91,3 +91,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What's the failure mode we haven't considered yet, and could an attacker exploit it?" + +## Greeting + +Greet the user briefly as Robin and confirm you're now active. Ask what they need tested or reviewed. diff --git a/profiles/sage.md b/profiles/sage.md index 7401740..4c73b36 100644 --- a/profiles/sage.md +++ b/profiles/sage.md @@ -72,3 +72,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "What is this decision going to cost you in money, time, and optionality, and is that trade-off worth it at this stage?" + +## Greeting + +Greet the user briefly as Sage and confirm you're now active. Ask what business decision or structure they're working through. diff --git a/profiles/sasha.md b/profiles/sasha.md index 61055b6..1e227ec 100644 --- a/profiles/sasha.md +++ b/profiles/sasha.md @@ -103,3 +103,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "How does this behave for a keyboard-only user, and could this expose sensitive data to an attacker?" + +## Greeting + +Greet the user briefly as Sasha and confirm you're now active. Ask what UI or frontend challenge they're working on. diff --git a/profiles/toni.md b/profiles/toni.md index 5cb50f3..22c2464 100644 --- a/profiles/toni.md +++ b/profiles/toni.md @@ -68,3 +68,7 @@ When the domain shifts and a handoff is appropriate, generate a Handoff Brief be ## Signature Question > "Who specifically benefits from this, and what would make them choose us over doing nothing?" + +## Greeting + +Greet the user briefly as Toni and confirm you're now active. Ask what they're positioning, launching, or trying to communicate. diff --git a/scripts/generate-agents.sh b/scripts/generate-agents.sh index 42561ff..9e69350 100755 --- a/scripts/generate-agents.sh +++ b/scripts/generate-agents.sh @@ -1,25 +1,65 @@ #!/usr/bin/env bash -# generate-agents.sh — Generate delegation subagents from persona profiles. +# generate-agents.sh — Generate subagents and slash commands from persona profiles. # -# profiles/.md is the single source of truth for each persona. -# This script derives agents/.md (Claude Code subagent format) from -# each profile plus the model tier in profiles/tiers.conf. Re-run after -# editing any profile or tier: +# profiles/.md is the single source of truth for each persona. This script +# derives both installed surfaces from it, plus the model tier in +# profiles/tiers.conf. Re-run after editing any profile or tier: # # bash scripts/generate-agents.sh # -# Subagents let any session DELEGATE to a persona ("have Robin review this -# diff") without switching its own persona; the /name slash commands remain -# the full-session takeover surface. +# agents/.md Delegation subagent. Lets any session delegate to a +# persona ("have Robin review this diff") without +# switching its own persona. +# commands/.md / slash command. The full-session takeover +# surface: the profile wrapped in a switch preamble, with +# Required Interactive Behaviors excised and the profile's +# greeting appended as the trailer. +# +# Both surfaces are generated here, in one script, because bin/claude-team sync +# calls this path before it copies profiles/, agents/, and commands/ into +# ~/.claude. A separate command generator would not be called there, so a synced +# slash command would lag the profile edit that prompted the sync. set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROFILES="$REPO_DIR/profiles" AGENTS="$REPO_DIR/agents" +COMMANDS="$REPO_DIR/commands" TIERS="$PROFILES/tiers.conf" -mkdir -p "$AGENTS" +# Everything above the "## Greeting" heading, with the blank line that separates +# them dropped. The greeting is the slash command trailer, so it must not reach +# agents/ and must not appear twice in commands/. +profile_body() { + awk ' + /^## Greeting$/ { exit } + NF == 0 { blanks = blanks $0 "\n"; next } + { printf "%s", blanks; blanks = ""; print } + ' "$1" +} + +# The greeting sentence: the first non-blank line under the "## Greeting" +# heading. This is the only part of a slash command that a profile cannot derive. +profile_greeting() { + awk ' + /^## Greeting$/ { found = 1; next } + found && NF { print; exit } + ' "$1" +} + +# The profile body without its "## Required Interactive Behaviors" section. The +# section ends where "## Signature Question" begins; no profile puts another +# heading between the two. Reads the body on stdin. +strip_interactive_behaviors() { + awk ' + /^## Required Interactive Behaviors$/ { skip = 1 } + /^## Signature Question$/ { skip = 0 } + !skip + ' +} + +mkdir -p "$AGENTS" "$COMMANDS" generated=0 for profile in "$PROFILES"/*.md; do @@ -33,7 +73,24 @@ for profile in "$PROFILES"/*.md; do display="${title%% —*}" model=$(awk -v p="$name" '$1 == p { print $2; exit }' "$TIERS" 2>/dev/null || echo "") - out="$AGENTS/$name.md" + body=$(profile_body "$profile") + greeting=$(profile_greeting "$profile") + if [[ -z "$greeting" ]]; then + echo "error: $profile has no '## Greeting' section, so /$name would have no trailer." >&2 + exit 1 + fi + + # The slash command description already ends in "persona", which makes a + # trailing "Consultant" or "Manager" in the role redundant. Keep that word + # when dropping it would leave one word: "Product Manager" must not become + # "Product". + short_role="$role" + case "$role" in + *" Consultant" | *" Manager") + case "${role% *}" in *" "*) short_role="${role% *}" ;; esac + ;; + esac + { printf -- '---\n' printf 'name: %s\n' "$name" @@ -42,11 +99,25 @@ for profile in "$PROFILES"/*.md; do [[ -n "$model" ]] && printf 'model: %s\n' "$model" printf -- '---\n\n' printf '\n\n' "$name" - cat "$profile" + printf '%s\n' "$body" printf '\n---\n\n' printf 'You are running as a delegated subagent. Do the requested work within your domain, then return a concise, structured result: findings or recommendations first, supporting detail after. If the request falls outside your domain, say which team member fits and return what you can within your own lane.\n' - } > "$out" + } > "$AGENTS/$name.md" + + { + printf -- '---\n' + printf 'description: Switch this session to %s, the %s persona\n' "$display" "$short_role" + printf 'disable-model-invocation: true\n' + printf -- '---\n\n' + printf 'This switch is scoped to THIS session only. Do NOT run `claude-team use` and do NOT modify `~/.claude/CLAUDE.md`. Other parallel sessions keep their own personas.\n\n' + printf 'You are now switching to %s. Adopt the following persona immediately and completely for the rest of this session. This overrides any previous persona:\n\n' "$display" + printf -- '---\n\n' + printf '%s\n' "$body" | strip_interactive_behaviors + printf -- '\n---\n\n' + printf '%s\n' "$greeting" + } > "$COMMANDS/$name.md" + generated=$((generated + 1)) done -echo "Generated $generated agents in $AGENTS/" +echo "Generated $generated agents in $AGENTS/ and $generated slash commands in $COMMANDS/" diff --git a/tests/run.sh b/tests/run.sh index dee9768..2e2668a 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -481,35 +481,49 @@ assert_contains "iris agent carries model tier" "model: claude-opus-4-8" "$(cat 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 cats the profile verbatim into the agent, -# so every non-blank profile line must appear verbatim in its agent. Catches a -# profile edited without regenerating. +# 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. 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=$(grep -v '^[[:space:]]*$' "$profile" | grep -F -x -v -c -f "$agent" - || true) + 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 -# commands/.md is hand-maintained and nothing validates it. By convention it -# is the profile with Required Interactive Behaviors excised, 16 of 16 before Iris. +# 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. 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} /^## Signature Question$/{skip=0} !skip' "$profile" \ + d=$(awk '/^## Required Interactive Behaviors$/{skip=1} /^## Signature Question$/{skip=0} + /^## Greeting$/{next} !skip' "$profile" \ | grep -v '^[[:space:]]*$' | grep -F -x -v -c -f "$cmd" - || true) [[ "$d" == "0" ]] || cdrift="$cdrift $pname($d)" done -if [[ -z "$cdrift" ]]; then ok "slash commands match their profiles" -else fail "slash commands match their profiles (drift:$cdrift)"; fi +if [[ -z "$cdrift" ]]; then ok "generated commands match their profiles" +else fail "generated commands match their profiles (drift:$cdrift)"; fi + +# 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. +if grep -q '^## Greeting$' "$REPO_DIR"/agents/*.md; then + fail "greeting excluded from generated agents" +else + ok "greeting excluded from generated agents" +fi # team-session-start hook behavior HOOK="$REPO_DIR/bin/team-session-start" @@ -530,18 +544,21 @@ rm -rf "$HOOK_REPO" echo "" -# claude-team sync: a persona lives as three self-contained installed files, so -# a profile edit must reach the profile copy AND the regenerated subagent, and a -# command edit must reach the slash command. Runs against a throwaway clone so -# the edits never touch the real repo. +# claude-team sync: a persona lives as three self-contained installed files, and +# the profile is the only one a human edits. One profile edit must reach the +# installed profile, the regenerated subagent, and the regenerated slash command. +# Runs against a throwaway clone so the edits never touch the real repo. echo "claude-team sync" SYNC_REPO=$(mktemp -d) SYNC_HOME=$(mktemp -d) cp -R "$REPO_DIR"/profiles "$REPO_DIR"/commands "$REPO_DIR"/agents \ "$REPO_DIR"/scripts "$REPO_DIR"/bin "$SYNC_REPO/" -printf '\nSYNC-PROFILE-MARKER\n' >> "$SYNC_REPO/profiles/robin.md" -printf '\nSYNC-COMMAND-MARKER\n' >> "$SYNC_REPO/commands/robin.md" +# Insert above ## Greeting, not at end of file: the greeting is the slash command +# trailer, and everything under that heading is excised from the other two copies. +awk '/^## Greeting$/ { print "SYNC-PROFILE-MARKER"; print "" } { print }' \ + "$SYNC_REPO/profiles/robin.md" > "$SYNC_REPO/profiles/robin.md.new" +mv "$SYNC_REPO/profiles/robin.md.new" "$SYNC_REPO/profiles/robin.md" if HOME="$SYNC_HOME" bash "$SYNC_REPO/bin/claude-team" sync >/dev/null 2>&1; then ok "claude-team sync runs clean" @@ -552,8 +569,8 @@ assert_file_has "sync propagates a profile edit to the installed profile" \ "$SYNC_HOME/.claude/team/robin.md" "SYNC-PROFILE-MARKER" assert_file_has "sync regenerates the subagent from the edited profile" \ "$SYNC_HOME/.claude/agents/robin.md" "SYNC-PROFILE-MARKER" -assert_file_has "sync propagates a command edit to the installed command" \ - "$SYNC_HOME/.claude/commands/robin.md" "SYNC-COMMAND-MARKER" +assert_file_has "sync regenerates the slash command from the edited profile" \ + "$SYNC_HOME/.claude/commands/robin.md" "SYNC-PROFILE-MARKER" assert_file_has "sync installs tiers.conf" "$SYNC_HOME/.claude/team/tiers.conf" "akira" rm -rf "$SYNC_REPO" "$SYNC_HOME" From 00b82aab350f73e4be0d7b3533c9cc398559dd59 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:35:24 +0000 Subject: [PATCH 3/6] fix: serialise writes to shared state, and rename within one filesystem ~/.claude/branches/INDEX.md and ~/.claude/CLAUDE.md are shared across every Claude Code session on the machine. Parallel sessions are this product's headline feature, so concurrent access is the designed-for case. Nothing serialised it: grep for flock over the CLI returned zero. Measured on the unlocked code with 40-way concurrency, five runs, every run lost data: 23 to 29 of 40 index rows survived and 5 to 8 of 20 status updates applied. An independent 12-way probe reproduced it at 8 of 12 rows. A second failure mode was found that the original report missed. block_install greps for its start marker, misses it against a concurrent write that has not landed, takes the append path, and writes a duplicate block. Two of five runs produced two to four CLAUDE-TEAM blocks in CLAUDE.md, after which get_active reads a corrupted awk range. Appends were never torn. Bash >> opens O_APPEND and POSIX makes the offset update and the write one step, so an 80-byte row is always intact. The appends were discarded: one landing between a rewriter's read and its rename is erased by that rename. Atomicity of an append says nothing about its survival, which is why branch start and session start must take the same lock as the rewriters despite looking safe. Serialisation uses a directory lock via mkdir. It is one filesystem operation on every platform and needs no binary that stock macOS omits, unlike flock. The accepted tradeoff is that the kernel does not release it when the holder dies, so liveness is handled here: EXIT, INT, and TERM traps release on every exit short of SIGKILL; a lock whose owner record names a dead PID on this host is broken; and a lock older than 120 seconds is broken, which covers a reboot where the PID may have been reused. A contender confirms any verdict twice before breaking anything. Rejected: flock with an mkdir fallback, because two schemes do not exclude each other and any host with mixed invocations silently loses exclusion, with the fallback running only on the platform CI does not cover it. Also rejected: noclobber and symlink locks, which cannot hold the owner record as a child and so need a second file and a second race. Verification found two bugs in the first implementation that review had not. Treating "no owner record" as stale broke live locks 73 times per run, because every holder passes through that state between its mkdir and its record write. Treating "no lock directory" as stale made contenders remove directories other contenders had just created. Both now count as live. All five temp files are now created beside their destination and renamed within one filesystem. This is not comment accuracy. A cross-device mv degrades to copy plus unlink, and a reader can observe a half-copied index during it, so this is what makes "readers need no lock" true. Proven with TMPDIR=/nonexistent-tmpdir, under which bare mktemp cannot create a file and every write path still works. Two latent bugs fixed in passing. awk > tmp && mv reported success when awk failed, because set -e exempts a failing command in an && list, so branch close and session done printed "marked as merged" without changing the index; both now die. ensure_branches_index could truncate when two sessions both found the index missing and both wrote the header with >; it now writes under noclobber. Fourteen tests added. Nine of them fail on the pre-fix code, including the duplicate coordinator block. The concurrency pair is timing-dependent by nature: it fails probabilistically on unlocked code, which is the direction that can only ever under-report, and passes deterministically on locked code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- bin/claude-team | 243 ++++++++++++++++++++++++++++++++++++++++++++---- tests/run.sh | 84 +++++++++++++++++ 2 files changed, 309 insertions(+), 18 deletions(-) diff --git a/bin/claude-team b/bin/claude-team index 535a2c3..252ff8b 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -34,14 +34,184 @@ die() { echo "$(red "error:") $*" >&2; exit 1; } lowercase() { printf '%s\n' "${1,,}"; } capitalize() { printf '%s\n' "${1^}"; } +# ─── Shared-state locking ──────────────────────────────────────────────────── + +# ~/.claude/CLAUDE.md and ~/.claude/branches/INDEX.md are shared by every +# Claude Code session on this machine, and several commands rewrite them +# read-modify-write. Every writer must take this lock, appends included: an +# append is atomic by itself, but an append that lands between a rewriter's +# read and its rename is discarded by that rename. Readers take no lock, +# because a rewrite lands with a rename inside one filesystem, so a reader +# sees either the whole old file or the whole new one. +# +# The lock is a directory. mkdir either creates the directory or fails, in one +# filesystem operation, on Linux and macOS alike. flock is the usual tool for +# this, but stock macOS does not ship it and this project supports macOS. +# +# The kernel does not release a directory lock when the holder dies, so: +# - the holder records host, PID, and acquisition time in /owner; +# - the EXIT, INT, and TERM traps release the lock on every exit except +# SIGKILL and power loss; +# - a contender breaks a lock whose holder is a dead process on this host, +# or whose age exceeds LOCK_STALE_SECONDS. The age rule also covers a lock +# left by a reboot, where the recorded PID may since have been reassigned +# to a live process. +# +# The critical sections are a few awk passes over a small file, so a wait of +# seconds already means a holder is stuck rather than queued. +LOCK_WAIT_SECONDS=15 +LOCK_STALE_SECONDS=120 +LOCK_POLL_SECONDS=0.05 +LOCK_HOST="${HOSTNAME:-$(uname -n 2>/dev/null || echo unknown)}" + +_LOCK_DIR="" # non-empty while this process holds a lock +_TMP_FILE="" # non-empty while a temp file waits for its rename + +_lock_owner() { cat "$1/owner" 2>/dev/null || true; } + +# True only when the lock holder is provably gone: a dead PID on this host, or +# an acquisition older than LOCK_STALE_SECONDS. +# +# Two states deliberately count as live. An absent lock directory is not +# stale: the next mkdir wins it, and breaking it would delete a directory that +# another contender has since created. A lock whose owner record is not +# readable yet is not stale either: every live holder passes through that +# state between its mkdir and its record write, so breaking on it removes live +# locks under load. lock_acquire handles a holder that died inside that window +# separately, at the deadline. +_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 + return 1 + fi + now=$(date '+%s') + if [[ "$started" =~ ^[0-9]+$ ]] && (( now - started > LOCK_STALE_SECONDS )); then + return 0 + fi + if [[ "$host" == "$LOCK_HOST" && "$pid" =~ ^[0-9]+$ ]] && ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + return 1 +} + +# Claim the lock directory. _LOCK_DIR is set before the owner record is +# written, so the traps remove the directory if this process dies in between. +_lock_take() { + local lock_dir="$1" + mkdir "$lock_dir" 2>/dev/null || return 1 + _LOCK_DIR="$lock_dir" + printf '%s %s %s\n' "$LOCK_HOST" "$$" "$(date '+%s')" > "$lock_dir/owner" \ + || die "Cannot write the lock owner record: $lock_dir/owner" + return 0 +} + +# Take the lock for a shared file. Waits up to LOCK_WAIT_SECONDS, then fails +# with the holder's identity and the command that clears the lock by hand. +# +# Before it breaks a stale lock, the contender confirms the verdict twice, +# LOCK_POLL_SECONDS apart, with an unchanged owner record, so a lock that +# another contender took in between survives. The residual window is the few +# microseconds between the second check and the removal, and it opens only +# once a holder has already died. +lock_acquire() { + local target="$1" lock_dir="$1.lock" deadline snapshot broke_once=false + deadline=$(( SECONDS + LOCK_WAIT_SECONDS )) + mkdir -p "$(dirname "$lock_dir")" + while true; do + if _lock_take "$lock_dir"; then + return 0 + fi + if _lock_is_stale "$lock_dir"; then + snapshot=$(_lock_owner "$lock_dir") + sleep "$LOCK_POLL_SECONDS" + if _lock_is_stale "$lock_dir" && [[ "$(_lock_owner "$lock_dir")" == "$snapshot" ]]; then + rm -rf "$lock_dir" + fi + continue + fi + if (( SECONDS >= deadline )); then + # An owner record still missing after the full wait belongs to a holder + # that died between its mkdir and its record write: no live holder stays + # in that state for seconds. Break it once, then wait again. + if [[ "$broke_once" == false && -d "$lock_dir" && ! -s "$lock_dir/owner" ]]; then + broke_once=true + rm -rf "$lock_dir" + deadline=$(( SECONDS + LOCK_WAIT_SECONDS )) + continue + fi + die "Timed out after ${LOCK_WAIT_SECONDS}s waiting for the lock on $target. +Holder (host pid epoch): $(_lock_owner "$lock_dir") +If no other claude-team command is running, remove the lock: + rm -rf $lock_dir" + fi + sleep "$LOCK_POLL_SECONDS" + done +} + +# Release the lock this process holds. The removal is conditional on the owner +# record still naming this process: if a contender broke the lock, the +# directory belongs to that contender and must survive. +lock_release() { + [[ -n "$_LOCK_DIR" ]] || return 0 + local dir="$_LOCK_DIR" owner_pid + _LOCK_DIR="" + owner_pid=$(awk '{print $2; exit}' "$dir/owner" 2>/dev/null || true) + if [[ "$owner_pid" == "$$" ]]; then + rm -rf "$dir" + fi + return 0 +} + +# Runs on a normal exit, on die, on a set -e failure, and on Ctrl-C. Nothing +# runs on SIGKILL or power loss; lock_acquire's staleness rule covers those. +_cleanup() { + lock_release + if [[ -n "$_TMP_FILE" ]]; then + rm -f "$_TMP_FILE" + _TMP_FILE="" + fi + return 0 +} +trap _cleanup EXIT +trap '_cleanup; exit 130' INT +trap '_cleanup; exit 143' TERM + +# Create a temp file in the target's own directory and register it for +# cleanup. mktemp otherwise writes to TMPDIR, which is often tmpfs and so a +# different filesystem from $HOME. mv across filesystems degrades to copy plus +# unlink, which is not atomic: a reader can see a partial file, and an +# interrupted copy leaves one behind. +tmp_beside() { + local target="$1" dir + dir=$(dirname "$target") + mkdir -p "$dir" + _TMP_FILE=$(mktemp "$dir/.claude-team.XXXXXX") + printf '%s\n' "$_TMP_FILE" +} + +# Put a temp file from tmp_beside in place. Source and target share a +# filesystem, so this is a rename: readers see the old file or the new one. +tmp_commit() { + mv "$1" "$2" + _TMP_FILE="" +} + require_profiles_dir() { [[ -d "$PROFILES_DIR" ]] || die "Profiles directory not found: $PROFILES_DIR Run the installer first: bash install.sh" } +# A profile name is an identifier, not a path: it is the basename of a file in +# PROFILES_DIR. The character check rejects everything else, so a name cannot +# reach the filesystem as '../' traversal, and cannot be read as an option by a +# command that is given it later. The check is a character class rather than +# path resolution because macOS ships no 'readlink -f'. resolve_name() { local input input=$(lowercase "$1") + [[ "$input" =~ ^[a-z0-9][a-z0-9_-]*$ ]] \ + || die "Invalid profile name: '$1'. A name uses letters, digits, '-', and '_', and starts with a letter or digit." local profile="$PROFILES_DIR/$input.md" [[ -f "$profile" ]] || die "No profile found for '$input'. Run 'claude-team list' to see available team members." echo "$profile" @@ -70,14 +240,17 @@ get_active() { } # Replace the marked block in a file, or append it if absent. All edits go -# through a temp file and land with mv: a redirection straight onto the +# through a temp file and land with a rename: a redirection straight onto the # target would truncate it before the pipeline runs, so any failure could -# destroy the user's file. +# 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. block_install() { local file="$1" start="$2" end="$3" content="$4" + lock_acquire "$file" touch "$file" local tmp - tmp=$(mktemp) + tmp=$(tmp_beside "$file") if grep -qF "$start" "$file"; then awk -v m="$start" 'index($0, m){exit} {print}' "$file" > "$tmp" printf '%s\n%s\n%s\n' "$start" "$content" "$end" >> "$tmp" @@ -86,16 +259,20 @@ block_install() { cat "$file" > "$tmp" printf '\n%s\n%s\n%s\n' "$start" "$content" "$end" >> "$tmp" fi - mv "$tmp" "$file" + tmp_commit "$tmp" "$file" + lock_release } -# Remove the marked block from a file atomically. Blank lines the removed -# block leaves at end-of-file are trimmed; everything above it, including -# the user's own trailing spaces and leading blank lines, is preserved. +# Remove the marked block from a file. The new content lands with a rename +# inside one filesystem, so a reader sees the file with the block or without +# it, never a partial file. Blank lines the removed block leaves at +# end-of-file are trimmed; everything above it, including the user's own +# trailing spaces and leading blank lines, is preserved. block_remove() { local file="$1" start="$2" end="$3" + lock_acquire "$file" local tmp - tmp=$(mktemp) + tmp=$(tmp_beside "$file") awk -v s="$start" -v e="$end" ' index($0, s){skip=1} skip && index($0, e){skip=0; next} @@ -105,7 +282,8 @@ block_remove() { for (i = 1; i <= n; i++) print lines[i] } ' "$file" > "$tmp" - mv "$tmp" "$file" + tmp_commit "$tmp" "$file" + lock_release } # ─── Commands ──────────────────────────────────────────────────────────────── @@ -349,11 +527,17 @@ get_project_name() { basename "$root" } +# Create the index if it is absent. Read-only commands call this too, so it +# takes no lock. noclobber supplies the exclusion instead: it makes the +# redirect fail rather than truncate when another session created the index +# between the test below and the write. ensure_branches_index() { if [[ ! -f "$BRANCHES_INDEX" ]]; then mkdir -p "$(dirname "$BRANCHES_INDEX")" + set -C printf '# Branch Index\n\n| Date | Project | Branch | Plan Slug | Status | Notes |\n|---|---|---|---|---|---|\n' \ - > "$BRANCHES_INDEX" + > "$BRANCHES_INDEX" 2>/dev/null || true + set +C fi } @@ -404,6 +588,10 @@ cmd_branch_start() { local project project=$(get_project_name) + # One lock spans the check and the append. Without it two sessions both find + # no active branch and both register one for the same project. + lock_acquire "$BRANCHES_INDEX" + # Block if an active branch already exists for this project local existing existing=$(get_active_branch "$project") @@ -419,6 +607,8 @@ Run 'claude-team branch done' or 'claude-team branch abandon' before starting a printf '| %s | %s | %s | %s | active | |\n' \ "$today" "$project" "$branch_name" "$plan_slug" >> "$BRANCHES_INDEX" + lock_release + echo "" echo "$(green "✓") Branch $(bold "$branch_name") registered for $(bold "$project")." [[ -n "$plan_slug" ]] && echo "$(dim " Linked to plan: $plan_slug")" @@ -431,12 +621,16 @@ cmd_branch_close() { local project project=$(get_project_name) + # One lock spans the read, the rewrite, and the rename. Without it a row + # another session appends after the read is discarded by the rename. + lock_acquire "$BRANCHES_INDEX" + local active active=$(get_active_branch "$project") [[ -n "$active" ]] || die "No active branch found for '$project'. Nothing to close." local tmp - tmp=$(mktemp) + tmp=$(tmp_beside "$BRANCHES_INDEX") awk -F'|' -v proj="$project" -v ns="$new_status" ' { p = $3; st = $6 @@ -451,7 +645,10 @@ cmd_branch_close() { } print } - ' "$BRANCHES_INDEX" > "$tmp" && mv "$tmp" "$BRANCHES_INDEX" + ' "$BRANCHES_INDEX" > "$tmp" || die "Failed to rewrite the branch index: $BRANCHES_INDEX" + tmp_commit "$tmp" "$BRANCHES_INDEX" + + lock_release echo "" if [[ "$new_status" == "merged" ]]; then @@ -519,7 +716,7 @@ cmd_branch_guard() { || die "A pre-commit hook already exists at $hook_file (not installed by claude-team). Remove it manually first." fi local tmp_hook - tmp_hook=$(mktemp) + tmp_hook=$(tmp_beside "$hook_file") cat > "$tmp_hook" << 'HOOK' #!/usr/bin/env bash # Installed by claude-team branch guard @@ -531,7 +728,7 @@ if [[ "$branch" == "main" || "$branch" == "master" ]]; then exit 1 fi HOOK - mv "$tmp_hook" "$hook_file" + tmp_commit "$tmp_hook" "$hook_file" chmod +x "$hook_file" echo "" echo "$(green "✓") Branch guard installed." @@ -754,12 +951,18 @@ Run: claude-team session status" fi fi - # Register in INDEX.md + # Register in INDEX.md. The append takes the lock even though an append to a + # file opened O_APPEND cannot be torn: an unlocked append that lands inside + # another session's read-modify-write window is discarded by its rename. + # The lock is taken here rather than around the whole command, so creating a + # worktree, which takes seconds, never blocks another session's index write. + lock_acquire "$BRANCHES_INDEX" ensure_branches_index local today today=$(date '+%Y-%m-%d') printf '| %s | %s | %s | %s | active | worktree |\n' \ "$today" "$project" "$branch_name" "$plan_slug" >> "$BRANCHES_INDEX" + lock_release echo "" echo "$(green "✓") Session started" @@ -814,9 +1017,11 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no git_common_dir=$(git -C "$worktree_path" rev-parse --git-common-dir 2>/dev/null) repo_root=$(dirname "$git_common_dir") - # Mark merged in INDEX.md + # Mark merged in INDEX.md. The lock spans the rewrite and the rename, so a + # row another session appends in between is not discarded. + lock_acquire "$BRANCHES_INDEX" local tmp - tmp=$(mktemp) + tmp=$(tmp_beside "$BRANCHES_INDEX") awk -F'|' -v proj="$project" -v br="$branch" ' { p = $3; b = $4; st = $6 @@ -831,7 +1036,9 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no } print } - ' "$BRANCHES_INDEX" > "$tmp" && mv "$tmp" "$BRANCHES_INDEX" + ' "$BRANCHES_INDEX" > "$tmp" || die "Failed to rewrite the branch index: $BRANCHES_INDEX" + 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 \ diff --git a/tests/run.sh b/tests/run.sh index 2e2668a..c64d9f5 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -434,6 +434,90 @@ if [[ ! -d "$SESSION_WT3" ]]; then ok "session done removes metachar worktree"; rm -rf "$SESSION_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. +# Measured on the unlocked code: 8 of 12 rows survived, and CLAUDE.md grew +# duplicate marker blocks when block_install took its append path against a +# writer that had not landed yet. +echo "shared-state concurrency" + +CONC_HOME=$(mktemp -d) +CONC_INDEX="$CONC_HOME/.claude/branches/INDEX.md" +mkdir -p "$CONC_HOME/.claude" +CONC_ROOT=$(mktemp -d) +for i in 1 2 3 4 5 6 7 8; do + git init -q "$CONC_ROOT/r$i" + (cd "$CONC_ROOT/r$i" && git commit -q --allow-empty -m init) +done +# Seed four registered branches, sequentially, so there is something to rewrite. +for i in 1 2 3 4; do + (cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch start "feat/c$i" >/dev/null 2>&1) +done +# Four rewriters and four appenders at once. A rewriter reads the whole index, +# transforms it, and renames over it; an append landing inside that window is +# erased by the rename, however atomic the append itself was. +for i in 1 2 3 4; do + (cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch done >/dev/null 2>&1) & +done +for i in 5 6 7 8; do + (cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch start "feat/c$i" >/dev/null 2>&1) & +done +wait +assert_count "concurrent writers keep every row" "$CONC_INDEX" '^| 2' 8 +assert_count "concurrent rewrites all applied" "$CONC_INDEX" '| merged |' 4 +assert_count "concurrent appends all survived" "$CONC_INDEX" '| active |' 4 +assert_file_has "concurrent writers keep the header" "$CONC_INDEX" "Branch Index" +if [[ ! -d "$CONC_INDEX.lock" ]]; then ok "no lock directory remains after success"; else fail "no lock directory remains after success"; fi +rm -rf "$CONC_ROOT" + +# CLAUDE.md has two independent marker blocks written by different commands. +# The duplicate-block count is the assertion that matters: block_install greps +# for its start marker, misses it against an unlanded concurrent write, and +# takes the append path, producing a second copy. +CONC_MD="$CONC_HOME/.claude/CLAUDE.md" +printf 'keep-this-line\n' > "$CONC_MD" +for _ in 1 2 3 4 5; do + (CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" use akira >/dev/null 2>&1) & + (CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" coordinator on >/dev/null 2>&1) & +done +wait +assert_count "concurrent writers leave one team block" "$CONC_MD" "CLAUDE-TEAM:START" 1 +assert_count "concurrent writers leave one coordinator block" "$CONC_MD" "CLAUDE-COORDINATOR:START" 1 +assert_count "concurrent writers keep user content" "$CONC_MD" "keep-this-line" 1 +rm -rf "$CONC_HOME" +echo "" + +# Temp files must be created beside their destination, not in TMPDIR. A +# cross-device rename degrades to copy plus unlink, during which a reader can +# see a half-written index. An unusable TMPDIR proves the temp file is not +# there: bare mktemp cannot create a file at all under this setting. +echo "writes do not depend on TMPDIR" + +TMP_HOME=$(mktemp -d) +mkdir -p "$TMP_HOME/.claude" +TMP_REPO=$(mktemp -d) +git init -q "$TMP_REPO" +(cd "$TMP_REPO" && git commit -q --allow-empty -m init) +run_notmp() { + TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" "$@" +} +for spec in "use akira:use" "reset:reset" "coordinator on:coordinator on"; do + if run_notmp ${spec%%:*} >/dev/null 2>&1; then ok "${spec##*:} works when TMPDIR is unusable" + else fail "${spec##*:} works when TMPDIR is unusable"; fi +done +if (cd "$TMP_REPO" && TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" branch start feat/notmp >/dev/null 2>&1); then + ok "branch start works when TMPDIR is unusable" +else fail "branch start works when TMPDIR is unusable"; fi +if (cd "$TMP_REPO" && TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" branch done >/dev/null 2>&1); then + ok "branch done works when TMPDIR is unusable" +else fail "branch done works when TMPDIR is unusable"; fi +if [[ -z "$(find "$TMP_HOME" -name '.claude-team.*' 2>/dev/null)" ]]; then + ok "no temp file litter left in ~/.claude" +else fail "no temp file litter left in ~/.claude"; fi +rm -rf "$TMP_HOME" "$TMP_REPO" +echo "" + # launch + plugin surfaces echo "launch + plugin surfaces" From b54f13c0c0fb372b99d026c48b3c17c77248a8f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:37:01 +0000 Subject: [PATCH 4/6] fix: keep the persona greeting out of the global pin Generating slash commands from profiles added a ## Greeting section to every profile, holding the line a persona says when a session switches to it. cmd_use cats the whole profile into ~/.claude/CLAUDE.md, which is the global pin, so 'claude-team use akira' wrote that greeting into state every future session reads. Pinned, it makes every new session open by greeting the user, forever, rather than once at the switch. profile_without_greeting stops at the heading. The section runs to end of file, so that is the whole excision, and a profile without the heading prints unchanged. cmd_launch is deliberately left alone. It opens one dedicated session with the persona as system prompt, so greeting once on arrival is the intended behaviour there. The difference is between pinning a default and starting a session. Two tests added. The greeting guard fails on the previous code; the companion assertion that the signature question still arrives guards the other direction, so a future change cannot satisfy the first by truncating the profile early. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- bin/claude-team | 14 +++++++++++++- tests/run.sh | 4 ++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/bin/claude-team b/bin/claude-team index 252ff8b..59a8cd3 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -231,6 +231,15 @@ title_role() { printf '%s\n' "${1#*— }" } +# Print a profile without its trailing "## Greeting" section. That section +# holds the line a persona says at the moment a session switches to it, so it +# belongs only where a session starts now. The section runs to end of file, so +# stopping at its heading is the whole excision. A profile without the heading +# prints unchanged. +profile_without_greeting() { + awk '/^## Greeting[[:space:]]*$/{exit} {print}' "$1" +} + get_active() { [[ -f "$CLAUDE_MD" ]] || { echo ""; return; } # Extract the name line from the active block @@ -343,8 +352,11 @@ cmd_use() { local profile profile=$(resolve_name "$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 + # the same line forever. local content - content=$(cat "$profile") + content=$(profile_without_greeting "$profile") block_install "$CLAUDE_MD" "$MARKER_START" "$MARKER_END" "$content" local display_name diff --git a/tests/run.sh b/tests/run.sh index c64d9f5..88d333f 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -143,6 +143,10 @@ run_cmd use robin >/dev/null assert_file_has "use injects CLAUDE-TEAM block" "$CLAUDE_MD" "CLAUDE-TEAM:START" assert_file_has "use injects Robin profile" "$CLAUDE_MD" "Robin — QA" assert_count "use creates exactly 1 block" "$CLAUDE_MD" "CLAUDE-TEAM:START" 1 +# 'use' pins globally, so a greeting here would fire on every future session +# rather than at the moment of the switch. The body must still arrive whole. +assert_file_lacks "use excludes the greeting" "$CLAUDE_MD" "^## Greeting" +assert_file_has "use keeps the signature question" "$CLAUDE_MD" "Signature Question" echo "" # use — persona switch From c080705ad4f05d4159fd8bcd2159e98320b6dddd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:43:02 +0000 Subject: [PATCH 5/6] fix: validate persona names, and repair the lint I broke Two unrelated things, both in the test suite's blast radius. CI lint was red from commit 00b82aa, which is mine. shellcheck read the bare word 'done' in "$CLI" branch done as a loop terminator (SC1010) at two sites, and flagged an unquoted expansion (SC2086) at a third. The rest of this suite already quotes "done" for exactly that reason, so the new code was the outlier. The unquoted expansion packed a command and its label into one colon-delimited string and relied on word splitting to unpack it. Quoting it would have passed "coordinator on" as a single argument and broken the test, so the packing is gone rather than quoted. Three plain lines beat one clever loop. resolve_name now requires a name to match ^[a-z0-9][a-z0-9_-]*$. It is the single choke point for show, use, and launch, and it stays orthogonal to the $CLAUDE_TEAM_PROFILES override the suite depends on. This is not a vulnerability fix and is not claimed as one. The CLI runs as the invoking user, reads with that user's permissions, never writes to the traversed path, and appends .md, so every file it could reach is one the caller can already cat. It earns its place for two other reasons. It fixes a real correctness bug: 'use ../gtm' previously exited 0, printed a success line with an empty name, and pinned a non-profile into ~/.claude/CLAUDE.md, where every future session reads it. It also stops a leading-dash name reaching 'claude -n' as an option. sanitize_branch_for_path was checked and deliberately left alone. It maps / to -, so a component with no / cannot traverse, and git's own check-ref-format rejects '..' before the path is built. Validation there would reduce no risk. Eleven tests added. Six fail on the previous code, including the one that matters most: a rejected name reaching the global pin. The first of them asserts the traversal target still exists, because an earlier run of this control passed for the wrong reason when gtm.md was absent from a scratch tree and the traversal resolved to nothing either way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- tests/run.sh | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 88d333f..0eb717c 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -137,6 +137,33 @@ assert_contains "case-insensitive show" "Robin — QA" "$out" assert_exits_nonzero "invalid name exits nonzero" "$CLI" show nobody echo "" +# A profile name is an identifier, not a path. resolve_name is the single choke +# point for show, use, and launch. '../gtm' is the regression target because +# $PROFILES_DIR/../gtm.md genuinely exists and is lowercase, so it resolved +# before the fix. Traversal read nothing the caller could not already cat, but +# it let a non-profile reach the global pin and printed a success line with an +# empty name. +echo "name validation" +# Guard the guard: if gtm.md ever moves, every assertion below would pass for +# the wrong reason, because the traversal would resolve to nothing either way. +if [[ -f "$REPO_DIR/gtm.md" ]]; then ok "traversal target exists, so these tests are not vacuous" +else fail "traversal target exists, so these tests are not vacuous (gtm.md moved: repoint these tests)"; fi +assert_exits_nonzero "show rejects traversal to a real file" "$CLI" show ../gtm +assert_exits_nonzero "use rejects traversal to a real file" "$CLI" use ../gtm +assert_exits_nonzero "launch rejects traversal to a real file" "$CLI" launch ../gtm --dry-run +assert_exits_nonzero "show rejects a nested traversal" "$CLI" show sub/../../gtm +assert_exits_nonzero "show rejects a leading dash" "$CLI" show -n +assert_exits_nonzero "show rejects an empty name" "$CLI" show "" +out=$(run_cmd show ../gtm 2>&1 || true) +assert_contains "traversal reports an invalid name" "Invalid profile name" "$out" +assert_not_contains "traversal discloses no outside content" "Go-to-Market" "$out" +run_cmd use ../gtm >/dev/null 2>&1 || true +assert_file_lacks "a rejected name never reaches the global pin" "$CLAUDE_MD" "Go-to-Market" +# The character class must not be so tight that real names stop resolving. +out=$(run_cmd show coordinator-prod) +assert_contains "hyphenated name still resolves" "Claude Team CLI" "$out" +echo "" + # use — basic injection echo "use" run_cmd use robin >/dev/null @@ -462,7 +489,7 @@ done # transforms it, and renames over it; an append landing inside that window is # erased by the rename, however atomic the append itself was. for i in 1 2 3 4; do - (cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch done >/dev/null 2>&1) & + (cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch "done" >/dev/null 2>&1) & done for i in 5 6 7 8; do (cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch start "feat/c$i" >/dev/null 2>&1) & @@ -506,14 +533,18 @@ git init -q "$TMP_REPO" run_notmp() { TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" "$@" } -for spec in "use akira:use" "reset:reset" "coordinator on:coordinator on"; do - if run_notmp ${spec%%:*} >/dev/null 2>&1; then ok "${spec##*:} works when TMPDIR is unusable" - else fail "${spec##*:} works when TMPDIR is unusable"; fi -done +if run_notmp use akira >/dev/null 2>&1; then ok "use works when TMPDIR is unusable" +else fail "use works when TMPDIR is unusable"; fi +if run_notmp reset >/dev/null 2>&1; then ok "reset works when TMPDIR is unusable" +else fail "reset works when TMPDIR is unusable"; fi +if run_notmp coordinator on >/dev/null 2>&1; then ok "coordinator on works when TMPDIR is unusable" +else fail "coordinator on works when TMPDIR is unusable"; fi if (cd "$TMP_REPO" && TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" branch start feat/notmp >/dev/null 2>&1); then ok "branch start works when TMPDIR is unusable" else fail "branch start works when TMPDIR is unusable"; fi -if (cd "$TMP_REPO" && TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" branch done >/dev/null 2>&1); then +# "done" is quoted throughout this suite so shellcheck does not read it as a +# loop terminator (SC1010). +if (cd "$TMP_REPO" && TMPDIR=/nonexistent-tmpdir CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TMP_HOME" "$CLI" branch "done" >/dev/null 2>&1); then ok "branch done works when TMPDIR is unusable" else fail "branch done works when TMPDIR is unusable"; fi if [[ -z "$(find "$TMP_HOME" -name '.claude-team.*' 2>/dev/null)" ]]; then From ecbda16f005aa75a7c83614c3ab75f7d99ef2905 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:43:43 +0000 Subject: [PATCH 6/6] devlog: v2 hardening, four specialists and a real race Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019j5DHEZsoeCGRueTbNLuTb --- DEVLOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/DEVLOG.md b/DEVLOG.md index 496b0f1..092e3c1 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -5,6 +5,56 @@ Auto-maintained via Claude devlog skill. Entries are reverse-chronological. --- +## [2026-07-29] v2 hardening: four specialists, a real race, and a lint I broke + +**Category:** `milestone` +**Tags:** `concurrency`, `locking`, `codegen`, `security`, `versioning`, `dogfooding`, `delegation` + +**Risk Level:** `medium` +**Breaking Change:** `no` + +### Summary + +An adversarial review of the codebase produced five findings. All five are fixed, each by the persona whose lane it sits in, working from the installed profiles rather than the repo copy. The suite went 136 to 163. The headline result: the parallel-sessions feature was silently losing data, and two of the four specialists found defects the review had missed. + +### Detail + +**Dogfooding was the precondition, not a nicety.** The team was cloned and installed through the documented path before any delegation. That validated the two install fixes shipped earlier the same day: the SessionStart hook registered itself in `~/.claude/settings.json`, and all three persona surfaces landed. It also surfaced a gap: Claude Code registers subagent types at session start, so seventeen freshly installed agents were invisible to the running session. The installer prints "Subagents installed (delegate with...)" as though they are live, while `install-commands` correctly states that slash commands need no restart. The one that needs a restart is the one that does not say so. The first round of delegation ran against profiles read from `~/.claude/team/` instead; the agent types appeared later in the session. + +**The race was real and worse than reported.** The review found that `cmd_branch_close` does a read-modify-write with no lock, and reasoned that the `>>` appends in `branch start` and `session start` were safe because POSIX makes an O_APPEND write atomic. Akira verified that reasoning, confirmed no row was ever torn, and then showed it was beside the point: an append landing between a rewriter's read and its rename is **erased** by that rename. Atomicity of an append says nothing about its survival. That is the load-bearing argument for locking the appenders too, and the review had explicitly waved it off. Measured at 40-way concurrency, five runs, every run lost data: 23 to 29 of 40 rows survived, 5 to 8 of 20 status updates applied. + +He also found a second failure mode nobody had looked for. `block_install` greps for its start marker, misses it against a concurrent write that has not landed, takes the append path, and writes a duplicate block. Two of five runs produced up to four `CLAUDE-TEAM` blocks in `CLAUDE.md`, after which `get_active` reads a corrupted awk range. + +**Verification beat reasoning, twice, in the same change.** Akira's first locking implementation treated "no owner record" as stale. Every live holder passes through that state for microseconds between its `mkdir` and its record write, so contenders broke live locks 73 times per run. It also treated "no lock directory" as stale, so contenders removed directories other contenders had just created. Neither was caught by review; both were caught by a harness built to look for them. + +**The commands generator closed a gap that had already bitten twice.** `commands/` was hand-maintained while being about 95 percent derivable, and the drift test already contained the transformation as a checker. Alex turned the checker into a generator. He also overruled the brief: offered a choice between extending `generate-agents.sh` and adding a sibling script, he extended it, because `claude-team sync` and `install.sh` both invoke that one script and a sibling would never run on either path, reopening the same gap one level down. + +**Two findings were correctly downgraded.** Morgan rated the path traversal Low, Informational as a vulnerability, and said so rather than inflating it: the CLI runs as the invoking user, reads with that user's permissions, and appends `.md`, so every reachable file is one the caller can already `cat`. He fixed it anyway on correctness grounds, because `use ../gtm` exited 0, printed a success line with an empty name, and pinned a non-profile into global state. He also checked `sanitize_branch_for_path`, found git's `check-ref-format` already rejects `..`, and left it alone on the grounds that a control reducing no risk is a liability. + +**The symlink finding was closed as working-as-intended.** If the clone moves, the CLI and hook break loudly, and loud is correct: the install genuinely is broken and `bash install.sh` repairs it. Silencing it would hide real breakage, and the symlink buys `git pull` updating the CLI with no reinstall. + +### Decisions Made + +- **A `mkdir` directory lock, not `flock` with a fallback.** `flock` is absent from stock macOS. A two-scheme design does not exclude itself, so any host with mixed invocations silently loses mutual exclusion, and the fallback would run only on the platform CI does not cover it. The accepted cost is owning liveness by hand: traps, a dead-PID rule, and a 120 second ceiling for the reboot case. +- **Temp files are created beside their destination.** Not comment accuracy. A cross-device `mv` degrades to copy plus unlink, and a reader can observe a half-copied index during it, so this is what makes "readers need no lock" true. +- **The greeting is excised from `use` but not from `launch`.** `use` pins a default that every future session reads; `launch` starts one session, which is the moment a greeting is for. +- **Version numbering normalised to v2.0.0 with no fabricated v1 release.** The repository has no git tags, so v1 is presented as the first generation rather than a dated release. The April roadmap row still reads "v0.6 shipping" and stays verbatim: a dated changelog entry is not rewritten to match a later renumber. + +### Mistakes Worth Recording + +- **I broke CI lint and shipped it.** Commit `00b82aa` introduced three shellcheck findings in `tests/run.sh`, two of them a bare `done` read as a loop terminator. The rest of the suite already quotes `"done"` for exactly that reason, so the new code was the outlier against a convention already visible in the file. Morgan caught it while working in an adjacent file. +- **Morgan's suggested fix for one of those was wrong.** Quoting `${spec%%:*}` would have passed `coordinator on` as a single argument and broken the test. The finding was right and the fix was not, which is the argument for verifying a subagent's patch rather than applying it. +- **A control test passed for the wrong reason.** The first attempt to prove the traversal tests were meaningful ran against a scratch tree that did not contain `gtm.md`, so the traversal resolved to nothing and every assertion passed on unfixed code. The suite now asserts the traversal target exists before testing against it. +- **A `git add` swept a running agent's work into an unrelated commit.** Morgan's `resolve_name` fix landed inside the locking commit. Harmless to the code, wrong for the history. Scoped `git add ` is the rule while agents are live. + +### Related + +- PRs #19, #20, #21: the same-day work this builds on +- `WRITING.md`: the plain technical English standard all four specialists wrote to +- ROADMAP: local profile overrides, now top priority and still the real gap + +--- + ## [2026-07-29] claude-team sync: one command for the three-copy persona problem **Category:** `decision`