Skip to content

Commit 7976f3e

Browse files
authored
feat: unify permission system, add sandbox, classifier, reasoning passback, reflection provider (#358)
* fix: cite portable commits in work handoffs * feat: add local software factory run and status * feat: include every workspace repository in factory * feat: build and verify Reflect against local Relay * feat: support Reflection AI provider count and golden help * feat: unify permission system, add sandbox, classifier, reasoning passback, reflection provider * style: gofmt formatting fixes * fix: golden help file to match published relay (28 providers) * fix: lint issues (gosec, errcheck, staticcheck, unused vars)
1 parent bab23d4 commit 7976f3e

34 files changed

Lines changed: 1568 additions & 142 deletions

‎cmd/autonomy_picker.go‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ type autonomyPickerEntry struct {
2525
// accident — the picker is a deliberate selection, so Supervised is fine here.
2626
var allAutonomyTiers = []safety.AutonomyLevel{
2727
safety.AutonomySupervised,
28-
safety.AutonomyBasic,
2928
safety.AutonomySemi,
30-
safety.AutonomyFull,
3129
safety.AutonomyYOLO,
3230
}
3331

‎cmd/autonomy_picker_test.go‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,13 @@ func TestPadRight_MultibyteDisplayWidth(t *testing.T) {
4040
}
4141
}
4242

43-
func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) {
43+
func TestAutonomyPicker_HasThreeTiers(t *testing.T) {
4444
ap := NewAutonomyPicker(80)
45-
if len(ap.entries) != 5 {
46-
t.Fatalf("expected 5 tiers, got %d", len(ap.entries))
45+
if len(ap.entries) != 3 {
46+
t.Fatalf("expected 3 tiers, got %d", len(ap.entries))
4747
}
4848
wantOrder := []safety.AutonomyLevel{
49-
safety.AutonomySupervised, safety.AutonomyBasic, safety.AutonomySemi,
50-
safety.AutonomyFull, safety.AutonomyYOLO,
49+
safety.AutonomySupervised, safety.AutonomySemi, safety.AutonomyYOLO,
5150
}
5251
for i, level := range wantOrder {
5352
if ap.entries[i].Level != level {
@@ -58,13 +57,13 @@ func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) {
5857

5958
func TestAutonomyPicker_OpenPreselectsCurrentTier(t *testing.T) {
6059
ap := NewAutonomyPicker(80)
61-
ap.Open(safety.AutonomyFull)
60+
ap.Open(safety.AutonomyYOLO)
6261
if !ap.IsOpen() {
6362
t.Fatal("expected picker to be open")
6463
}
6564
sel := ap.Selected()
66-
if sel == nil || sel.Level != safety.AutonomyFull {
67-
t.Fatalf("expected preselected tier AutonomyFull, got %+v", sel)
65+
if sel == nil || sel.Level != safety.AutonomyYOLO {
66+
t.Fatalf("expected preselected tier AutonomyYOLO, got %+v", sel)
6867
}
6968
}
7069

@@ -76,16 +75,16 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) {
7675
if !handled || chosen != nil {
7776
t.Fatalf("KeyDown should navigate, not select: chosen=%v handled=%v", chosen, handled)
7877
}
79-
if ap.Selected().Level != safety.AutonomyBasic {
80-
t.Fatalf("expected selection to move to AutonomyBasic, got %v", ap.Selected().Level)
78+
if ap.Selected().Level != safety.AutonomySemi {
79+
t.Fatalf("expected selection to move to AutonomySemi, got %v", ap.Selected().Level)
8180
}
8281

8382
chosen, handled = ap.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
8483
if !handled {
8584
t.Fatal("expected Enter to be handled")
8685
}
87-
if chosen == nil || chosen.Level != safety.AutonomyBasic {
88-
t.Fatalf("expected chosen AutonomyBasic, got %v", chosen)
86+
if chosen == nil || chosen.Level != safety.AutonomySemi {
87+
t.Fatalf("expected chosen AutonomySemi, got %v", chosen)
8988
}
9089
if ap.IsOpen() {
9190
t.Error("expected picker to close after Enter")
@@ -94,7 +93,7 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) {
9493

9594
func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) {
9695
ap := NewAutonomyPicker(80)
97-
ap.Open(safety.AutonomyBasic)
96+
ap.Open(safety.AutonomySemi)
9897

9998
chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
10099
if !handled || chosen != nil {
@@ -107,12 +106,12 @@ func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) {
107106

108107
func TestAutonomyPicker_FilterByName(t *testing.T) {
109108
ap := NewAutonomyPicker(80)
110-
ap.Open(safety.AutonomyBasic)
109+
ap.Open(safety.AutonomySemi)
111110

112-
for _, r := range "scout" {
111+
for _, r := range "builder" {
113112
ap.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
114113
}
115-
if len(ap.filtered) != 1 || ap.filtered[0].Level != safety.AutonomyBasic {
116-
t.Fatalf("expected filter 'scout' to match only Basic tier, got %+v", ap.filtered)
114+
if len(ap.filtered) != 1 || ap.filtered[0].Level != safety.AutonomySemi {
115+
t.Fatalf("expected filter 'builder' to match only Builder tier, got %+v", ap.filtered)
117116
}
118117
}

‎cmd/autonomy_tiers.go‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,12 @@ import (
1414
// deliberate double-press to land on (see chat_update.go ctrl+l handling) so
1515
// repeated key-presses can't accidentally drop the user into max-friction mode.
1616
var autonomyTiers = []safety.AutonomyLevel{
17-
safety.AutonomyBasic,
1817
safety.AutonomySemi,
19-
safety.AutonomyFull,
2018
safety.AutonomyYOLO,
2119
safety.AutonomySupervised,
2220
}
2321

24-
var autonomyTierNames = []string{
25-
"Scout",
26-
"Builder",
27-
"Operator",
28-
"Autonomous",
29-
"Always Ask",
30-
}
22+
// autonomyTierNames removed — names resolved by autonomyTierName switch.
3123

3224
// DefaultAutonomy is the default host-execution permission tier.
3325
const DefaultAutonomy = safety.AutonomySemi
@@ -37,13 +29,17 @@ const DefaultAutonomy = safety.AutonomySemi
3729
const yoloConfirmToken = "continue"
3830

3931
func autonomyTierName(level safety.AutonomyLevel) string {
40-
if level == safety.AutonomySupervised {
32+
switch level {
33+
case safety.AutonomySupervised:
4134
return "Always Ask"
42-
}
43-
for i, l := range autonomyTiers {
44-
if l == level {
45-
return autonomyTierNames[i]
46-
}
35+
case safety.AutonomyBasic:
36+
return "Scout"
37+
case safety.AutonomySemi:
38+
return "Builder"
39+
case safety.AutonomyFull:
40+
return "Operator"
41+
case safety.AutonomyYOLO:
42+
return "Autonomous"
4743
}
4844
return "Builder"
4945
}

‎cmd/autonomy_tiers_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ func TestAutonomyTierNames(t *testing.T) {
2323
}
2424

2525
func TestNextAutonomyTier(t *testing.T) {
26-
if nextAutonomyTier(safety.AutonomyYOLO) != safety.AutonomyBasic {
27-
t.Fatal("expected Autonomous -> Scout wrap")
26+
if nextAutonomyTier(safety.AutonomyYOLO) != safety.AutonomySemi {
27+
t.Fatal("expected Autonomous -> Builder wrap")
2828
}
29-
if nextAutonomyTier(safety.AutonomySemi) != safety.AutonomyFull {
30-
t.Fatal("expected Builder -> Operator")
29+
if nextAutonomyTier(safety.AutonomySemi) != safety.AutonomyYOLO {
30+
t.Fatal("expected Builder -> Autonomous")
3131
}
3232
}
3333

‎cmd/chat_journey_e2e_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,13 @@ func TestChatJourney_ConfigPermissionsAndCoreCommands(t *testing.T) {
5959
t.Fatalf("session provider = %q, want openrouter", got)
6060
}
6161

62-
result, _ = m.handleCommand("/autonomy allow Bash(git:*)")
62+
result, _ = m.handleCommand("/permission allow Bash(git:*)")
6363
m = requireChatModel(t, result)
6464
if got := lastSystemMessage(m.messages); !strings.Contains(got, "Allow rules updated.") {
6565
t.Fatalf("unexpected allow update message: %q", got)
6666
}
6767

68-
result, _ = m.handleCommand("/autonomy rules")
68+
result, _ = m.handleCommand("/permission rules")
6969
m = requireChatModel(t, result)
7070
if got := lastSystemMessage(m.messages); !strings.Contains(got, "Bash") || !strings.Contains(got, "git") {
7171
t.Fatalf("permission rules summary missing allow rule: %q", got)

‎cmd/chat_model_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ func TestChatModel_ManyCommands(t *testing.T) {
251251
"/compact", "/diff", "/branch", "/vim",
252252
"/power", "/fast", "/effort",
253253
"/memory", "/plugins", "/mcp",
254-
"/autonomy",
254+
"/permission",
255255
"/usage", "/metrics", "/integrity",
256256
"/keybindings", "/cron", "/tasks",
257257
"/files", "/branches", "/provider-status",

‎cmd/chat_subcommand_simple.go‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -810,15 +810,7 @@ func init() {
810810
})
811811

812812
// /autonomy — show/set trust tier, permissions, and rules
813-
subcommandRegistry.Register(&delegatingCommand{
814-
name: "autonomy",
815-
description: "show/set trust tier, permissions, and rules (delegates to handleAutonomyCommand)",
816-
usage: "/autonomy [subcommand]",
817-
handler: func(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
818-
next, cmd := m.handleAutonomyCommand(append([]string{"/autonomy"}, args...))
819-
return next, cmd
820-
},
821-
})
813+
// /autonomy removed — unified into /permission (development cleanup).
822814

823815
// /permission — focused control plane for access decisions and folder trust.
824816
subcommandRegistry.Register(&delegatingCommand{

‎cmd/chat_subcommand_spec.go‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,5 @@ func handleSpecConfig(m *chatModel, arg string) (tea.Model, tea.Cmd) {
157157
}
158158
}
159159

160-
func init() {
161-
subcommandRegistry.Register(&specSubcommand{})
162-
}
160+
// /spec removed — unified into /permission spec (development cleanup).
161+
// specSubcommand type retained for direct Handle calls in tests.

‎cmd/chat_welcome.go‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"image/color"
77
"os"
8+
"os/exec"
89
"sort"
910
"strings"
1011

@@ -101,6 +102,9 @@ func buildWelcomeMessageWithSnapshotAndMascot(sess *engine.Session, sessionID st
101102
var b strings.Builder
102103
b.WriteString("\n")
103104
heading := ansiBold + "Reflect" + ansiReset + ansiDim + " " + version + ansiReset
105+
if gitBranch := currentGitBranch(); gitBranch != "" {
106+
heading += ansiDim + " · " + gitBranch + ansiReset
107+
}
104108
selection := "Choose a model with /config"
105109
if sess != nil && strings.TrimSpace(sess.Model()) != "" {
106110
selection = shortModelID(sess.Model())
@@ -125,7 +129,7 @@ func buildWelcomeMessageWithSnapshotAndMascot(sess *engine.Session, sessionID st
125129
} else {
126130
b.WriteString(line(`Try "explain this repo" or describe a task.`))
127131
}
128-
b.WriteString(line(ansiDim + "/help for commands" + ansiReset))
132+
b.WriteString(line(ansiDim + "/help · Shift+Tab spec · Ctrl+L tier · Ctrl+Shift+C copy" + ansiReset))
129133
if resume := actLine(saved, sessionID); resume != "" {
130134
b.WriteString("\n")
131135
b.WriteString(line(ansiDim + resume + ansiReset))
@@ -319,6 +323,15 @@ func apiKeyStatusLines() []string {
319323
return lines
320324
}
321325

326+
// currentGitBranch returns the current git branch name, or "" if not in a repo.
327+
func currentGitBranch() string {
328+
out, err := exec.CommandContext(context.Background(), "git", "rev-parse", "--abbrev-ref", "HEAD").Output()
329+
if err != nil {
330+
return ""
331+
}
332+
return strings.TrimSpace(string(out))
333+
}
334+
322335
func displayConfigValue(value string) string {
323336
if strings.TrimSpace(value) == "" {
324337
return "(empty)"

‎cmd/errors_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010

1111
reflectconfig "github.com/GrayCodeAI/reflect/internal/config"
12+
reflectgateway "github.com/GrayCodeAI/reflect/internal/provider/gateway"
1213
)
1314

1415
func TestFriendlyErrorNil(t *testing.T) {
@@ -408,6 +409,11 @@ func TestValidateStartupMissingKey(t *testing.T) {
408409
}
409410
}()
410411

412+
// Isolate from any OS keychain secret so the missing-key warning fires.
413+
store := &reflectgateway.MapStore{Data: map[string]string{}}
414+
reflectgateway.SetDefaultStore(store)
415+
t.Cleanup(func() { reflectgateway.SetDefaultStore(nil) })
416+
411417
settings := emptySettings()
412418
settings.Provider = "anthropic"
413419
warnings := validateStartup(settings)

0 commit comments

Comments
 (0)