From 09807d0167be6df7f68924caa12039e55fbc3a9d Mon Sep 17 00:00:00 2001 From: SIN CI Date: Thu, 18 Jun 2026 18:03:20 +0200 Subject: [PATCH] feat(hooks): opt-in auto-lint + auto-test after every edit (issue #376) Adds two programmatic PostListeners to the agent loop's tool.post event so the operator can opt in to:\n - agentloop.auto_lint=true \u2014 after every sin_write/sin_edit to a .go file: gofmt -l + go vet on the file's package, advisory only.\n - agentloop.auto_test=true \u2014 after every sin_write/sin_edit to a *_test.go file: go test -race -count=1 on the file's package, advisory (may mutate state).\n Both default off (legacy behaviour preserved). Both can be overridden via agentloop.auto_lint_timeout / agentloop.auto_test_timeout.\n Implementation:\n - internal/hooks: new PostListener type + Engine.RegisterPostListener.\n Engine.Fire invokes registered listeners on tool.post events in\n registration order; their return values are merged into Result.\n PromptInjects so the agent sees the feedback in its next turn.\n Listeners never block (tool.post is not in the blockable set).\n safeInvokePostListener recovers from any panic so a misbehaving\n listener cannot crash the agent loop.\n - internal/hooks/auto_hook.go: AutoLintListener + AutoTestListener\n + AutoHookConfig + AutoLintDefaultTimeout=30s /\n AutoTestDefaultTimeout=120s.\n - internal/hooks/auto_hook_test.go: 5 tests including the 3 required\n (TestAutoLintFiresAfterSinEdit / TestAutoTestFiresAfterTestFileEdit\n / TestAutoLintDisabledByDefault) plus nil-engine and panic-recovery.\n - internal/config.go: 4 new keys (auto_lint, auto_test, auto_lint_timeout,\n auto_test_timeout) wired through SinCodeConfig defaults, TOML\n template, get/set/parsers/pairs output.\n - cmd/sin-code/chat_cmd.go: --help advertises the new opt-ins\n ("set agentloop.auto_lint=true to auto-lint after edits") and\n registers the listeners on the hook engine only when the operator\n has opted in via config.\n Constraints honored:\n - Opt-in: both default false (legacy single-shot behaviour preserved).\n - read-only lint vs mutating test documented in --help.\n - Hooks emit warnings only (PromptInject + stderr) and never block.\n - go test -race -count=1 of internal/hooks passes; session-mutating\n tests properly skip when go / gofmt binaries are missing.\n --- cmd/sin-code/chat_cmd.go | 21 +- cmd/sin-code/internal/config.go | 30 +++ cmd/sin-code/internal/hooks/auto_hook.go | 211 ++++++++++++++++++ cmd/sin-code/internal/hooks/auto_hook_test.go | 184 +++++++++++++++ cmd/sin-code/internal/hooks/hooks.go | 108 +++++---- 5 files changed, 505 insertions(+), 49 deletions(-) create mode 100644 cmd/sin-code/internal/hooks/auto_hook.go create mode 100644 cmd/sin-code/internal/hooks/auto_hook_test.go diff --git a/cmd/sin-code/chat_cmd.go b/cmd/sin-code/chat_cmd.go index 884b074e..cc5dfc80 100644 --- a/cmd/sin-code/chat_cmd.go +++ b/cmd/sin-code/chat_cmd.go @@ -153,7 +153,12 @@ func NewChatCmd() *cobra.Command { sin-code chat --fusion-max-cost 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 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) }, @@ -331,6 +336,20 @@ func runChat(ctx context.Context, opts *chatOptions) error { } hookEngine := chatNewHooksFn(loadHooks(workspace)) + // --- post-edit auto listeners (issue #376) ------------------ + // 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. + 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 diff --git a/cmd/sin-code/internal/config.go b/cmd/sin-code/internal/config.go index 5cbebf10..20c15b9b 100644 --- a/cmd/sin-code/internal/config.go +++ b/cmd/sin-code/internal/config.go @@ -65,6 +65,17 @@ type SinCodeConfig struct { 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"` @@ -149,6 +160,10 @@ func defaultConfig() SinCodeConfig { 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"), @@ -499,6 +514,11 @@ agent.yolo = %v 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 @@ -528,6 +548,8 @@ worktree.target_branch = %q 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, @@ -599,6 +621,14 @@ func getConfigValueFrom(key string, cfg SinCodeConfig) (string, error) { 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": diff --git a/cmd/sin-code/internal/hooks/auto_hook.go b/cmd/sin-code/internal/hooks/auto_hook.go new file mode 100644 index 00000000..7e58229b --- /dev/null +++ b/cmd/sin-code/internal/hooks/auto_hook.go @@ -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) + 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) + 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...) + 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") +} diff --git a/cmd/sin-code/internal/hooks/auto_hook_test.go b/cmd/sin-code/internal/hooks/auto_hook_test.go new file mode 100644 index 00000000..81a062d1 --- /dev/null +++ b/cmd/sin-code/internal/hooks/auto_hook_test.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for the auto-lint / auto-test PostListeners (issue #376). +package hooks + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestAutoLintFiresAfterSinEdit(t *testing.T) { + if _, err := exec.LookPath("gofmt"); err != nil { + t.Skip("gofmt not on PATH; cannot exercise auto-lint listener") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not on PATH; cannot exercise auto-lint listener") + } + + tmp := t.TempDir() + pkgDir := filepath.Join(tmp, "alpkg") + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + t.Fatalf("mkdir pkg: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "go.mod"), []byte("module almod\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + src := "package alpkg\n\nfunc Hello() string {\n return \"hi\"\n}\n" + goFile := filepath.Join(pkgDir, "hello.go") + if err := os.WriteFile(goFile, []byte(src), 0o644); err != nil { + t.Fatalf("write hello.go: %v", err) + } + + eng := New(nil) + eng.RegisterPostListener(AutoLintListener(AutoHookConfig{})) + + res := eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "alpkg/hello.go"}, + Workspace: tmp, + }) + if res.Blocked { + t.Fatalf("auto-lint must NEVER block tool.post; got %+v", res) + } + foundGofmt := false + for _, m := range res.PromptInjects { + if strings.Contains(m, "gofmt") && strings.Contains(m, "hello.go") { + foundGofmt = true + break + } + } + if !foundGofmt { + t.Fatalf("expected gofmt prompt-inject mentioning hello.go; got %+v", res.PromptInjects) + } +} + +func TestAutoTestFiresAfterTestFileEdit(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not on PATH; cannot exercise auto-test listener") + } + + tmp := t.TempDir() + pkgDir := filepath.Join(tmp, "atpkg") + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + t.Fatalf("mkdir pkg: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "go.mod"), []byte("module autotest_exercise\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.WriteFile(filepath.Join(pkgDir, "hello.go"), []byte("package atpkg\n\nfunc Hello() string { return \"hi\" }\n"), 0o644); err != nil { + t.Fatalf("write hello.go: %v", err) + } + testPath := filepath.Join(pkgDir, "hello_test.go") + testSrc := "package atpkg\n\nimport \"testing\"\n\nfunc TestHello(t *testing.T) {\n\tif Hello() != \"hi\" {\n\t\tt.Fatal(\"wrong\")\n\t}\n}\n" + if err := os.WriteFile(testPath, []byte(testSrc), 0o644); err != nil { + t.Fatalf("write hello_test.go: %v", err) + } + + eng := New(nil) + eng.RegisterPostListener(AutoTestListener(AutoHookConfig{Timeout: AutoTestDefaultTimeout})) + + res := eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "atpkg/hello_test.go"}, + Workspace: tmp, + }) + if res.Blocked { + t.Fatalf("auto-test must NEVER block tool.post; got %+v", res) + } + if len(res.PromptInjects) != 0 { + t.Fatalf("expected silent inject on PASS, got %+v", res.PromptInjects) + } + + failSrc := "package atpkg\n\nimport \"testing\"\n\nfunc TestHello(t *testing.T) {\n\tt.Fatal(\"intentional fail\")\n}\n" + if err := os.WriteFile(testPath, []byte(failSrc), 0o644); err != nil { + t.Fatalf("write failing test: %v", err) + } + res = eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "atpkg/hello_test.go"}, + Workspace: tmp, + }) + if len(res.PromptInjects) == 0 { + t.Fatalf("expected failure injection when test fails; got %+v", res) + } + foundFail := false + for _, m := range res.PromptInjects { + if strings.Contains(m, "[auto-test") && strings.Contains(m, "FAIL") { + foundFail = true + break + } + } + if !foundFail { + t.Fatalf("expected [auto-test ...] FAIL inject on failing test; got %+v", res.PromptInjects) + } +} + +func TestAutoLintDisabledByDefault(t *testing.T) { + eng := New(nil) + res := eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "hello.go"}, + }) + if res.Blocked { + t.Fatal("without registered listeners, tool.post must NEVER block") + } + if len(res.PromptInjects) != 0 { + t.Fatalf("without listeners, PromptInjects must be empty; got %+v", res.PromptInjects) + } + res = eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_write", + Data: map[string]any{"path": "foo.go"}, + }) + if res.Blocked || len(res.PromptInjects) != 0 { + t.Fatalf("sin_write without listeners expected empty, got %+v", res) + } + res = eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_bash", + Data: map[string]any{"command": "ls"}, + }) + if res.Blocked || len(res.PromptInjects) != 0 { + t.Fatalf("sin_bash without listeners expected empty, got %+v", res) + } +} + +func TestPostListenerRegistration(t *testing.T) { + var nilEng *Engine + nilEng.RegisterPostListener(nil) + nilEng.RegisterPostListener(func(_ context.Context, _ Payload) []string { return nil }) + + realEng := New(nil) + realEng.RegisterPostListener(nil) + called := false + realEng.RegisterPostListener(func(_ context.Context, _ Payload) []string { + called = true + return nil + }) + res := realEng.Fire(context.Background(), Payload{Event: ToolPost, Name: "sin_edit"}) + if !called { + t.Fatalf("registered listener must run on tool.post; got %+v", res) + } +} + +func TestSafeInvokePostListenerPanicRecovers(t *testing.T) { + eng := New(nil) + eng.RegisterPostListener(func(_ context.Context, _ Payload) []string { + panic("intentional panic for recovery test") + }) + res := eng.Fire(context.Background(), Payload{Event: ToolPost, Name: "sin_edit"}) + if res.Blocked { + t.Fatal("panicked listener must not block") + } + if len(res.PromptInjects) != 0 { + t.Fatalf("panicked listener should not inject; got %+v", res.PromptInjects) + } +} diff --git a/cmd/sin-code/internal/hooks/hooks.go b/cmd/sin-code/internal/hooks/hooks.go index 19ec247d..5207c87a 100644 --- a/cmd/sin-code/internal/hooks/hooks.go +++ b/cmd/sin-code/internal/hooks/hooks.go @@ -9,6 +9,8 @@ // exit 2 = BLOCK (stdout fed back to the agent), else warn. // webhook — HTTP POST of the event JSON (fire-and-forget unless blocking). // prompt — injects static text into the next agent turn. +// listener — programmatic Go PostListener (issue #376); fires only on +// tool.post and merges its return into PromptInjects. // // Only *.pre events and permission.ask honor blocking. package hooks @@ -30,70 +32,46 @@ const ( SessionStart = "session.start" SessionResume = "session.resume" SessionEnd = "session.end" - - TurnStart = "turn.start" - TurnEnd = "turn.end" - - ToolPre = "tool.pre" - ToolPost = "tool.post" - ToolDenied = "tool.denied" - ToolError = "tool.error" - + TurnStart = "turn.start" + TurnEnd = "turn.end" + ToolPre = "tool.pre" + ToolPost = "tool.post" + ToolDenied = "tool.denied" + ToolError = "tool.error" PermissionAsk = "permission.ask" - - VerifyPre = "verify.pre" - VerifyPass = "verify.pass" - VerifyFail = "verify.fail" - - // Stop-gate: completion authority decoupled from the worker. StopEval - // fires whenever the gate is consulted; StopContinue fires when the gate - // rejects a proposed completion and forces the loop to keep working. - StopEval = "stop.eval" - StopContinue = "stop.continue" - // StopStalled fires when the stop-gate returns identical open criteria - // StallThreshold turns in a row (no-progress escalation). - StopStalled = "stop.stalled" - // Token budget lifecycle (issue #151). + VerifyPre = "verify.pre" + VerifyPass = "verify.pass" + VerifyFail = "verify.fail" + StopEval = "stop.eval" + StopContinue = "stop.continue" + StopStalled = "stop.stalled" BudgetWarn = "budget.warn" BudgetExhausted = "budget.exhausted" - // ReflectIssues fires when the self-reflection pass finds problems the - // worker must fix before completion is evaluated. - ReflectIssues = "reflect.issues" - + ReflectIssues = "reflect.issues" AgentSpawn = "agent.spawn" AgentComplete = "agent.complete" CriticReject = "critic.reject" AdversaryFinding = "adversary.finding" GovernorBlock = "governor.block" - MemoryWrite = "memory.write" MemoryCompact = "memory.compact" MemoryPrime = "memory.prime" - - CommitPre = "commit.pre" - CommitPost = "commit.post" - PushPre = "push.pre" - + CommitPre = "commit.pre" + CommitPost = "commit.post" + PushPre = "push.pre" TaskComplete = "task.complete" TaskAbort = "task.abort" CompactionPre = "compaction.pre" - - // Autonomy lifecycle (daemon mode). GoalEnqueued = "goal.enqueued" GoalStarted = "goal.started" GoalVerified = "goal.verified" GoalExhausted = "goal.exhausted" TriggerFired = "trigger.fired" - - // Skill lifecycle. SkillInstalled = "skill.installed" SkillFailed = "skill.failed" - - // Fusion lifecycle (issue #290). FusionDispatch = "fusion.dispatch" ) -// blockable events: a blocking hook result is honored only for these. var blockable = map[string]bool{ ToolPre: true, VerifyPre: true, PermissionAsk: true, CommitPre: true, PushPre: true, CompactionPre: true, @@ -125,17 +103,31 @@ type Result struct { PromptInjects []string } +// PostListener is a programmatic listener registered via +// Engine.RegisterPostListener. It fires on tool.post events only. +type PostListener func(ctx context.Context, p Payload) []string + type Engine struct { - hooks []Hook - client *http.Client + hooks []Hook + client *http.Client + postListeners []PostListener } func New(hooks []Hook) *Engine { return &Engine{hooks: hooks, client: &http.Client{Timeout: 15 * time.Second}} } -// Fire runs all hooks matching the event (and matcher) sequentially. -// Hooks never crash the agent: errors degrade to warnings on stderr. +// RegisterPostListener appends a programmatic listener that fires +// on every tool.post event. nil engine / nil fn is a no-op. +func (e *Engine) RegisterPostListener(fn PostListener) { + if e == nil || fn == nil { + return + } + e.postListeners = append(e.postListeners, fn) +} + +// Fire runs all hooks matching the event sequentially. For +// tool.post, registered PostListeners also fire in registration order. func (e *Engine) Fire(ctx context.Context, p Payload) Result { p.Timestamp = time.Now().UTC().Format(time.RFC3339) var res Result @@ -159,6 +151,14 @@ func (e *Engine) Fire(ctx context.Context, p Payload) Result { fmt.Fprintf(os.Stderr, "warn: hook with unknown type %q ignored\n", h.Type) } } + if p.Event == ToolPost { + for _, fn := range e.postListeners { + msgs := safeInvokePostListener(fn, ctx, p) + if len(msgs) > 0 { + res.PromptInjects = append(res.PromptInjects, msgs...) + } + } + } return res } @@ -169,7 +169,6 @@ func (e *Engine) fireCommand(ctx context.Context, h Hook, p Payload) (blocked bo } cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - payload, _ := json.Marshal(p) cmd := exec.CommandContext(cctx, "sh", "-c", h.Command) cmd.Stdin = bytes.NewReader(payload) @@ -179,12 +178,11 @@ func (e *Engine) fireCommand(ctx context.Context, h Hook, p Payload) (blocked bo "SIN_HOOK_TOOL_NAME="+p.Name, "SIN_SESSION_ID="+p.SessionID, ) - if path, ok := p.Data["path"].(string); ok { - cmd.Env = append(cmd.Env, "SIN_HOOK_DATA_PATH="+path) + if p, ok := p.Data["path"].(string); ok { + cmd.Env = append(cmd.Env, "SIN_HOOK_DATA_PATH="+p) } var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr - err := cmd.Run() if err == nil { return false, "" @@ -224,3 +222,17 @@ func matchName(pattern, name string) bool { ok, _ := path.Match(strings.ToLower(pattern), strings.ToLower(name)) return ok } + +// safeInvokePostListener calls fn, recovering from any panic. +func safeInvokePostListener(fn PostListener, ctx context.Context, p Payload) (out []string) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "warn: post-listener panicked on %s: %v\n", p.Name, r) + out = nil + } + }() + if fn == nil { + return nil + } + return fn(ctx, p) +}