diff --git a/.claude/skills/implementer/SKILL.md b/.claude/skills/implementer/SKILL.md index fe5c7c6..db44295 100644 --- a/.claude/skills/implementer/SKILL.md +++ b/.claude/skills/implementer/SKILL.md @@ -1,24 +1,21 @@ --- name: implementer -description: Implementer-role playbook for a bridged worker — you are in an isolated git worktree on a dedicated branch; implement the assigned task, commit, push, open your own PR to main, and hand off the PR URL via bridge_reply. You never merge. Load this when you have been delegated an implementation task over bridged. +description: Implementer-role procedure for a bridged worker — verify your worktree, implement the scope, commit, push, open your own PR, and hand off the PR URL. Load this when the lead delegates you an implementation task over bridged. --- -# Implementer worker +# Implementer worker — procedure -You are an **implementer** in the claude-bridge fleet. The lead delegated you one scoped task, -and you are running in an **isolated git worktree on your own branch** — a full peer of the -primary (same `CLAUDE.md`, skills, memory, MCP), differing only in the model behind you and the -branch you sit on. Your job for this turn: **implement the task, then hand off a PR the lead can -review and merge.** You do the work; the lead (or human) is the merge gate — you never merge. +The turn contract (one `bridge_reply`, `bridge_ask` for the lead's decisions, honest reporting, +never merge, never commit `.mcp.json` or `wiki/`) is in **`CLAUDE.md` → Bridge communication → +Worker** and already applies. This skill is only the *implement-and-hand-off procedure*. -Delivery mechanics (how the task reached you, how your reply resolves the lead's blocked send) -are in [`docs/MCP-Contract.md`](../../../docs/MCP-Contract.md); the worktree/PR model is in -[`docs/Worker-Git-Workflow.md`](../../../docs/Worker-Git-Workflow.md). You only need the steps -below. +You run in an **isolated git worktree on your own branch** — a full peer of the primary (same +repo, `CLAUDE.md`, skills, MCP), differing in the model behind you and the branch you sit on. +The worktree model is documented in [`docs/Worker-Git-Workflow.md`](../../../docs/Worker-Git-Workflow.md). -## 1. Confirm where you are — a worktree on a dedicated branch +## 1. Confirm where you are -Before touching anything, verify your ground truth: +Before touching anything: ```bash git rev-parse --show-toplevel # your worktree root — NOT the primary's main tree @@ -26,46 +23,40 @@ git branch --show-current # your dedicated branch: worker/- git status # should be clean at the start ``` -Do **all** work here, on this branch. **Never** switch to `main`, never `git checkout main`, -never rebase onto or push to `main` directly. The branch is your isolation — respect it. +Do **all** work here, on this branch. Never `git checkout main`, never rebase onto or push to +`main`. The branch is your isolation — respect it. -## 2. Implement the task +## 2. Implement -- Implement exactly the scope the lead named. Keep changes focused; if you notice something out - of scope, note it in your reply rather than expanding the diff. -- Match the surrounding code's style, naming, and idioms. Follow project `CLAUDE.md`. -- **You cannot run the IDE MCP tools** (intellij-index / jetbrains are the primary's, not yours). - So **never claim a file is "IDE-clean" or "diagnostics-clean"** — you cannot verify that. State - only what you actually ran (e.g. `mvn`, a test) and its real output. A fabricated clean claim is - worse than an honest "I could not verify inspections here." -- Run whatever build/test you can and **report the true result** — including failures. +- Implement exactly the scope the lead named. Keep the diff focused; note anything out of scope + in your reply instead of widening it. +- Match the surrounding code's style, naming, and idioms. +- Run whatever build/test you can — `mvn clean install` from the module root. Read its **full** + output; a piped `mvn ... | tail` hides failures. -## 3. Commit — focused, and never the excluded files +## 3. Commit ```bash -git add +git add # explicitly — never `git add -A` / `git add .` git commit -m ": " ``` -**Excluded from every commit, always:** `.mcp.json` (the primary's local, session-modified copy — -present only for parity) and `wiki/` (a separate submodule). Stage files explicitly; do **not** -`git add -A` / `git add .` blindly, or you risk staging them. If `.mcp.json` shows as modified, -leave it — it is flagged `--skip-worktree` and is not yours to commit. +`.mcp.json` will show as modified. Leave it — it is `--skip-worktree` and not yours to commit. -## 4. Push your branch +## 4. Push ```bash git push -u origin HEAD ``` -Push is over SSH as the same user — no extra credential needed. Push the branch as-is; do not -force-push over anything you did not create. +Push is over SSH as the same user — no extra credential needed. Never force-push over anything +you did not create. ## 5. Open your own PR to `main` -Open the PR via the gitea REST API. The daemon injected a **repo-scoped token** (`GITEA_TOKEN`) -and the forge host (`GITEA_HOST`) into your env for exactly this — the token can create a PR but -**cannot merge** (that stays the lead/human gate). +Via the gitea REST API. The daemon injected a **repo-scoped token** (`GITEA_TOKEN`) and the forge +host (`GITEA_HOST`) into your env for exactly this — the token can create a PR but **cannot +merge**. ```bash API="${GITEA_HOST%/}/api/v1/repos/lms/claude-bridge/pulls" @@ -81,16 +72,14 @@ JSON )" ``` -The response JSON includes `"html_url"` — that is your PR URL. If the call fails (non-2xx), read -the error body, fix the cause if it is yours (e.g. branch not pushed yet), and report the failure -honestly in your reply rather than inventing a URL. If `GITEA_TOKEN` is unset, your profile was -not granted PR-create — push the branch (step 4) and report the branch name so the lead opens the -PR. +The response JSON carries `"html_url"` — that is your PR URL. On a non-2xx, read the error body, +fix it if the cause is yours (e.g. branch not pushed yet), and report the failure rather than +inventing a URL. If `GITEA_TOKEN` is unset your profile was not granted PR-create: push the branch +and report its name so the lead opens the PR. -## 6. Reply via `bridge_reply` — the PR is the handoff +## 6. Hand off — what goes in `bridge_reply` -End your turn with **exactly one** `bridge_reply`. That reply is the entire handoff — the lead -cannot see your terminal. Include: +The reply is the entire handoff; the lead cannot see your terminal. ``` PR: " + branch name> @@ -100,27 +89,21 @@ tests: "> summary: <2-3 lines: what you implemented and any caveat the reviewer needs> ``` -Then stop. **Do not merge. Do not touch `.mcp.json` or `wiki/`.** One reply closes the turn. - ```mermaid sequenceDiagram autonumber participant L as Lead - participant B as bridged participant I as Implementer (you) participant G as git / gitea - L->>B: bridge_send(task) — blocks - B-->>I: your assignment (in a worktree on your branch) + L->>I: delegated task (you are in a worktree on your branch) I->>I: implement + build/test here I->>G: git commit (never .mcp.json / wiki) I->>G: git push -u origin HEAD I->>G: POST /pulls (GITEA_TOKEN) — open PR to main G-->>I: html_url - I->>B: bridge_reply(PR url, branch, files, tests) - B-->>L: { outcome:"reply", text } - Note over L,G: lead reviews the PR, merges on green — you never merge + I->>L: bridge_reply(PR url, branch, files, tests) + Note over L,G: the lead reviews the PR and merges on green — you never merge ``` -*The implement turn: work in the worktree, commit → push → open the PR, hand off the URL. The -lead is the merge gate.* +*The implement turn: work in the worktree, commit → push → open the PR, hand off the URL.* diff --git a/.claude/skills/reviewer/SKILL.md b/.claude/skills/reviewer/SKILL.md index 8796052..985b4c4 100644 --- a/.claude/skills/reviewer/SKILL.md +++ b/.claude/skills/reviewer/SKILL.md @@ -1,69 +1,39 @@ --- name: reviewer -description: Reviewer-role playbook for a bridged worker — read the assigned scope, find the real issues, ask the lead via bridge_ask when a decision is genuinely theirs, and report the finding via bridge_reply. Load this when you have been delegated a code review over bridged. +description: Reviewer-role procedure for a bridged worker — how to work a review scope and the exact shape of the finding to report. Load this when the lead delegates you a code review over bridged. --- -# Reviewer worker +# Reviewer worker — procedure -You are a **reviewer** in the claude-bridge fleet. The lead delegated you one scoped review -over `bridged`, and your whole job is **this single turn**: examine the scope it named, and -report back. You are not the owner of the code and you do not merge anything — you surface -what the owner needs to know, then hand the turn back. - -Delivery mechanics (how the task reached you, how your reply resolves the lead's blocked -send) are in [`docs/MCP-Contract.md`](../../../docs/MCP-Contract.md); you only need the three -rules below. +The turn contract (one `bridge_reply`, `bridge_ask` for the lead's decisions, honest reporting, +never merge) is in **`CLAUDE.md` → Bridge communication → Worker** and already applies. This +skill is only the *review procedure*: how to work the scope, and the exact shape of what you +send back. ## 1. Read the whole scope before you judge The delegation names your scope — a file, a diff, a PR, a function. **Read all of it first.** -A review that fires on a snippet misses the caller that makes it safe (or the one that makes -it a bug). Reviewing only part of the scope and guessing the rest is the most common way a -reviewer worker is wrong. +A review that fires on a snippet misses the caller that makes it safe (or the one that makes it +a bug). Reviewing part of the scope and guessing the rest is the most common way a reviewer is +wrong. -## 2. Stay in your lane +## 2. Stay in the scope -- Review **only** the assigned scope. If you notice something elsewhere, mention it in one - line — do **not** go hunt it. Wandering is how two workers end up reporting the same thing - and neither covers what it was given. -- Do **not** edit files, run the build, or spawn other workers. You review; the owner acts. -- You never set `ANTHROPIC_BASE_URL` and never touch herdr — you are a Claude Code process, - not part of the transport. +- Review **only** what you were assigned. Something elsewhere looks wrong? One line in your + reply — do not go hunt it. Wandering is how two reviewers report the same thing and neither + covers what it was given. +- Do **not** edit files or run the build. You review; the owner acts. -## 3. When the decision is the lead's — ask, don't guess +## 3. Reach for `bridge_ask` only for a genuine fork -Some things you cannot resolve from the code: an ambiguous requirement, a missing acceptance -criterion, "is this behavior intended or a bug?", or a choice between two defensible fixes. -Guessing there produces a confident-but-wrong finding. Instead **pause and ask the lead** with -`bridge_ask` — a single crisp question. The call blocks; when the lead answers you **resume -the same turn** with the answer and finish. Ask only when the answer changes your finding; -don't narrate options you could decide yourself. +Ambiguous requirement, a missing acceptance criterion, "intended or a bug?", or two defensible +fixes with different consequences — those are the lead's call, and guessing produces a +confident-but-wrong finding. Anything you could settle by reading more code is yours to settle. -```mermaid -sequenceDiagram - participant L as Lead - participant B as bridged - participant R as Reviewer (you) +## 4. The finding — what goes in `bridge_reply` - L->>B: bridge_send(review scope) — blocks - B-->>R: your assignment - R->>R: read the full scope - opt a decision only the lead can make - R->>B: bridge_ask("intended, or a bug?") — you block - B-->>L: { outcome:"question", turn_id } - L->>B: bridge_send(answer, turn_id) - B-->>R: { answer } — you resume the SAME turn - end - R->>B: bridge_reply(structured finding) — ends your turn - B-->>L: { outcome:"reply", text } -``` - -*The review turn, with the optional `bridge_ask` detour when the call is the lead's to make.* - -## 4. Report with `bridge_reply` — one structured finding - -End your turn with **exactly one** `bridge_reply`. Report the **single most important** real -issue in the scope, in these four lines, under ~90 words: +Report the **single most important** real issue in the scope, in these four lines, under +~90 words: ``` 1. : @@ -72,13 +42,10 @@ issue in the scope, in these four lines, under ~90 words: 4. severity: high | medium | low ``` -- Found nothing real after reading? Reply `NO ISSUE` and one line saying why — a clean review - is a valid result, and a fabricated issue is worse than none. +- **Nothing real after reading?** Reply `NO ISSUE` and one line saying why. A clean review is a + valid result; a fabricated issue is worse than none. - **Severity:** `high` = wrong result, data loss, security, or a hang/crash on a real path · `medium` = a real bug on an edge path, or a correctness risk under load/concurrency · `low` = clarity, a latent foot-gun, or a smell with no current failure. -- Be specific and verifiable: a line number and a one-line repro beat an adjective. If you - can't point to where it goes wrong, you haven't found it yet. - -One reply closes the turn. If you asked mid-turn, the answer you got is already folded into -this finding — you do not ask again after replying. +- Be specific and verifiable: a line number and a one-line repro beat an adjective. If you can't + point at where it goes wrong, you haven't found it yet. diff --git a/CLAUDE.md b/CLAUDE.md index e61d610..4afcedd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,184 @@ # claude-bridge — project instructions +## Bridge communication (enforced — read this first) + +> **Canonical block.** Everything down to §Layering is the portable bridge charter, copied verbatim +> into every project that mounts the bridge MCP. Keep it byte-identical with the template in the +> wiki ([Use Cases](https://git.ltms.dev/lms/claude-bridge/wiki/7-Use-Cases) → *The portable +> CLAUDE.md block*); improvements go to the template first, then out to each project. Anything +> specific to *this* repo lives under §Project addendum below, never inline above it. + +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 +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 +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. + +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. + +### 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 + 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 primary-only; reply/ask are + worker-only-and-only-as-itself. 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`. +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. + +### Primary (lead) — orchestration + +**Delegate by default — that is the job.** With the bridge mounted you are an orchestrator on a +metered subscription, and workers are cheap, parallel, and disposable. The default answer to "who +does this?" is **a worker**, not you. Reach for `bridge_send` before you reach for `Edit`. + +- **Delegate**: implementation behind a clear spec, test writing, per-file or wide-area review, log + and failure triage, mechanical refactors, doc passes, and any investigation with a stated + question. If the unit of work is independent, fan out — one worker per file, area, or dimension — + and reduce the replies yourself. +- **Keep**: the conversation with the user, decomposition and planning, the final judgment call, + merges, and anything that depends on context only you hold. +- **The bar is not "could I do this faster myself?"** — usually you could. It is **"can I write a + brief good enough for a worker to succeed?"** If yes, write the brief and send it. A wasted worker + turn costs a worker turn; doing it yourself costs your context and your subscription. +- **Parallelize instead of serializing.** For independent units, spawn one worktree worker each + (`bridge_spawn{worktree:true, ticket:…}`), dispatch every one with `wait:false`, then poll the + tickets. Waiting for worker A before briefing worker B is the most common way this layer is wasted. +- **Delegating does not delegate responsibility.** You still review, verify, and merge. + +| Intent | Tool | +|---|---| +| 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` · one worker'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` | +| Collect a held reply | `bridge_poll{target}` · then `bridge_ack{target, msgId}` | +| Tear down | `bridge_stop{paneId}` | + +- **Pass `profile:` explicitly.** Profiles differ in model and cost, not in tier — don't assume the + default is what you want. +- **Prefer `wait:false` + `bridge_poll` for anything non-trivial.** A blocking `bridge_send` is + capped by *your own* MCP client call timeout (~60s), well below the task's real runtime; the ticket + path is what survives a long task. +- **Every delegation names the worker's playbook.** If this project ships role skills, make the + first line of `content` `Load the skill.` — those skills are opt-in, and that line is what + makes them reliable. With no such skill, spell the procedure out in the brief instead. +- **A delegation must be self-contained**: scope, the files or PR in question, acceptance criteria, + and exactly what to report back. The worker sees your message and the repo — nothing of your + context, your plan, or your screen. +- **You are the gate.** Workers open PRs; you review and merge. Never delegate the merge. +- **Verify what a worker claims.** A worker mounts only the bridge MCP and cannot run your other + tooling, and a piped build command (`… | tail`) hides failures behind a zero exit — re-run the + build and the checks yourself before you believe "clean". + +### Worker — 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. +3. **`bridge_ask{question}`** when a decision is genuinely the lead's (ambiguous requirement, two + defensible fixes, "bug or intended?"). It blocks and you resume the *same* turn with the answer. + 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. +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. +6. **Never merge.** Stage files explicitly — never `git add -A` — and leave alone anything the + project marks as not-yours-to-commit. + +### Where each rule lives (don't duplicate — extend the right layer) + +| 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 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 +`CLAUDE.md` (non-Claude adapters) get the charter only, so any rule *they* must obey belongs in the +charter, not here. + +## Project addendum — claude-bridge (not part of the canonical block) + +- **This repo is the bridge.** The daemon is `bridged`, its MCP mount is `http://127.0.0.1:8765/mcp`, + and the code behind the rules above is `mcp/BridgeMcp` (tools), `auth/Authz` (the role table), + `mcp/ConnectionIdentity` (connection→role), and `worker/*Launcher` (`REPLY_CHARTER`). +- **Skills available to delegate:** `implementer` (worktree → commit → push → own PR) and + `reviewer` (scoped review → one structured finding). Name one in every delegation. +- **Never commit** `.mcp.json` (the primary's local copy, flagged `--skip-worktree`) or `wiki/` + (a submodule with its own remote). +- **Flows and the error model** — rendezvous, `bridge_ask`, detached delivery, turn-done fallback — + are diagrammed in `docs/MCP-Contract.md` §6, kept out of this file because it loads into every + session's context. + +### The prompt is part of the product — update it with the code (mandatory) + +This repo *is* the bridge, so the canonical block above is not documentation about someone else's +system: it is the instruction surface this codebase ships. **Every change here must end by asking +whether the block still tells the truth.** A code change that silently invalidates it is an +incomplete change — the agents reading it have no other source. + +Before you call any work done, check the row that matches what you touched: + +| You changed… | Re-read and update… | +|---|---| +| a `bridge_*` tool — added, removed, renamed, or its params/semantics | the primary's intent→tool table; any rule that names that tool | +| `Authz` / the role table | invariant 3, and the primary-only vs worker-only claims | +| `ConnectionIdentity` / how a caller is resolved | the `bridge_whoami` paragraph and the fallback ladder | +| `REPLY_CHARTER`, or a launcher's mount/flags | the fallback ladder (`mcp__bridge__*`), and the layering table's top row | +| the injector / status gating | invariant 4 | +| worktree provisioning or the parity overlay | the "both roles read this file" premise — it rests on the worker's worktree being a checkout of this repo | +| `.claude/skills/**` | the addendum's skill list, and the "name the playbook" rule | +| a new peer kind (non-Claude adapter) | what that peer can read — anything it must obey belongs in its charter, not in the block | + +Then **propagate**: the block in this file and the template in the wiki +([Use Cases](https://git.ltms.dev/lms/claude-bridge/wiki/7-Use-Cases) → *The portable `CLAUDE.md` +block*) must stay byte-identical, and other projects carrying the block need the same edit. Verify +rather than trust: + +```bash +python3 - <<'PY' +import pathlib +c = pathlib.Path("CLAUDE.md").read_text() +w = pathlib.Path("wiki/7-Use-Cases.md").read_text() +S, E = "## Bridge communication (enforced", "## Project addendum — claude-bridge" +block = c[c.index(S):c.index(E)].rstrip() + "\n" +i = w.index("```markdown\n") + len("```markdown\n") +print("in sync:", w[i:w.index("\n```\n", i) + 1] == block) +PY +``` + ## IDE MCP tools & validation workflow (enforced) +> **Primary only.** Workers have no IDE MCP mount — if you are a worker, skip this section and +> report the build/test output you actually ran (see §Bridge communication → Worker). + Two IDE MCP servers are connected: **intellij-index** (semantic code intelligence) and **jetbrains** (file problems, reformat, debugger). IntelliJ has multiple projects open; our module is **`bridged`**. Always pass these to IDE MCP tools: diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index 19e7cda..37c72ac 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -198,6 +198,11 @@ public final class BridgeMcp { if (denied != null) return denied; return profiles(workers); }) + .toolCall(whoamiTool(), (exchange, _) -> { + McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); + if (denied != null) return denied; + return whoami(principal(exchange), sessions); + }) .build(); this.authz = callers; this.metrics = metrics; @@ -460,6 +465,49 @@ public final class BridgeMcp { } } + /** + * {@code bridge_whoami}: the caller's own identity, as the daemon already resolved it. + * + *

Every other tool consumes this identity — the authorization gate, the reply + * rendezvous, the cwd inherit — but none reported it, so an agent had to infer its own role + * from side channels the daemon does not control: a charter string in its system prompt, the + * name its MCP mount happens to carry, or {@code ANTHROPIC_BASE_URL} (which Claude-model + * workers do not set). The failure mode of guessing is asymmetric and silent: a primary that + * mistakes itself for a worker is refused by {@link Authz} and learns immediately, while a + * worker that mistakes itself for the primary ends its turn without {@code bridge_reply} and + * the sender simply receives nothing. This tool removes the guess. + * + *

For a worker the session registry adds what it knows about that session. A worker the + * registry has no record of — one that outlived a daemon restart — still gets its role and + * {@code sessionId}, which is the load-bearing part. + */ + static McpSchema.CallToolResult whoami(Principal caller, SessionManager sessions) { + Map m = new LinkedHashMap<>(); + m.put("role", caller.role().name().toLowerCase()); + if (!caller.isWorker()) { + return text(json(m)); + } + m.put("sessionId", caller.terminal()); + sessions.roster().stream() + .filter(s -> caller.terminal().equals(s.terminalId())) + .findFirst() + .ifPresent(s -> { + m.put("paneId", s.paneId()); + m.put("profile", s.profile()); + m.put("state", s.state().name().toLowerCase()); + if (s.worktree() != null) { + m.put("worktree", s.worktree()); + } + if (s.branch() != null) { + m.put("branch", s.branch()); + } + if (s.ownerTerminal() != null) { + m.put("owner", s.ownerTerminal()); + } + }); + return text(json(m)); + } + // --- fleet management logic (CB-108 / CB-301) -------------------------------------------- /** {@code bridge_spawn} without cwd/caller context (default resolution). */ @@ -689,6 +737,18 @@ public final class BridgeMcp { List.of("sessionId"))); } + private static McpSchema.Tool whoamiTool() { + return tool("bridge_whoami", + "Report who YOU are on the bridge — your role is resolved from your connection " + + "(unforgeable), never from anything you claim. Returns role 'primary' (you " + + "orchestrate: spawn/send/stop, and you must never call bridge_reply) or " + + "'worker' (you were delegated to: you must end every turn with exactly one " + + "bridge_reply, and cannot spawn or send), plus your own sessionId, profile, " + + "worktree and branch when you are a worker. Call this first when following " + + "role-conditional instructions rather than guessing your role.", + objectSchema(Map.of(), List.of())); + } + // --- small helpers ------------------------------------------------------------------------- // The SDK 2.0.0 deprecates its own Tool builders without a stable replacement — isolate it here. diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index c56109d..d06ebc3 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -1,5 +1,6 @@ package dev.ltms.bridged.mcp; +import dev.ltms.bridged.auth.Principal; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.guard.SubscriptionGuard; import dev.ltms.bridged.herdr.AgentControl; @@ -343,4 +344,58 @@ class BridgeMcpTest { assertNotEquals(Boolean.TRUE, res.isError()); assertEquals("blocked", textOf(res)); } + + // --- bridge_whoami: the caller's own identity, so an agent never has to guess its role ------- + + @Test + void whoamiReportsThePrimaryAsPrimaryAndNothingElse() { + FakeHerdr h = new FakeHerdr(); + McpSchema.CallToolResult res = BridgeMcp.whoami( + Principal.primary(100), sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + + assertNotEquals(Boolean.TRUE, res.isError()); + String out = textOf(res); + assertTrue(out.contains("\"role\":\"primary\""), out); + // The primary owns no session — leaking a sessionId here would invite it to reply as one. + assertFalse(out.contains("sessionId"), out); + } + + @Test + void whoamiReportsAWorkerWithItsRegisteredSession() { + FakeHerdr h = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager( + workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), worktrees); + WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary", + new WorktreeRequest("cb-517", null)); + + McpSchema.CallToolResult res = BridgeMcp.whoami(Principal.worker(s.terminalId(), 200), sessions); + + assertNotEquals(Boolean.TRUE, res.isError()); + String out = textOf(res); + assertTrue(out.contains("\"role\":\"worker\""), out); + assertTrue(out.contains("\"sessionId\":\"" + s.terminalId() + "\""), out); + assertTrue(out.contains("\"profile\":\"ltms-local\""), out); + assertTrue(out.contains("\"worktree\":\"" + s.worktree() + "\""), out); + assertTrue(out.contains("\"branch\":\"" + s.branch() + "\""), out); + assertTrue(out.contains("\"owner\":\"term_primary\""), out); + } + + /** + * A worker the registry has no record of — it outlived a daemon restart — must still learn the + * load-bearing fact. Degrading to "I don't know who you are" would put it back to guessing, + * which is the failure this tool exists to remove. + */ + @Test + void whoamiStillReportsWorkerRoleWhenTheSessionIsUnregistered() { + FakeHerdr h = new FakeHerdr(); + McpSchema.CallToolResult res = BridgeMcp.whoami(Principal.worker("term_orphan", 200), + sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + + assertNotEquals(Boolean.TRUE, res.isError()); + String out = textOf(res); + assertTrue(out.contains("\"role\":\"worker\""), out); + assertTrue(out.contains("\"sessionId\":\"term_orphan\""), out); + assertFalse(out.contains("profile"), out); // nothing invented for a session we don't track + } } diff --git a/wiki b/wiki index f4af2a1..da015de 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit f4af2a1c22ef226b994b61bd02cc41feb219762f +Subproject commit da015defc4290058785e894df4e90a3be33e0047