Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
24b4c95
Add Factory Droid hook config and run command (Phase 1)
lukeaus Jun 26, 2026
64d3e8d
Add Factory Droid hook install and dump (Phase 2)
lukeaus Jun 26, 2026
c32be63
Add Factory Droid skill set (Phase 3)
lukeaus Jun 27, 2026
5ad0d78
Add droid-hook status/reset and Droid hook docs (Phase 4)
lukeaus Jun 27, 2026
933ac57
Accept Droid Execute tool events in agent hooks
lukeaus Jun 27, 2026
6fe0939
Fold Droid hooks into agent-hook
wesm Jun 28, 2026
34188f1
Migrate legacy Droid hook commands
wesm Jun 28, 2026
012e0ec
Revert "Migrate legacy Droid hook commands"
wesm Jun 28, 2026
6045c8f
Keep Droid hook runner matching distinct
wesm Jun 28, 2026
635fa4d
Preserve flagged agent-hook installs
wesm Jun 28, 2026
db034af
Classify Droid agent-hook runner flags
wesm Jun 28, 2026
a1c4996
Add Droid lookahead review skills
lukeaus Jun 28, 2026
19805a0
Support Droid lookahead review skills
wesm Jun 28, 2026
f67751d
Align Droid branch with lookahead review type
wesm Jun 28, 2026
9a04a68
Disable project-scoped Droid hook installs
wesm Jun 28, 2026
b106a7c
Reject Droid hook config path bypass
wesm Jun 28, 2026
f39e6eb
Allow Droid user hook path from home
wesm Jun 28, 2026
4e60e34
Reject repo-root Droid hook config paths
wesm Jun 28, 2026
6b6366e
Reject target repo Droid hook configs
wesm Jun 28, 2026
82d871a
Fix DefaultDroidHooksPath on Windows
lukeaus Jun 28, 2026
5a4cc4a
Harden Droid hook and skill paths
wesm Jun 29, 2026
96a945b
Resolve symlinked parent dirs in Droid hook path validation
lukeaus Jun 29, 2026
37d5879
Skip Droid symlink parent tests when unsupported
wesm Jun 29, 2026
2abb346
Make Droid project hook path checks case-aware
wesm Jun 29, 2026
2f79786
Use heredoc comments in fix skills
wesm Jun 29, 2026
dd1572b
Normalize fix skill test line endings
wesm Jun 29, 2026
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
29 changes: 19 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ your agentic loop while context is fresh.
```bash
roborev init # layer 1: per-commit reviews
roborev skills install
roborev agent-hook install # layer 2: mid-session fix loop
roborev agent-hook install # layer 2: mid-session fix loop (Codex/Claude)
roborev agent-hook install --agent droid # layer 2: mid-session fix loop (Factory Droid)
```

Before you ship, run the `/roborev-refine` skill: it re-reviews and fixes your
Expand All @@ -52,8 +53,9 @@ roborev tui # View reviews in interactive UI
If roborev is managed by a version manager, `roborev init` and
`roborev agent-hook install` try to install hooks with the stable shim/symlink.
You can also choose the exact binary path with
`roborev init --binary ~/.local/share/mise/shims/roborev` or
`roborev agent-hook install --binary ~/.local/share/mise/shims/roborev`.
`roborev init --binary ~/.local/share/mise/shims/roborev`,
`roborev agent-hook install --binary ~/.local/share/mise/shims/roborev`, or
`roborev agent-hook install --agent droid --binary ~/.local/share/mise/shims/roborev`.

![roborev review](https://roborev.io/assets/generated/tui-review.svg)

Expand All @@ -63,8 +65,9 @@ You can also choose the exact binary path with
git hooks. No remote review workflow required.
- **Auto-Fix** - `roborev fix` feeds review findings to an agent that
applies fixes and commits. `roborev refine` iterates until reviews pass.
- **Agent Hook** - Optional Codex and Claude Code harness hooks can prompt
active sessions to run `$roborev-fix` when roborev has open failed reviews.
- **Agent Hook** - Optional Codex, Claude Code, and Factory Droid harness hooks
can prompt active sessions to run the fix skill when roborev has open failed
reviews.
- **Code Analysis** - Built-in analysis types (duplication, complexity,
refactoring, test fixtures, dead code, security) that agents can fix
automatically.
Expand Down Expand Up @@ -92,11 +95,12 @@ command line non-interactively with `roborev fix`.
changes and commits. The new commit gets reviewed automatically,
closing the loop.

For Codex and Claude Code sessions, `roborev agent-hook install` can add an
optional harness hook that prompts the active session to invoke `$roborev-fix`
after configured turn, commit, or failed-review thresholds are met. The hook
uses a separate local `roborev-agent-hook` daemon for session counters; it does
not run inside the main roborev daemon.
For Codex, Claude Code, and Factory Droid sessions, `roborev agent-hook install`
can add an optional harness hook that prompts the active session to invoke
`$roborev-fix` (or `/roborev-fix` for Droid) after configured turn, commit, or
failed-review thresholds are met.
The hook uses a separate local `roborev-agent-hook` daemon for session counters;
it does not run inside the main roborev daemon.

For fully automated iteration (advanced feature), use `refine`:

Expand Down Expand Up @@ -192,6 +196,7 @@ If the hook rewrites files, re-stage them and re-run `git commit`. Use
| `roborev refine` | Auto-fix loop: fix, re-review, repeat |
| `roborev analyze <type>` | Run code analysis with optional auto-fix |
| `roborev agent-hook install` | Install optional Codex/Claude agent harness hooks |
| `roborev agent-hook install --agent droid` | Install optional Factory Droid harness hooks |
| `roborev compact` | Verify and consolidate open review findings |
| `roborev show [sha]` | Display review for commit |
| `roborev run "<task>"` | Execute a task with an AI agent |
Expand Down Expand Up @@ -274,6 +279,9 @@ hook, so a configured integration never goes dark unnoticed.
| `ROBOREV_AGENT_HOOK_TURN_THRESHOLD` | Override agent-hook Stop threshold |
| `ROBOREV_AGENT_HOOK_COMMIT_THRESHOLD` | Override agent-hook commit threshold |
| `ROBOREV_AGENT_HOOK_FAILED_REVIEW_THRESHOLD` | Override agent-hook failed-review threshold |
| `ROBOREV_DROID_HOOK_TURN_THRESHOLD` | Override Factory Droid agent-hook Stop threshold |
| `ROBOREV_DROID_HOOK_COMMIT_THRESHOLD` | Override Factory Droid agent-hook commit threshold |
| `ROBOREV_DROID_HOOK_FAILED_REVIEW_THRESHOLD` | Override Factory Droid agent-hook failed-review threshold |
| `NO_COLOR` | Set to any value to disable all color output ([no-color.org](https://no-color.org)) |

## Supported Agents
Expand Down Expand Up @@ -384,6 +392,7 @@ Full documentation available at **[roborev.io](https://roborev.io)**:
- [Code Analysis and Assisted Refactoring](https://roborev.io/guides/assisted-refactoring/)
- [Hooks](https://roborev.io/guides/hooks/)
- [Agent Hook](docs/agent-hook.md)
- [Factory Droid Agent Hook](docs/droid-hook.md)
- [Agent Skills](https://roborev.io/guides/agent-skills/)
- [PostgreSQL Sync](https://roborev.io/guides/postgres-sync/)

Expand Down
41 changes: 32 additions & 9 deletions cmd/roborev/agent_hook_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"
Expand All @@ -31,20 +32,22 @@ func agentHookCmd() *cobra.Command {

func agentHookRunCmd() *cobra.Command {
opts := agenthook.DefaultOptions()
agent := ""
cmd := &cobra.Command{
Use: "run",
Short: "Read an agent hook payload from stdin and emit hook JSON",
Args: cobra.NoArgs,
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, _ []string) error {
resolved, err := agenthook.ResolveOptions(opts, agentHookFlagChanges(cmd))
resolved, err := agenthook.ResolveOptionsForAgent(agent, opts, agentHookFlagChanges(cmd))
if err != nil {
return err
}
return runAgentHook(resolved, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr())
},
}
addAgentHookRunFlags(cmd, &opts)
cmd.Flags().StringVar(&agent, "agent", agent, "hook option profile for this run: droid or empty/default")
return cmd
}

Expand Down Expand Up @@ -117,6 +120,7 @@ func agentHookInstallCmd() *cobra.Command {
Agent: "all",
CodexConfigPath: agenthook.DefaultCodexHooksPath(),
ClaudeConfigPath: agenthook.DefaultClaudeSettingsPath(),
Scope: "user",
Timeout: 10 * time.Second,
}
cmd := &cobra.Command{
Expand All @@ -125,7 +129,11 @@ func agentHookInstallCmd() *cobra.Command {
Args: cobra.NoArgs,
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, _ []string) error {
command, notice, err := agenthook.ResolveHookCommandWithBinary(opts.Command, hookBinary)
runner := "agent-hook run"
if strings.EqualFold(strings.TrimSpace(opts.Agent), "droid") {
runner = "agent-hook run --agent droid"
}
command, notice, err := agenthook.ResolveHookCommandWithRunner(opts.Command, hookBinary, runner)
if err != nil {
return err
}
Expand All @@ -136,11 +144,13 @@ func agentHookInstallCmd() *cobra.Command {
return agenthook.RunInstall(opts, cmd.OutOrStdout())
},
}
cmd.Flags().StringVar(&opts.Agent, "agent", opts.Agent, "agent config to update: codex, claude, or all")
cmd.Flags().StringVar(&opts.Agent, "agent", opts.Agent, "agent config to update: codex, claude, droid, or all")
cmd.Flags().StringVar(&opts.Command, "command", opts.Command, "hook command to install; defaults to this binary plus 'agent-hook run'")
cmd.Flags().StringVar(&hookBinary, "binary", "", "roborev binary path to bake into agent hooks (for version-manager shims)")
cmd.Flags().StringVar(&opts.ConfigPath, "config", opts.ConfigPath, "hook config path for a single selected agent")
cmd.Flags().StringVar(&opts.CodexConfigPath, "codex-config", opts.CodexConfigPath, "Codex hooks.json path")
cmd.Flags().StringVar(&opts.ClaudeConfigPath, "claude-config", opts.ClaudeConfigPath, "Claude settings.json path")
cmd.Flags().StringVar(&opts.Scope, "scope", opts.Scope, "Factory Droid config scope to update: user")
cmd.Flags().Var(&agentHookSecondsOrDuration{d: &opts.Timeout}, "timeout", "Codex hook timeout (e.g. 10s, 1m, or bare integer seconds)")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", opts.DryRun, "print what would change without writing files")
return cmd
Expand All @@ -154,22 +164,27 @@ func agentHookDumpCmd() *cobra.Command {
Args: cobra.NoArgs,
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, _ []string) error {
command, notice, err := agenthook.ResolveHookCommand(opts.Command)
runner := "agent-hook run"
if strings.EqualFold(strings.TrimSpace(opts.Agent), "droid") {
runner = "agent-hook run --agent droid"
}
command, notice, err := agenthook.ResolveHookCommandWithRunner(opts.Command, "", runner)
if err != nil {
return err
}
// Notices are advisory warnings; keep them off stdout so the dumped
// JSON config stays clean for piping.
if notice != "" {
fmt.Fprintln(cmd.ErrOrStderr(), notice)
fmt.Fprintln(cmd.ErrOrStderr(), agenthook.TranslateBinaryNotice(notice))
}
opts.Command = command
return agenthook.RunDump(opts, cmd.OutOrStdout())
},
}
cmd.Flags().StringVar(&opts.Agent, "agent", opts.Agent, "agent config to dump: codex or claude")
cmd.Flags().StringVar(&opts.Agent, "agent", opts.Agent, "agent config to dump: codex, claude, or droid")
cmd.Flags().StringVar(&opts.Command, "command", opts.Command, "hook command to install; defaults to this binary plus 'agent-hook run'")
cmd.Flags().StringVar(&opts.ConfigPath, "config", opts.ConfigPath, "config path to read and merge into; defaults to the agent's standard path")
cmd.Flags().StringVar(&opts.Scope, "scope", opts.Scope, "Factory Droid config scope to dump: user")
cmd.Flags().Var(&agentHookSecondsOrDuration{d: &opts.Timeout}, "timeout", "Codex hook timeout (e.g. 10s, 1m, or bare integer seconds)")
return cmd
}
Expand Down Expand Up @@ -206,12 +221,20 @@ func agentHookResetCmd() *cobra.Command {
}

func runAgentHook(opts agenthook.Options, stdin io.Reader, stdout, stderr io.Writer) error {
return runHook(opts, "agent-hook", stdin, stdout, stderr)
}

// runHook is the shared core behind the agent-hook run command. It reads an
// agent harness hook payload from stdin, records it with the shared
// agenthook daemon, and emits the harness-compatible JSON output. label is used
// in diagnostics so the invoking agent knows which integration produced them.
func runHook(opts agenthook.Options, label string, stdin io.Reader, stdout, stderr io.Writer) error {
var input agenthook.Input
if err := json.NewDecoder(stdin).Decode(&input); err != nil {
return fmt.Errorf("decode agent hook input: %w", err)
return fmt.Errorf("decode %s input: %w", label, err)
}
if input.SessionID == "" {
return fmt.Errorf("agent hook input missing session_id")
return fmt.Errorf("%s input missing session_id", label)
}

resp, err := postAgentHook(context.Background(), agenthook.Request{
Expand All @@ -223,7 +246,7 @@ func runAgentHook(opts agenthook.Options, stdin io.Reader, stdout, stderr io.Wri
RoborevServerAddr: opts.RoborevServerAddr,
})
if err != nil {
fmt.Fprintf(stderr, "roborev agent-hook: %v\n", err)
fmt.Fprintf(stderr, "roborev %s: %v\n", label, err)
return json.NewEncoder(stdout).Encode(map[string]any{})
}

Expand Down
54 changes: 54 additions & 0 deletions cmd/roborev/agent_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,35 @@ func TestAgentHookDumpCodexCreatesHookConfig(t *testing.T) {
assert.InDelta(10, firstAgentHookCommandTimeout(t, root, "Stop", command), 0)
}

func TestAgentHookDumpDroidCreatesHookConfig(t *testing.T) {
assert := assert.New(t)
path := filepath.Join(t.TempDir(), "hooks.json")
command := "/tmp/roborev agent-hook run --agent droid"

cmd := agentHookCmd()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{
"dump",
"--agent", "droid",
"--command", command,
"--config", path,
"--scope", "user",
})

require.NoError(t, cmd.Execute())

var root map[string]any
require.NoError(t, json.Unmarshal(stdout.Bytes(), &root))
assertAgentHookCommandCount(t, root, "PreToolUse", command, 1)
assertAgentHookCommandCount(t, root, "PostToolUse", command, 1)
assertAgentHookCommandCount(t, root, "Stop", command, 1)
assert.Equal("Execute", firstAgentHookMatcher(t, root, "PreToolUse"))
assert.Equal("Execute", firstAgentHookMatcher(t, root, "PostToolUse"))
assert.Empty(firstAgentHookMatcher(t, root, "Stop"))
assert.InDelta(10, firstAgentHookCommandTimeout(t, root, "Stop", command), 0)
}

func TestAgentHookInstallSupportsBinaryOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
Expand All @@ -70,6 +99,31 @@ func TestAgentHookInstallSupportsBinaryOverride(t *testing.T) {
assertAgentHookCommandContains(t, root, "Stop", binPath, 1)
}

func TestAgentHookInstallDroidWritesFactoryHooks(t *testing.T) {
path := filepath.Join(t.TempDir(), "hooks.json")

cmd := agentHookCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"install",
"--agent", "droid",
"--command", "/tmp/roborev agent-hook run --agent droid",
"--config", path,
"--scope", "user",
})

require.NoError(t, cmd.Execute())
assert.Contains(t, out.String(), "installed Factory Droid agent hooks")

body, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(body), "agent-hook run --agent droid")
assert.Contains(t, string(body), `"Execute"`)
assert.Contains(t, string(body), `"Stop"`)
}

func TestAgentHookDaemonHasLifecycleSubcommands(t *testing.T) {
daemonCmd, _, err := agentHookCmd().Find([]string{"daemon"})
require.NoError(t, err)
Expand Down
7 changes: 6 additions & 1 deletion cmd/roborev/completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ func registerReasoningCompletion(cmd *cobra.Command) {
// Panics if the flag doesn't exist on the command (programming error).
func registerReviewTypeCompletion(cmd *cobra.Command) {
if err := cmd.RegisterFlagCompletionFunc("type", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return []cobra.Completion{config.ReviewTypeSecurity, config.ReviewTypeDesign, config.ReviewTypeLookahead}, cobra.ShellCompDirectiveNoFileComp
types := config.ExplicitReviewTypes()
completions := make([]cobra.Completion, len(types))
for i, t := range types {
completions[i] = cobra.Completion(t)
}
return completions, cobra.ShellCompDirectiveNoFileComp
}); err != nil {
panic(fmt.Sprintf("registering review type completion for %s: %v", cmd.Name(), err))
}
Expand Down
1 change: 1 addition & 0 deletions cmd/roborev/quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ func agentsWithRequiredQuickstartSkills(statuses []skills.AgentStatus) []string
labels := map[skills.Agent]string{
skills.AgentClaude: "Claude Code",
skills.AgentCodex: "Codex",
skills.AgentDroid: "Factory Droid",
}
var installedFor []string
for _, status := range statuses {
Expand Down
11 changes: 5 additions & 6 deletions cmd/roborev/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,10 @@ Examples:
}

// Validate --type flag
if reviewType != "" && reviewType != "security" && reviewType != "design" && reviewType != "lookahead" {
return usageErr(cmd, fmt.Errorf("invalid --type %q (valid: security, design, lookahead)", reviewType))
switch reviewType {
case "", config.ReviewTypeSecurity, config.ReviewTypeDesign, config.ReviewTypeLookahead:
default:
return usageErr(cmd, fmt.Errorf("invalid --type %q (valid: %s)", reviewType, config.ExplicitReviewTypesHelp()))
}

// Auto-install/upgrade hooks when running from CLI
Expand Down Expand Up @@ -455,10 +457,7 @@ func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent string, di
}

// Map review_type to config workflow (matches daemon behavior)
workflow := "review"
if !config.IsDefaultReviewType(reviewType) {
workflow = reviewType
}
workflow := config.WorkflowForReviewType(reviewType)
if err := config.ValidateRepoConfig(repoPath); err != nil {
return fmt.Errorf("resolve workflow config: %w", err)
}
Expand Down
13 changes: 11 additions & 2 deletions cmd/roborev/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"slices"
"strings"

"github.com/spf13/cobra"
Expand All @@ -13,7 +14,7 @@ func skillsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
Short: "Manage AI agent skills",
Long: "Install and manage roborev skills for AI agents (Claude Code, Codex)",
Long: "Install and manage roborev skills for AI agents (Claude Code, Codex, Factory Droid)",
RunE: func(cmd *cobra.Command, args []string) error {
available, err := skills.ListSkills()
if err != nil {
Expand All @@ -35,6 +36,7 @@ func skillsCmd() *cobra.Command {
agents := []agentLabel{
{skills.AgentClaude, "Claude Code", "/"},
{skills.AgentCodex, "Codex", "$"},
{skills.AgentDroid, "Factory Droid", "/"},
}

fmt.Println("Skills:")
Expand All @@ -45,6 +47,10 @@ func skillsCmd() *cobra.Command {
}

for _, a := range agents {
if !slices.Contains(s.SupportedAgents, a.agent) {
continue
}

var as *skills.AgentStatus
for i := range statuses {
if statuses[i].Agent == a.agent {
Expand Down Expand Up @@ -108,6 +114,7 @@ func skillsCmd() *cobra.Command {
Skills are installed for agents whose config directories exist:
- Claude Code: ~/.claude/skills/
- Codex: ~/.codex/skills/
- Factory Droid: ~/.factory/skills/

This command is idempotent - running it multiple times is safe.`,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -153,7 +160,7 @@ This command is idempotent - running it multiple times is safe.`,
}

if !anyInstalled {
fmt.Println("\nNo agents found. Install Claude Code or Codex first, then run this command.")
fmt.Println("\nNo agents found. Install Claude Code, Codex, or Factory Droid first, then run this command.")
} else {
fmt.Println("\nSkills installed! Try:")
for _, agent := range installedAgents {
Expand All @@ -162,6 +169,8 @@ This command is idempotent - running it multiple times is safe.`,
fmt.Println(" Claude Code: /roborev-review, /roborev-review-branch, /roborev-design-review, /roborev-design-review-branch, /roborev-fix, /roborev-respond")
case skills.AgentCodex:
fmt.Println(" Codex: $roborev-review, $roborev-review-branch, $roborev-design-review, $roborev-design-review-branch, $roborev-fix, $roborev-respond")
case skills.AgentDroid:
fmt.Println(" Factory Droid: /roborev-review, /roborev-review-branch, /roborev-design-review, /roborev-design-review-branch, /roborev-lookahead-review, /roborev-lookahead-review-branch, /roborev-fix, /roborev-respond")
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/roborev/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ launchd or systemd).`,

// Update skills using the NEW binary (current process has old embedded skills)
// Use "skills update" to only update agents that already have skills installed
if skills.IsInstalled(skills.AgentClaude) || skills.IsInstalled(skills.AgentCodex) {
if skills.IsInstalled(skills.AgentClaude) || skills.IsInstalled(skills.AgentCodex) || skills.IsInstalled(skills.AgentDroid) {
fmt.Print("Updating skills... ")
newBinary := filepath.Join(binDir, "roborev")
if runtime.GOOS == "windows" {
Expand Down
Loading