Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .cursor/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
Comment thread
migmartri marked this conversation as resolved.
"hooks": {
"afterFileEdit": [
{
"command": "chainloop trace hook cursor after-file-edit",
"timeout": 30
}
],
"sessionEnd": [
{
"command": "chainloop trace hook cursor session-end",
"timeout": 30
}
],
"sessionStart": [
{
"command": "chainloop trace hook cursor session-start",
"timeout": 30
}
]
},
"version": 1
}
86 changes: 64 additions & 22 deletions .opencode/plugins/chainloop-trace.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,44 @@
import type { Plugin } from "@opencode-ai/plugin"

export const ChainloopTrace: Plugin = async ({ $ }) => {
// The commit-msg hook links sessions to commits by cross-referencing
// staged files against AI line attributions recorded by post-tool-use.
// If no file-writing tools (edit, write, apply_patch) are invoked during
// the session, there will be no attributions and the commit will not be
// marked as AI-assisted.
const fileWritingTools = ["edit","write","apply_patch"]
const commandTools = ["bash"]

function filePathFromArgs(args: any): string {
if (args?.filePath) return args.filePath
if (args?.path) return args.path
return ""
function filePathsFromArgs(args: any): string[] {
if (args?.filePath) return [args.filePath]
if (args?.path) return [args.path]
if (args?.patchText) return parsePatchPaths(args.patchText)
return []
}

// parsePatchPaths extracts affected file paths from an apply_patch
// patchText payload. Each section starts with *** Add File:, *** Update
// File:, or *** Delete File: followed by the path. Paths are deduplicated
// while preserving first-seen order.
function parsePatchPaths(patchText: string): string[] {
const paths: string[] = []
const seen = new Set<string>()
const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm
let m
while ((m = re.exec(patchText)) !== null) {
const p = m[1].trim()
if (p && !seen.has(p)) {
seen.add(p)
paths.push(p)
}
}
return paths
}

// fire-and-forget: tracing must never block tool execution. If chainloop
// is unavailable or errors, log to stderr and move on.
async function fire(event: string, payload: Record<string, any>) {
const json = JSON.stringify(payload)
await $`echo ${json} | chainloop trace hook opencode ${event}`
try {
await $`echo ${json} | chainloop trace hook opencode ${event}`
Comment thread
migmartri marked this conversation as resolved.
} catch (err) {
console.error(`chainloop-trace: ${event} hook failed: ${err}`)
}
}

return {
Expand All @@ -31,22 +53,42 @@ export const ChainloopTrace: Plugin = async ({ $ }) => {
}
},
"tool.execute.before": async (input, output) => {
if (commandTools.includes(input.tool)) {
await fire("pre-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.before",
tool_name: input.tool,
})
return
}
if (!fileWritingTools.includes(input.tool)) return
await fire("pre-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.before",
tool_name: input.tool,
file_path: filePathFromArgs(output.args),
})
for (const fp of filePathsFromArgs(output.args)) {
await fire("pre-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.before",
tool_name: input.tool,
file_path: fp,
})
}
},
"tool.execute.after": async (input) => {
if (commandTools.includes(input.tool)) {
await fire("post-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.after",
tool_name: input.tool,
})
return
}
if (!fileWritingTools.includes(input.tool)) return
await fire("post-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.after",
tool_name: input.tool,
file_path: filePathFromArgs(input.args),
})
for (const fp of filePathsFromArgs(input.args)) {
await fire("post-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.after",
tool_name: input.tool,
file_path: fp,
})
}
},
}
}
96 changes: 96 additions & 0 deletions app/cli/internal/trace/claude/announce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package claude

import (
"encoding/json"
"io"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestAnnounceToUser pins the wire shape Claude Code expects. systemMessage
// must stay top-level: nested inside hookSpecificOutput it is silently ignored.
func TestAnnounceToUser(t *testing.T) {
const msg = "Coding Session Available at https://app.chainloop.dev/u/chainloop/sessions/ses_1"

testCases := []struct {
name string
msg string
wantEmitted bool
}{
{
name: "a message goes out on both channels",
msg: msg,
wantEmitted: true,
},
{
name: "nothing to say emits nothing",
msg: "",
wantEmitted: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := captureStdout(t, func() {
require.NoError(t, New().AnnounceToUser(tc.msg))
})

if !tc.wantEmitted {
assert.Empty(t, out, "no message means no stdout, so the hook stays a no-op")
return
}

var got map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &got))

assert.Equal(t, tc.msg, got["systemMessage"], "systemMessage must be top-level")

hookOut, ok := got["hookSpecificOutput"].(map[string]any)
require.True(t, ok, "hookSpecificOutput must be present")
assert.Equal(t, "PostToolUse", hookOut["hookEventName"])

// The model needs the message verbatim to be able to repeat it.
assert.Contains(t, hookOut["additionalContext"], tc.msg)
})
}
}

// captureStdout runs fn with os.Stdout redirected to a pipe and returns
// everything written to it. Reads to EOF rather than into a fixed buffer: a
// truncated read would corrupt the payload these tests parse as JSON.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()

orig := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
t.Cleanup(func() { os.Stdout = orig })

fn()
require.NoError(t, w.Close())

out, err := io.ReadAll(r)
require.NoError(t, err)
require.NoError(t, r.Close())

return string(out)
}
39 changes: 39 additions & 0 deletions app/cli/internal/trace/claude/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ func (p *Provider) IsCommandTool(toolName string) bool {
return slices.Contains(commandTools, toolName)
}

// SupportsSystemMessage is true for Claude Code: it renders the
// systemMessage field of a hook response directly to the user.
func (p *Provider) SupportsSystemMessage() bool {
return true
}

// SystemMessage writes a message to stdout for Claude Code to display on session start.
func (p *Provider) SystemMessage(msg string) error {
if msg == "" {
Expand All @@ -159,6 +165,39 @@ func (p *Provider) SystemMessage(msg string) error {
return json.NewEncoder(os.Stdout).Encode(resp)
}

// AnnounceToUser emits a PostToolUse hook response on both of Claude Code's
// delivery channels: systemMessage, which the client prints to the user
// without involving the model, and additionalContext, which reaches the model
// so it can repeat the message in its own reply.
//
// Both are used because only the second is confirmed to render in every
// build. Should systemMessage prove universally reliable, dropping
// additionalContext here would spare the model a turn, and this is the one
// place that would have to change.
func (p *Provider) AnnounceToUser(msg string) error {
if msg == "" {
return nil
}

type hookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext,omitempty"`
}

resp := struct {
SystemMessage string `json:"systemMessage"`
HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"`
}{
SystemMessage: msg,
HookSpecificOutput: hookSpecificOutput{
HookEventName: eventPostToolUse,
AdditionalContext: "Tell the user the following, including any link verbatim: " + msg,
},
}

return json.NewEncoder(os.Stdout).Encode(resp)
}

// ParseSession parses a Claude Code session JSONL and returns structured evidence.
func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) {
jsonlPath, err := findJSONLPath(opts.SessionDir, opts.SessionID)
Expand Down
13 changes: 13 additions & 0 deletions app/cli/internal/trace/cursor/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ func (p *Provider) SystemMessage(_ string) error {
return nil
}

// SupportsSystemMessage is false for Cursor, so callers skip the cost of
// composing a message that SystemMessage would drop.
func (p *Provider) SupportsSystemMessage() bool {
return false
}

// AnnounceToUser is unsupported for Cursor: it installs only sessionStart,
// sessionEnd and afterFileEdit, so no hook fires after a shell command and
// there is nowhere to deliver the message.
func (p *Provider) AnnounceToUser(_ string) error {
return trace.ErrAnnounceUnsupported
}

// CaptureFileSnapshot is a no-op for Cursor: the afterFileEdit hook
// delivers old/new strings directly, so no pre-edit snapshot is needed.
func (p *Provider) CaptureFileSnapshot(_ *state.Store, _ *trace.HookInput) error {
Expand Down
14 changes: 14 additions & 0 deletions app/cli/internal/trace/opencode/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ func (p *Provider) SystemMessage(_ string) error {
return nil
}

// SupportsSystemMessage is false for opencode, so callers skip the cost of
// composing a message that SystemMessage would drop.
func (p *Provider) SupportsSystemMessage() bool {
return false
}

// AnnounceToUser is unsupported for OpenCode until its plugin's response
// shape for surfacing a message is verified against a live session, the way
// Claude Code's was. The hook after a shell command already fires, so wiring
// this up later is a change to this method alone.
func (p *Provider) AnnounceToUser(_ string) error {
return trace.ErrAnnounceUnsupported
}

// ParseSession reads the copied export JSON for sessionID and returns
// structured evidence.
func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) {
Expand Down
23 changes: 23 additions & 0 deletions app/cli/internal/trace/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,19 @@ package trace

import (
"context"
"errors"
"io"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
)

// ErrAnnounceUnsupported is returned by AnnounceToUser when the agent has no
// channel for showing the user a message. It means nothing was displayed, as
// opposed to a delivery that was attempted and failed, so a caller holding
// single-use content can keep it rather than throw it away unseen.
var ErrAnnounceUnsupported = errors.New("agent cannot show messages to the user")

// Provider discovers and parses AI coding sessions for a specific agent.
//
// Providers are stateless singletons from a registry, so the state-touching
Expand Down Expand Up @@ -97,6 +104,22 @@ type Provider interface {

// SystemMessage writes a message to stdout for the agent to display on session start.
SystemMessage(msg string) error

// SupportsSystemMessage reports whether SystemMessage reaches the user
// rather than being discarded. Callers check it before assembling a
// message that costs something to produce, since for agents without
// such a channel that work buys nothing.
SupportsSystemMessage() bool

// AnnounceToUser writes a hook response to stdout so the agent puts msg
// in front of the user, after a shell command the agent ran. Which
// channel that uses is the provider's business: agents differ in whether
// they render text directly, relay it through the model, or both.
//
// Providers with no way to reach the user return ErrAnnounceUnsupported,
// so callers can tell "shown" apart from "nothing happened" and avoid
// discarding a message nobody saw.
AnnounceToUser(msg string) error
}

// HookInput represents parsed hook invocation data from an AI agent.
Expand Down
Loading
Loading