Skip to content
Closed
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
21 changes: 20 additions & 1 deletion cmd/sin-code/chat_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,12 @@
sin-code chat --fusion-max-cost <usd> USD kill-switch per tournament invocation (default 5.0)
sin-code chat --thinking-enabled send thinking{type:"enabled"} on each request (per-provider reasoning budget)
sin-code chat --thinking-budget <n> per-request thinking.budget_tokens cap (0 = unbounded / provider default)
Oracle-mode fusion is experimental; set fusion.oracle_mode=true via config. Prefer PoC mode for verifiable tasks.`,
Oracle-mode fusion is experimental; set fusion.oracle_mode=true via config. Prefer PoC mode for verifiable tasks.

Post-edit automation (issue #376, opt-in via ~/.config/sin/sin-code.toml):
agentloop.auto_lint=true after every sin_write/sin_edit to a .go file: run gofmt -l + go vet (read-only — advisory)
agentloop.auto_test=true after every sin_write/sin_edit to a *_test.go file: run go test -race -count=1 on the file's package (may mutate state — advisory)
Both default off. Set agentloop.auto_lint=true to auto-lint after edits. Both keys are advisory: warnings only, never block.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runChat(cmd.Context(), opts)
},
Expand Down Expand Up @@ -331,6 +336,20 @@
}

hookEngine := chatNewHooksFn(loadHooks(workspace))
// --- post-edit auto listeners (issue #376) ------------------

Check warning

Code scanning / gosec

Errors unhandled Warning

Errors unhandled
// Register the lint + test listeners ONLY when the operator has opted
// in via config. Default behaviour (no listener registered) preserves
// the legacy single-shot semantics and stays off in headless / CI runs.

Check warning

Code scanning / gosec

Errors unhandled Warning

Errors unhandled
if sinCfg.AgentLoopAutoLint {
hookEngine.RegisterPostListener(hooks.AutoLintListener(hooks.AutoHookConfig{
Timeout: time.Duration(sinCfg.AgentLoopAutoLintTimeout) * time.Second,
}))
}
if sinCfg.AgentLoopAutoTest {
hookEngine.RegisterPostListener(hooks.AutoTestListener(hooks.AutoHookConfig{
Timeout: time.Duration(sinCfg.AgentLoopAutoTestTimeout) * time.Second,
}))
}

// --- auto-activation hook (issue #176) ------------------------------
// Off by default. Privacy-first: only opens when the operator sets
Expand Down
30 changes: 30 additions & 0 deletions cmd/sin-code/internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@
AgentYolo bool `toml:"agent.yolo"`
AgentLoopRequiredTools []string `toml:"agentloop.required_tools"`
AgentLoopForbiddenTools []string `toml:"agentloop.forbidden_tools"`
// AgentLoopAutoLint enables post-edit auto-lint listener (issue #376).
// Read-only: gofmt -l + go vet on every .go file edited by sin_write/sin_edit.
// Default false; opt-in only.
AgentLoopAutoLint bool `toml:"agentloop.auto_lint"`
// AgentLoopAutoTest enables post-edit auto-test listener (issue #376).
// go test -count=1 on every *_test.go file; may produce side-effects.
// Default false; opt-in only.
AgentLoopAutoTest bool `toml:"agentloop.auto_test"`
// Per-command timeout cap (seconds). 0 -> 30 lint / 120 test.
AgentLoopAutoLintTimeout int `toml:"agentloop.auto_lint_timeout"`
AgentLoopAutoTestTimeout int `toml:"agentloop.auto_test_timeout"`
ToolsAllow []string `toml:"permissions.tools_allow"`
ToolsDeny []string `toml:"permissions.tools_deny"`
PathsMCPConfig string `toml:"paths.mcp_config"`
Expand Down Expand Up @@ -149,6 +160,10 @@
AgentYolo: false,
AgentLoopRequiredTools: []string{},
AgentLoopForbiddenTools: []string{},
AgentLoopAutoLint: false,
AgentLoopAutoTest: false,
AgentLoopAutoLintTimeout: 30,
AgentLoopAutoTestTimeout: 120,
ToolsAllow: []string{},
ToolsDeny: []string{},
PathsMCPConfig: filepath.Join("~", ".sin-code", "mcp.json"),
Expand Down Expand Up @@ -485,7 +500,7 @@
# Agent output verbosity (issue #167):
# "default"|"verbose" = no ruleset injected (legacy behavior)
# "normal" = drop pleasantries + tool narration
# "terse" = caveman-`+"`full`"+` analog

Check failure

Code scanning / gosec

Expect directory permissions to be 0750 or less Error

Expect directory permissions to be 0750 or less
# "ultra" = caveman-`+"`ultra`"+` analog (tightest valid compression)
llm.style = %q

Expand All @@ -499,6 +514,11 @@
agentloop.required_tools = %q
agentloop.forbidden_tools = %q

agentloop.auto_lint = %v
agentloop.auto_test = %v
agentloop.auto_lint_timeout = %d
agentloop.auto_test_timeout = %d

permissions.tools_allow = %q
permissions.tools_deny = %q

Expand Down Expand Up @@ -528,6 +548,8 @@
cfg.LLMStyle,
cfg.AgentVerifyMode, cfg.AgentMaxTurns, cfg.AgentHeadless, cfg.AgentYolo,
strings.Join(cfg.AgentLoopRequiredTools, ","), strings.Join(cfg.AgentLoopForbiddenTools, ","),
cfg.AgentLoopAutoLint, cfg.AgentLoopAutoTest,
cfg.AgentLoopAutoLintTimeout, cfg.AgentLoopAutoTestTimeout,
strings.Join(cfg.ToolsAllow, ","), strings.Join(cfg.ToolsDeny, ","),
cfg.PathsMCPConfig, cfg.PathsSkillsDir,
cfg.TestCoverageThreshold, cfg.TestMutationThreshold, cfg.TestAutoGenerate, cfg.TestTimeoutSeconds, cfg.TestUseLLM, cfg.TestRepairRounds,
Expand Down Expand Up @@ -599,6 +621,14 @@
return strings.Join(cfg.AgentLoopRequiredTools, ","), nil
case "agentloop.forbidden_tools":
return strings.Join(cfg.AgentLoopForbiddenTools, ","), nil
case "agentloop.auto_lint":
return fmt.Sprintf("%v", cfg.AgentLoopAutoLint), nil
case "agentloop.auto_test":
return fmt.Sprintf("%v", cfg.AgentLoopAutoTest), nil
case "agentloop.auto_lint_timeout":
return fmt.Sprintf("%d", cfg.AgentLoopAutoLintTimeout), nil
case "agentloop.auto_test_timeout":
return fmt.Sprintf("%d", cfg.AgentLoopAutoTestTimeout), nil
case "permissions.tools_allow":
return strings.Join(cfg.ToolsAllow, ","), nil
case "permissions.tools_deny":
Expand Down
211 changes: 211 additions & 0 deletions cmd/sin-code/internal/hooks/auto_hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: MIT
// Purpose: opt-in post-edit automation tied to the agent loop's
// tool.post event (issue #376). AutoLintListener runs gofmt + go vet
// on every .go file edited by sin_write/sin_edit when agentloop.auto_lint
// is true (read-only). AutoTestListener runs `go test -race -count=1` on
// the enclosing package whenever a *_test.go file is touched when
// agentloop.auto_test is true (may produce side-effects). Both listeners
// are gated on dedicated config keys — default behaviour preserved.
package hooks

import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

func AutoLintListener(cfg AutoHookConfig) PostListener {
cfg = cfg.normalized()
return func(ctx context.Context, p Payload) []string {
if p.Name != "sin_write" && p.Name != "sin_edit" {
return nil
}
path, _ := p.Data["path"].(string)
if path == "" || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
workdir := p.Workspace
if workdir == "" {
workdir = "."
}
absPath := path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(workdir, path)
}
if _, err := os.Stat(absPath); err != nil {
return nil
}
reports := runLintCommands(ctx, absPath, workdir, cfg.Timeout)
if len(reports) == 0 {
return nil
}
for _, r := range reports {
fmt.Fprintf(os.Stderr, "[auto-lint] %s: %s\n", path, r)
}
out := make([]string, 0, len(reports))
for _, r := range reports {
out = append(out, fmt.Sprintf("[auto-lint %s] %s", path, r))
}
return out
}
}

func AutoTestListener(cfg AutoHookConfig) PostListener {
cfg = cfg.normalized()
return func(ctx context.Context, p Payload) []string {
if p.Name != "sin_write" && p.Name != "sin_edit" {
return nil
}
path, _ := p.Data["path"].(string)
if path == "" || !strings.HasSuffix(path, "_test.go") {
return nil
}
workdir := p.Workspace
if workdir == "" {
workdir = "."
}
absPath := path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(workdir, path)
}
if _, err := os.Stat(absPath); err != nil {
return nil
}
report := runTestCommand(ctx, absPath, workdir, cfg.Timeout)
if report == "" {
fmt.Fprintf(os.Stderr, "[auto-test] %s: PASS\n", path)
return nil
}
fmt.Fprintf(os.Stderr, "[auto-test] %s: FAIL\n", path)
if len(report) > 4096 {
report = report[:4096] + "\n[... truncated; rerun `sin_test` for the full log]"
}
return []string{fmt.Sprintf("[auto-test %s] FAIL: %s", path, report)}
}
}

type AutoHookConfig struct {
Timeout time.Duration
}

func (c AutoHookConfig) normalized() AutoHookConfig {
if c.Timeout <= 0 {
c.Timeout = AutoLintDefaultTimeout
}
return c
}

const (
AutoLintDefaultTimeout time.Duration = 30 * time.Second
AutoTestDefaultTimeout time.Duration = 120 * time.Second
)

func runLintCommands(ctx context.Context, goFile, workdir string, timeout time.Duration) []string {
var out []string
if !strings.HasSuffix(goFile, ".go") {
return out
}
gofmtCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
gofmtCmd := exec.CommandContext(gofmtCtx, "gofmt", "-l", goFile)

Check failure

Code scanning / gosec

Subprocess launched with variable Error

Subprocess launched with variable
gofmtCmd.Dir = workdir
var gofmtOut, gofmtErr bytes.Buffer
gofmtCmd.Stdout = &gofmtOut
gofmtCmd.Stderr = &gofmtErr
if runErr := gofmtCmd.Run(); runErr != nil {
if !isNotFound(runErr) {
out = append(out, fmt.Sprintf("gofmt exec failed: %v", runErr))
}
} else if gofmtOut.Len() > 0 {
out = append(out, fmt.Sprintf("gofmt: %s needs `gofmt -w`", filepath.Base(goFile)))
}

vetCtx, cancel2 := context.WithTimeout(ctx, timeout)
defer cancel2()
dirOfFile := filepath.Dir(goFile)
if dirOfFile == "" {
dirOfFile = "."
}
pkgDir := filepath.Base(dirOfFile)
cmdWorkdir := filepath.Dir(dirOfFile)
if cmdWorkdir == "" {
cmdWorkdir = "."
}
vetCmd := exec.CommandContext(vetCtx, "go", "vet", "./"+pkgDir)

Check failure

Code scanning / gosec

Subprocess launched with variable Error

Subprocess launched with a potential tainted input or cmd arguments
vetCmd.Dir = cmdWorkdir
var vetOut, vetErr bytes.Buffer
vetCmd.Stdout = &vetOut
vetCmd.Stderr = &vetErr
if runErr := vetCmd.Run(); runErr != nil {
combined := strings.TrimSpace(vetOut.String() + vetErr.String())
if combined != "" {
first := firstLine(combined)
out = append(out, fmt.Sprintf("go vet: %s", first))
}
}
return out
}

// runTestCommand runs go test against the file's package after editing a
// *_test.go file. The conventional "filter to TestX" doesn't apply
// cleanly here because file names do not dictate function names (e.g.
// `foo_test.go` usually contains `TestFoo`, but a per-config test may
// use any `Test*` symbol, and some authors write `testFoo` lower-case).
// Default behaviour: run the whole package without -run filter so the
// listener surfaces real test outcomes to the agent regardless of the
// `func Test*` shape inside.
func runTestCommand(ctx context.Context, testFile, workdir string, timeout time.Duration) string {
if !strings.HasSuffix(testFile, "_test.go") {
return ""
}
dirOfFile := filepath.Dir(testFile)
if dirOfFile == "" {
dirOfFile = "."
}
cmdWorkdir := filepath.Dir(dirOfFile)
if cmdWorkdir == "" {
cmdWorkdir = "."
}
args := []string{
"test",
"./" + filepath.Base(dirOfFile),
"-count=1",
}
cmd := exec.CommandContext(ctx, "go", args...)

Check failure

Code scanning / gosec

Subprocess launched with variable Error

Subprocess launched with variable
cmd.Dir = cmdWorkdir
var out, errOut bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errOut
runErr := cmd.Run()
if runErr == nil {
return ""
}
combined := strings.TrimSpace(out.String() + errOut.String())
if combined == "" {
return runErr.Error()
}
return combined
}

func firstLine(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line != "" {
return line
}
}
return s
}

func isNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "executable file not found") ||
strings.Contains(err.Error(), "no such file")
}
Loading
Loading