CB-618: one charter file, and agent files need a name:
CI / contract (push) Successful in 1m10s
CI / build (push) Successful in 1m27s

Two defects in CB-617 that only a live spawn could find. Both were shipped
green: every test passed because every test read the argv we built, and none
ran the binary that has to accept it.

1. Claude Code refuses to start when both --append-system-prompt and
   --append-system-prompt-file are on the command line:

     Error: Cannot use both --append-system-prompt and
     --append-system-prompt-file. Please use only one.

   CB-617 put the role charter on the file flag and left the reply charter on
   the inline flag, so every Claude-profile spawn with a role charter died at
   launch. The pane exited on its own and bridged reported it as
   spawn_timeout ("did not reach injectable state within 20000ms"), which
   hides the real cause.

   Both charters now go in 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. A member with only a reply charter keeps the proven inline
   flag, which is also the only form that reaches a member with no repo
   checkout.

2. A Claude Code agent definition needs `name:` in its frontmatter. Ours had
   only `description:`, so the files were skipped and --agent architect failed
   with "not found. Available agents: claude, Explore, ...". Added to all
   three. The OpenCode files take their name from the filename and are
   unchanged.

Checked on this host, in this repo, with the real binary:

  claude --model claude-sonnet-5 --agent architect \
    --append-system-prompt-file /tmp/combined.md -p '...'
  -> ROLEOK, REPLYOK, yes

so the agent definition, the role charter and the reply charter all compose.

The updated test now asserts the constraint that actually binds: with a role
charter present, --append-system-prompt must be absent, and the file must open
with the role charter and end with the reply charter.

874 tests pass.
This commit is contained in:
Dai Ha
2026-08-22 12:32:44 +02:00
parent 37edd9134b
commit cc47672b7c
5 changed files with 32 additions and 18 deletions
+1
View File
@@ -1,4 +1,5 @@
---
name: architect
description: Refine work into clear, independent units before implementation.
---
+1
View File
@@ -1,4 +1,5 @@
---
name: dev
description: Implement one assigned unit, test it, and open a pull request.
---
+1
View File
@@ -1,4 +1,5 @@
---
name: reviewer
description: Review one assigned scope and report the most important real issue.
---
@@ -279,9 +279,15 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
* 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. The reply charter is always exactly one line and demonstrably encodes, and it
* must reach a member with no repo checkout (nowhere to put a file), so it keeps its inline
* {@code --append-system-prompt} delivery.
* 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, LaunchSpec spec) {
String roleCharter = nonBlank(spec.roleCharter());
@@ -298,10 +304,10 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
argv.add(mcpJson);
}
if (roleCharter != null) {
String combined = replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
argv.add("--append-system-prompt-file");
argv.add(writeRoleCharterFile(roleCharter).toString());
}
if (replyCharter != null) {
argv.add(writeCharterFile(combined).toString());
} else if (replyCharter != null) {
argv.add("--append-system-prompt");
argv.add(replyCharter);
}
@@ -325,10 +331,10 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
* 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 writeRoleCharterFile(String roleCharter) {
private static Path writeCharterFile(String charterText) {
try {
Path file = Files.createTempFile("bridged-role-charter-", ".md");
Files.writeString(file, roleCharter);
Files.writeString(file, charterText);
file.toFile().deleteOnExit();
return file;
} catch (IOException e) {
@@ -109,17 +109,22 @@ class ClaudeCodeLauncherTest {
"the operator's own args are preserved, in order, ahead of the model flag");
}
// --- CB-617: the role charter travels by file, never inline; the reply charter stays inline ---
// --- 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; the one-line reply charter still may. This drives the real launcher entry point
* ({@code spawn}), the same path a live spawn takes — not the argv builder in isolation.
* 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
void roleCharterTravelsByFileAndReplyCharterStaysInline() {
void bothChartersTravelInOneFileAndNeverOnBothFlags() {
FakeHerdr herdr = new FakeHerdr();
String roleCharter = "You review changes.\nLine two.\nLine three.";
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
@@ -138,15 +143,15 @@ class ClaudeCodeLauncherTest {
int fileFlag = args.indexOf("--append-system-prompt-file");
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)));
assertEquals(roleCharter, written, "the file holds exactly the role charter text");
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");
int inlineFlag = args.indexOf("--append-system-prompt");
assertTrue(inlineFlag >= 0, "the reply charter still travels inline: " + args);
assertEquals(HerdrPeerLauncher.REPLY_CHARTER, args.get(inlineFlag + 1),
"only the reply charter (one line) rides inline — the role charter never does");
}
@Test