Skip to content

Commit a928aa2

Browse files
Frank Guoclaude
andcommitted
fix: capture plan content from Read tool results + use HEAD commit message for orphan branch
Plan files read via the Read tool were not being captured. Track Read tool_use IDs targeting .claude/plans/ files and extract the plan text from the corresponding tool_result in the next user message. Also use the main branch HEAD commit message for orphan branch commits instead of the hardcoded "rekal: checkpoint". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 51be14a commit a928aa2

3 files changed

Lines changed: 219 additions & 26 deletions

File tree

cmd/rekal/cli/export.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,16 @@ func commitWireFormat(gitRoot string, bodyData, dictData []byte) (string, error)
255255
}
256256
treeHash := strings.TrimSpace(string(treeOut))
257257

258+
// Use the HEAD commit message from the main branch.
259+
msg := "rekal: checkpoint"
260+
if headMsg, err := exec.Command("git", "-C", gitRoot, "log", "-1", "--format=%s", "HEAD").Output(); err == nil {
261+
if m := strings.TrimSpace(string(headMsg)); m != "" {
262+
msg = m
263+
}
264+
}
265+
258266
commitOut, err := exec.Command("git", "-C", gitRoot,
259-
"commit-tree", treeHash, "-p", parent, "-m", "rekal: checkpoint",
267+
"commit-tree", treeHash, "-p", parent, "-m", msg,
260268
).Output()
261269
if err != nil {
262270
return "", fmt.Errorf("commit-tree: %w", err)

cmd/rekal/cli/session/parse.go

Lines changed: 150 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,15 @@ type rawMessage struct {
5555
}
5656

5757
// contentBlock represents a single block in an assistant message's content array.
58+
// Also used for tool_result blocks in user messages.
5859
type contentBlock struct {
59-
Type string `json:"type"`
60-
Text string `json:"text"`
61-
Name string `json:"name"`
62-
Input json.RawMessage `json:"input"`
60+
Type string `json:"type"`
61+
Text string `json:"text"`
62+
Name string `json:"name"`
63+
ID string `json:"id"` // tool_use block ID
64+
ToolUseID string `json:"tool_use_id"` // tool_result reference
65+
Input json.RawMessage `json:"input"`
66+
Content json.RawMessage `json:"content"` // tool_result content (string or array)
6367
}
6468

6569
// toolInput holds common fields from tool_use input blocks.
@@ -78,6 +82,10 @@ func ParseTranscript(data []byte) (*SessionPayload, error) {
7882
ActorType: "human",
7983
}
8084

85+
// pendingPlanReads tracks tool_use IDs for Read calls targeting .claude/plans/ files.
86+
// When the corresponding tool_result arrives in a user message, we extract the plan text.
87+
pendingPlanReads := make(map[string]bool)
88+
8189
scanner := bufio.NewScanner(bytes.NewReader(data))
8290
// Increase scanner buffer for large lines (tool results can be huge).
8391
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
@@ -114,21 +122,22 @@ func ParseTranscript(data []byte) (*SessionPayload, error) {
114122

115123
switch raw.Type {
116124
case "user":
117-
turn, err := parseUserTurn(raw.Message, ts)
125+
turns, err := parseUserTurn(raw.Message, ts, pendingPlanReads)
118126
if err != nil {
119127
continue
120128
}
121-
if turn != nil {
122-
payload.Turns = append(payload.Turns, *turn)
123-
}
129+
payload.Turns = append(payload.Turns, turns...)
124130

125131
case "assistant":
126-
turns, toolCalls, err := parseAssistantMessage(raw.Message, ts)
132+
turns, toolCalls, planReadIDs, err := parseAssistantMessage(raw.Message, ts)
127133
if err != nil {
128134
continue
129135
}
130136
payload.Turns = append(payload.Turns, turns...)
131137
payload.ToolCalls = append(payload.ToolCalls, toolCalls...)
138+
for _, id := range planReadIDs {
139+
pendingPlanReads[id] = true
140+
}
132141
}
133142
}
134143

@@ -141,8 +150,10 @@ func ParseTranscript(data []byte) (*SessionPayload, error) {
141150
}
142151

143152
// parseUserTurn extracts the text content from a user message.
144-
// It skips tool_result blocks (which contain file bodies, command outputs).
145-
func parseUserTurn(msgRaw json.RawMessage, ts time.Time) (*Turn, error) {
153+
// It skips tool_result blocks (which contain file bodies, command outputs),
154+
// except for tool_results matching pendingPlanReads — those contain plan file
155+
// content that should be indexed.
156+
func parseUserTurn(msgRaw json.RawMessage, ts time.Time, pendingPlanReads map[string]bool) ([]Turn, error) {
146157
if len(msgRaw) == 0 {
147158
return nil, nil
148159
}
@@ -156,37 +167,48 @@ func parseUserTurn(msgRaw json.RawMessage, ts time.Time) (*Turn, error) {
156167
return nil, nil
157168
}
158169

170+
var turns []Turn
171+
172+
// Extract plan content from tool_result blocks matching pending plan reads.
173+
if len(pendingPlanReads) > 0 {
174+
planTurns := extractPlanToolResults(msg.Content, ts, pendingPlanReads)
175+
turns = append(turns, planTurns...)
176+
}
177+
159178
text := extractTextContent(msg.Content)
160-
if text == "" {
161-
return nil, nil
179+
if text != "" {
180+
turns = append(turns, Turn{
181+
Role: "human",
182+
Content: text,
183+
Timestamp: ts,
184+
})
162185
}
163186

164-
return &Turn{
165-
Role: "human",
166-
Content: text,
167-
Timestamp: ts,
168-
}, nil
187+
return turns, nil
169188
}
170189

171190
// parseAssistantMessage extracts text turns and tool calls from an assistant message.
172191
// It discards thinking blocks and tool results.
173-
func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []ToolCall, error) {
192+
// It also returns IDs of Read tool_use blocks targeting .claude/plans/ files,
193+
// so the caller can match them against subsequent tool_result blocks.
194+
func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []ToolCall, []string, error) {
174195
if len(msgRaw) == 0 {
175-
return nil, nil, nil
196+
return nil, nil, nil, nil
176197
}
177198

178199
var msg rawMessage
179200
if err := json.Unmarshal(msgRaw, &msg); err != nil {
180-
return nil, nil, err
201+
return nil, nil, nil, err
181202
}
182203

183204
if msg.Role != "assistant" {
184-
return nil, nil, nil
205+
return nil, nil, nil, nil
185206
}
186207

187208
// Content can be a string or an array of blocks.
188209
var turns []Turn
189210
var toolCalls []ToolCall
211+
var planReadIDs []string
190212

191213
// Try as string first.
192214
var textContent string
@@ -198,13 +220,13 @@ func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []Tool
198220
Timestamp: ts,
199221
})
200222
}
201-
return turns, nil, nil
223+
return turns, nil, nil, nil
202224
}
203225

204226
// Parse as array of content blocks.
205227
var blocks []contentBlock
206228
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
207-
return nil, nil, err
229+
return nil, nil, nil, err
208230
}
209231

210232
var textParts []string
@@ -225,6 +247,10 @@ func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []Tool
225247
Timestamp: ts,
226248
})
227249
}
250+
// Track Read calls targeting plan files.
251+
if id := extractPlanReadID(b); id != "" {
252+
planReadIDs = append(planReadIDs, id)
253+
}
228254
// Discard: "thinking", "tool_result", etc.
229255
}
230256
}
@@ -244,7 +270,7 @@ func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []Tool
244270
})
245271
}
246272

247-
return turns, toolCalls, nil
273+
return turns, toolCalls, planReadIDs, nil
248274
}
249275

250276
// extractTextContent pulls text from a message content field.
@@ -341,6 +367,105 @@ func extractPlanContent(b contentBlock) string {
341367
return inp.Content
342368
}
343369

370+
// extractPlanReadID returns the tool_use ID if this is a Read tool call
371+
// targeting a .claude/plans/ file. The caller uses this to match the
372+
// corresponding tool_result in the next user message.
373+
func extractPlanReadID(b contentBlock) string {
374+
if b.Name != "Read" {
375+
return ""
376+
}
377+
if len(b.Input) == 0 || b.ID == "" {
378+
return ""
379+
}
380+
381+
var inp toolInput
382+
if err := json.Unmarshal(b.Input, &inp); err != nil {
383+
return ""
384+
}
385+
386+
path := inp.FilePath
387+
if path == "" {
388+
path = inp.Path
389+
}
390+
if !strings.Contains(path, ".claude/plans/") {
391+
return ""
392+
}
393+
394+
return b.ID
395+
}
396+
397+
// extractPlanToolResults scans user message content blocks for tool_result
398+
// blocks whose tool_use_id matches a pending plan read. For each match, it
399+
// extracts the text and emits it as an assistant turn (the content originated
400+
// from the assistant's Read call). Matched IDs are removed from the map.
401+
func extractPlanToolResults(content json.RawMessage, ts time.Time, pending map[string]bool) []Turn {
402+
if len(content) == 0 {
403+
return nil
404+
}
405+
406+
var blocks []contentBlock
407+
if err := json.Unmarshal(content, &blocks); err != nil {
408+
return nil
409+
}
410+
411+
var turns []Turn
412+
for _, b := range blocks {
413+
if b.Type != "tool_result" {
414+
continue
415+
}
416+
if !pending[b.ToolUseID] {
417+
continue
418+
}
419+
420+
text := extractToolResultText(b.Content)
421+
if text != "" {
422+
turns = append(turns, Turn{
423+
Role: "assistant",
424+
Content: text,
425+
Timestamp: ts,
426+
})
427+
}
428+
delete(pending, b.ToolUseID)
429+
}
430+
return turns
431+
}
432+
433+
// extractToolResultText extracts text from a tool_result content field,
434+
// which can be a plain string or an array of content blocks.
435+
func extractToolResultText(content json.RawMessage) string {
436+
if len(content) == 0 {
437+
return ""
438+
}
439+
440+
// Try string.
441+
var s string
442+
if err := json.Unmarshal(content, &s); err == nil {
443+
return s
444+
}
445+
446+
// Try array of blocks.
447+
var blocks []contentBlock
448+
if err := json.Unmarshal(content, &blocks); err != nil {
449+
return ""
450+
}
451+
452+
var parts []string
453+
for _, b := range blocks {
454+
if b.Type == "text" && b.Text != "" {
455+
parts = append(parts, b.Text)
456+
}
457+
}
458+
459+
combined := ""
460+
for i, p := range parts {
461+
if i > 0 {
462+
combined += "\n"
463+
}
464+
combined += p
465+
}
466+
return combined
467+
}
468+
344469
func truncate(s string, maxLen int) string {
345470
if len(s) <= maxLen {
346471
return s

cmd/rekal/cli/session/parse_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,66 @@ func TestParseTranscript_PlanContentCaptured(t *testing.T) {
175175
}
176176
}
177177

178+
func TestParseTranscript_PlanReadCaptured(t *testing.T) {
179+
t.Parallel()
180+
181+
// Simulate: assistant reads a plan file, then user message contains the tool_result with plan content.
182+
input := `{"uuid":"pr1","sessionId":"s5","timestamp":"2025-01-15T10:00:00Z","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Let me read the plan."},{"type":"tool_use","id":"tu-read-plan","name":"Read","input":{"file_path":"/home/user/.claude/plans/my-plan.md"}}]},"gitBranch":"main"}
183+
{"uuid":"pr2","sessionId":"s5","timestamp":"2025-01-15T10:00:01Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-read-plan","content":"# Plan\n\n## Step 1\nDo the thing.\n\n## Step 2\nDo the other thing."}]},"gitBranch":"main"}`
184+
185+
payload, err := ParseTranscript([]byte(input))
186+
if err != nil {
187+
t.Fatalf("ParseTranscript: %v", err)
188+
}
189+
190+
// Expect 2 turns: assistant text + plan content extracted from tool_result.
191+
if len(payload.Turns) != 2 {
192+
t.Fatalf("len(Turns) = %d, want 2", len(payload.Turns))
193+
}
194+
195+
// Plan content comes first (extracted from user tool_result, emitted as assistant turn).
196+
wantPlan := "# Plan\n\n## Step 1\nDo the thing.\n\n## Step 2\nDo the other thing."
197+
if payload.Turns[0].Role != "assistant" {
198+
t.Errorf("Turns[0].Role = %q, want assistant", payload.Turns[0].Role)
199+
}
200+
if payload.Turns[0].Content != "Let me read the plan." {
201+
t.Errorf("Turns[0].Content = %q", payload.Turns[0].Content)
202+
}
203+
204+
if payload.Turns[1].Role != "assistant" {
205+
t.Errorf("Turns[1].Role = %q, want assistant", payload.Turns[1].Role)
206+
}
207+
if payload.Turns[1].Content != wantPlan {
208+
t.Errorf("Turns[1].Content = %q, want %q", payload.Turns[1].Content, wantPlan)
209+
}
210+
211+
// Tool call should still be captured.
212+
if len(payload.ToolCalls) != 1 {
213+
t.Fatalf("len(ToolCalls) = %d, want 1", len(payload.ToolCalls))
214+
}
215+
if payload.ToolCalls[0].Tool != "Read" {
216+
t.Errorf("ToolCalls[0].Tool = %q, want Read", payload.ToolCalls[0].Tool)
217+
}
218+
}
219+
220+
func TestParseTranscript_PlanReadNonPlanIgnored(t *testing.T) {
221+
t.Parallel()
222+
223+
// Read of a non-plan file should NOT capture the tool_result.
224+
input := `{"uuid":"nr1","sessionId":"s6","timestamp":"2025-01-15T10:00:00Z","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-read-src","name":"Read","input":{"file_path":"src/main.go"}}]},"gitBranch":"main"}
225+
{"uuid":"nr2","sessionId":"s6","timestamp":"2025-01-15T10:00:01Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-read-src","content":"package main\nfunc main() {}"}]},"gitBranch":"main"}`
226+
227+
payload, err := ParseTranscript([]byte(input))
228+
if err != nil {
229+
t.Fatalf("ParseTranscript: %v", err)
230+
}
231+
232+
// No turns — non-plan Read result should be discarded, no text blocks.
233+
if len(payload.Turns) != 0 {
234+
t.Fatalf("len(Turns) = %d, want 0", len(payload.Turns))
235+
}
236+
}
237+
178238
func TestParseTranscript_NonPlanWriteNotCaptured(t *testing.T) {
179239
t.Parallel()
180240

0 commit comments

Comments
 (0)