Skip to content

Commit a97ed24

Browse files
clkaoclaude
andauthored
journeymetrics: fold dispatched-ensign transcript into --read adoption (#448)
The --read adoption counters (status_read_calls/scoped_read_calls) parsed only the FO front-door stream, but `status --read` adoption is principally an ENSIGN behavior — the ensign runs as a separate sub-agent session whose transcript lands on disk, never in the FO stream. So four real FO captures all measured 0/0. - FoldEnsignReadAdoption sums each ensign transcript's two adoption counters onto the FO observation (parse-per-transcript-then-sum, so per-session tool IDs never cross-count). turns/tokens/tool_calls keep FO-front-door semantics. - commandInvokesStatusRead now normalizes the launcher token preceding `status` (strip surrounding quotes, then accept a literal launcher name or the SPACEDOCK_BIN variable family), so the dominant real forms (spacedock_launcher, ./spacedock) and the contract-canonical quoted "${SPACEDOCK_BIN:-spacedock}" all count. $SPACEDOCK stays unrecognized. - The live harness globs the ensign subagents/agent-*.jsonl under the FO session's subagents dir and folds them in emitClaudeScenarioMetrics. Re-parsing the spike's real ensign transcript now yields status_read_calls=2 (was 0) and scoped_read_calls=6. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 87db28e commit a97ed24

4 files changed

Lines changed: 192 additions & 8 deletions

File tree

internal/ensigncycle/claude_live_runner_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ type liveResult struct {
9898
stream string
9999
artifactDir string
100100
duration time.Duration
101+
// configDir and cwd locate the dispatched-ensign sub-agent transcripts on disk
102+
// (under {configDir}/projects/{encode(cwd)}/{FO-session-id}/subagents), so the
103+
// journey-metrics fold can observe the ensign's --read adoption. cwd is the
104+
// EvalSymlinks-resolved FO working dir — the form Claude Code encodes into the
105+
// projects path. Empty on transports that do not record them (the pty driver),
106+
// so the fold no-ops to FO-front-door counts.
107+
configDir string
108+
cwd string
101109
}
102110

103111
type claudeLiveScenario struct {
@@ -382,8 +390,18 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work
382390
// fresh slice (never a mutation of the shared r.env) keeps the parallel
383391
// invocations race-free.
384392
cmd.Env = r.env
393+
configDir, _ := envValue(r.env, "CLAUDE_CONFIG_DIR")
385394
if base, ok := envValue(r.env, "CLAUDE_CONFIG_DIR"); ok {
386-
cmd.Env = withClaudeConfigDir(r.env, filepath.Join(base, scenario.name))
395+
configDir = filepath.Join(base, scenario.name)
396+
cmd.Env = withClaudeConfigDir(r.env, configDir)
397+
}
398+
399+
// The resolved cwd is what Claude Code encodes into its projects path; the FO
400+
// subprocess runs in workflowRoot, so resolve its symlinks (macOS t.TempDir is
401+
// under /var -> /private/var) to match the on-disk subagents dir.
402+
resolvedCwd := workflowRoot
403+
if resolved, err := filepath.EvalSymlinks(workflowRoot); err == nil {
404+
resolvedCwd = resolved
387405
}
388406

389407
// stdout carries the stream-json transcript the watcher drains for liveness;
@@ -447,6 +465,8 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work
447465
stream: stream,
448466
artifactDir: artifactDir,
449467
duration: duration,
468+
configDir: configDir,
469+
cwd: resolvedCwd,
450470
}
451471
}
452472

internal/ensigncycle/journey_metrics_live_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package ensigncycle
55
import (
66
"os"
77
"path/filepath"
8+
"strings"
89
"testing"
910

1011
"github.com/spacedock-dev/spacedock/internal/journeymetrics"
@@ -22,6 +23,14 @@ func emitClaudeScenarioMetrics(t *testing.T, scenario sharedRuntimeScenario, res
2223
}
2324
observation := parsed.Observation
2425
observation.Duration = result.duration
26+
// Fold the dispatched-ensign sub-agent transcripts' --read adoption onto the FO
27+
// front-door counts: `status --read` adoption is principally an ensign behavior,
28+
// and the ensign runs as a separate sub-agent session whose transcript lands on
29+
// disk, never in result.stream.
30+
observation, err = journeymetrics.FoldEnsignReadAdoption(observation, ensignTranscripts(result))
31+
if err != nil {
32+
t.Fatalf("fold ensign --read adoption for %s: %v", scenario.name, err)
33+
}
2534
record := journeymetrics.BuildRecord(journeymetrics.JourneySpec{
2635
ScenarioID: scenario.name,
2736
Source: "live-harness",
@@ -36,6 +45,36 @@ func emitClaudeScenarioMetrics(t *testing.T, scenario sharedRuntimeScenario, res
3645
}
3746
}
3847

48+
// ensignTranscripts reads the dispatched-ensign sub-agent transcripts for this
49+
// journey from disk. The ensign runs as a separate sub-agent session whose
50+
// transcript lands under the FO session's subagents dir — never in result.stream —
51+
// so the glob mirrors the proven scanSubagentMeta shape:
52+
// {configDir}/projects/{encode(cwd)}/{FO-session-id}/subagents/agent-*.jsonl. The
53+
// FO session id comes from the stream's system/init event. Returns nil when the run
54+
// did not record a config dir / cwd / session id (e.g. the pty transport), so the
55+
// fold no-ops to FO-front-door counts.
56+
func ensignTranscripts(result liveResult) [][]byte {
57+
if result.configDir == "" || result.cwd == "" {
58+
return nil
59+
}
60+
sessionID := initEventSessionID(strings.Split(result.stream, "\n"))
61+
if sessionID == "" {
62+
return nil
63+
}
64+
pattern := filepath.Join(result.configDir, "projects",
65+
encodeProjectDir(result.cwd), sessionID, "subagents", "agent-*.jsonl")
66+
matches, _ := filepath.Glob(pattern)
67+
var transcripts [][]byte
68+
for _, p := range matches {
69+
data, err := os.ReadFile(p)
70+
if err != nil {
71+
continue
72+
}
73+
transcripts = append(transcripts, data)
74+
}
75+
return transcripts
76+
}
77+
3978
func emitCodexScenarioMetrics(t *testing.T, scenario sharedRuntimeScenario, result codexScenarioResult) {
4079
t.Helper()
4180
dir := os.Getenv("SPACEDOCK_JOURNEY_METRICS_DIR")

internal/journeymetrics/readadoption.go

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,43 @@ import (
77
"strings"
88
)
99

10-
// statusReadLaunchers are the command prefixes under which `status` is the
11-
// spacedock subcommand: the two installed binaries plus the
12-
// ${SPACEDOCK_BIN:-spacedock} form the fetch commands emit.
10+
// statusReadLaunchers are the literal command names under which `status` is the
11+
// spacedock subcommand: the two installed binaries, the `./spacedock` dev build,
12+
// and the `spacedock_launcher` shell function the dispatch fetch commands define
13+
// and call. The ${SPACEDOCK_BIN:-spacedock} variable form is recognized separately
14+
// (launcherIsSpacedock) since it is a value reference, not a fixed name.
1315
var statusReadLaunchers = map[string]bool{
14-
"spacedock": true,
15-
"sd": true,
16-
"${SPACEDOCK_BIN:-spacedock}": true,
16+
"spacedock": true,
17+
"sd": true,
18+
"./spacedock": true,
19+
"spacedock_launcher": true,
20+
}
21+
22+
// launcherIsSpacedock reports whether the token immediately preceding `status`
23+
// launches the spacedock binary. It normalizes the token first — stripping one
24+
// layer of surrounding quotes, so the contract-canonical quoted
25+
// "${SPACEDOCK_BIN:-spacedock}" form (shared-core invariant) is recognized — then
26+
// accepts either a literal launcher name or any reference to the SPACEDOCK_BIN
27+
// variable family (${SPACEDOCK_BIN:-spacedock}, quoted or not). $SPACEDOCK is a
28+
// legacy variable, NOT the SPACEDOCK_BIN family, so it stays unrecognized.
29+
func launcherIsSpacedock(tok string) bool {
30+
tok = stripSurroundingQuotes(tok)
31+
if statusReadLaunchers[tok] {
32+
return true
33+
}
34+
return strings.Contains(tok, "SPACEDOCK_BIN")
35+
}
36+
37+
// stripSurroundingQuotes removes one matching pair of surrounding single or double
38+
// quotes from s, leaving an unquoted or unbalanced token untouched.
39+
func stripSurroundingQuotes(s string) string {
40+
if len(s) >= 2 {
41+
q := s[0]
42+
if (q == '"' || q == '\'') && s[len(s)-1] == q {
43+
return s[1 : len(s)-1]
44+
}
45+
}
46+
return s
1747
}
1848

1949
// commandInvokesStatusRead reports whether cmd invokes the spacedock `status`
@@ -28,7 +58,7 @@ func commandInvokesStatusRead(cmd string) bool {
2858
if tok != "status" {
2959
continue
3060
}
31-
isSubcommand := i == 0 || statusReadLaunchers[tokens[i-1]]
61+
isSubcommand := i == 0 || launcherIsSpacedock(tokens[i-1])
3262
if !isSubcommand {
3363
continue
3464
}
@@ -41,6 +71,31 @@ func commandInvokesStatusRead(cmd string) bool {
4171
return false
4272
}
4373

74+
// FoldEnsignReadAdoption sums each dispatched-ensign sub-agent transcript's
75+
// `status --read` and scoped-`Read` counts onto obs's two --read adoption
76+
// counters. `status --read` adoption is principally an ensign behavior, and the
77+
// ensign runs as a separate sub-agent session whose transcript never reaches the
78+
// FO front-door stream — so the metric must fold those transcripts to observe the
79+
// surface the contract sites actually steer.
80+
//
81+
// Each transcript is parsed independently and its counts SUMMED (not concatenated
82+
// then parsed): tool IDs are unique per session, so summing cannot cross-count and
83+
// the per-stream multi-delta tool-ID dedup stays correct within each transcript.
84+
// Concatenating would risk tool-ID collision across sessions. Only the two
85+
// adoption counters fold; turns/tokens/tool_calls keep their FO-front-door
86+
// semantics.
87+
func FoldEnsignReadAdoption(obs Observation, transcripts [][]byte) (Observation, error) {
88+
for _, transcript := range transcripts {
89+
parsed, err := ParseClaudeJSONL(transcript)
90+
if err != nil {
91+
return obs, err
92+
}
93+
obs.StatusReadCalls += parsed.Observation.StatusReadCalls
94+
obs.ScopedReadCalls += parsed.Observation.ScopedReadCalls
95+
}
96+
return obs, nil
97+
}
98+
4499
// readInputIsScoped reports whether a Read tool_use input is a scoped read — one
45100
// carrying a non-zero offset or limit (the line-range fields). A whole-file Read
46101
// carries only file_path, so neither field is present and the read is unscoped.

internal/journeymetrics/readadoption_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,63 @@ func TestCodexCharacterizationSurfacesStatusReadCalls(t *testing.T) {
7777
}
7878
}
7979

80+
func TestFoldEnsignReadAdoption(t *testing.T) {
81+
// FO front-door stream with a known baseline: a plain `spacedock status` (no
82+
// --read) and one scoped Read (offset/limit). Baseline = (status_read=0,
83+
// scoped_read=1) — `status --read` adoption is principally an ENSIGN behavior,
84+
// so the FO front-door alone registers ~0.
85+
foStream := `{"type":"system","subtype":"init","model":"claude-sonnet-4-6"}
86+
{"type":"assistant","message":{"id":"msg_fo1","model":"claude-sonnet-4-6","usage":{"input_tokens":100},"content":[{"type":"tool_use","id":"toolu_fo_status","name":"Bash","input":{"command":"spacedock status --json"}}]}}
87+
{"type":"assistant","message":{"id":"msg_fo2","model":"claude-sonnet-4-6","usage":{"input_tokens":120},"content":[{"type":"tool_use","id":"toolu_fo_read","name":"Read","input":{"file_path":"/path/entity.md","offset":10,"limit":20}}]}}`
88+
parsed, err := ParseClaudeJSONL([]byte(foStream))
89+
if err != nil {
90+
t.Fatalf("ParseClaudeJSONL (FO front-door): %v", err)
91+
}
92+
baseline := parsed.Observation
93+
if baseline.StatusReadCalls != 0 || baseline.ScopedReadCalls != 1 {
94+
t.Fatalf("FO baseline counts = (%d,%d), want (0,1)", baseline.StatusReadCalls, baseline.ScopedReadCalls)
95+
}
96+
97+
// A dispatched-ensign sub-agent transcript carrying one recognized-form
98+
// `spacedock_launcher status --read` Bash call + one scoped Read. The on-disk
99+
// agent-*.jsonl shape carries extra top-level fields (parentUuid, agentId, …)
100+
// the map[string]json.RawMessage decode ignores.
101+
ensignTranscript := `{"parentUuid":"p","agentId":"a1","type":"assistant","message":{"id":"msg_en1","model":"claude-sonnet-4-6","usage":{"input_tokens":80},"content":[{"type":"tool_use","id":"toolu_en_status","name":"Bash","input":{"command":"spacedock_launcher status --read /path/entity.md --json"}}]}}
102+
{"parentUuid":"p","agentId":"a1","type":"assistant","message":{"id":"msg_en2","model":"claude-sonnet-4-6","usage":{"input_tokens":90},"content":[{"type":"tool_use","id":"toolu_en_read","name":"Read","input":{"file_path":"/path/entity.md","offset":40,"limit":12}}]}}`
103+
104+
folded, err := FoldEnsignReadAdoption(baseline, [][]byte{[]byte(ensignTranscript)})
105+
if err != nil {
106+
t.Fatalf("FoldEnsignReadAdoption: %v", err)
107+
}
108+
// Both the ensign's status --read and its scoped Read fold onto the FO counts.
109+
if folded.StatusReadCalls != 1 || folded.ScopedReadCalls != 2 {
110+
t.Errorf("folded counts = (%d,%d), want (1,2)", folded.StatusReadCalls, folded.ScopedReadCalls)
111+
}
112+
// Non-tautological by perturbation: the fold moved BOTH counters strictly above
113+
// the FO-only baseline. With the fold removed (a no-op FoldEnsignReadAdoption)
114+
// the counts stay at the baseline (0,1) and this assertion fails — the baseline
115+
// can move the wrong way, so a passing assertion proves the ensign contribution
116+
// was actually folded in, not that the numbers happen to match.
117+
if folded.StatusReadCalls <= baseline.StatusReadCalls || folded.ScopedReadCalls <= baseline.ScopedReadCalls {
118+
t.Errorf("fold did not raise counts above FO-only baseline (%d,%d) -> (%d,%d)",
119+
baseline.StatusReadCalls, baseline.ScopedReadCalls, folded.StatusReadCalls, folded.ScopedReadCalls)
120+
}
121+
122+
// Zero case: an ensign transcript carrying NEITHER a status --read (a plain
123+
// `spacedock status`) NOR a scoped Read (a whole-file Read) folds to exactly the
124+
// FO-only baseline.
125+
noAdoption := `{"type":"assistant","message":{"id":"msg_en3","model":"claude-sonnet-4-6","usage":{"input_tokens":70},"content":[{"type":"tool_use","id":"toolu_en_plain","name":"Bash","input":{"command":"spacedock status --json"}}]}}
126+
{"type":"assistant","message":{"id":"msg_en4","model":"claude-sonnet-4-6","usage":{"input_tokens":75},"content":[{"type":"tool_use","id":"toolu_en_full","name":"Read","input":{"file_path":"/path/entity.md"}}]}}`
127+
zero, err := FoldEnsignReadAdoption(baseline, [][]byte{[]byte(noAdoption)})
128+
if err != nil {
129+
t.Fatalf("FoldEnsignReadAdoption (zero case): %v", err)
130+
}
131+
if zero.StatusReadCalls != baseline.StatusReadCalls || zero.ScopedReadCalls != baseline.ScopedReadCalls {
132+
t.Errorf("zero-case counts = (%d,%d), want FO-only baseline (%d,%d)",
133+
zero.StatusReadCalls, zero.ScopedReadCalls, baseline.StatusReadCalls, baseline.ScopedReadCalls)
134+
}
135+
}
136+
80137
func TestCommandInvokesStatusRead(t *testing.T) {
81138
cases := []struct {
82139
name string
@@ -89,6 +146,14 @@ func TestCommandInvokesStatusRead(t *testing.T) {
89146
{"sd launcher", "sd status --read /path/entity.md", true},
90147
{"SPACEDOCK_BIN launcher", "${SPACEDOCK_BIN:-spacedock} status --read /path/entity.md", true},
91148
{"bare status", "status --read /path/entity.md", true},
149+
// The dominant real launcher forms the dispatch fetch commands emit: the
150+
// spacedock_launcher shell function and the ./spacedock dev build.
151+
{"spacedock_launcher function", "spacedock_launcher status --read /path/entity.md", true},
152+
{"dev build ./spacedock", "./spacedock status --read /path/entity.md", true},
153+
// The contract-canonical QUOTED launcher (shared-core invariant). The exact-
154+
// token map dropped it; normalization strips the surrounding quotes so it
155+
// counts — the M10 regression guard.
156+
{"quoted SPACEDOCK_BIN launcher", `"${SPACEDOCK_BIN:-spacedock}" status --read /path/entity.md`, true},
92157
// Flag order independence: --read after another flag still counts.
93158
{"flag order independent", "spacedock status --json --read /path/entity.md", true},
94159
// --json default appears too; the --read token anywhere after status counts.
@@ -104,6 +169,11 @@ func TestCommandInvokesStatusRead(t *testing.T) {
104169
{"echo quoted status read", "echo 'status --read'", false},
105170
// --read on a non-status command is not a status --read.
106171
{"readonly other flag", "spacedock dispatch build --readme", false},
172+
// A launcher token alone, with no following status --read, does not match.
173+
{"launcher token alone", "spacedock_launcher", false},
174+
// $SPACEDOCK is a legacy non-canonical variable, NOT the SPACEDOCK_BIN family
175+
// the contract pins, so it stays unrecognized.
176+
{"legacy SPACEDOCK var", "$SPACEDOCK status --read /path/entity.md", false},
107177
}
108178
for _, tc := range cases {
109179
t.Run(tc.name, func(t *testing.T) {

0 commit comments

Comments
 (0)