diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java index 82dd8a8..324de7e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.herdr.Tab; import dev.ltms.bridged.herdr.Workspace; import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerLauncher; @@ -269,8 +270,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { // --- spawn --------------------------------------------------------------------------------- - /** A started peer plus the launch's agent-session id (the resume handle, or null). */ - private record Spawned(Agent agent, String agentSessionId) { + /** + * A started peer plus the launch's agent-session id (the resume handle, or null) and the + * charter receipt (CB-575) the base composed for it. + */ + private record Spawned(Agent agent, String agentSessionId, CharterReceipt receipt) { } /** @@ -305,12 +309,41 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { String replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null; String charter = roleCharter == null ? replyCharter : replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter; - Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter)); - String cwd = resolveCwd(requestedCwd, cfg, callerCwd); - Agent agent = cfg.tabPlacement() - ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet) - : spawnAsPane(cfg, launch.env(), launch.argv(), cwd); - return new Spawned(agent, launch.agentSessionId()); + // CB-575: fingerprint the exact composed charter bytes once, here in the base, before the + // string leaves for an adapter — so Claude and OpenCode derive the same digest. A failed + // 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. + CharterReceipt receipt = CharterReceipt.compose(role, cfg.profile(), roleCharter, charter); + try { + Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter)); + String cwd = resolveCwd(requestedCwd, cfg, callerCwd); + Agent agent = cfg.tabPlacement() + ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet) + : spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter); + logCharterReceipt(receipt, true); + return new Spawned(agent, launch.agentSessionId(), receipt); + } catch (RuntimeException e) { + logCharterReceipt(receipt, false); + throw e; + } + } + + /** + * The one place the charter's size and digest appear in the logs. {@code success} true after a + * start, false from the failure path of {@link #spawnInternal} where no handle or roster row + * exists to carry the receipt. Always metadata only — never the charter text. + */ + private static void logCharterReceipt(CharterReceipt receipt, boolean success) { + String role = receipt.role() == null ? "" : receipt.role().wireName(); + if (success) { + log.info("spawned role={} profile={} charterSource={} charterSha256={} charterBytes={}", + role, receipt.profile(), receipt.charterSource(), + receipt.charterSha256(), receipt.charterBytes()); + } else { + log.warn("spawn failed; charter role={} profile={} charterSource={} charterSha256={} charterBytes={}", + role, receipt.profile(), receipt.charterSource(), + receipt.charterSha256(), receipt.charterBytes()); + } } /** @@ -349,7 +382,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { String id = UUID.randomUUID().toString(); paneByAgentId.put(id, paneId); return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile(), - req.sessionName(), spawned.agentSessionId()); + req.sessionName(), spawned.agentSessionId(), spawned.receipt()); } @Override @@ -433,11 +466,19 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } } - /** Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. */ + /** + * Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. + * + *
CB-575: this is the one legacy log that printed the full argv, and the charter travels
+ * inside argv — so the charter text went to the daemon log on every pane-placement spawn. The
+ * {@code spawnInTab} path never logs argv, so only this site is fixed. {@code charter} is the
+ * composed charter, if any; its argv element is replaced by its digest so the log still shows
+ * which args were passed without exposing the charter prose.
+ */
private Agent spawnAsPane(BridgedConfig.Profile cfg, Map Lets an operator prove which charter a member actually got, without ever logging the
+ * charter text. The digest covers the exact composed UTF-8 string {@code HerdrPeerLauncher} passes
+ * to its adapter as {@code LaunchSpec.charter()}, so every adapter that receives the same string —
+ * Claude inlining it, OpenCode writing it to a file — produces the same digest for the same config.
+ * Two spawns of the same role from the same config agree; editing the charter changes the digest.
+ *
+ * Deliberately places no charter prose. A charter is operator-authored text that may name
+ * internal projects or unreleased plans, and logs get tailed, shipped, and pasted into tickets.
+ * The {@code charterSource} key is what the operator wants to confirm, and it carries no content.
+ */
+public record CharterReceipt(
+ MemberRole role,
+ String profile,
+ String charterSource,
+ String charterSha256,
+ int charterBytes) {
+
+ /** Source reported when the role has no configured charter, so the field is never omitted. */
+ public static final String NO_SOURCE = "none";
+
+ /**
+ * The config key that supplied the role's charter text, e.g. {@code fleet.charters.architect}.
+ */
+ public static String sourceKey(MemberRole role) {
+ return "fleet.charters." + (role == null ? "?" : role.wireName());
+ }
+
+ /**
+ * Fingerprint the composed charter for {@code role} on {@code profile}. {@code configured} is
+ * the role's charter text as read from config ({@code null} when none is configured);
+ * {@code composed} is the exact string the launcher will pass to the adapter — the reply
+ * charter may be appended to {@code configured}, or stand alone when no role charter exists.
+ *
+ * No composed charter at all is reported as an explicit absence — a {@code null} digest and
+ * a zero byte count — never a digest of the empty string, which would hide the fact that no
+ * text was supplied. {@code configured} being {@code null} while {@code composed} is the reply
+ * charter alone is a normal case, and the source says so.
+ */
+ public static CharterReceipt compose(MemberRole role, String profile,
+ String configured, String composed) {
+ String source = (configured == null || configured.isBlank())
+ ? NO_SOURCE : sourceKey(role);
+ if (composed == null) {
+ return new CharterReceipt(role, profile, source, null, 0);
+ }
+ byte[] bytes = composed.getBytes(StandardCharsets.UTF_8);
+ return new CharterReceipt(role, profile, source, digestOf(composed), bytes.length);
+ }
+
+ /** Whether the composed charter was absent (no text was given to the member). */
+ public boolean absent() {
+ return charterSha256 == null;
+ }
+
+ /**
+ * The stable SHA-256 hex digest of {@code text}, or {@code null} for null/blank text. Used both
+ * for the receipt's fingerprint and to redact a charter argument in a spawn log.
+ */
+ public static String digestOf(String text) {
+ if (text == null || text.isBlank()) {
+ return null;
+ }
+ return sha256Hex(text.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String sha256Hex(byte[] bytes) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(md.digest(bytes));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e);
+ }
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
index b5ed481..13fac9c 100644
--- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
@@ -67,4 +67,16 @@ public interface PeerHandle {
default String agentSessionId() {
return null;
}
+
+ /**
+ * The charter receipt (CB-575) for this peer's launch — the fingerprint of the exact charter
+ * bytes it was started with. {@code null} when the launcher records none (a non-instrumented
+ * adapter, or a launcher before this field); the session registry stores it so the spawn result
+ * and the roster row can show an operator which charter a member actually got.
+ *
+ * @return the fingerprint, or {@code null} when the launcher carries none
+ */
+ default CharterReceipt charterReceipt() {
+ return null;
+ }
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java
index 8c80bdb..0b9f072 100644
--- a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java
+++ b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java
@@ -1,5 +1,6 @@
package dev.ltms.bridged.session;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole;
/**
@@ -23,6 +24,8 @@ import dev.ltms.bridged.peer.MemberRole;
* @param lastActivityAtNanos {@link System#nanoTime()} of the most recent lifecycle event
* @param turnCount number of delegated turns that have been delivered to this session
* @param state current lifecycle state in the one-shot FSM
+ * @param charterReceipt the fingerprint (CB-575) of the charter bytes this member was started
+ * with; {@code null} for a session whose launcher recorded none
*/
public record MemberSession(
String paneId,
@@ -36,7 +39,8 @@ public record MemberSession(
int turnCount,
State state,
String worktree,
- String branch) {
+ String branch,
+ CharterReceipt charterReceipt) {
/** One-shot worker lifecycle states. */
public enum State {
@@ -48,21 +52,34 @@ public record MemberSession(
RELEASED
}
+ /**
+ * Backward-compatible shape: a session with no charter receipt (a test or a launcher before
+ * CB-575). A separate constructor rather than a new parameter on the canonical one, so existing
+ * call sites that have nothing to record keep compiling unchanged.
+ */
+ public MemberSession(String paneId, String terminalId, String profile, MemberRole role,
+ String cwd, String ownerTerminal, long spawnedAtNanos,
+ long lastActivityAtNanos, int turnCount, State state,
+ String worktree, String branch) {
+ this(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
+ lastActivityAtNanos, turnCount, state, worktree, branch, null);
+ }
+
/** Return a copy of this session in {@code state}. */
public MemberSession withState(State state) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
- lastActivityAtNanos, turnCount, state, worktree, branch);
+ lastActivityAtNanos, turnCount, state, worktree, branch, charterReceipt);
}
/** Return a copy with {@code lastActivityAtNanos} updated to {@code nowNanos}. */
public MemberSession withActivity(long nowNanos) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
- nowNanos, turnCount, state, worktree, branch);
+ nowNanos, turnCount, state, worktree, branch, charterReceipt);
}
/** Return a copy with the turn count incremented and activity timestamped at {@code nowNanos}. */
public MemberSession bumpTurn(long nowNanos) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
- nowNanos, turnCount + 1, state, worktree, branch);
+ nowNanos, turnCount + 1, state, worktree, branch, charterReceipt);
}
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
index fdb5766..11d7b02 100644
--- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
+++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
@@ -162,7 +162,8 @@ public final class SessionManager implements TurnListener {
0,
MemberSession.State.SPAWNING,
null,
- null);
+ null,
+ handle.charterReceipt());
registry.put(handle.id(), session);
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
log.debug("acquired session id={} terminal={} profile={} owner={}",
@@ -344,7 +345,8 @@ public final class SessionManager implements TurnListener {
0,
MemberSession.State.SPAWNING,
path,
- branch);
+ branch,
+ handle.charterReceipt());
registry.put(handle.id(), session);
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
@@ -410,6 +412,16 @@ public final class SessionManager implements TurnListener {
if (session.ownerTerminal() != null) {
m.put("owner", session.ownerTerminal());
}
+ // CB-575: which charter this member was started with — never the charter text itself. The
+ // digest lets a lead tell at a glance whether all members got the same charter; the source
+ // records whether a role charter was configured ("fleet.charters.