Skip to content

Commit d8f7a4e

Browse files
authored
feat(trace): capture specs during the whole session (#3524)
1 parent 3733bb4 commit d8f7a4e

26 files changed

Lines changed: 978 additions & 101 deletions

‎.opencode/plugins/chainloop-trace.ts‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,27 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
4141
}
4242
}
4343

44-
// startSession fires the session-start hook and returns what it wrote to
45-
// stdout: the instruction for the model, if Chainloop has one.
46-
async function startSession(sessionID: string): Promise<string> {
47-
const json = JSON.stringify({ session_id: sessionID, hook_event_name: "session.created" })
44+
// instructionFrom fires a hook that can answer with an instruction for the
45+
// model, and returns what it wrote to stdout: that instruction, if
46+
// Chainloop has one.
47+
async function instructionFrom(event: string, sessionID: string, hookEventName: string): Promise<string> {
48+
const json = JSON.stringify({ session_id: sessionID, hook_event_name: hookEventName })
4849
try {
49-
const out = await $`echo ${json} | chainloop trace hook opencode session-start`.text()
50+
const out = await $`echo ${json} | chainloop trace hook opencode ${event}`.text()
5051
return out.trim() ? (JSON.parse(out).instruction ?? "") : ""
5152
} catch (err) {
52-
console.error(`chainloop-trace: session-start hook failed: ${err}`)
53+
console.error(`chainloop-trace: ${event} hook failed: ${err}`)
5354
return ""
5455
}
5556
}
5657

58+
// childSessions holds the sessions of subagents, whose parent session
59+
// already gets the instruction and the reminder.
60+
const childSessions = new Set<string>()
61+
// reminding holds the sessions that have a reminder being posted. The post
62+
// is a message too, and must not ask for another reminder.
63+
const reminding = new Set<string>()
64+
5765
// postInstruction adds the instruction to the session as a context-only
5866
// message: noReply stores it without asking the model for an answer. The
5967
// caller waits for it, so a first turn sent right away still finds it.
@@ -73,22 +81,44 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
7381
if (event.type === "session.created") {
7482
const info = event.properties?.info
7583
const sessionID = info?.id ?? ""
76-
const instruction = await startSession(sessionID)
7784
// A child session belongs to a subagent, whose parent already has
7885
// the instruction.
86+
if (info?.parentID) childSessions.add(sessionID)
87+
const instruction = await instructionFrom("session-start", sessionID, "session.created")
7988
if (instruction && !info?.parentID) await postInstruction(sessionID, instruction)
8089
}
8190
if (event.type === "session.deleted") {
8291
const sessionID = event.properties?.info?.id ?? ""
92+
childSessions.delete(sessionID)
8393
await fire("session-end", { session_id: sessionID, hook_event_name: "session.deleted" })
8494
}
8595
},
96+
// At each user message, the user-prompt-submit hook can answer with a
97+
// short reminder to capture a new or changed spec. A message made only of
98+
// synthetic parts is one that the plugin posted itself.
99+
"chat.message": async (input, output) => {
100+
const sessionID = input.sessionID
101+
if (childSessions.has(sessionID) || reminding.has(sessionID)) return
102+
const parts: any[] = output?.parts ?? []
103+
if (parts.length > 0 && parts.every((p) => p?.synthetic)) return
104+
const reminder = await instructionFrom("user-prompt-submit", sessionID, "chat.message")
105+
if (!reminder) return
106+
reminding.add(sessionID)
107+
try {
108+
await postInstruction(sessionID, reminder)
109+
} finally {
110+
reminding.delete(sessionID)
111+
}
112+
},
86113
"tool.execute.before": async (input, output) => {
87114
if (commandTools.includes(input.tool)) {
115+
// callID pairs this hook with the tool.execute.after of the same
116+
// call, so overlapping commands keep their own snapshots.
88117
await fire("pre-tool-use", {
89118
session_id: input.sessionID,
90119
hook_event_name: "tool.execute.before",
91120
tool_name: input.tool,
121+
tool_use_id: input.callID,
92122
})
93123
return
94124
}
@@ -108,6 +138,7 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
108138
session_id: input.sessionID,
109139
hook_event_name: "tool.execute.after",
110140
tool_name: input.tool,
141+
tool_use_id: input.callID,
111142
})
112143
return
113144
}

‎app/cli/cmd/trace_hook.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func newTraceHookClaudeCmd() *cobra.Command {
130130

131131
cmd.AddCommand(
132132
newTraceHookClaudeSessionStartCmd(),
133+
newTraceHookClaudeUserPromptSubmitCmd(),
133134
newTraceHookClaudeSessionEndCmd(),
134135
newTraceHookClaudePreToolUseCmd(),
135136
newTraceHookClaudePostToolUseCmd(),
@@ -153,6 +154,21 @@ func newTraceHookClaudeSessionStartCmd() *cobra.Command {
153154
}
154155
}
155156

157+
func newTraceHookClaudeUserPromptSubmitCmd() *cobra.Command {
158+
return &cobra.Command{
159+
Use: "user-prompt-submit",
160+
Short: "Remind Claude Code to capture new or changed specs at each user prompt",
161+
Annotations: map[string]string{
162+
"skipActionOptsInit": "true",
163+
},
164+
RunE: func(_ *cobra.Command, _ []string) error {
165+
cleanup := InitHookLogger()
166+
defer cleanup()
167+
return action.HandleAgentPromptSubmit(claude.New(), logger)
168+
},
169+
}
170+
}
171+
156172
func newTraceHookClaudeSessionEndCmd() *cobra.Command {
157173
return &cobra.Command{
158174
Use: "session-end",
@@ -266,6 +282,7 @@ func newTraceHookOpenCodeCmd() *cobra.Command {
266282

267283
cmd.AddCommand(
268284
newTraceHookOpenCodeSessionStartCmd(),
285+
newTraceHookOpenCodeUserPromptSubmitCmd(),
269286
newTraceHookOpenCodeSessionEndCmd(),
270287
newTraceHookOpenCodePreToolUseCmd(),
271288
newTraceHookOpenCodePostToolUseCmd(),
@@ -289,6 +306,21 @@ func newTraceHookOpenCodeSessionStartCmd() *cobra.Command {
289306
}
290307
}
291308

309+
func newTraceHookOpenCodeUserPromptSubmitCmd() *cobra.Command {
310+
return &cobra.Command{
311+
Use: "user-prompt-submit",
312+
Short: "Remind opencode to capture new or changed specs at each user prompt",
313+
Annotations: map[string]string{
314+
"skipActionOptsInit": "true",
315+
},
316+
RunE: func(_ *cobra.Command, _ []string) error {
317+
cleanup := InitHookLogger()
318+
defer cleanup()
319+
return action.HandleAgentPromptSubmit(opencode.New(), logger)
320+
},
321+
}
322+
}
323+
292324
func newTraceHookOpenCodeSessionEndCmd() *cobra.Command {
293325
return &cobra.Command{
294326
Use: "session-end",

‎app/cli/internal/trace/claude/announce_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,46 @@ func TestAnnounceSessionStart(t *testing.T) {
159159
}
160160
}
161161

162+
// TestAnnouncePromptSubmit pins the prompt-submit wire shape: Claude Code adds
163+
// additionalContext of a UserPromptSubmit response to the context of the turn.
164+
func TestAnnouncePromptSubmit(t *testing.T) {
165+
const reminder = "Chainloop spec capture. Folder: /repo/.chainloop/specs/abc-123"
166+
167+
testCases := []struct {
168+
name string
169+
reminder string
170+
wantEmitted bool
171+
}{
172+
{name: "a reminder reaches the model", reminder: reminder, wantEmitted: true},
173+
{name: "nothing to say emits nothing", reminder: "", wantEmitted: false},
174+
}
175+
176+
for _, tc := range testCases {
177+
t.Run(tc.name, func(t *testing.T) {
178+
out := captureStdout(t, func() {
179+
require.NoError(t, New().AnnouncePromptSubmit(tc.reminder))
180+
})
181+
182+
if !tc.wantEmitted {
183+
assert.Empty(t, out)
184+
return
185+
}
186+
187+
var got map[string]any
188+
require.NoError(t, json.Unmarshal([]byte(out), &got))
189+
190+
// The reminder is for the model only: a banner at every turn
191+
// would distract the user.
192+
assert.NotContains(t, got, "systemMessage")
193+
194+
hookOut, ok := got["hookSpecificOutput"].(map[string]any)
195+
require.True(t, ok, "hookSpecificOutput must be present")
196+
assert.Equal(t, "UserPromptSubmit", hookOut["hookEventName"], "the event must be the one that fired")
197+
assert.Equal(t, tc.reminder, hookOut["additionalContext"])
198+
})
199+
}
200+
}
201+
162202
// captureStdout runs fn with os.Stdout redirected to a pipe and returns
163203
// everything written to it. Reads to EOF rather than into a fixed buffer: a
164204
// truncated read would corrupt the payload these tests parse as JSON.

‎app/cli/internal/trace/claude/hooks.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ const (
3232
settingsFile = ".claude/settings.json"
3333

3434
// Claude Code hook event names.
35-
eventSessionStart = "SessionStart"
36-
eventPreToolUse = "PreToolUse"
37-
eventPostToolUse = "PostToolUse"
35+
eventSessionStart = "SessionStart"
36+
eventUserPromptSubmit = "UserPromptSubmit"
37+
eventPreToolUse = "PreToolUse"
38+
eventPostToolUse = "PostToolUse"
3839
// eventPostToolUseFailure fires instead of PostToolUse when a tool call
3940
// fails. A failed shell command can still have changed files (e.g. a
4041
// script that writes files and then runs a failing linter), so it runs
@@ -64,6 +65,7 @@ type hookEvent struct {
6465

6566
var hookEvents = []hookEvent{
6667
{eventSessionStart, "chainloop trace hook claude session-start", ""},
68+
{eventUserPromptSubmit, "chainloop trace hook claude user-prompt-submit", ""},
6769
{eventPreToolUse, "chainloop trace hook claude pre-tool-use", hookToolMatcher},
6870
{eventPostToolUse, "chainloop trace hook claude post-tool-use", hookToolMatcher},
6971
{eventPostToolUseFailure, "chainloop trace hook claude post-tool-use", hookToolMatcher},

‎app/cli/internal/trace/claude/hooks_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func TestInstallHooks(t *testing.T) {
4444

4545
// Verify command contents
4646
assertHookCommand(t, hooks, "SessionStart", "chainloop trace hook claude session-start")
47+
assertHookCommand(t, hooks, "UserPromptSubmit", "chainloop trace hook claude user-prompt-submit")
4748
assertHookCommand(t, hooks, "PreToolUse", "chainloop trace hook claude pre-tool-use")
4849
assertHookCommand(t, hooks, "PostToolUse", "chainloop trace hook claude post-tool-use")
4950
// A failed tool call fires PostToolUseFailure instead of PostToolUse,
@@ -131,6 +132,9 @@ func TestInstallHooks(t *testing.T) {
131132
sessionStart := hooks["SessionStart"].([]any)
132133
assert.Len(t, sessionStart, 1)
133134

135+
promptSubmit := hooks["UserPromptSubmit"].([]any)
136+
assert.Len(t, promptSubmit, 1)
137+
134138
preToolUse := hooks["PreToolUse"].([]any)
135139
assert.Len(t, preToolUse, 1)
136140

@@ -211,6 +215,7 @@ func TestUninstallHooks(t *testing.T) {
211215
hooks := settings["hooks"].(map[string]any)
212216
assert.Contains(t, hooks, "PostToolUse")
213217
assert.NotContains(t, hooks, "SessionStart")
218+
assert.NotContains(t, hooks, "UserPromptSubmit")
214219
assert.NotContains(t, hooks, "PreToolUse")
215220
assert.NotContains(t, hooks, "PostToolUseFailure")
216221
})
@@ -353,6 +358,7 @@ func TestInstallHooksForTraceRun(t *testing.T) {
353358
hooks := settings["hooks"].(map[string]any)
354359

355360
assert.Contains(t, hooks, "SessionStart")
361+
assert.Contains(t, hooks, "UserPromptSubmit")
356362
assert.Contains(t, hooks, "PreToolUse")
357363
assert.Contains(t, hooks, "PostToolUse")
358364
assert.Contains(t, hooks, "PostToolUseFailure")

‎app/cli/internal/trace/claude/provider.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,38 @@ func (p *Provider) AnnounceSessionStart(msg trace.SessionStartMessage) error {
249249
return json.NewEncoder(os.Stdout).Encode(resp)
250250
}
251251

252+
// SupportsPromptReminder is true for Claude Code: it adds the
253+
// additionalContext field of a UserPromptSubmit hook response to the context
254+
// of the turn.
255+
func (p *Provider) SupportsPromptReminder() bool {
256+
return true
257+
}
258+
259+
// AnnouncePromptSubmit emits a UserPromptSubmit hook response that carries
260+
// the reminder in additionalContext only. It has no systemMessage: a banner
261+
// at every turn would only distract the user.
262+
func (p *Provider) AnnouncePromptSubmit(reminder string) error {
263+
if reminder == "" {
264+
return nil
265+
}
266+
267+
type hookSpecificOutput struct {
268+
HookEventName string `json:"hookEventName"`
269+
AdditionalContext string `json:"additionalContext"`
270+
}
271+
272+
resp := struct {
273+
HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"`
274+
}{
275+
HookSpecificOutput: hookSpecificOutput{
276+
HookEventName: eventUserPromptSubmit,
277+
AdditionalContext: reminder,
278+
},
279+
}
280+
281+
return json.NewEncoder(os.Stdout).Encode(resp)
282+
}
283+
252284
// AnnounceToUser emits a PostToolUse hook response on both of Claude Code's
253285
// delivery channels: systemMessage, which the client prints to the user
254286
// without involving the model, and additionalContext, which reaches the model

‎app/cli/internal/trace/cursor/announce_test.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ func TestAnnounceSessionStart(t *testing.T) {
7979
}
8080
}
8181

82+
// TestAnnouncePromptSubmit pins that Cursor gets no reminder: it documents no
83+
// channel that adds context at prompt submit (D-008 of Spec 003).
84+
func TestAnnouncePromptSubmit(t *testing.T) {
85+
out := captureStdout(t, func() {
86+
require.NoError(t, New().AnnouncePromptSubmit("Chainloop spec capture."))
87+
})
88+
89+
assert.Empty(t, out)
90+
}
91+
8292
// captureStdout runs fn and returns what it wrote to stdout.
8393
func captureStdout(t *testing.T, fn func()) string {
8494
t.Helper()

‎app/cli/internal/trace/cursor/provider.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ func (p *Provider) SupportsSessionStartInstruction() bool {
123123
return true
124124
}
125125

126+
// SupportsPromptReminder is false for Cursor: it documents no channel that
127+
// adds context at prompt submit, so it gets the session-start instruction
128+
// only.
129+
func (p *Provider) SupportsPromptReminder() bool {
130+
return false
131+
}
132+
133+
// AnnouncePromptSubmit writes nothing for Cursor, which installs no
134+
// prompt-submit hook.
135+
func (p *Provider) AnnouncePromptSubmit(_ string) error {
136+
return nil
137+
}
138+
126139
// AnnounceToUser is unsupported for Cursor: it installs only sessionStart,
127140
// sessionEnd and afterFileEdit, so no hook fires after a shell command and
128141
// there is nowhere to deliver the message.

‎app/cli/internal/trace/opencode/announce_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,39 @@ func TestAnnounceSessionStart(t *testing.T) {
7979
}
8080
}
8181

82+
// TestAnnouncePromptSubmit pins the prompt-submit response: the plugin reads
83+
// instruction and posts it to the session as a context-only message, as it
84+
// does at session start.
85+
func TestAnnouncePromptSubmit(t *testing.T) {
86+
const reminder = "Chainloop spec capture. Folder: /repo/.chainloop/specs/abc-123"
87+
88+
testCases := []struct {
89+
name string
90+
reminder string
91+
want map[string]any
92+
}{
93+
{name: "a reminder is emitted", reminder: reminder, want: map[string]any{"instruction": reminder}},
94+
{name: "nothing to say emits nothing", reminder: ""},
95+
}
96+
97+
for _, tc := range testCases {
98+
t.Run(tc.name, func(t *testing.T) {
99+
out := captureStdout(t, func() {
100+
require.NoError(t, New().AnnouncePromptSubmit(tc.reminder))
101+
})
102+
103+
if tc.want == nil {
104+
assert.Empty(t, out)
105+
return
106+
}
107+
108+
var got map[string]any
109+
require.NoError(t, json.Unmarshal([]byte(out), &got))
110+
assert.Equal(t, tc.want, got)
111+
})
112+
}
113+
}
114+
82115
// captureStdout runs fn and returns what it wrote to stdout.
83116
func captureStdout(t *testing.T, fn func()) string {
84117
t.Helper()

0 commit comments

Comments
 (0)