CB-302: worker checkpoint — repo-scoped forge token injection + implementer skill
The worker "checkpoint" is commit → push → open its own PR. Push is free over SSH (same user, same keys); the only incremental grant is PR-create, so the daemon injects a repo-scoped gitea token into the worker env — opt-in per profile, never mutating bridged's own environment. - BridgedConfig.Worker: gitTokenEnv/gitHostEnv fields (opt-in; gitHostEnv defaults to GITEA_HOST). Backward-compat 12-arg constructor keeps pre-CB-302 call sites + YAML working. hasGitToken() gates injection. - WorkerService.spawn: inject GITEA_TOKEN (and paired GITEA_HOST) only when the profile grants a token AND the host env resolves one. resolveEnv() tolerates unset var names. - WorkerServiceTest: injection present for a granting profile; absent when not (proving the gate is config, not a missing env var). - .claude/skills/implementer/SKILL.md: worktree-aware playbook — confirm the worktree/ branch, implement, commit (never .mcp.json/wiki), push, open PR via gitea REST with GITEA_TOKEN, hand off the PR URL via bridge_reply. Never merge; workers can't run IDE diagnostics so never claim IDE-clean. Whole-project gate: mvn clean install green, 164 tests, 0 failures.
This commit is contained in:
@@ -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/<ticket>-<nonce>
|
||||
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 <the files you changed>
|
||||
git commit -m "<ticket>: <clear one-line summary>"
|
||||
```
|
||||
|
||||
**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 <<JSON
|
||||
{"head": "${BRANCH}", "base": "main",
|
||||
"title": "<ticket>: <concise change summary>",
|
||||
"body": "<what changed and why; reference the ticket; note tests run and their result>"}
|
||||
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: <html_url from step 5, or "not created: <reason>" + branch name>
|
||||
branch: <your branch>
|
||||
files: <the files you changed>
|
||||
tests: <what you ran and its REAL result — or "not run: <why>">
|
||||
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.*
|
||||
@@ -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} <em>only</em> when {@code gitTokenEnv} is set
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Worker(String profile, String baseUrl, String model,
|
||||
String configDir, String tokenEnv, List<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd,
|
||||
List<String> parityOverlay) {
|
||||
List<String> 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<String> argv,
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd, List<String> 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. */
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> startEnv(FakeHerdr herdr) {
|
||||
return (Map<String, String>) ((Map<String, Object>) 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<String, String> 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<String, String> 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<String, String> 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
|
||||
|
||||
Reference in New Issue
Block a user