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