diff --git a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index d60e678..bd5ab4a 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -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 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 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 charters = new java.util.ArrayList<>(3); + List 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. + * + *

Safety gate: the overlay is written ONLY when {@code cwd/.git} is a + * regular file — a provisioned worktree keeps a {@code .git} FILE holding a + * {@code gitdir: } 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. + * + *

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. */ diff --git a/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java index ff9b31b..3acc7d4 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java @@ -204,9 +204,10 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { @Override protected Launch buildLaunch(FleetConfig.Profile cfg, LaunchSpec spec) { Map 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 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); diff --git a/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java b/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java index dd4d583..08d1eb4 100644 --- a/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java @@ -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 diff --git a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index c1fbf71..1d0e257 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -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: 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 diff --git a/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java index 9643b3c..5611161 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java @@ -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"); + } }