Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bce7e37b6 | |||
| 5cabd09705 | |||
| cc47672b7c | |||
| 37edd9134b | |||
| 80f167b1f7 | |||
| bd6547fca3 | |||
| 7e97f5bff5 |
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: architect
|
||||||
|
description: Refine work into clear, independent units before implementation.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- CB-617: The model comes from bridged.yaml because the launch flag overrides model here on both backends. -->
|
||||||
|
|
||||||
|
You are an architect in this fleet. You refine work before anyone builds it: scope,
|
||||||
|
acceptance criteria, risks, and a unit split. You read the repo and write analysis.
|
||||||
|
You never commit production code and never open a pull request.
|
||||||
|
|
||||||
|
A design task is worked by two architects. Design alone first, then exchange and
|
||||||
|
say plainly where you disagree. Do not concede just to agree.
|
||||||
|
|
||||||
|
Do only the assigned scope. Note anything outside that scope in one line and do not
|
||||||
|
investigate it further. Use `bridge_ask{question}` only when a decision belongs to
|
||||||
|
the lead, such as an unclear requirement or two defensible fixes. Do not ask about
|
||||||
|
something you can decide by reading more code.
|
||||||
|
|
||||||
|
Report only work you actually did and the real output of checks you ran. Do not
|
||||||
|
claim a result from a tool you could not use. The primary's IDE tools are not yours.
|
||||||
|
A mounted forge tool may use a blocked credential and fail by design.
|
||||||
|
|
||||||
|
The launcher provides the required bridge reply instructions for every member.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: dev
|
||||||
|
description: Implement one assigned unit, test it, and open a pull request.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- CB-617: The model comes from bridged.yaml because the launch flag overrides model here on both backends. -->
|
||||||
|
|
||||||
|
You implement the one unit you were given and nothing else. Work in your assigned
|
||||||
|
git worktree and branch. Never check out, rebase onto, or push to `main`. Confirm
|
||||||
|
the worktree root and branch before you edit. Use only paths under that root.
|
||||||
|
|
||||||
|
Do only the assigned scope. Note anything outside that scope in one line and do not
|
||||||
|
investigate it further. Use `bridge_ask{question}` only when a decision belongs to
|
||||||
|
the lead, such as an unclear requirement or two defensible fixes. Do not ask about
|
||||||
|
something you can decide by reading more code.
|
||||||
|
|
||||||
|
Implement the change and run the full required build in your worktree. Read the
|
||||||
|
complete output and report its real result. Do not hide failures with a pipe. State
|
||||||
|
only checks you actually ran. The primary's IDE tools are not yours. A mounted forge
|
||||||
|
tool may use a blocked credential and fail by design.
|
||||||
|
|
||||||
|
Stage only files you changed. Never use `git add -A` or `git add .`. Never commit
|
||||||
|
`.mcp.json` or `wiki/`. Commit with a clear message, push your branch, and open your
|
||||||
|
own pull request against `main`. Never merge.
|
||||||
|
|
||||||
|
Your handoff must name the pull request or why it was not created, the branch, the
|
||||||
|
files changed, the build result, and any caveat for review.
|
||||||
|
|
||||||
|
The launcher provides the required bridge reply instructions for every member.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: reviewer
|
||||||
|
description: Review one assigned scope and report the most important real issue.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- CB-617: The model comes from bridged.yaml because the launch flag overrides model here on both backends. -->
|
||||||
|
|
||||||
|
You review the diff you were given. Report bugs, risks, and missing tests. You do
|
||||||
|
not change code.
|
||||||
|
|
||||||
|
Read the whole assigned scope before judging it. Review only that scope. If you see
|
||||||
|
something outside it, note it in one line and do not investigate it further. Do not
|
||||||
|
run the build. The owner makes changes and runs checks.
|
||||||
|
|
||||||
|
Use `bridge_ask{question}` only when a decision belongs to the lead, such as an
|
||||||
|
unclear requirement or two defensible fixes. Do not ask about something you can
|
||||||
|
decide by reading more code.
|
||||||
|
|
||||||
|
Report the single most important real issue in this form:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. <path>:<line>
|
||||||
|
2. issue: <one sentence: what is wrong and why it matters>
|
||||||
|
3. fix: <one line: the concrete change>
|
||||||
|
4. severity: high | medium | low
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is no real issue, report `NO ISSUE` and one line saying why. A clean review
|
||||||
|
is valid. Do not invent an issue. Use high for a wrong result, data loss, security,
|
||||||
|
or a hang or crash on a real path. Use medium for an edge-path bug or a correctness
|
||||||
|
risk under load or concurrency. Use low for clarity, a latent foot-gun, or a smell
|
||||||
|
with no current failure.
|
||||||
|
|
||||||
|
The launcher provides the required bridge reply instructions for every member.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: Refine work into clear, independent units before implementation.
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- CB-617: The model comes from bridged.yaml because the launch flag overrides model here on both backends. -->
|
||||||
|
|
||||||
|
You are an architect in this fleet. You refine work before anyone builds it: scope,
|
||||||
|
acceptance criteria, risks, and a unit split. You read the repo and write analysis.
|
||||||
|
You never commit production code and never open a pull request.
|
||||||
|
|
||||||
|
A design task is worked by two architects. Design alone first, then exchange and
|
||||||
|
say plainly where you disagree. Do not concede just to agree.
|
||||||
|
|
||||||
|
Do only the assigned scope. Note anything outside that scope in one line and do not
|
||||||
|
investigate it further. Use `bridge_ask{question}` only when a decision belongs to
|
||||||
|
the lead, such as an unclear requirement or two defensible fixes. Do not ask about
|
||||||
|
something you can decide by reading more code.
|
||||||
|
|
||||||
|
Report only work you actually did and the real output of checks you ran. Do not
|
||||||
|
claim a result from a tool you could not use. The primary's IDE tools are not yours.
|
||||||
|
A mounted forge tool may use a blocked credential and fail by design.
|
||||||
|
|
||||||
|
The launcher provides the required bridge reply instructions for every member.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: Implement one assigned unit, test it, and open a pull request.
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- CB-617: The model comes from bridged.yaml because the launch flag overrides model here on both backends. -->
|
||||||
|
|
||||||
|
You implement the one unit you were given and nothing else. Work in your assigned
|
||||||
|
git worktree and branch. Never check out, rebase onto, or push to `main`. Confirm
|
||||||
|
the worktree root and branch before you edit. Use only paths under that root.
|
||||||
|
|
||||||
|
Do only the assigned scope. Note anything outside that scope in one line and do not
|
||||||
|
investigate it further. Use `bridge_ask{question}` only when a decision belongs to
|
||||||
|
the lead, such as an unclear requirement or two defensible fixes. Do not ask about
|
||||||
|
something you can decide by reading more code.
|
||||||
|
|
||||||
|
Implement the change and run the full required build in your worktree. Read the
|
||||||
|
complete output and report its real result. Do not hide failures with a pipe. State
|
||||||
|
only checks you actually ran. The primary's IDE tools are not yours. A mounted forge
|
||||||
|
tool may use a blocked credential and fail by design.
|
||||||
|
|
||||||
|
Stage only files you changed. Never use `git add -A` or `git add .`. Never commit
|
||||||
|
`.mcp.json` or `wiki/`. Commit with a clear message, push your branch, and open your
|
||||||
|
own pull request against `main`. Never merge.
|
||||||
|
|
||||||
|
Your handoff must name the pull request or why it was not created, the branch, the
|
||||||
|
files changed, the build result, and any caveat for review.
|
||||||
|
|
||||||
|
The launcher provides the required bridge reply instructions for every member.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: Review one assigned scope and report the most important real issue.
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- CB-617: The model comes from bridged.yaml because the launch flag overrides model here on both backends. -->
|
||||||
|
|
||||||
|
You review the diff you were given. Report bugs, risks, and missing tests. You do
|
||||||
|
not change code.
|
||||||
|
|
||||||
|
Read the whole assigned scope before judging it. Review only that scope. If you see
|
||||||
|
something outside it, note it in one line and do not investigate it further. Do not
|
||||||
|
run the build. The owner makes changes and runs checks.
|
||||||
|
|
||||||
|
Use `bridge_ask{question}` only when a decision belongs to the lead, such as an
|
||||||
|
unclear requirement or two defensible fixes. Do not ask about something you can
|
||||||
|
decide by reading more code.
|
||||||
|
|
||||||
|
Report the single most important real issue in this form:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. <path>:<line>
|
||||||
|
2. issue: <one sentence: what is wrong and why it matters>
|
||||||
|
3. fix: <one line: the concrete change>
|
||||||
|
4. severity: high | medium | low
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is no real issue, report `NO ISSUE` and one line saying why. A clean review
|
||||||
|
is valid. Do not invent an issue. Use high for a wrong result, data loss, security,
|
||||||
|
or a hang or crash on a real path. Use medium for an edge-path bug or a correctness
|
||||||
|
risk under load or concurrency. Use low for clarity, a latent foot-gun, or a smell
|
||||||
|
with no current failure.
|
||||||
|
|
||||||
|
The launcher provides the required bridge reply instructions for every member.
|
||||||
@@ -173,12 +173,14 @@ you.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| the launcher's reply charter | the one rule that must survive with no repo: *end every turn with `bridge_reply`* | every spawned member, at launch, every peer kind — never a lead |
|
| the launcher's reply charter | the one rule that must survive with no repo: *end every turn with `bridge_reply`* | every spawned member, at launch, every peer kind — never a lead |
|
||||||
| **this section** | protocol + orchestration policy | primary **and** every member that reads the repo — tracked in git, so worktrees inherit it |
|
| **this section** | protocol + orchestration policy | primary **and** every member that reads the repo — tracked in git, so worktrees inherit it |
|
||||||
|
| role agent definition files | role contract and per-job procedure | a member whose launcher binds its role to the matching file in its worktree |
|
||||||
| role playbook skills | per-job procedure (commit/PR recipe, finding format) | a member told to load one |
|
| role playbook skills | per-job procedure (commit/PR recipe, finding format) | a member told to load one |
|
||||||
| the bridge's own docs | design detail, flows, error model | on demand |
|
| the bridge's own docs | design detail, flows, error model | on demand |
|
||||||
|
|
||||||
A rule belongs in **exactly one** layer — the outermost one that must obey it. Peers that don't read
|
A rule belongs in **exactly one** layer — the outermost one that must obey it. A member without a
|
||||||
`CLAUDE.md` (non-Claude adapters) get the charter only, so any rule *they* must obey belongs in the
|
repo checkout still gets the launcher's reply charter, which is why that one rule stays there.
|
||||||
charter, not here.
|
Peers that don't read `CLAUDE.md` (non-Claude adapters) get the charter only, so any rule *they*
|
||||||
|
must obey belongs in the charter, not here.
|
||||||
|
|
||||||
## Project addendum — claude-bridge (not part of the canonical block)
|
## Project addendum — claude-bridge (not part of the canonical block)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import dev.ltms.bridged.peer.Capability;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -230,7 +234,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
|||||||
// model flag so --model keeps outranking the operator's own argv.
|
// model flag so --model keeps outranking the operator's own argv.
|
||||||
// mutableArgv: argvWithBridge may hand back the profile's own (immutable) List.of when it
|
// mutableArgv: argvWithBridge may hand back the profile's own (immutable) List.of when it
|
||||||
// has neither MCP nor a charter — session flags must be added into a list we own.
|
// has neither MCP nor a charter — session flags must be added into a list we own.
|
||||||
List<String> argv = mutableArgv(argvWithBridge(cfg, spec.charter()));
|
List<String> argv = mutableArgv(argvWithBridge(cfg, spec));
|
||||||
String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId());
|
String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId());
|
||||||
return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId);
|
return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId);
|
||||||
}
|
}
|
||||||
@@ -265,13 +269,31 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The launch argv, plus an inline {@code --mcp-config} when {@code worker.mcpUrl} is set and
|
* The launch argv, plus an inline {@code --mcp-config} when {@code worker.mcpUrl} is set, the
|
||||||
* {@code --append-system-prompt} when the base composed a charter. Neither touches the profile's
|
* CB-617 charter flags, and {@code --agent <role>} when the role has an agent-definition file
|
||||||
* config; both are pure command-line flags. This inline-flag mount is Claude Code specific —
|
* under the worker's cwd. Neither touches the profile's config; all are pure command-line flags.
|
||||||
* other adapters mount MCP and instructions their own way.
|
* This inline-flag mount is Claude Code specific — other adapters mount MCP and instructions
|
||||||
|
* their own way.
|
||||||
|
*
|
||||||
|
* <p>CB-617: the role charter is operator-authored and often multi-line, so it can never be a
|
||||||
|
* single inline argv element — herdr refuses to shell-encode a multi-line argument
|
||||||
|
* ({@code invalid_agent_argument}). It is written to a temp file instead and mounted with
|
||||||
|
* {@code --append-system-prompt-file}, which this host confirms Claude Code accepts for a
|
||||||
|
* multi-line file.
|
||||||
|
*
|
||||||
|
* <p>CB-618: Claude Code refuses to start when BOTH {@code --append-system-prompt} and
|
||||||
|
* {@code --append-system-prompt-file} are on the command line ("Cannot use both ... Please use
|
||||||
|
* only one"), so the two charters can never travel on separate flags. When both are present they
|
||||||
|
* are concatenated into the one file, role charter first and reply charter last — last is where
|
||||||
|
* the reply rule must sit, because it is the rule that must survive. When only the reply charter
|
||||||
|
* is present it keeps its proven inline {@code --append-system-prompt} delivery, which is also
|
||||||
|
* the only form that reaches a member with no repo checkout.
|
||||||
*/
|
*/
|
||||||
private List<String> argvWithBridge(BridgedConfig.Profile cfg, String charter) {
|
private List<String> argvWithBridge(BridgedConfig.Profile cfg, LaunchSpec spec) {
|
||||||
if (!cfg.hasMcp() && charter == null) {
|
String roleCharter = nonBlank(spec.roleCharter());
|
||||||
|
String replyCharter = nonBlank(spec.replyCharter());
|
||||||
|
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents");
|
||||||
|
if (!cfg.hasMcp() && roleCharter == null && replyCharter == null && agentFile == null) {
|
||||||
return cfg.argv();
|
return cfg.argv();
|
||||||
}
|
}
|
||||||
List<String> argv = mutableArgv(cfg.argv());
|
List<String> argv = mutableArgv(cfg.argv());
|
||||||
@@ -281,13 +303,45 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
|||||||
argv.add("--mcp-config");
|
argv.add("--mcp-config");
|
||||||
argv.add(mcpJson);
|
argv.add(mcpJson);
|
||||||
}
|
}
|
||||||
if (charter != null) {
|
if (roleCharter != null) {
|
||||||
|
String combined = replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
|
||||||
|
argv.add("--append-system-prompt-file");
|
||||||
|
argv.add(writeCharterFile(combined).toString());
|
||||||
|
} else if (replyCharter != null) {
|
||||||
argv.add("--append-system-prompt");
|
argv.add("--append-system-prompt");
|
||||||
argv.add(charter);
|
argv.add(replyCharter);
|
||||||
|
}
|
||||||
|
if (agentFile != null) {
|
||||||
|
argv.add("--agent");
|
||||||
|
argv.add(spec.role().wireName());
|
||||||
}
|
}
|
||||||
return argv;
|
return argv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */
|
||||||
|
private static String nonBlank(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the role charter to a fresh temp file so it can be mounted with
|
||||||
|
* {@code --append-system-prompt-file} instead of riding inline in argv (CB-617). Best-effort
|
||||||
|
* cleaned via {@code deleteOnExit} — the same disposable-worker-config cleanup
|
||||||
|
* {@link OpenCodeLauncher#writeConfig} already uses for its charter file, since the process that
|
||||||
|
* reads this file (the spawned peer) outlives this JVM call and there is no spawn-scoped teardown
|
||||||
|
* hook to delete it synchronously.
|
||||||
|
*/
|
||||||
|
private static Path writeCharterFile(String charterText) {
|
||||||
|
try {
|
||||||
|
Path file = Files.createTempFile("bridged-role-charter-", ".md");
|
||||||
|
Files.writeString(file, charterText);
|
||||||
|
file.toFile().deleteOnExit();
|
||||||
|
return file;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException("cannot write role charter temp file", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pin the model on the command line as well as in {@code ANTHROPIC_MODEL} (CB-533).
|
* Pin the model on the command line as well as in {@code ANTHROPIC_MODEL} (CB-533).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import dev.ltms.bridged.peer.SpawnRequest;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -279,8 +281,35 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All per-spawn values adapters may need, including the base-composed effective charter. */
|
/**
|
||||||
protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter) {
|
* All per-spawn values adapters may need.
|
||||||
|
*
|
||||||
|
* <p>{@code charter} is the base-composed effective charter (role charter, then the reply
|
||||||
|
* charter, joined by a blank line) — kept for an adapter that mounts both as one blob (opencode
|
||||||
|
* writes it to a single file) and as the exact input {@link CharterReceipt#compose} fingerprints.
|
||||||
|
* {@code roleCharter} and {@code replyCharter} are the same text split back into its two parts
|
||||||
|
* (CB-617), for an adapter that must deliver them differently: the role charter is
|
||||||
|
* operator-authored and often multi-line, so it cannot travel as an inline argv element (herdr
|
||||||
|
* refuses to shell-encode a multi-line argument); the reply charter is always one line and is
|
||||||
|
* demonstrated to encode, so it may still go inline. {@code cwd} is the spawn's resolved working
|
||||||
|
* directory (CB-112), needed to look up a role's agent-definition file before the peer starts.
|
||||||
|
*/
|
||||||
|
protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter,
|
||||||
|
String roleCharter, String replyCharter, String cwd) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role's agent-definition file under {@code <cwd>/<dir1>/<dir2>/<role>.md}, or {@code null}
|
||||||
|
* when absent or inapplicable (no role, no cwd, or the file does not exist) — CB-617. A member
|
||||||
|
* whose role has no such file must still spawn, so this is a lookup, never a requirement: the
|
||||||
|
* caller passes {@code --agent <role>} only when the return value is non-null.
|
||||||
|
*/
|
||||||
|
protected static Path agentDefinitionFile(String cwd, MemberRole role, String dir1, String dir2) {
|
||||||
|
if (role == null || cwd == null || cwd.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path candidate = Path.of(cwd, dir1, dir2, role.wireName() + ".md");
|
||||||
|
return Files.isRegularFile(candidate) ? candidate : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- profile surface -----------------------------------------------------------------------
|
// --- profile surface -----------------------------------------------------------------------
|
||||||
@@ -387,9 +416,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
|||||||
// start has no bridge_spawn result and no roster row, so the failure log below is the only
|
// start has no bridge_spawn result and no roster row, so the failure log below is the only
|
||||||
// surface the byte count can appear on. The charter text itself is never logged.
|
// surface the byte count can appear on. The charter text itself is never logged.
|
||||||
CharterReceipt receipt = CharterReceipt.compose(role, cfg.profile(), roleCharter, charter);
|
CharterReceipt receipt = CharterReceipt.compose(role, cfg.profile(), roleCharter, charter);
|
||||||
|
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
|
||||||
try {
|
try {
|
||||||
Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter));
|
Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter,
|
||||||
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
|
roleCharter, replyCharter, cwd));
|
||||||
Agent agent = cfg.tabPlacement()
|
Agent agent = cfg.tabPlacement()
|
||||||
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
|
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
|
||||||
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
|
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
|
||||||
|
|||||||
@@ -208,8 +208,24 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
|||||||
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString());
|
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString());
|
||||||
}
|
}
|
||||||
applyGitToken(workerEnv, cfg);
|
applyGitToken(workerEnv, cfg);
|
||||||
return new Launch(workerEnv,
|
List<String> argv = argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId());
|
||||||
argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId()));
|
return new Launch(workerEnv, argvWithAgent(argv, spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launch argv plus, when the role has an agent-definition file under the worker's cwd,
|
||||||
|
* opencode's {@code --agent <role>} flag (CB-617). A role with no such file gets nothing added —
|
||||||
|
* the member must still spawn.
|
||||||
|
*/
|
||||||
|
private List<String> argvWithAgent(List<String> argv, LaunchSpec spec) {
|
||||||
|
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".opencode", "agent");
|
||||||
|
if (agentFile == null) {
|
||||||
|
return argv;
|
||||||
|
}
|
||||||
|
List<String> withAgent = mutableArgv(argv);
|
||||||
|
withAgent.add("--agent");
|
||||||
|
withAgent.add(spec.role().wireName());
|
||||||
|
return withAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import dev.ltms.bridged.peer.PeerHandle;
|
|||||||
import dev.ltms.bridged.peer.PeerUnreachableException;
|
import dev.ltms.bridged.peer.PeerUnreachableException;
|
||||||
import dev.ltms.bridged.peer.SpawnRequest;
|
import dev.ltms.bridged.peer.SpawnRequest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -106,10 +109,24 @@ class ClaudeCodeLauncherTest {
|
|||||||
"the operator's own args are preserved, in order, ahead of the model flag");
|
"the operator's own args are preserved, in order, ahead of the model flag");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CB-617/CB-618: both charters travel in ONE file; the reply charter alone stays inline ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CB-617: herdr refuses to shell-encode a multi-line inline argv argument
|
||||||
|
* ({@code invalid_agent_argument}) — the exact failure this reproduced on profile {@code opus}.
|
||||||
|
* The role charter is operator-authored and often multi-line, so it must never appear as an argv
|
||||||
|
* element.
|
||||||
|
*
|
||||||
|
* <p>CB-618: and Claude Code itself refuses to start when both {@code --append-system-prompt} and
|
||||||
|
* {@code --append-system-prompt-file} are given ("Cannot use both ... Please use only one"), so
|
||||||
|
* the reply charter cannot ride inline alongside a role charter either. Both go in the one file,
|
||||||
|
* reply charter last. This drives the real launcher entry point ({@code spawn}), the same path a
|
||||||
|
* live spawn takes — not the argv builder in isolation.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
void appendsTheBaseComposedRoleAndReplyCharter() {
|
void bothChartersTravelInOneFileAndNeverOnBothFlags() {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
String roleCharter = "You review changes.";
|
String roleCharter = "You review changes.\nLine two.\nLine three.";
|
||||||
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||||
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
|
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
|
||||||
List.of("claude"), "tab", "bridged-workers", "w #{n}",
|
List.of("claude"), "tab", "bridged-workers", "w #{n}",
|
||||||
@@ -121,11 +138,20 @@ class ClaudeCodeLauncherTest {
|
|||||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
|
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
|
||||||
|
|
||||||
List<String> args = spawnedArgs(herdr);
|
List<String> args = spawnedArgs(herdr);
|
||||||
int flag = args.indexOf("--append-system-prompt");
|
assertTrue(args.stream().noneMatch(a -> a.contains("\n")),
|
||||||
assertEquals(1, args.stream().filter("--append-system-prompt"::equals).count(),
|
"no argv element may be multi-line — herdr cannot shell-encode one: " + args);
|
||||||
"the composed charter is passed once");
|
|
||||||
assertEquals(roleCharter + "\n\n" + HerdrPeerLauncher.REPLY_CHARTER, args.get(flag + 1),
|
int fileFlag = args.indexOf("--append-system-prompt-file");
|
||||||
"the role charter comes first and the reply rule comes last");
|
assertTrue(fileFlag >= 0, "the role charter is mounted via --append-system-prompt-file: " + args);
|
||||||
|
assertFalse(args.contains("--append-system-prompt"),
|
||||||
|
"CB-618: Claude Code refuses to start with both flags — the reply charter must not "
|
||||||
|
+ "ride inline beside a role charter: " + args);
|
||||||
|
assertDoesNotThrow(() -> {
|
||||||
|
String written = Files.readString(Path.of(args.get(fileFlag + 1)));
|
||||||
|
assertTrue(written.startsWith(roleCharter), "the file opens with the role charter: " + written);
|
||||||
|
assertTrue(written.endsWith(HerdrPeerLauncher.REPLY_CHARTER),
|
||||||
|
"the reply charter is last — it is the rule that must survive: " + written);
|
||||||
|
}, "the --append-system-prompt-file path must be a readable file");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -135,11 +161,13 @@ class ClaudeCodeLauncherTest {
|
|||||||
|
|
||||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
|
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
|
||||||
|
|
||||||
assertFalse(spawnedArgs(herdr).contains("--append-system-prompt"));
|
List<String> args = spawnedArgs(herdr);
|
||||||
|
assertFalse(args.contains("--append-system-prompt"));
|
||||||
|
assertFalse(args.contains("--append-system-prompt-file"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void profileWithoutMcpStillGetsItsRoleCharter() {
|
void profileWithoutMcpStillGetsItsRoleCharterAsAFileNotInline() {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
String roleCharter = "You design changes.";
|
String roleCharter = "You design changes.";
|
||||||
ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(Map.of("architect", roleCharter), null));
|
ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(Map.of("architect", roleCharter), null));
|
||||||
@@ -147,12 +175,52 @@ class ClaudeCodeLauncherTest {
|
|||||||
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT));
|
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT));
|
||||||
|
|
||||||
List<String> args = spawnedArgs(herdr);
|
List<String> args = spawnedArgs(herdr);
|
||||||
int flag = args.indexOf("--append-system-prompt");
|
int flag = args.indexOf("--append-system-prompt-file");
|
||||||
assertTrue(flag >= 0, "a role charter does not need an MCP mount");
|
assertTrue(flag >= 0, "a role charter does not need an MCP mount");
|
||||||
assertEquals(roleCharter, args.get(flag + 1));
|
assertDoesNotThrow(() -> assertEquals(roleCharter,
|
||||||
|
Files.readString(Path.of(args.get(flag + 1)))),
|
||||||
|
"the file holds the role charter");
|
||||||
|
assertFalse(args.contains("--append-system-prompt"), "no reply charter without an MCP mount");
|
||||||
assertFalse(args.contains("--mcp-config"));
|
assertFalse(args.contains("--mcp-config"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CB-617: --agent <role> when the role has an agent-definition file --------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void agentFlagIsPassedWhenTheRoleAgentDefinitionFileExists(@TempDir Path cwd) throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Path agentsDir = Files.createDirectories(cwd.resolve(".claude/agents"));
|
||||||
|
Files.writeString(agentsDir.resolve("reviewer.md"), "---\nname: reviewer\n---\nBe a reviewer.");
|
||||||
|
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||||
|
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
|
||||||
|
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||||
|
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||||
|
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||||
|
|
||||||
|
svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.REVIEWER));
|
||||||
|
|
||||||
|
List<String> args = spawnedArgs(herdr);
|
||||||
|
int flag = args.indexOf("--agent");
|
||||||
|
assertTrue(flag >= 0, "--agent is passed when the role's agent file exists: " + args);
|
||||||
|
assertEquals("reviewer", args.get(flag + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noAgentFlagWhenTheRoleAgentDefinitionFileIsAbsent(@TempDir Path cwd) {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
||||||
|
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
|
||||||
|
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||||
|
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||||
|
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||||
|
|
||||||
|
PeerHandle handle = svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.REVIEWER));
|
||||||
|
|
||||||
|
assertNotNull(handle, "the member still spawns with no agent-definition file");
|
||||||
|
assertFalse(spawnedArgs(herdr).contains("--agent"),
|
||||||
|
"no --agent flag when the role has no agent-definition file");
|
||||||
|
}
|
||||||
|
|
||||||
private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) {
|
private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) {
|
||||||
BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder",
|
BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder",
|
||||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import dev.ltms.bridged.herdr.FakeHerdr;
|
|||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.peer.Capability;
|
import dev.ltms.bridged.peer.Capability;
|
||||||
import dev.ltms.bridged.peer.CharterReceipt;
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
|
import dev.ltms.bridged.peer.MemberRole;
|
||||||
import dev.ltms.bridged.peer.PeerHandle;
|
import dev.ltms.bridged.peer.PeerHandle;
|
||||||
import dev.ltms.bridged.peer.PeerUnreachableException;
|
import dev.ltms.bridged.peer.PeerUnreachableException;
|
||||||
import dev.ltms.bridged.peer.SpawnRequest;
|
import dev.ltms.bridged.peer.SpawnRequest;
|
||||||
@@ -208,6 +209,35 @@ class OpenCodeLauncherTest {
|
|||||||
"--auto is unconditional: a model-less worker still must never block on approval");
|
"--auto is unconditional: a model-less worker still must never block on approval");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CB-617: --agent <role> when the role has an agent-definition file --------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void agentFlagIsPassedWhenTheRoleAgentDefinitionFileExists(@TempDir Path root) throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Path agentsDir = Files.createDirectories(root.resolve(".opencode/agent"));
|
||||||
|
Files.writeString(agentsDir.resolve("dev.md"), "You are a dev.");
|
||||||
|
OpenCodeLauncher svc = service(herdr, root, opencodeCfg(null, null, null));
|
||||||
|
|
||||||
|
svc.spawn(new SpawnRequest(null, root.toString(), null, null, null, MemberRole.DEV));
|
||||||
|
|
||||||
|
List<String> args = startArgs(herdr);
|
||||||
|
int flag = args.indexOf("--agent");
|
||||||
|
assertTrue(flag >= 0, "--agent is passed when the role's agent file exists: " + args);
|
||||||
|
assertEquals("dev", args.get(flag + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noAgentFlagWhenTheRoleAgentDefinitionFileIsAbsent(@TempDir Path root) {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
OpenCodeLauncher svc = service(herdr, root, opencodeCfg(null, null, null));
|
||||||
|
|
||||||
|
PeerHandle handle = svc.spawn(new SpawnRequest(null, root.toString(), null, null, null, MemberRole.DEV));
|
||||||
|
|
||||||
|
assertNotNull(handle, "the member still spawns with no agent-definition file");
|
||||||
|
assertFalse(startArgs(herdr).contains("--agent"),
|
||||||
|
"no --agent flag when the role has no agent-definition file");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) {
|
void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"CLAUDE.md"
|
"CLAUDE.md"
|
||||||
],
|
],
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"bridged": {
|
"fleetd": {
|
||||||
"type": "remote",
|
"type": "remote",
|
||||||
"url": "http://127.0.0.1:8765/mcp",
|
"url": "http://127.0.0.1:8765/mcp",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
+1
-1
Submodule wiki updated: 7c50cce52e...aa750de78e
Reference in New Issue
Block a user