From 890190263e3b6e94405aeb7c3f7a85a81a67d451 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 04:37:03 +0200 Subject: [PATCH 1/2] CB-560/562/563: the shipped block tells the truth again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical CLAUDE.md block is the instruction surface this daemon ships to every agent that mounts it, so a code change that silently invalidates it is an incomplete change. CB-548 added a third principal kind and the block was never revisited. Four statements in it were simply false: * whoami was documented as returning only primary or worker; * "spawn/stop/send/drain are lead-only" — Authz permits SEND to an architect; * delivery was documented as idle/blocked — injectable() is IDLE|BLOCKED|DONE, and a spawned member must also have mounted the bridge MCP, which is the exact condition that made every architect undeliverable for a day; * the tool table said bridge_list returns `workers` — the JSON key is `members`. Also corrected: the fallback ladder claimed each one-way signal identifies a "worker", but an architect gets the same charter, the same mount and the same env, so those signals identify a spawned member and only bridge_whoami separates the two. The safe default stays "act as a worker" — it is the most restricted member role. The turn contract now covers both member kinds, and says why the completion fallback is not a substitute for bridge_reply: it returns at most the last 4000 characters, so a long report reaches the lead with its end cut off. That is not hypothetical — it happened twice today. Found by a reviewer asked whether the block still matches the code. Verified against injectable(), Authz, BridgeMcp.listFleet and LeadLauncher before applying. The wiki template is updated in the same shape and re-checked byte-identical. --- CLAUDE.md | 70 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2f7ca7..882e951 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,41 +11,45 @@ If no `bridge_*` MCP tools are mounted in this session, this section does not apply — skip it. `bridged` is the **sole communication gateway** between agents here. The orchestrating session (the -**primary**) and every delegated peer (a **worker**) mount the *same* MCP server and talk only +**primary**) and every delegated peer (a **member**) mount the *same* MCP server and talk only through its `bridge_*` tools. No session addresses a peer, a broker, or the network directly. ### Which role am I? — settle this before acting -**Both roles read this file.** A worker runs in a git worktree of this same repo, so it inherits +**Every role reads this file.** A member runs in a git worktree of this same repo, so it inherits this `CLAUDE.md` verbatim, and every rule below is role-conditional. -**Call `bridge_whoami`.** It returns `{"role":"primary"}` or `{"role":"worker","sessionId":…, -"profile":…,"worktree":…,"branch":…}`, resolved by the daemon from your connection — unforgeable, -and the same resolution its authorization gate uses. Don't infer what you can ask. +**Call `bridge_whoami`.** It returns `primary`, `worker`, or `architect`, resolved by the daemon from +your connection — unforgeable, and the same resolution its authorization gate uses. A worker also +carries its `sessionId`, `profile`, `worktree` and `branch`; an architect carries the slot name it +was bound to. Don't infer what you can ask. Only if that call is unavailable, fall back to these — each is one-way, so keep reading until one fires: the reply charter in your system prompt (*"You are an off-subscription worker in the -claude-bridge fleet"*) ⇒ **worker**; bridge tools prefixed `mcp__bridge__*` ⇒ **worker** (the -launcher fixes that mount name; a primary's mount is named by whoever wrote its `.mcp.json`, so it -varies); `ANTHROPIC_BASE_URL` set ⇒ **worker** (Claude-model workers run on a clean env, so its -*absence* proves nothing). **Still unsure ⇒ act as a worker.** The two mistakes are not symmetric: a -primary acting as a worker is refused by the authorization gate — loud and self-correcting — while a -worker acting as the primary ends its turn with no `bridge_reply`, and the sender silently receives -nothing. Fail toward the recoverable error. +claude-bridge fleet"*) ⇒ **spawned member**; bridge tools prefixed `mcp__bridge__*` ⇒ **spawned +member** (the launcher fixes that mount name; a primary's mount is named by whoever wrote its +`.mcp.json`, so it varies); `ANTHROPIC_BASE_URL` set ⇒ **spawned member** (Claude-model members run +on a clean env, so its *absence* proves nothing). None of these separate a worker from an architect — +only `bridge_whoami` does. **Still unsure ⇒ act as a worker**, the most restricted member role. The +two mistakes are not symmetric: a primary acting as a worker is refused by the authorization gate — +loud and self-correcting — while a member acting as the primary ends its turn with no `bridge_reply`, +and the sender silently receives nothing. Fail toward the recoverable error. ### Invariants — both roles, no exceptions 1. **Never set, export, or forward `ANTHROPIC_BASE_URL`** (or `ANTHROPIC_AUTH_TOKEN`). The primary - stays on subscription; only the bridge puts a worker off it, at spawn. Mounting the bridge must + stays on subscription; only the bridge puts a member off it, at spawn. Mounting the bridge must never move a session across that boundary. 2. **The bridge is the only channel.** Text you print in your terminal reaches nobody — the other side cannot see your screen. An answer that isn't in a `bridge_*` call is silently discarded. 3. **Identity comes from the connection, never an argument.** Workers never pass a target; you - cannot act as another session. Spawn/stop/send/drain are lead-only; reply/ask are - only-as-itself — any peer may answer for its own pane, and for no other. A call outside your - role is refused, not queued. + cannot act as another session. Spawn/stop/drain are lead-only; **send is lead or architect**; + reply/ask are only-as-itself — any peer may answer for its own pane, and for no other. A call + outside your role is refused, not queued. 4. **Delivery is status-gated: one message per turn.** Don't busy-poll a peer's terminal and don't - re-send because a call looks slow — the bridge delivers when the peer is `idle`/`blocked`. + re-send because a call looks slow — the bridge delivers when the peer is `idle`, `blocked` or + `done`. A spawned member must **also** have mounted the bridge MCP: until it has, it is not + deliverable, and a send waits on that gate for ~60s and then fails without ever reaching its pane. 5. **Never drive the terminal multiplexer directly** (no `herdr` CLI, no socket). The bridge owns policy; the multiplexer owns PTYs. Going around the bridge bypasses every rule above. @@ -101,26 +105,26 @@ the merge — and merging on a reviewer's word is delegating it by proxy. |---|---| | Confirm your own role | `bridge_whoami` | | See backends available | `bridge_profiles` | -| Start a worker | `bridge_spawn{profile?, cwd?, worktree?, ticket?}` → `sessionId` + `paneId` | -| See the fleet | `bridge_list` → `leads` (your peers) + `workers` · one peer's state: `bridge_status{sessionId}` | +| Start a member | `bridge_spawn{role?, profile?, cwd?, worktree?, ticket?}` → `sessionId` + `paneId` | +| See the fleet | `bridge_list` → `leads` (your peers) + `members` · one peer's state: `bridge_status{sessionId}` | | Delegate (blocking) | `bridge_send{sessionId, content}` | | Delegate (long task) | `bridge_send{sessionId, content, wait:false}` → ticket → `bridge_poll{ticket}` | -| Answer a worker's `bridge_ask` | `bridge_send{turnId, content}` — **not** `sessionId` | +| Answer a member's `bridge_ask` | `bridge_send{turnId, content}` — **not** `sessionId` | | Message a **peer lead** | `bridge_send{sessionId: , content}` — `bridge_list` → `leads` reports it. Coordination only, **never** a task | | Answer a peer lead that messaged you | `bridge_reply{content}` — the one case a lead replies | | Collect a held reply | `bridge_poll{target}` · then `bridge_ack{target, msgId}` | -| Tear down | `bridge_stop{paneId}` | +| Tear down a member | `bridge_stop{paneId}` | ### Lead ↔ lead — coordinate, never delegate -`bridge_list` returns `leads` alongside `workers`; your own row carries `self: true`. Every other row -is a peer — an orchestrator with its own context, its own workers, and its own judgment. An empty -`workers` array means no workers are spawned; it says nothing about peers. +`bridge_list` returns `leads` alongside `members`; your own row carries `self: true`. Every other row +is a peer — an orchestrator with its own context, its own members, and its own judgment. An empty +`members` array means no members are spawned; it says nothing about peers. -**A lead never assigns a task to another lead.** Work goes to workers — only ever downward, never -sideways. Sending a peer a brief with acceptance criteria is a category error: a brief is a worker's +**A lead never assigns a task to another lead.** Work goes to members — only ever downward, never +sideways. Sending a peer a brief with acceptance criteria is a category error: a brief is a member's artefact, and a peer is not yours to task. If a unit needs doing and it falls in your area, spawn a -worker and delegate it yourself; if it falls in the peer's area, say so and let the peer assign it. +member and delegate it yourself; if it falls in the peer's area, say so and let the peer assign it. The traffic between leads is coordination and nothing else: 1. **Divide the map, not the work.** Agree who owns which area, then each of you assigns inside your @@ -138,7 +142,7 @@ Being messaged by a peer does not make you its worker: answer with `bridge_reply the substance if it is wrong. A peer that simply complies has thrown away the reason there are two of you. -### Worker — the turn contract +### Member (worker or architect) — the turn contract 1. **Load the playbook skill the lead named** before doing anything else. 2. **Do the assigned scope only.** Note anything you spot outside it in one line; don't go hunt it. @@ -147,6 +151,10 @@ you. Don't ask what you could decide yourself. 4. **End the turn with exactly one `bridge_reply{content}`**, carrying your complete answer. This is the whole handoff. No `bridge_reply` ⇒ the sender gets nothing and the exchange stalls. + Do **not** lean on the completion fallback to carry your answer for you: when you end a turn + without replying, the bridge scrapes your pane, and it can return only the last 4000 characters. + A clipped scrape is marked as partial, but the missing text is gone — your report reaches the + lead with its end cut off. 5. **Report honestly.** State only what you actually ran and its real output, including failures. You mount **only** the bridge MCP — the primary's other servers (IDE, forge, docs) are not yours, so never claim the result of a check you had no way to run. @@ -157,9 +165,9 @@ you. | Layer | Scope | Reaches | |---|---|---| -| the launcher's reply charter | the one rule that must survive with no repo: *end every turn with `bridge_reply`* | every worker, at launch, every peer kind | -| **this section** | protocol + orchestration policy | primary **and** every Claude worker — tracked in git, so worktrees inherit it | -| role playbook skills | per-job procedure (commit/PR recipe, finding format) | a worker told to load one | +| the launcher's reply charter | the one rule that must survive with no repo: *end every turn with `bridge_reply`* | every spawned member, at launch, every peer kind — never a lead | +| **this section** | protocol + orchestration policy | primary **and** every member that reads the repo — tracked in git, so worktrees inherit it | +| role playbook skills | per-job procedure (commit/PR recipe, finding format) | a member told to load one | | the bridge's own docs | design detail, flows, error model | on demand | A rule belongs in **exactly one** layer — the outermost one that must obey it. Peers that don't read -- 2.52.0 From d9e45f8c2a59d9e6e77b5eb620eadc84ef5431da Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 04:49:53 +0200 Subject: [PATCH 2/2] CB-566: add fleet charter config --- bridged/bridged.example.yaml | 22 ++++++- .../main/java/dev/ltms/bridged/Bridged.java | 1 + .../ltms/bridged/config/BridgedConfig.java | 45 +++++++++++++- .../dev/ltms/bridged/config/ConfigRef.java | 5 +- .../bridged/config/BridgedConfigTest.java | 22 +++++++ .../ltms/bridged/config/ConfigRefTest.java | 58 +++++++++++++++++++ 6 files changed, 148 insertions(+), 5 deletions(-) diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index 0752f19..6b68030 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -231,8 +231,8 @@ placement: weighted # # Not every key can move under a running daemon, and the difference is about what already exists # when the reload happens — not about how important the key is: -# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool and -# `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are +# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool, +# `charters`, and `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are # hot because the placement policy reads them through a supplier — being config is # not by itself enough to make a key hot. # DEFERRED → accepted into the new config, but the wiring built at startup keeps the old value @@ -274,6 +274,24 @@ placement: weighted # the candidates, in definition order. A dev and a reviewer staying anonymous is exactly compatible # with being listed here; the entry key just names the entry. fleet: + # Optional launch-charter text, keyed only by the singular role wire names: architect, dev, + # reviewer. Changes are HOT and reach the next spawn without a daemon restart. Do not put secrets + # here: a later launch step writes this text to a world-readable temp file, and ${ENV} interpolation + # is deliberately not supported. + charters: + architect: |- + You are an architect in this fleet. You refine work before anyone builds it: + scope, acceptance criteria, risks, and a unit split. You read the repo and + write analysis. You never commit production code and never open a PR. + A design task is worked by two architects. Design alone first, then exchange + and say plainly where you disagree. Do not concede just to agree. + dev: |- + You implement the one unit you were given, and nothing else. You test it, + commit it, and open your own pull request. You never merge. + reviewer: |- + You review the diff you were given. You report bugs, risks and missing tests. + You do not change code. + # Optional. Template for a member tab's label; {role}, {profile}, {model} and {n} are substituted. # {n} counts per role+profile, so `dev: sonnet #2` really is the second sonnet dev. Because {role} # comes from a closed enum, a generated label can never begin with a lead's tabPrefix. diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index f2be472..73f6e06 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -96,6 +96,7 @@ public final class Bridged { // CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would // reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load. cfg.validateSubscriptionProfiles(); + cfg.validateCharters(); // CB-548: every architect slot must name a configured workers: profile — the strong-model // backend the future spawn lifecycle would read. A stale reference dies here, not later. cfg.validateMembers(); diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 547edc9..d6dfb82 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -532,6 +532,7 @@ public record BridgedConfig( * @param architects profiles the {@code architect} role may run on * @param developers profiles the {@code dev} role may run on * @param reviewers profiles the {@code reviewer} role may run on + * @param charters optional launch-charter text keyed by singular role wire name * @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}}, * {@code {model}} and {@code {n}} (a per role+profile counter) are * substituted. Default {@link #DEFAULT_TAB_LABEL} @@ -541,6 +542,7 @@ public record BridgedConfig( Map architects, Map developers, Map reviewers, + Map charters, String tabLabel) { /** @@ -556,9 +558,16 @@ public record BridgedConfig( architects = unmodifiableOrEmpty(architects); developers = unmodifiableOrEmpty(developers); reviewers = unmodifiableOrEmpty(reviewers); + charters = unmodifiableOrEmpty(charters); tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel; } + /** Convenience constructor for code that does not configure launch charters. */ + public Fleet(Map leaders, Map architects, + Map developers, Map reviewers, String tabLabel) { + this(leaders, architects, developers, reviewers, null, tabLabel); + } + /** * Deliberately not {@code Map.copyOf}: its iteration order is salted per JVM run, which * would discard YAML definition order. The {@code fixed} placement policy answers with a @@ -582,6 +591,11 @@ public record BridgedConfig( }; } + /** The configured launch charter for {@code role}, or {@code null} when it is absent. */ + public String charterFor(MemberRole role) { + return role == null ? null : charters.get(role.wireName()); + } + /** * The profile names {@code role} may run on, in definition order, without repeats. * @@ -1052,7 +1066,7 @@ public record BridgedConfig( // fleet IS defaulted, unlike the leadScan: block it replaced, because an empty Fleet is not // the same as an enabled one: every pool is empty, so no lead is scanned for or created and // no role has a pool. Constructing it saves every reader a null check for no behaviour change. - Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null); + Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null, null); // leadHeartbeat is left as-is (CB-551): null is "off", and LeadHeartbeat's own compact // constructor defaults the fields of a block that IS present. Defaulting it here would // switch the feature on for every config that never mentioned it. @@ -1190,6 +1204,35 @@ public record BridgedConfig( } } + /** + * Reject configured charter entries that would remove a role's contract or never be read. + * + *

The map deliberately retains every key from {@code fleet.charters:}. A typed record would + * silently discard an unknown child because {@link Fleet} ignores unknown JSON properties, which + * would make a typo look like an accepted configuration. + * + * @throws IllegalStateException when a charter key is not a role wire name or its value is blank + */ + public void validateCharters() { + if (fleet == null || fleet.charters().isEmpty()) { + return; + } + List valid = java.util.Arrays.stream(MemberRole.values()) + .map(MemberRole::wireName) + .toList(); + List bad = new ArrayList<>(); + fleet.charters().forEach((key, charter) -> { + if (!valid.contains(key)) { + bad.add("fleet.charters." + key + " is not a role wire name (valid: " + valid + ")."); + } else if (charter == null || charter.isBlank()) { + bad.add("fleet.charters." + key + " is blank; a configured role needs charter text."); + } + }); + if (!bad.isEmpty()) { + throw new IllegalStateException("refusing to start: " + String.join(" ", bad)); + } + } + /** * Reject a member slot whose {@code role} or {@code profile} does not resolve. * diff --git a/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java b/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java index 37001d5..ccce915 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java @@ -27,8 +27,8 @@ import java.util.function.Supplier; * *

    *
  • Hot — re-read per use, so a reload takes effect on the next spawn: - * {@code fleet:} (every role pool and {@code tabLabel}), {@code placement:}, and an existing - * profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on + * {@code fleet:} (every role pool, {@code charters}, and {@code tabLabel}), + * {@code placement:}, and an existing profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on * {@code CompositePeerLauncher}, which is what makes them hot — not the fact that they are * config.
  • *
  • Deferred — accepted into the new snapshot, but the wiring built at startup @@ -150,6 +150,7 @@ public final class ConfigRef implements Supplier { fresh.validateAuthExposure(); fresh.validateLeadTabPrefixes(); fresh.validateSubscriptionProfiles(); + fresh.validateCharters(); fresh.validateMembers(); } catch (RuntimeException e) { String msg = e.getMessage() == null ? e.toString() : e.getMessage(); diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index 9475e69..52f2284 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -111,6 +111,28 @@ class BridgedConfigTest { assertDoesNotThrow(() -> BridgedConfig.load(f)); } + @Test + void absentChartersRemainValidAndPresentChartersUseRoleWireNames(@TempDir Path dir) throws Exception { + Path absent = dir.resolve("absent.yaml"); + Files.writeString(absent, "fleet: {}\n"); + BridgedConfig withoutCharters = BridgedConfig.load(absent); + assertDoesNotThrow(withoutCharters::validateCharters); + assertNull(withoutCharters.fleet().charterFor(MemberRole.ARCHITECT)); + + Path blank = dir.resolve("blank.yaml"); + Files.writeString(blank, "fleet:\n charters:\n architect: ' '\n"); + IllegalStateException blankError = assertThrows(IllegalStateException.class, + () -> BridgedConfig.load(blank).validateCharters()); + assertTrue(blankError.getMessage().contains("fleet.charters.architect is blank")); + + Path unknown = dir.resolve("unknown.yaml"); + Files.writeString(unknown, "fleet:\n charters:\n architetc: text\n"); + IllegalStateException unknownError = assertThrows(IllegalStateException.class, + () -> BridgedConfig.load(unknown).validateCharters()); + assertTrue(unknownError.getMessage().contains("architetc")); + assertTrue(unknownError.getMessage().contains("[architect, dev, reviewer]")); + } + /** * CB-530. Unknown keys stay ignored — config must be allowed to run ahead of the code — but they * must be NAMED at load. A whole block that parses, is dropped, and is never mentioned again is diff --git a/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java b/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java index f9550d7..735cd96 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java @@ -66,6 +66,64 @@ class ConfigRefTest { assertEquals("[{profile}] {role}", ref.get().fleet().tabLabel()); } + @Test + void aCharterChangeIsHotAndReachesTheLiveConfig(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, yaml(""" + fleet: + charters: + architect: old charter + """)); + ConfigRef ref = refFor(f); + assertEquals("old charter", ref.get().fleet().charterFor( + dev.ltms.bridged.peer.MemberRole.ARCHITECT)); + + Files.writeString(f, yaml(""" + fleet: + charters: + architect: new charter + """)); + ConfigRef.Outcome out = ref.reload(); + + assertTrue(out.applied()); + assertTrue(out.deferred().isEmpty()); + assertEquals("new charter", ref.get().fleet().charterFor( + dev.ltms.bridged.peer.MemberRole.ARCHITECT)); + } + + @Test + void invalidChartersRefuseReloadAndKeepTheRunningConfig(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, yaml(""" + fleet: + charters: + architect: valid charter + """)); + ConfigRef ref = refFor(f); + BridgedConfig before = ref.get(); + + Files.writeString(f, yaml(""" + fleet: + charters: + architect: " " + """)); + ConfigRef.Outcome blank = ref.reload(); + assertFalse(blank.applied()); + assertTrue(blank.error().contains("fleet.charters.architect is blank")); + assertSame(before, ref.get()); + + Files.writeString(f, yaml(""" + fleet: + charters: + architetc: valid charter + """)); + ConfigRef.Outcome unknown = ref.reload(); + assertFalse(unknown.applied()); + assertTrue(unknown.error().contains("architetc")); + assertTrue(unknown.error().contains("architect")); + assertSame(before, ref.get()); + } + /** * The point of the whole class: a consumer holding the ref sees the new value without being * rebuilt. A component that captured {@code get()} into a field would still show the old one. -- 2.52.0