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
2 changes: 1 addition & 1 deletion .opencode/plugins/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const TracePlugin: Plugin = async ({ directory }) => {
}

/**
* Pipe JSON payload to an entire hooks command (async).
* Pipe JSON payload to an trace hooks command (async).
* Errors are logged but never thrown — plugin failures must not crash OpenCode.
*/
async function callHook(hookName: string, payload: Record<string, unknown>) {
Expand Down
1 change: 1 addition & 0 deletions cmd/trace/cli/agent/architecture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ func discoverAgentPackages(t *testing.T, agentDir string) []string {
"testutil": true, // shared test utilities
"external": true, // external agent adapter, not a self-registering agent
"skilldiscovery": true, // review skill discovery utility, not an agent
"spawn": true, // Spawner interface, not an agent implementation
}

entries, err := os.ReadDir(agentDir)
Expand Down
30 changes: 30 additions & 0 deletions cmd/trace/cli/agent/claudecode/spawner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package claudecode

import (
"context"
"os/exec"

"github.com/GrayCodeAI/trace/cmd/trace/cli/agent"
"github.com/GrayCodeAI/trace/cmd/trace/cli/agent/spawn"
)

// claudeCodeSpawner produces argv: claude -p <prompt>; no stdin.
type claudeCodeSpawner struct{}

// NewSpawner returns a Spawner for claude-code's non-interactive review/investigate mode.
func NewSpawner() spawn.Spawner { //nolint:ireturn // factory returns interface by design
return claudeCodeSpawner{}
}

func (claudeCodeSpawner) Name() string { return string(agent.AgentNameClaudeCode) }

func (claudeCodeSpawner) BuildCmd(ctx context.Context, env []string, prompt string) *exec.Cmd {
// --permission-mode bypassPermissions auto-accepts every tool call.
// `-p` (print) mode has no UI to answer permission prompts, so the
// default mode silently denies anything not pre-approved — including
// Bash (so `git`, `grep`, `ls` would be blocked). The prompt forbids
// destructive commands; the flag is a no-op for the review path.
cmd := exec.CommandContext(ctx, "claude", "-p", "--permission-mode", "bypassPermissions", prompt)
cmd.Env = env
return cmd
}
42 changes: 42 additions & 0 deletions cmd/trace/cli/agent/claudecode/spawner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package claudecode

import (
"context"
"reflect"
"testing"
)

// TestClaudeCodeSpawner_Name asserts the spawner reports the stable
// registry name used by both review and investigate callers.
func TestClaudeCodeSpawner_Name(t *testing.T) {
t.Parallel()
if got := NewSpawner().Name(); got != "claude-code" {
t.Errorf("Name() = %q, want %q", got, "claude-code")
}
}

// TestClaudeCodeSpawner_Argv pins the argv contract:
//
// claude -p --permission-mode bypassPermissions <prompt>
//
// The prompt is the last positional. --permission-mode bypassPermissions is
// required so file writes succeed in non-interactive mode (see
// spawner.go); stdin is unused.
func TestClaudeCodeSpawner_Argv(t *testing.T) {
t.Parallel()
env := []string{"FOO=bar", "BAZ=qux"}
cmd := NewSpawner().BuildCmd(context.Background(), env, "the-prompt")

wantArgs := []string{"claude", "-p", "--permission-mode", "bypassPermissions", "the-prompt"}
if !reflect.DeepEqual(cmd.Args, wantArgs) {
t.Errorf("Args = %v, want %v", cmd.Args, wantArgs)
}

if !reflect.DeepEqual(cmd.Env, env) {
t.Errorf("Env = %v, want %v", cmd.Env, env)
}

if cmd.Stdin != nil {
t.Errorf("Stdin = %v, want nil (claude uses argv, not stdin)", cmd.Stdin)
}
}
5 changes: 5 additions & 0 deletions cmd/trace/cli/agent/claudecode/testdata/stream_session.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"type":"system","subtype":"init","cwd":"/redacted/worktree","session_id":"f6da5c2c-aaaa-aaaa-aaaa-aaaaaaaaaaaa","model":"claude-opus-4-7[1m]","permissionMode":"default","output_style":"default","apiKeySource":"none","fast_mode_state":"off","uuid":"c5a08ce6-3a63-4db2-b456-ab2c5a8e289d"}
{"type":"assistant","message":{"model":"claude-opus-4-7","id":"msg_01G5oM1j98ym4vJKwdys6MUm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"REDACTED"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":32750,"cache_read_input_tokens":22082,"output_tokens":0}},"parent_tool_use_id":null,"session_id":"f6da5c2c-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"2eedb798-4bb3-4e2a-8620-a699aa00bd02"}
{"type":"assistant","message":{"model":"claude-opus-4-7","id":"msg_01G5oM1j98ym4vJKwdys6MUm","type":"message","role":"assistant","content":[{"type":"text","text":"Cats are among the most enigmatic companions humans have welcomed into their homes. Domesticated roughly 9,000 years ago in the Fertile Crescent, they originally earned their place hunting rodents in grain stores."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":32750,"cache_read_input_tokens":22082,"output_tokens":0}},"parent_tool_use_id":null,"session_id":"f6da5c2c-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"98509118-f965-4e8b-9195-5dbe1c93d937"}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1778614200,"rateLimitType":"five_hour","overageStatus":"allowed","overageResetsAt":1780272000,"isUsingOverage":false},"uuid":"c771b482-c0a9-4115-8e9c-9eef2dccabe2","session_id":"f6da5c2c-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":16014,"duration_api_ms":11681,"num_turns":1,"result":"Cats are among the most enigmatic companions humans have welcomed into their homes.","stop_reason":"end_turn","session_id":"f6da5c2c-aaaa-aaaa-aaaa-aaaaaaaaaaaa","total_cost_usd":0.23315850000000002,"usage":{"input_tokens":6,"cache_creation_input_tokens":32750,"cache_read_input_tokens":22082,"output_tokens":696,"service_tier":"standard"},"terminal_reason":"completed","fast_mode_state":"off","uuid":"ddcee86e-c4ce-47be-905a-263c2723e9cf"}
44 changes: 44 additions & 0 deletions cmd/trace/cli/agent/codex/spawner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package codex

import (
"context"
"os/exec"
"strings"

"github.com/GrayCodeAI/trace/cmd/trace/cli/agent"
"github.com/GrayCodeAI/trace/cmd/trace/cli/agent/spawn"
)

// codexSpawner produces argv:
//
// codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -
//
// Prompt is piped on stdin. The "dangerously-bypass" flag is codex's
// documented way to run autonomously without sandbox + approval gates.
// Less aggressive options (-s workspace-write, --add-dir) are NOT
// sufficient for `trace investigate`: codex's workspace-write policy
// excludes `.git/` regardless of --add-dir, so the agent could not
// write to <git-common-dir>/trace-investigations/<run-id>/
// (findings.md / state.json) even when that path was added. The user
// explicitly invoked the agent; the prompt forbids destructive commands.
type codexSpawner struct{}

// NewSpawner returns a Spawner for codex's non-interactive review/investigate mode.
func NewSpawner() spawn.Spawner { //nolint:ireturn // factory returns interface by design
return codexSpawner{}
}

func (codexSpawner) Name() string { return string(agent.AgentNameCodex) }

func (codexSpawner) BuildCmd(ctx context.Context, env []string, prompt string) *exec.Cmd {
cmd := exec.CommandContext(
ctx, string(agent.AgentNameCodex),
codexExecCommand,
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"-",
)
cmd.Stdin = strings.NewReader(prompt)
cmd.Env = env
return cmd
}
81 changes: 81 additions & 0 deletions cmd/trace/cli/agent/codex/spawner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package codex

import (
"context"
"io"
"reflect"
"testing"
)

// TestCodexSpawner_Name asserts the spawner reports the stable registry name.
func TestCodexSpawner_Name(t *testing.T) {
t.Parallel()
if got := NewSpawner().Name(); got != wantCodexAgentName {
t.Errorf("Name() = %q, want %q", got, wantCodexAgentName)
}
}

// TestCodexSpawner_Argv pins the argv + stdin contract:
//
// codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -
//
// Prompt is piped on stdin. The bypass-approvals-and-sandbox flag is
// codex's documented way to run autonomously: less aggressive options
// (-s workspace-write, --add-dir) are not sufficient because codex's
// workspace-write policy excludes anything under `.git/` regardless of
// --add-dir, which blocks investigate's per-run dir at
// <git-common-dir>/trace-investigations/<run-id>/.
func TestCodexSpawner_Argv(t *testing.T) {
t.Parallel()
env := []string{"FOO=bar", "BAZ=qux"}
cmd := NewSpawner().BuildCmd(context.Background(), env, "the-prompt")

wantArgs := []string{
wantCodexAgentName, "exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"-",
}
if !reflect.DeepEqual(cmd.Args, wantArgs) {
t.Errorf("Args = %v, want %v", cmd.Args, wantArgs)
}

if !reflect.DeepEqual(cmd.Env, env) {
t.Errorf("Env = %v, want %v", cmd.Env, env)
}

if cmd.Stdin == nil {
t.Fatal("Stdin = nil, want a reader carrying the prompt")
}
got, err := io.ReadAll(cmd.Stdin)
if err != nil {
t.Fatalf("read stdin: %v", err)
}
if string(got) != "the-prompt" {
t.Errorf("stdin = %q, want %q", string(got), "the-prompt")
}
}

// TestCodexSpawner_Argv_StableUnderInvestigateEnv pins the contract
// that the argv does NOT change based on env vars. (A previous
// implementation appended --add-dir from TRACE_INVESTIGATE_FINDINGS_DOC;
// that approach didn't actually unblock writes under .git/, so we
// dropped it. This test pins the regression.)
func TestCodexSpawner_Argv_StableUnderInvestigateEnv(t *testing.T) {
t.Parallel()
env := []string{
"FOO=bar",
"TRACE_INVESTIGATE_FINDINGS_DOC=/repo/.git/trace-investigations/abcdef012345/findings.md",
}
cmd := NewSpawner().BuildCmd(context.Background(), env, "prompt")

wantArgs := []string{
wantCodexAgentName, "exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"-",
}
if !reflect.DeepEqual(cmd.Args, wantArgs) {
t.Errorf("Args = %v, want %v", cmd.Args, wantArgs)
}
}
6 changes: 6 additions & 0 deletions cmd/trace/cli/agent/codex/testdata/json_session.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{"type":"thread.started","thread_id":"019e1cad-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc \"sed -n '1,200p' /Users/redacted/SKILL.md\"","aggregated_output":"","exit_code":null,"status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc \"sed -n '1,200p' /Users/redacted/SKILL.md\"","aggregated_output":"---\nname: using-superpowers\ndescription: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions\n---\n\nfile content trimmed for fixture brevity\n","exit_code":0,"status":"completed"}}
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hi"}}
{"type":"turn.completed","usage":{"input_tokens":50787,"cached_input_tokens":29952,"output_tokens":354,"reasoning_output_tokens":251}}
28 changes: 28 additions & 0 deletions cmd/trace/cli/agent/geminicli/spawner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package geminicli

import (
"context"
"os/exec"
"strings"

"github.com/GrayCodeAI/trace/cmd/trace/cli/agent/spawn"
)

// geminiSpawner produces argv: gemini -p " "; prompt via stdin.
// The " " argv placeholder triggers headless mode; the prompt goes via stdin
// because gemini's -p flag appends to stdin content.
type geminiSpawner struct{}

// NewSpawner returns a Spawner for gemini-cli's non-interactive review/investigate mode.
func NewSpawner() spawn.Spawner { //nolint:ireturn // factory returns interface by design
return geminiSpawner{}
}

func (geminiSpawner) Name() string { return "gemini-cli" }

func (geminiSpawner) BuildCmd(ctx context.Context, env []string, prompt string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "gemini", "-p", " ")
cmd.Stdin = strings.NewReader(prompt)
cmd.Env = env
return cmd
}
44 changes: 44 additions & 0 deletions cmd/trace/cli/agent/geminicli/spawner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package geminicli

import (
"context"
"io"
"reflect"
"testing"
)

// TestGeminiCLISpawner_Name asserts the spawner reports the stable registry name.
func TestGeminiCLISpawner_Name(t *testing.T) {
t.Parallel()
if got := NewSpawner().Name(); got != "gemini-cli" {
t.Errorf("Name() = %q, want %q", got, "gemini-cli")
}
}

// TestGeminiCLISpawner_Argv pins the argv + stdin contract:
// gemini -p " " (space placeholder triggers headless mode), prompt via stdin.
func TestGeminiCLISpawner_Argv(t *testing.T) {
t.Parallel()
env := []string{"FOO=bar", "BAZ=qux"}
cmd := NewSpawner().BuildCmd(context.Background(), env, "the-prompt")

wantArgs := []string{"gemini", "-p", " "}
if !reflect.DeepEqual(cmd.Args, wantArgs) {
t.Errorf("Args = %v, want %v", cmd.Args, wantArgs)
}

if !reflect.DeepEqual(cmd.Env, env) {
t.Errorf("Env = %v, want %v", cmd.Env, env)
}

if cmd.Stdin == nil {
t.Fatal("Stdin = nil, want a reader carrying the prompt")
}
got, err := io.ReadAll(cmd.Stdin)
if err != nil {
t.Fatalf("read stdin: %v", err)
}
if string(got) != "the-prompt" {
t.Errorf("stdin = %q, want %q", string(got), "the-prompt")
}
}
2 changes: 1 addition & 1 deletion cmd/trace/cli/agent/pi/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const (
// content (and so it survives the TRACE_CMD placeholder substitution).
entireMarker = "Auto-generated by `trace enable --agent pi`"

// entireCmdPlaceholder is replaced at install time with either `entire`
// entireCmdPlaceholder is replaced at install time with either `trace`
// (production) or a `go run …` path (local-dev).
entireCmdPlaceholder = "__TRACE_CMD__"
)
Expand Down
2 changes: 1 addition & 1 deletion cmd/trace/cli/agent/skilldiscovery/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type InstallHint struct {

// curatedBuiltins lists the review-adjacent commands that ship with each
// agent binary (no plugin install required). See
// docs/superpowers/specs/2026-04-22-entire-review-picker-install-awareness-design.md
// docs/superpowers/specs/2026-04-22-trace-review-picker-install-awareness-design.md
// §Data model for the sources these names came from. Gemini CLI has no
// built-in review command and relies on the install hint below.
var curatedBuiltins = map[string][]CuratedSkill{
Expand Down
30 changes: 30 additions & 0 deletions cmd/trace/cli/agent/spawn/spawn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Package spawn provides the Spawner interface used by both `entire review`
// and `trace investigate` to start an agent process non-interactively.
//
// The interface is intentionally env-contract-agnostic: callers compose
// their own TRACE_REVIEW_* or TRACE_INVESTIGATE_* env via
// review.AppendReviewEnv or investigate.AppendInvestigateEnv before calling
// BuildCmd. Spawners only own the agent-specific argv shape and stdin
// wiring; they do not append review/investigate env.
package spawn

import (
"context"
"os/exec"
)

// Spawner builds *exec.Cmd values for a specific agent in non-interactive,
// review/investigate mode. The returned Cmd MUST NOT be started yet —
// callers may attach pipes, modify env, etc., before invoking Start.
type Spawner interface {
// Name returns the agent's stable registry name (e.g. "claude-code").
Name() string

// BuildCmd constructs the *exec.Cmd to spawn the agent.
// - env: the full process environment to set on cmd.Env (the caller has
// already appended TRACE_REVIEW_* or TRACE_INVESTIGATE_* values
// and stripped any stale entries before calling).
// - prompt: the composed prompt string. The spawner decides whether
// this goes via argv or stdin per the agent's CLI shape.
BuildCmd(ctx context.Context, env []string, prompt string) *exec.Cmd
}
Loading
Loading