Skip to content

Commit 1728b98

Browse files
shahar-cauraclaude
andcommitted
forge: Add NL classification, hook retry with agent fix, and lefthook bypass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2bab41 commit 1728b98

21 files changed

Lines changed: 360 additions & 34 deletions

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ lint:
2727
golangci-lint run ./...
2828

2929
fmt:
30+
gofumpt -w .
3031
goimports -w .
3132

3233
vet:

cmd/forge/cmd_nl.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import (
1111
"github.com/spf13/cobra"
1212
)
1313

14+
// nlClassifying guards against classify → execute → classify recursion.
15+
var nlClassifying bool
16+
1417
func runNaturalLanguage(cmd *cobra.Command, logger *slog.Logger, args []string) error {
1518
if len(args) == 0 {
1619
return cmd.Help()
1720
}
1821

1922
// Recursion guard: prevent classify → execute → classify loops.
20-
if os.Getenv("FORGE_NL_CLASSIFIED") == "1" {
23+
if nlClassifying {
2124
return fmt.Errorf("unknown command %q", args[0])
2225
}
2326

@@ -47,8 +50,8 @@ func runNaturalLanguage(cmd *cobra.Command, logger *slog.Logger, args []string)
4750

4851
fmt.Fprintf(os.Stderr, "=> forge %s\n", strings.Join(result.Argv, " "))
4952

50-
os.Setenv("FORGE_NL_CLASSIFIED", "1")
51-
defer os.Unsetenv("FORGE_NL_CLASSIFIED")
53+
nlClassifying = true
54+
defer func() { nlClassifying = false }()
5255

5356
cmd.Root().SetArgs(result.Argv)
5457
return cmd.Root().Execute()

cmd/forge/cmd_nl_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"log/slog"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestRunNaturalLanguage_EmptyArgs(t *testing.T) {
11+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
12+
root := newRootCmd(logger)
13+
14+
// Empty args should print help, not error.
15+
err := runNaturalLanguage(root, logger, []string{})
16+
if err != nil {
17+
t.Fatalf("expected no error for empty args, got: %v", err)
18+
}
19+
}
20+
21+
func TestRunNaturalLanguage_RecursionGuard(t *testing.T) {
22+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
23+
root := newRootCmd(logger)
24+
25+
nlClassifying = true
26+
defer func() { nlClassifying = false }()
27+
28+
err := runNaturalLanguage(root, logger, []string{"something"})
29+
if err == nil {
30+
t.Fatal("expected error from recursion guard")
31+
}
32+
if !strings.Contains(err.Error(), "unknown command") {
33+
t.Fatalf("expected 'unknown command' error, got: %v", err)
34+
}
35+
}
36+
37+
func TestRunNaturalLanguage_NoClaude(t *testing.T) {
38+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
39+
root := newRootCmd(logger)
40+
41+
// Override PATH so claude is not found.
42+
t.Setenv("PATH", "")
43+
44+
err := runNaturalLanguage(root, logger, []string{"run", "the", "auth", "plan"})
45+
if err == nil {
46+
t.Fatal("expected error when claude CLI is not available")
47+
}
48+
if !strings.Contains(err.Error(), "install claude CLI") {
49+
t.Fatalf("expected 'install claude CLI' hint, got: %v", err)
50+
}
51+
}
52+
53+
func TestRootCmd_UnknownArgsTriggersNL(t *testing.T) {
54+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
55+
root := newRootCmd(logger)
56+
57+
// Without claude in PATH, unknown args should trigger NL which falls back to error.
58+
t.Setenv("PATH", "")
59+
root.SetArgs([]string{"do", "something", "cool"})
60+
err := root.Execute()
61+
if err == nil {
62+
t.Fatal("expected error for unknown args without claude CLI")
63+
}
64+
if !strings.Contains(err.Error(), "install claude CLI") {
65+
t.Fatalf("expected NL classification error, got: %v", err)
66+
}
67+
}

cmd/forge/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"fmt"
45
"log/slog"
56
"os"
67

@@ -17,6 +18,7 @@ func main() {
1718
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
1819

1920
if err := newRootCmd(logger).Execute(); err != nil {
21+
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
2022
os.Exit(1)
2123
}
2224
}
@@ -27,6 +29,13 @@ func newRootCmd(logger *slog.Logger) *cobra.Command {
2729
Short: "Execute a development plan end-to-end",
2830
SilenceUsage: true,
2931
SilenceErrors: true,
32+
Args: cobra.ArbitraryArgs,
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
if len(args) == 0 {
35+
return cmd.Help()
36+
}
37+
return runNaturalLanguage(cmd, logger, args)
38+
},
3039
}
3140

3241
root.PersistentFlags().String("agent", "", "override agent provider (e.g. claude, codex, gemini)")

forge.yaml.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ tracker:
1313
base_url: ${JIRA_URL} # e.g. https://yourco.atlassian.net
1414
email: ${JIRA_EMAIL}
1515
token: ${JIRA_TOKEN}
16+
# board_id: "123" # Optional: Jira board ID
1617

1718
notifier:
1819
provider: slack
@@ -21,14 +22,20 @@ notifier:
2122
agent:
2223
provider: claude # claude, ralph, codex, gemini
2324
timeout: 45m # Max time for agent execution
25+
# providers: # Optional: multi-agent pool for batch runs (round-robin + fallback).
26+
# - claude # When set, overrides provider for batch; provider is still used
27+
# - gemini # for single runs (forge run) and as default if providers is empty.
28+
# allowed_tools: "" # Optional: comma-separated tool allowlist (auto-set for ralph)
2429

2530
worktree:
2631
create_cmd: "./scripts/git-worktree-add.sh {{.Branch}} {{.Path}} {{.BaseBranch}}"
2732
remove_cmd: "git worktree remove --force {{.Path}}"
2833
cleanup: true # Remove worktree after PR is opened
34+
cleanup_on_merge: false # Automatically remove worktree when PR is merged
2935

3036
hooks:
3137
pre_commit: "make fmt && make vet" # Run before pushing commits
38+
max_hook_retries: 2 # Agent retry attempts on hook failure (0 = fail fast)
3239

3340
state:
3441
retention: 168h # How long to keep completed run states (7 days)
@@ -43,6 +50,9 @@ cr:
4350
# comment_pattern: "" # Regex to match CR bot comment (poll mode only)
4451
# fix_strategy: amend # "amend" or "new-commit"
4552

53+
server:
54+
# port: 8080 # Dashboard HTTP server port
55+
4656
editor:
4757
enabled: false # Open editor automatically on forge edit
4858
command: code # Editor command (forge edit)

internal/config/config.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ type ServerConfig struct {
4848

4949
// HooksConfig holds lifecycle hook commands.
5050
type HooksConfig struct {
51-
PreCommit string `yaml:"pre_commit"` // shell command to run before commit
51+
PreCommit string `yaml:"pre_commit"` // shell command to run before commit
52+
MaxHookRetries int `yaml:"max_hook_retries"` // agent retry attempts on hook failure (default 2)
5253
}
5354

5455
// CRConfig controls the code review feedback loop.
@@ -157,6 +158,10 @@ func Load(path string) (*Config, error) {
157158
}
158159
}
159160

161+
if cfg.Hooks.MaxHookRetries == 0 {
162+
cfg.Hooks.MaxHookRetries = 2
163+
}
164+
160165
if cfg.Editor.Command == "" {
161166
cfg.Editor.Command = "code"
162167
}

internal/intent/classify.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package intent
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -12,6 +13,9 @@ import (
1213
// CommandContext is the function used to create exec.Cmd. Override in tests.
1314
var CommandContext = exec.CommandContext
1415

16+
// MinConfidence is the minimum confidence score required to accept a classification.
17+
const MinConfidence = 0.5
18+
1519
// Classify interprets a natural language query as a forge command.
1620
func Classify(ctx context.Context, query string) (*Result, error) {
1721
if _, err := exec.LookPath("claude"); err != nil {
@@ -21,19 +25,22 @@ func Classify(ctx context.Context, query string) (*Result, error) {
2125
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
2226
defer cancel()
2327

24-
dc := GatherContext()
28+
dc := GatherContext(".")
2529
prompt := BuildPrompt(query, dc)
2630

2731
cmd := CommandContext(ctx, "claude", "-p", prompt, "--output-format", "json", "--max-tokens", "256")
28-
out, err := cmd.CombinedOutput()
32+
var stdout, stderr bytes.Buffer
33+
cmd.Stdout = &stdout
34+
cmd.Stderr = &stderr
35+
err := cmd.Run()
2936
if err != nil {
3037
if ctx.Err() == context.DeadlineExceeded {
3138
return nil, fmt.Errorf("%w: timed out after 30s", ErrClassificationFailed)
3239
}
33-
return nil, fmt.Errorf("%w: %s", ErrClassificationFailed, string(out))
40+
return nil, fmt.Errorf("%w: %s", ErrClassificationFailed, stderr.String())
3441
}
3542

36-
return parseResponse(string(out))
43+
return parseResponse(stdout.String())
3744
}
3845

3946
// parseResponse extracts a Result from the claude CLI JSON output.
@@ -55,6 +62,10 @@ func parseResponse(raw string) (*Result, error) {
5562
return nil, fmt.Errorf("%w: empty argv", ErrClassificationFailed)
5663
}
5764

65+
if r.Confidence < MinConfidence {
66+
return nil, fmt.Errorf("%w: confidence %.2f below threshold %.2f: %s", ErrClassificationFailed, r.Confidence, MinConfidence, r.Reasoning)
67+
}
68+
5869
return &r, nil
5970
}
6071

internal/intent/classify_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"os"
77
"os/exec"
8+
"strings"
89
"testing"
910
)
1011

@@ -60,9 +61,7 @@ func TestClassify_Success(t *testing.T) {
6061

6162
func TestClassify_NoClaude(t *testing.T) {
6263
// Override PATH so claude is not found.
63-
orig := os.Getenv("PATH")
64-
os.Setenv("PATH", "")
65-
defer os.Setenv("PATH", orig)
64+
t.Setenv("PATH", "")
6665

6766
_, err := Classify(context.Background(), "anything")
6867
if !errors.Is(err, ErrNoClaude) {
@@ -131,6 +130,22 @@ func TestParseResponse_DirectJSON(t *testing.T) {
131130
}
132131
}
133132

133+
func TestClassify_LowConfidence(t *testing.T) {
134+
envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.2,\"reasoning\":\"not sure\"}"}`
135+
136+
orig := CommandContext
137+
CommandContext = fakeCommandContext(envelope, false)
138+
defer func() { CommandContext = orig }()
139+
140+
_, err := Classify(context.Background(), "maybe run something")
141+
if !errors.Is(err, ErrClassificationFailed) {
142+
t.Fatalf("expected ErrClassificationFailed, got: %v", err)
143+
}
144+
if err == nil || !strings.Contains(err.Error(), "confidence") {
145+
t.Fatalf("expected confidence-related error, got: %v", err)
146+
}
147+
}
148+
134149
func TestStripCodeFences(t *testing.T) {
135150
tests := []struct {
136151
name string

internal/intent/context.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@ type DynamicContext struct {
1515
}
1616

1717
// GatherContext collects plan files and recent run IDs for prompt injection.
18-
func GatherContext() DynamicContext {
18+
func GatherContext(rootDir string) DynamicContext {
1919
var dc DynamicContext
2020

21-
plans, _ := filepath.Glob("plans/*.md")
21+
plans, _ := filepath.Glob(filepath.Join(rootDir, "plans", "*.md"))
2222
for _, p := range plans {
2323
dc.PlanFiles = append(dc.PlanFiles, filepath.Base(p))
2424
}
2525

2626
runs, err := state.List()
2727
if err == nil {
28-
cap := min(10, len(runs))
29-
for _, r := range runs[:cap] {
28+
n := min(10, len(runs))
29+
for _, r := range runs[:n] {
3030
dc.RunIDs = append(dc.RunIDs, r.ID)
3131
}
3232
}

internal/intent/context_test.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,10 @@ func TestGatherContext_WithPlanFiles(t *testing.T) {
2323
t.Fatal(err)
2424
}
2525

26-
// Change to temp dir so glob finds plans/.
27-
orig, _ := os.Getwd()
28-
os.Chdir(dir)
29-
defer os.Chdir(orig)
30-
3126
// Point state to empty dir so List() returns nothing.
3227
state.SetRunsDir(filepath.Join(dir, "no-runs"))
3328

34-
dc := GatherContext()
29+
dc := GatherContext(dir)
3530
if len(dc.PlanFiles) != 2 {
3631
t.Fatalf("expected 2 plan files, got %d", len(dc.PlanFiles))
3732
}
@@ -42,13 +37,10 @@ func TestGatherContext_WithPlanFiles(t *testing.T) {
4237

4338
func TestGatherContext_NoPlanFiles(t *testing.T) {
4439
dir := t.TempDir()
45-
orig, _ := os.Getwd()
46-
os.Chdir(dir)
47-
defer os.Chdir(orig)
4840

4941
state.SetRunsDir(filepath.Join(dir, "no-runs"))
5042

51-
dc := GatherContext()
43+
dc := GatherContext(dir)
5244
if len(dc.PlanFiles) != 0 {
5345
t.Fatalf("expected 0 plan files, got %d", len(dc.PlanFiles))
5446
}

0 commit comments

Comments
 (0)