CB-634: deliver IDE guidance as an on-disk overlay, not the system-prompt charter

Move the IDE guidance text to PeerLauncher.ideOverlayText (shared by both
launchers). ClaudeCodeLauncher drops it from the reply-charter file and writes
CLAUDE.local.md into a provisioned worktree instead, gated on a .git FILE
(safety: never writes into the primary's real .git-DIRECTORY checkout) and
registers it in info/exclude. OpenCodeLauncher mounts the intellij server and
adds the rules file to the instructions array.
This commit is contained in:
Dai Ha
2026-08-23 16:37:08 +02:00
parent d811b30df3
commit a3fc7e4df8
5 changed files with 196 additions and 38 deletions
@@ -212,6 +212,14 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
guard.assertWorker(baseUrl); // hard stop before we spawn anything
}
// CB-634: the IDE guidance is delivered as an on-disk CLAUDE.local.md overlay, NOT through
// the charter — the charter returns to role -> reply only. Best-effort: a failed overlay
// must never fail the spawn, and `writeIdeOverlay` no-ops unless the cwd is a provisioned
// worktree (see its .git-file safety gate).
if (cfg.hasIdeMcp()) {
writeIdeOverlay(spec.cwd());
}
Map<String, String> workerEnv = baseEnv(cfg);
if (onSubscription) {
// CB-542 belt-and-braces: on the subscription path no guard vets these two keys, and the
@@ -292,13 +300,11 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
*/
private List<String> argvWithFleet(FleetConfig.Profile cfg, LaunchSpec spec) {
String roleCharter = nonBlank(spec.roleCharter());
// CB-634: the IDE charter is only mounted when the profile also mounts the IDE MCP, and it
// pins every ide_* call to the member's own worktree (spec.cwd()). Ordered between role and
// reply — the reply charter must stay last, it is the rule that must survive.
String ideCharter = cfg.hasIdeMcp() ? ideCharter(spec.cwd()) : null;
// CB-634: the IDE guidance is delivered as an on-disk overlay (writeIdeOverlay), not through
// the charter. The charter file is role -> reply only.
String replyCharter = nonBlank(spec.replyCharter());
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents");
if (!cfg.mountsAnyMcp() && roleCharter == null && ideCharter == null
if (!cfg.mountsAnyMcp() && roleCharter == null
&& replyCharter == null && agentFile == null) {
return cfg.argv();
}
@@ -307,14 +313,13 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
argv.add("--mcp-config");
argv.add(mcpConfigJson(cfg));
}
// Combine the charters in order role -> ide -> reply, dropping any that are absent. When two
// Combine the charters in order role -> reply, dropping any that are absent. When two
// or more survive they must ride one --append-system-prompt-file (CB-618 forbids the inline
// flag and the file flag together). A lone reply charter keeps its proven inline delivery.
List<String> charters = new java.util.ArrayList<>(3);
List<String> charters = new java.util.ArrayList<>(2);
if (roleCharter != null) charters.add(roleCharter);
if (ideCharter != null) charters.add(ideCharter);
if (replyCharter != null) charters.add(replyCharter);
if (charters.size() == 1 && replyCharter != null && roleCharter == null && ideCharter == null) {
if (charters.size() == 1 && replyCharter != null && roleCharter == null) {
argv.add("--append-system-prompt");
argv.add(replyCharter);
} else if (!charters.isEmpty()) {
@@ -349,21 +354,44 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
}
/**
* The IDE charter fragment (CB-634): tells the member to prefer the mounted IDE Index MCP over
* text search, and — the load-bearing rule — to pin every {@code ide_*} call to its own
* worktree. A bare call errors {@code multiple_projects_open}; a call with any other path reads
* a different checkout, which is exactly the CB-525 wrong-tree failure this pin prevents.
* Deliver the shared IDE guidance ({@link PeerLauncher#ideOverlayText}) as an on-disk
* {@code CLAUDE.local.md} overlay beside the project's own {@code CLAUDE.md} (CB-634), and
* register the overlay in the worktree's {@code info/exclude} so it never shows as untracked.
*
* <p><strong>Safety gate:</strong> the overlay is written ONLY when {@code cwd/.git} is a
* <em>regular file</em> — a provisioned worktree keeps a {@code .git} FILE holding a
* {@code gitdir: <path>} pointer, while the primary's real checkout has a {@code .git}
* DIRECTORY. Returning without writing when {@code .git} is a directory is the whole safety of
* the feature: it must never write into a non-worktree cwd, i.e. never clobber a project that
* does not want the overlay.
*
* <p>Best-effort: a failure is logged at debug and swallowed — a failed overlay must never fail
* the spawn.
*/
private static String ideCharter(String worktree) {
return "## IDE code intelligence — your worktree only\n"
+ "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it "
+ "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and "
+ "diagnostics — it resolves the real AST, text search does not.\n\n"
+ "Every `ide_*` call MUST pass `project_path: \"" + worktree + "\"` — your own "
+ "worktree — and never any other path. A call without it errors "
+ "`multiple_projects_open`; a call with a different path reads another checkout, "
+ "not your changes. This is not the primary's IDE: it is your worktree, pinned to "
+ "you.";
private static void writeIdeOverlay(String cwd) {
try {
Path dotGit = Path.of(cwd, ".git");
if (!Files.isRegularFile(dotGit)) {
// Not a provisioned worktree (primary's real checkout has a .git directory, or the
// cwd is not a repo at all). Never write into it.
return;
}
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(cwd));
String gitdirLine = Files.readString(dotGit).trim();
Path gitDir = Path.of(gitdirLine.replaceFirst("^gitdir:\\s*", ""));
if (!gitDir.isAbsolute()) {
gitDir = Path.of(cwd).resolve(gitDir).normalize();
}
Path exclude = gitDir.resolve("info").resolve("exclude");
Files.createDirectories(exclude.getParent());
String overlayLine = "CLAUDE.local.md";
if (!Files.exists(exclude) || Files.readAllLines(exclude).stream().noneMatch(overlayLine::equals)) {
Files.writeString(exclude, (Files.exists(exclude) ? System.lineSeparator() : "")
+ overlayLine + System.lineSeparator());
}
} catch (Exception e) {
log.debug("cannot write IDE overlay into worktree '{}'", cwd, e);
}
}
/** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */
@@ -204,9 +204,10 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
@Override
protected Launch buildLaunch(FleetConfig.Profile cfg, LaunchSpec spec) {
Map<String, String> workerEnv = baseEnv(cfg);
// A config file is needed for the bridge MCP mount, a member charter, or a pinned endpoint (CB-508).
if (cfg.hasMcp() || spec.charter() != null || hasCustomProvider(cfg)) {
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString());
// A config file is needed for the bridge MCP mount, a member charter, the IDE MCP (+ its
// guidance overlay, CB-634), or a pinned endpoint (CB-508).
if (cfg.hasMcp() || cfg.hasIdeMcp() || spec.charter() != null || hasCustomProvider(cfg)) {
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter(), spec.cwd()).toString());
}
applyGitToken(workerEnv, cfg);
List<String> argv = argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId());
@@ -290,7 +291,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
* {@code OPENCODE_CONFIG}. The dir is unique per spawn so concurrent workers never race on it;
* it is best-effort cleaned on JVM exit (worker config is disposable — regenerated every spawn).
*/
private Path writeConfig(FleetConfig.Profile cfg, String charterText) {
private Path writeConfig(FleetConfig.Profile cfg, String charterText, String cwd) {
try {
Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-");
dir.toFile().deleteOnExit();
@@ -321,11 +322,31 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
root.putArray("instructions").add(charter.toAbsolutePath().toString());
}
if (cfg.hasMcp()) {
ObjectNode mount = root.putObject("mcp").putObject(PeerLauncher.MCP_MOUNT_NAME);
mount.put("type", "remote");
mount.put("url", cfg.mcpUrl());
mount.put("enabled", true);
if (cfg.hasMcp() || cfg.hasIdeMcp()) {
// One shared mcp node for both servers — putObject would replace the node (and thus
// the other server) on the second call, so build into a single get-or-create node.
ObjectNode mcp = root.withObject("mcp");
if (cfg.hasMcp()) {
ObjectNode mount = mcp.putObject(PeerLauncher.MCP_MOUNT_NAME);
mount.put("type", "remote");
mount.put("url", cfg.mcpUrl());
mount.put("enabled", true);
}
// CB-634: mount the IDE Index MCP in the same shape as the bridge remote server, and
// deliver its guidance via the instructions array (opencode does not read
// CLAUDE.local.md) rather than any system-prompt string.
if (cfg.hasIdeMcp()) {
ObjectNode ide = mcp.putObject("intellij");
ide.put("type", "remote");
ide.put("url", cfg.ideMcpUrl());
ide.put("enabled", true);
Path rules = dir.resolve("ide-rules.md");
Files.writeString(rules, PeerLauncher.ideOverlayText(cwd));
rules.toFile().deleteOnExit();
// The array may already hold the member-charter path; withArray gets-or-creates.
root.withArray("instructions").add(rules.toAbsolutePath().toString());
}
}
if (hasCustomProvider(cfg)) {
addCustomProvider(root, cfg);
@@ -30,6 +30,26 @@ public interface PeerLauncher {
*/
String MCP_MOUNT_NAME = "fleet";
/**
* Shared IDE-guidance text (CB-634), delivered per-backend as an on-disk overlay rather than
* any one adapter's system-prompt charter, so a project's own {@code CLAUDE.md} is never
* clobbered. It pins every {@code ide_*} call to the member's own worktree, which is the whole
* point of the mechanism. Both launchers render their own overlay from this single source.
*
* @param worktree the member's isolated worktree path, embedded as the {@code project_path} pin
*/
static String ideOverlayText(String worktree) {
return "## IDE code intelligence — your worktree only\n"
+ "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it "
+ "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and "
+ "diagnostics — it resolves the real AST, text search does not.\n\n"
+ "Every `ide_*` call MUST pass `project_path: \"" + worktree + "\"` — your own "
+ "worktree — and never any other path. A call without it errors "
+ "`multiple_projects_open`; a call with a different path reads another checkout, "
+ "not your changes. This is not the primary's IDE: it is your worktree, pinned to "
+ "you.";
}
/**
* The set of {@link Capability capabilities} this launcher declares. A peer whose profile
* opts into a git-forge token should include {@link Capability#SELF_PR}; the base set for
@@ -108,7 +108,7 @@ class ClaudeCodeLauncherTest {
}
@Test
void ideCharterPinsTheWorktreeAndSitsBetweenRoleAndReply() {
void roleAndReplyOnlyComposeTheCharterFile_NotIdeGuidance() {
FakeHerdr herdr = new FakeHerdr();
String roleCharter = "You review changes.";
String worktree = "/tmp/.fleet-worktrees/rev-1";
@@ -124,21 +124,58 @@ class ClaudeCodeLauncherTest {
assertTrue(args.stream().noneMatch(a -> a.contains("\n")),
"no argv element may be multi-line: " + args);
assertFalse(args.contains("--append-system-prompt"),
"CB-618: role + ide + reply ride one --append-system-prompt-file, never both flags: " + args);
"CB-618: role + reply ride one --append-system-prompt-file, never both flags: " + args);
int fileFlag = args.indexOf("--append-system-prompt-file");
assertTrue(fileFlag >= 0, "the combined charter is mounted via file: " + args);
assertDoesNotThrow(() -> {
String w = Files.readString(Path.of(args.get(fileFlag + 1)));
assertTrue(w.startsWith(roleCharter), "role charter first: " + w);
assertTrue(w.contains("project_path: \"" + worktree + "\""),
"the ide charter pins the member's own worktree: " + w);
assertTrue(w.indexOf("project_path") < w.indexOf(HerdrPeerLauncher.REPLY_CHARTER),
"ide charter sits before the reply charter");
assertFalse(w.contains("project_path"),
"CB-634: the IDE guidance is delivered as an on-disk overlay, not the charter: " + w);
assertTrue(w.endsWith(HerdrPeerLauncher.REPLY_CHARTER),
"the reply charter is last — it is the rule that must survive: " + w);
}, "the --append-system-prompt-file path must be a readable file");
}
// CB-634: the IDE guidance is delivered as a CLAUDE.local.md overlay (written only into a
// provisioned worktree — cwd with a `.git` FILE) and registered in the worktree's info/exclude.
@Test
void writeIdeOverlayWritesClaudeLocalAndAddsItToInfoExclude(@TempDir Path root) throws Exception {
Path worktree = Files.createDirectory(root.resolve("worktree"));
Path gitDir = Files.createDirectory(root.resolve("gitdir"));
Files.createDirectories(gitDir.resolve("info"));
// A provisioned worktree keeps its gitdir as a `.git` FILE holding a gitdir: <path> pointer.
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
FakeHerdr herdr = new FakeHerdr();
launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http",
worktree.toString())).spawn();
Path overlay = worktree.resolve("CLAUDE.local.md");
assertTrue(Files.exists(overlay), "the overlay is written beside the project's CLAUDE.md");
assertTrue(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
"the overlay pins every ide_* call to the member's own worktree");
Path exclude = gitDir.resolve("info").resolve("exclude");
assertTrue(Files.exists(exclude), "info/exclude is created from the gitdir pointer");
assertTrue(Files.readAllLines(exclude).contains("CLAUDE.local.md"),
"the overlay is registered so it never shows as untracked");
}
@Test
void writeIdeOverlayDoesNothingWhenDotGitIsADirectory(@TempDir Path root) throws Exception {
Path worktree = Files.createDirectory(root.resolve("worktree"));
// The primary's real checkout has a `.git` DIRECTORY, not the worktree's `.git` FILE.
Files.createDirectories(worktree.resolve(".git"));
FakeHerdr herdr = new FakeHerdr();
launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http",
worktree.toString())).spawn();
assertFalse(Files.exists(worktree.resolve("CLAUDE.local.md")),
"the safety gate refuses to write into a non-worktree cwd (.git directory)");
}
@Test
void startRetriesWhileTheSeedShellBoots() {
// tab.create returns before the seed shell reaches its prompt; herdr refuses agent.start
@@ -494,4 +494,56 @@ class OpenCodeLauncherTest {
assertTrue(json.path("provider").isMissingNode(),
"without a baseUrl opencode resolves its own provider as before");
}
// --- CB-634: IDE Index MCP + guidance overlay (opencode does not read CLAUDE.local.md) -------
private static FleetConfig.Profile opencodeIdeCfg(String mcpUrl, String ideUrl, String cwd) {
return new FleetConfig.Profile("gemini", null, null, null, "BRIDGED_WORKER_TOKEN",
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
cwd, null, null, null, FleetConfig.Profile.KIND_OPENCODE,
null, null, null, null, null, null, ideUrl);
}
@Test
void ideMcpUrlAddsTheIntellijServerAndAnInstructionsRulesEntry(@TempDir Path root) throws Exception {
FakeHerdr herdr = new FakeHerdr();
Path cwd = Files.createDirectory(root.resolve("checkout"));
service(herdr, root, opencodeIdeCfg("http://127.0.0.1:8765/mcp",
"http://127.0.0.1:29170/index-mcp/streamable-http", cwd.toString())).spawn();
String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG");
assertNotNull(cfgPath, "an IDE profile needs a config file");
JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile());
JsonNode ide = json.path("mcp").path("intellij");
assertEquals("remote", ide.path("type").asText(),
"the IDE server uses the same remote shape as the bridge mount");
assertEquals("http://127.0.0.1:29170/index-mcp/streamable-http", ide.path("url").asText());
assertTrue(ide.path("enabled").asBoolean(), "the IDE server is enabled");
assertEquals("remote", json.path("mcp").path("fleet").path("type").asText(),
"the bridge mount still coexists with the IDE server");
// The instructions array gains an entry pointing at a real rules file pinning the worktree.
String rulesContent = null;
for (JsonNode n : json.path("instructions")) {
Path p = Path.of(n.asText());
if (p.getFileName().toString().equals("ide-rules.md")) {
rulesContent = Files.readString(p);
}
}
assertNotNull(rulesContent, "an ide-rules.md instructions entry is present");
assertTrue(rulesContent.contains("project_path: \"" + cwd + "\""),
"the rules pin every ide_* call to the worker's own cwd");
}
@Test
void noIdeServerWhenIdeMcpUrlUnset(@TempDir Path root) throws Exception {
FakeHerdr herdr = new FakeHerdr();
service(herdr, root, opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null))
.spawn();
JsonNode json = new ObjectMapper()
.readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile());
assertTrue(json.path("mcp").path("intellij").isMissingNode(),
"no IDE server when ideMcpUrl is unset");
}
}