diff --git a/.claude/skills/implementer/SKILL.md b/.claude/skills/implementer/SKILL.md new file mode 100644 index 0000000..fe5c7c6 --- /dev/null +++ b/.claude/skills/implementer/SKILL.md @@ -0,0 +1,126 @@ +--- +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. +--- + +# Implementer worker + +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. + +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. + +## 1. Confirm where you are — a worktree on a dedicated branch + +Before touching anything, verify your ground truth: + +```bash +git rev-parse --show-toplevel # your worktree root — NOT the primary's main tree +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. + +## 2. Implement the task + +- 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. + +## 3. Commit — focused, and never the excluded files + +```bash +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. + +## 4. Push your branch + +```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. + +## 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). + +```bash +API="${GITEA_HOST%/}/api/v1/repos/lms/claude-bridge/pulls" +BRANCH="$(git branch --show-current)" +curl -sS -X POST "$API" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(cat <: ", + "body": ""} +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. + +## 6. Reply via `bridge_reply` — the PR is the handoff + +End your turn with **exactly one** `bridge_reply`. That reply is the entire handoff — the lead +cannot see your terminal. Include: + +``` +PR: " + branch name> +branch: +files: +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) + 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 +``` + +*The implement turn: work in the worktree, commit → push → open the PR, hand off the URL. The +lead is the merge gate.* 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 eb74547..1394d5f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -70,13 +70,20 @@ public record BridgedConfig( * {@code null}/blank → inherit the primary's cwd, else the daemon's * @param parityOverlay repo-relative paths copied primary→worktree for config parity; null/empty * defaults to a sensible set of local config files + * @param gitTokenEnv name of the host env var holding the git-forge API token; when set, its + * value is injected as {@code GITEA_TOKEN} so the worker can open its own PR + * at checkpoint (CB-302). {@code null}/blank ⇒ no token is injected + * (minimal-grant default — push over SSH stays free, PR-create is opt-in) + * @param gitHostEnv name of the host env var holding the forge host (default {@code GITEA_HOST}); + * injected as {@code GITEA_HOST} only when {@code gitTokenEnv} is set */ @JsonIgnoreProperties(ignoreUnknown = true) public record Worker(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, String cwd, - List parityOverlay) { + List parityOverlay, + String gitTokenEnv, String gitHostEnv) { public Worker { argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv); tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv; @@ -86,12 +93,33 @@ public record BridgedConfig( parityOverlay = (parityOverlay == null || parityOverlay.isEmpty()) ? List.of(".mcp.json", ".claude/settings.local.json", ".env", ".envrc") : List.copyOf(parityOverlay); + // gitTokenEnv stays null when unset (opt-in). gitHostEnv defaults so operators enabling + // checkpoints need only set gitTokenEnv; it is injected only alongside a resolved token. + gitHostEnv = (gitHostEnv == null || gitHostEnv.isBlank()) ? "GITEA_HOST" : gitHostEnv; + } + + /** + * Backward-compatible constructor without the CB-302 git-forge fields — the worker is + * granted no PR-create token (push over SSH is unaffected). Keeps pre-CB-302 call sites + * (and any {@code workers:} YAML that omits the git keys) working unchanged. + */ + public Worker(String profile, String baseUrl, String model, + String configDir, String tokenEnv, List argv, + String placement, String workspace, String tabLabel, String mcpUrl, + String cwd, List parityOverlay) { + this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, + mcpUrl, cwd, parityOverlay, null, null); } /** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */ public Worker withProfile(String p) { return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, - mcpUrl, cwd, parityOverlay); + mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv); + } + + /** True when this profile's workers are granted a forge token to open their own PR (CB-302). */ + public boolean hasGitToken() { + return gitTokenEnv != null && !gitTokenEnv.isBlank(); } /** True when workers should land in their own tab in the worker space. */ diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java index ea134bc..96a734d 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -150,6 +150,18 @@ public final class WorkerService { String token = env.apply(cfg.tokenEnv()); putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", token); + // CB-302: the worker checkpoint (commit → push → open its own PR). Push is free over SSH; + // the only incremental grant is PR-create, a repo-scoped forge token injected here — opt-in + // per profile via gitTokenEnv, and never mutating bridged's own env. The paired forge host + // rides along only when a token is actually granted, so non-implementer profiles get neither. + if (cfg.hasGitToken()) { + String gitToken = resolveEnv(cfg.gitTokenEnv()); + if (gitToken != null) { + workerEnv.put("GITEA_TOKEN", gitToken); + putIfPresent(workerEnv, "GITEA_HOST", resolveEnv(cfg.gitHostEnv())); + } + } + // Mount the bridge MCP + reply charter as launch FLAGS (non-invasive: nothing written to // the worker's profile/config dir). Identity is connection-based, so the mount is shared. List argv = argvWithBridge(cfg); @@ -419,4 +431,9 @@ public final class WorkerService { m.put(k, v); } } + + /** Host env lookup that tolerates an unconfigured (null/blank) var name — returns null then. */ + private String resolveEnv(String name) { + return (name == null || name.isBlank()) ? null : env.apply(name); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java index f72a70f..200aefc 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import static org.junit.jupiter.api.Assertions.*; @@ -74,8 +75,9 @@ class WorkerServiceTest { @Test void spawnRejectsAnUnknownProfile() { - FakeHerdr herdr = new FakeHerdr(); - assertThrows(IllegalArgumentException.class, () -> multiProfile(herdr).spawn("nope")); + try (FakeHerdr herdr = new FakeHerdr()) { + assertThrows(IllegalArgumentException.class, () -> multiProfile(herdr).spawn("nope")); + } } @SuppressWarnings("unchecked") @@ -111,6 +113,50 @@ class WorkerServiceTest { assertEquals("/primary/project", startCwd(herdr), "no explicit/config cwd → inherit the primary's"); } + // --- CB-302 git-forge token injection (worker checkpoint grant) ------------ + + @SuppressWarnings("unchecked") + private static Map startEnv(FakeHerdr herdr) { + return (Map) ((Map) herdr.lastCall("agent.start").params()).get("env"); + } + + @Test + void injectsForgeTokenAndHostWhenProfileGrantsIt() { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Worker cfg = new BridgedConfig.Worker( + "impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", + List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null, + "GITEA_ACCESS_TOKEN", null); // parityOverlay null; gitHostEnv null → defaults to GITEA_HOST + Function host = name -> switch (name) { + case "GITEA_ACCESS_TOKEN" -> "gt-secret"; + case "GITEA_HOST" -> "git.ltms.dev"; + default -> null; + }; + new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of("impl", cfg), "impl", host).spawn(); + + Map env = startEnv(herdr); + assertEquals("gt-secret", env.get("GITEA_TOKEN"), "the forge token is injected for a granting profile"); + assertEquals("git.ltms.dev", env.get("GITEA_HOST"), "the paired forge host rides along with the token"); + } + + @Test + void noForgeTokenWhenProfileDoesNotGrantIt() { + FakeHerdr herdr = new FakeHerdr(); + // gitTokenEnv unset (12-arg ctor); the env would resolve a token if asked, proving the gate + // is the profile config, not a missing env var. + BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder", + null, "BRIDGED_WORKER_TOKEN", List.of("ccs"), "tab", "bridged-workers", "w #{n}", + null, null, null); + new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of("ltms-local", cfg), "ltms-local", + _ -> "would-be-secret").spawn(); + + Map env = startEnv(herdr); + assertNull(env.get("GITEA_TOKEN"), "no forge token when the profile does not opt in"); + assertNull(env.get("GITEA_HOST"), "no forge host without a granted token"); + } + // --- CB-117 orphan reap: the pure predicate -------------------------------- @Test