feat: migrate dispatch, agent, and taskgit commands from CLI

Move business logic from core/cli cmd/ai into proper homes:
- cmd/dispatch: ticket processing, multi-runner exec, forge reporting
- cmd/agent: fleet management, SSH keys, add/remove/setup
- cmd/taskgit: task-linked git (task:commit, task:pr)

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 20:42:14 +00:00
parent 61e01bfdf1
commit 272864cbb8
6 changed files with 1570 additions and 0 deletions

436
cmd/agent/cmd.go Normal file
View file

@ -0,0 +1,436 @@
package agent
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
agentic "forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go-scm/agentci"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/config"
)
func init() {
cli.RegisterCommands(AddAgentCommands)
}
// Style aliases from shared package.
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
taskPriorityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
)
const defaultWorkDir = "ai-work"
// AddAgentCommands registers the 'agent' subcommand group under 'ai'.
func AddAgentCommands(parent *cli.Command) {
agentCmd := &cli.Command{
Use: "agent",
Short: "Manage AgentCI dispatch targets",
}
agentCmd.AddCommand(agentAddCmd())
agentCmd.AddCommand(agentListCmd())
agentCmd.AddCommand(agentStatusCmd())
agentCmd.AddCommand(agentLogsCmd())
agentCmd.AddCommand(agentSetupCmd())
agentCmd.AddCommand(agentRemoveCmd())
agentCmd.AddCommand(agentFleetCmd())
parent.AddCommand(agentCmd)
}
func loadConfig() (*config.Config, error) {
return config.New()
}
func agentAddCmd() *cli.Command {
cmd := &cli.Command{
Use: "add <name> <user@host>",
Short: "Add an agent to the config and verify SSH",
Args: cli.ExactArgs(2),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
host := args[1]
forgejoUser, _ := cmd.Flags().GetString("forgejo-user")
if forgejoUser == "" {
forgejoUser = name
}
queueDir, _ := cmd.Flags().GetString("queue-dir")
if queueDir == "" {
queueDir = "/home/claude/ai-work/queue"
}
model, _ := cmd.Flags().GetString("model")
dualRun, _ := cmd.Flags().GetBool("dual-run")
// Scan and add host key to known_hosts.
parts := strings.Split(host, "@")
hostname := parts[len(parts)-1]
fmt.Printf("Scanning host key for %s... ", hostname)
scanCmd := exec.Command("ssh-keyscan", "-H", hostname)
keys, err := scanCmd.Output()
if err != nil {
fmt.Println(errorStyle.Render("FAILED"))
return fmt.Errorf("failed to scan host keys: %w", err)
}
home, _ := os.UserHomeDir()
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open known_hosts: %w", err)
}
if _, err := f.Write(keys); err != nil {
f.Close()
return fmt.Errorf("failed to write known_hosts: %w", err)
}
f.Close()
fmt.Println(successStyle.Render("OK"))
// Test SSH with strict host key checking.
fmt.Printf("Testing SSH to %s... ", host)
testCmd := agentci.SecureSSHCommand(host, "echo ok")
out, err := testCmd.CombinedOutput()
if err != nil {
fmt.Println(errorStyle.Render("FAILED"))
return fmt.Errorf("SSH failed: %s", strings.TrimSpace(string(out)))
}
fmt.Println(successStyle.Render("OK"))
cfg, err := loadConfig()
if err != nil {
return err
}
ac := agentci.AgentConfig{
Host: host,
QueueDir: queueDir,
ForgejoUser: forgejoUser,
Model: model,
DualRun: dualRun,
Active: true,
}
if err := agentci.SaveAgent(cfg, name, ac); err != nil {
return err
}
fmt.Printf("Agent %s added (%s)\n", successStyle.Render(name), host)
return nil
},
}
cmd.Flags().String("forgejo-user", "", "Forgejo username (defaults to agent name)")
cmd.Flags().String("queue-dir", "", "Remote queue directory (default: /home/claude/ai-work/queue)")
cmd.Flags().String("model", "sonnet", "Primary AI model")
cmd.Flags().Bool("dual-run", false, "Enable Clotho dual-run verification")
return cmd
}
func agentListCmd() *cli.Command {
return &cli.Command{
Use: "list",
Short: "List configured agents",
RunE: func(cmd *cli.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
if len(agents) == 0 {
fmt.Println(dimStyle.Render("No agents configured. Use 'core ai agent add' to add one."))
return nil
}
table := cli.NewTable("NAME", "HOST", "MODEL", "DUAL", "ACTIVE", "QUEUE")
for name, ac := range agents {
active := dimStyle.Render("no")
if ac.Active {
active = successStyle.Render("yes")
}
dual := dimStyle.Render("no")
if ac.DualRun {
dual = successStyle.Render("yes")
}
// Quick SSH check for queue depth.
queue := dimStyle.Render("-")
checkCmd := agentci.SecureSSHCommand(ac.Host, fmt.Sprintf("ls %s/ticket-*.json 2>/dev/null | wc -l", ac.QueueDir))
out, err := checkCmd.Output()
if err == nil {
n := strings.TrimSpace(string(out))
if n != "0" {
queue = n
} else {
queue = "0"
}
}
table.AddRow(name, ac.Host, ac.Model, dual, active, queue)
}
table.Render()
return nil
},
}
}
func agentStatusCmd() *cli.Command {
return &cli.Command{
Use: "status <name>",
Short: "Check agent status via SSH",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found", name)
}
script := `
echo "=== Queue ==="
ls ~/ai-work/queue/ticket-*.json 2>/dev/null | wc -l
echo "=== Active ==="
ls ~/ai-work/active/ticket-*.json 2>/dev/null || echo "none"
echo "=== Done ==="
ls ~/ai-work/done/ticket-*.json 2>/dev/null | wc -l
echo "=== Lock ==="
if [ -f ~/ai-work/.runner.lock ]; then
PID=$(cat ~/ai-work/.runner.lock)
if kill -0 "$PID" 2>/dev/null; then
echo "RUNNING (PID $PID)"
else
echo "STALE (PID $PID)"
fi
else
echo "IDLE"
fi
`
sshCmd := agentci.SecureSSHCommand(ac.Host, script)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
return sshCmd.Run()
},
}
}
func agentLogsCmd() *cli.Command {
cmd := &cli.Command{
Use: "logs <name>",
Short: "Stream agent runner logs",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
follow, _ := cmd.Flags().GetBool("follow")
lines, _ := cmd.Flags().GetInt("lines")
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found", name)
}
remoteCmd := fmt.Sprintf("tail -n %d ~/ai-work/logs/runner.log", lines)
if follow {
remoteCmd = fmt.Sprintf("tail -f -n %d ~/ai-work/logs/runner.log", lines)
}
sshCmd := agentci.SecureSSHCommand(ac.Host, remoteCmd)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
sshCmd.Stdin = os.Stdin
return sshCmd.Run()
},
}
cmd.Flags().BoolP("follow", "f", false, "Follow log output")
cmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
return cmd
}
func agentSetupCmd() *cli.Command {
return &cli.Command{
Use: "setup <name>",
Short: "Bootstrap agent machine (create dirs, copy runner, install cron)",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found — use 'core ai agent add' first", name)
}
// Find the setup script relative to the binary or in known locations.
scriptPath := findSetupScript()
if scriptPath == "" {
return fmt.Errorf("agent-setup.sh not found — expected in scripts/ directory")
}
fmt.Printf("Setting up %s on %s...\n", name, ac.Host)
setupCmd := exec.Command("bash", scriptPath, ac.Host)
setupCmd.Stdout = os.Stdout
setupCmd.Stderr = os.Stderr
if err := setupCmd.Run(); err != nil {
return fmt.Errorf("setup failed: %w", err)
}
fmt.Println(successStyle.Render("Setup complete!"))
return nil
},
}
}
func agentRemoveCmd() *cli.Command {
return &cli.Command{
Use: "remove <name>",
Short: "Remove an agent from config",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
cfg, err := loadConfig()
if err != nil {
return err
}
if err := agentci.RemoveAgent(cfg, name); err != nil {
return err
}
fmt.Printf("Agent %s removed.\n", name)
return nil
},
}
}
func agentFleetCmd() *cli.Command {
cmd := &cli.Command{
Use: "fleet",
Short: "Show fleet status from the go-agentic registry",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
if workDir == "" {
home, _ := os.UserHomeDir()
workDir = filepath.Join(home, defaultWorkDir)
}
dbPath := filepath.Join(workDir, "registry.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
fmt.Println(dimStyle.Render("No registry found. Start a dispatch watcher first: core ai dispatch watch"))
return nil
}
registry, err := agentic.NewSQLiteRegistry(dbPath)
if err != nil {
return fmt.Errorf("failed to open registry: %w", err)
}
defer registry.Close()
// Reap stale agents (no heartbeat for 10 minutes).
reaped := registry.Reap(10 * time.Minute)
if len(reaped) > 0 {
for _, id := range reaped {
fmt.Printf(" Reaped stale agent: %s\n", dimStyle.Render(id))
}
fmt.Println()
}
agents := registry.List()
if len(agents) == 0 {
fmt.Println(dimStyle.Render("No agents registered."))
return nil
}
table := cli.NewTable("ID", "STATUS", "LOAD", "LAST HEARTBEAT", "CAPABILITIES")
for _, a := range agents {
status := dimStyle.Render(string(a.Status))
switch a.Status {
case agentic.AgentAvailable:
status = successStyle.Render("available")
case agentic.AgentBusy:
status = taskPriorityMediumStyle.Render("busy")
case agentic.AgentOffline:
status = errorStyle.Render("offline")
}
load := fmt.Sprintf("%d/%d", a.CurrentLoad, a.MaxLoad)
hb := a.LastHeartbeat.Format("15:04:05")
ago := time.Since(a.LastHeartbeat).Truncate(time.Second)
hbStr := fmt.Sprintf("%s (%s ago)", hb, ago)
caps := "-"
if len(a.Capabilities) > 0 {
caps = strings.Join(a.Capabilities, ", ")
}
table.AddRow(a.ID, status, load, hbStr, caps)
}
table.Render()
return nil
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
return cmd
}
// findSetupScript looks for agent-setup.sh in common locations.
func findSetupScript() string {
exe, _ := os.Executable()
if exe != "" {
dir := filepath.Dir(exe)
candidates := []string{
filepath.Join(dir, "scripts", "agent-setup.sh"),
filepath.Join(dir, "..", "scripts", "agent-setup.sh"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
}
cwd, _ := os.Getwd()
if cwd != "" {
p := filepath.Join(cwd, "scripts", "agent-setup.sh")
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}

679
cmd/dispatch/cmd.go Normal file
View file

@ -0,0 +1,679 @@
package dispatch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/log"
agentic "forge.lthn.ai/core/go-agentic"
)
func init() {
cli.RegisterCommands(AddDispatchCommands)
}
// AddDispatchCommands registers the 'dispatch' subcommand group under 'ai'.
// These commands run ON the agent machine to process the work queue.
func AddDispatchCommands(parent *cli.Command) {
dispatchCmd := &cli.Command{
Use: "dispatch",
Short: "Agent work queue processor (runs on agent machine)",
}
dispatchCmd.AddCommand(dispatchRunCmd())
dispatchCmd.AddCommand(dispatchWatchCmd())
dispatchCmd.AddCommand(dispatchStatusCmd())
parent.AddCommand(dispatchCmd)
}
// dispatchTicket represents the work item JSON structure.
type dispatchTicket struct {
ID string `json:"id"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
IssueNumber int `json:"issue_number"`
IssueTitle string `json:"issue_title"`
IssueBody string `json:"issue_body"`
TargetBranch string `json:"target_branch"`
EpicNumber int `json:"epic_number"`
ForgeURL string `json:"forge_url"`
ForgeToken string `json:"forge_token"`
ForgeUser string `json:"forgejo_user"`
Model string `json:"model"`
Runner string `json:"runner"`
Timeout string `json:"timeout"`
CreatedAt string `json:"created_at"`
}
const (
defaultWorkDir = "ai-work"
lockFileName = ".runner.lock"
)
type runnerPaths struct {
root string
queue string
active string
done string
logs string
jobs string
lock string
}
func getPaths(baseDir string) runnerPaths {
if baseDir == "" {
home, _ := os.UserHomeDir()
baseDir = filepath.Join(home, defaultWorkDir)
}
return runnerPaths{
root: baseDir,
queue: filepath.Join(baseDir, "queue"),
active: filepath.Join(baseDir, "active"),
done: filepath.Join(baseDir, "done"),
logs: filepath.Join(baseDir, "logs"),
jobs: filepath.Join(baseDir, "jobs"),
lock: filepath.Join(baseDir, lockFileName),
}
}
func dispatchRunCmd() *cli.Command {
cmd := &cli.Command{
Use: "run",
Short: "Process a single ticket from the queue",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
paths := getPaths(workDir)
if err := ensureDispatchDirs(paths); err != nil {
return err
}
if err := acquireLock(paths.lock); err != nil {
log.Info("Runner locked, skipping run", "lock", paths.lock)
return nil
}
defer releaseLock(paths.lock)
ticketFile, err := pickOldestTicket(paths.queue)
if err != nil {
return err
}
if ticketFile == "" {
return nil
}
_, err = processTicket(paths, ticketFile)
return err
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
return cmd
}
// fastFailThreshold is how quickly a job must fail to be considered rate-limited.
// Real work always takes longer than 30 seconds; a 3-second exit means the CLI
// was rejected before it could start (rate limit, auth error, etc.).
const fastFailThreshold = 30 * time.Second
// maxBackoffMultiplier caps the exponential backoff at 8x the base interval.
const maxBackoffMultiplier = 8
func dispatchWatchCmd() *cli.Command {
cmd := &cli.Command{
Use: "watch",
Short: "Run as a daemon, polling the queue",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
interval, _ := cmd.Flags().GetDuration("interval")
agentID, _ := cmd.Flags().GetString("agent-id")
paths := getPaths(workDir)
if err := ensureDispatchDirs(paths); err != nil {
return err
}
// Register this agent in the go-agentic registry.
registry, events, cleanup := registerAgent(agentID, paths)
if cleanup != nil {
defer cleanup()
}
log.Info("Starting dispatch watcher", "dir", paths.root, "interval", interval, "agent", agentID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Heartbeat loop — keeps agent status fresh.
if registry != nil {
go heartbeatLoop(ctx, registry, agentID, interval/2)
}
// Backoff state: consecutive fast failures increase the poll delay.
backoffMultiplier := 1
currentInterval := interval
ticker := time.NewTicker(currentInterval)
defer ticker.Stop()
adjustTicker := func(fastFail bool) {
if fastFail {
if backoffMultiplier < maxBackoffMultiplier {
backoffMultiplier *= 2
}
currentInterval = interval * time.Duration(backoffMultiplier)
log.Warn("Fast failure detected, backing off",
"multiplier", backoffMultiplier, "next_poll", currentInterval)
} else {
if backoffMultiplier > 1 {
log.Info("Job succeeded, resetting backoff")
}
backoffMultiplier = 1
currentInterval = interval
}
ticker.Reset(currentInterval)
}
fastFail := runCycleWithEvents(paths, registry, events, agentID)
adjustTicker(fastFail)
for {
select {
case <-ticker.C:
ff := runCycleWithEvents(paths, registry, events, agentID)
adjustTicker(ff)
case <-sigChan:
log.Info("Shutting down watcher...")
if registry != nil {
_ = registry.Deregister(agentID)
}
return nil
case <-ctx.Done():
return nil
}
}
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
cmd.Flags().Duration("interval", 5*time.Minute, "Polling interval")
cmd.Flags().String("agent-id", defaultAgentID(), "Agent identifier for registry")
return cmd
}
// defaultAgentID returns a sensible agent ID from hostname.
func defaultAgentID() string {
host, _ := os.Hostname()
if host == "" {
return "unknown"
}
return host
}
// registerAgent creates a SQLite registry and registers this agent.
// Returns the registry, event emitter, and a cleanup function.
func registerAgent(agentID string, paths runnerPaths) (agentic.AgentRegistry, agentic.EventEmitter, func()) {
dbPath := filepath.Join(paths.root, "registry.db")
registry, err := agentic.NewSQLiteRegistry(dbPath)
if err != nil {
log.Warn("Failed to create agent registry", "error", err, "path", dbPath)
return nil, nil, nil
}
info := agentic.AgentInfo{
ID: agentID,
Name: agentID,
Status: agentic.AgentAvailable,
LastHeartbeat: time.Now().UTC(),
MaxLoad: 1,
}
if err := registry.Register(info); err != nil {
log.Warn("Failed to register agent", "error", err)
} else {
log.Info("Agent registered", "id", agentID)
}
events := agentic.NewChannelEmitter(64)
// Drain events to log.
go func() {
for ev := range events.Events() {
log.Debug("Event", "type", string(ev.Type), "task", ev.TaskID, "agent", ev.AgentID)
}
}()
return registry, events, func() {
events.Close()
}
}
// heartbeatLoop sends periodic heartbeats to keep the agent status fresh.
func heartbeatLoop(ctx context.Context, registry agentic.AgentRegistry, agentID string, interval time.Duration) {
if interval < 30*time.Second {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = registry.Heartbeat(agentID)
}
}
}
// runCycleWithEvents wraps runCycle with registry status updates and event emission.
// Returns true if the cycle resulted in a fast failure (likely rate-limited).
func runCycleWithEvents(paths runnerPaths, registry agentic.AgentRegistry, events agentic.EventEmitter, agentID string) bool {
if registry != nil {
if agent, err := registry.Get(agentID); err == nil {
agent.Status = agentic.AgentBusy
_ = registry.Register(agent)
}
}
fastFail := runCycle(paths)
if registry != nil {
if agent, err := registry.Get(agentID); err == nil {
agent.Status = agentic.AgentAvailable
agent.LastHeartbeat = time.Now().UTC()
_ = registry.Register(agent)
}
}
return fastFail
}
func dispatchStatusCmd() *cli.Command {
cmd := &cli.Command{
Use: "status",
Short: "Show runner status",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
paths := getPaths(workDir)
lockStatus := "IDLE"
if data, err := os.ReadFile(paths.lock); err == nil {
pidStr := strings.TrimSpace(string(data))
pid, _ := strconv.Atoi(pidStr)
if isProcessAlive(pid) {
lockStatus = fmt.Sprintf("RUNNING (PID %d)", pid)
} else {
lockStatus = fmt.Sprintf("STALE (PID %d)", pid)
}
}
countFiles := func(dir string) int {
entries, _ := os.ReadDir(dir)
count := 0
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") {
count++
}
}
return count
}
fmt.Println("=== Agent Dispatch Status ===")
fmt.Printf("Work Dir: %s\n", paths.root)
fmt.Printf("Status: %s\n", lockStatus)
fmt.Printf("Queue: %d\n", countFiles(paths.queue))
fmt.Printf("Active: %d\n", countFiles(paths.active))
fmt.Printf("Done: %d\n", countFiles(paths.done))
return nil
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
return cmd
}
// runCycle picks and processes one ticket. Returns true if the job fast-failed
// (likely rate-limited), signalling the caller to back off.
func runCycle(paths runnerPaths) bool {
if err := acquireLock(paths.lock); err != nil {
log.Debug("Runner locked, skipping cycle")
return false
}
defer releaseLock(paths.lock)
ticketFile, err := pickOldestTicket(paths.queue)
if err != nil {
log.Error("Failed to pick ticket", "error", err)
return false
}
if ticketFile == "" {
return false // empty queue, no backoff needed
}
start := time.Now()
success, err := processTicket(paths, ticketFile)
elapsed := time.Since(start)
if err != nil {
log.Error("Failed to process ticket", "file", ticketFile, "error", err)
}
// Detect fast failure: job failed in under 30s → likely rate-limited.
if !success && elapsed < fastFailThreshold {
log.Warn("Job finished too fast, likely rate-limited",
"elapsed", elapsed.Round(time.Second), "file", filepath.Base(ticketFile))
return true
}
return false
}
// processTicket processes a single ticket. Returns (success, error).
// On fast failure the caller is responsible for detecting the timing and backing off.
// The ticket is moved active→done on completion, or active→queue on fast failure.
func processTicket(paths runnerPaths, ticketPath string) (bool, error) {
fileName := filepath.Base(ticketPath)
log.Info("Processing ticket", "file", fileName)
activePath := filepath.Join(paths.active, fileName)
if err := os.Rename(ticketPath, activePath); err != nil {
return false, fmt.Errorf("failed to move ticket to active: %w", err)
}
data, err := os.ReadFile(activePath)
if err != nil {
return false, fmt.Errorf("failed to read ticket: %w", err)
}
var t dispatchTicket
if err := json.Unmarshal(data, &t); err != nil {
return false, fmt.Errorf("failed to unmarshal ticket: %w", err)
}
jobDir := filepath.Join(paths.jobs, fmt.Sprintf("%s-%s-%d", t.RepoOwner, t.RepoName, t.IssueNumber))
repoDir := filepath.Join(jobDir, t.RepoName)
if err := os.MkdirAll(jobDir, 0755); err != nil {
return false, err
}
if err := prepareRepo(t, repoDir); err != nil {
reportToForge(t, false, fmt.Sprintf("Git setup failed: %v", err))
moveToDone(paths, activePath, fileName)
return false, err
}
prompt := buildPrompt(t)
logFile := filepath.Join(paths.logs, fmt.Sprintf("%s-%s-%d.log", t.RepoOwner, t.RepoName, t.IssueNumber))
start := time.Now()
success, exitCode, runErr := runAgent(t, prompt, repoDir, logFile)
elapsed := time.Since(start)
// Fast failure: agent exited in <30s without success → likely rate-limited.
// Requeue the ticket so it's retried after the backoff period.
if !success && elapsed < fastFailThreshold {
log.Warn("Agent rejected fast, requeuing ticket", "elapsed", elapsed.Round(time.Second), "file", fileName)
requeuePath := filepath.Join(paths.queue, fileName)
if err := os.Rename(activePath, requeuePath); err != nil {
// Fallback: move to done if requeue fails.
moveToDone(paths, activePath, fileName)
}
return false, runErr
}
msg := fmt.Sprintf("Agent completed work on #%d. Exit code: %d.", t.IssueNumber, exitCode)
if !success {
msg = fmt.Sprintf("Agent failed on #%d (exit code: %d). Check logs on agent machine.", t.IssueNumber, exitCode)
if runErr != nil {
msg += fmt.Sprintf(" Error: %v", runErr)
}
}
reportToForge(t, success, msg)
moveToDone(paths, activePath, fileName)
log.Info("Ticket complete", "id", t.ID, "success", success, "elapsed", elapsed.Round(time.Second))
return success, nil
}
func prepareRepo(t dispatchTicket, repoDir string) error {
user := t.ForgeUser
if user == "" {
host, _ := os.Hostname()
user = fmt.Sprintf("%s-%s", host, os.Getenv("USER"))
}
cleanURL := strings.TrimPrefix(t.ForgeURL, "https://")
cleanURL = strings.TrimPrefix(cleanURL, "http://")
cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s.git", user, t.ForgeToken, cleanURL, t.RepoOwner, t.RepoName)
if _, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil {
log.Info("Updating existing repo", "dir", repoDir)
cmds := [][]string{
{"git", "fetch", "origin"},
{"git", "checkout", t.TargetBranch},
{"git", "pull", "origin", t.TargetBranch},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
if args[1] == "checkout" {
createCmd := exec.Command("git", "checkout", "-b", t.TargetBranch, "origin/"+t.TargetBranch)
createCmd.Dir = repoDir
if _, err2 := createCmd.CombinedOutput(); err2 == nil {
continue
}
}
return fmt.Errorf("git command %v failed: %s", args, string(out))
}
}
} else {
log.Info("Cloning repo", "url", t.RepoOwner+"/"+t.RepoName)
cmd := exec.Command("git", "clone", "-b", t.TargetBranch, cloneURL, repoDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %s", string(out))
}
}
return nil
}
func buildPrompt(t dispatchTicket) string {
return fmt.Sprintf(`You are working on issue #%d in %s/%s.
Title: %s
Description:
%s
The repo is cloned at the current directory on branch '%s'.
Create a feature branch from '%s', make minimal targeted changes, commit referencing #%d, and push.
Then create a PR targeting '%s' using the forgejo MCP tools or git push.`,
t.IssueNumber, t.RepoOwner, t.RepoName,
t.IssueTitle,
t.IssueBody,
t.TargetBranch,
t.TargetBranch, t.IssueNumber,
t.TargetBranch,
)
}
func runAgent(t dispatchTicket, prompt, dir, logPath string) (bool, int, error) {
timeout := 30 * time.Minute
if t.Timeout != "" {
if d, err := time.ParseDuration(t.Timeout); err == nil {
timeout = d
}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
model := t.Model
if model == "" {
model = "sonnet"
}
log.Info("Running agent", "runner", t.Runner, "model", model)
// For Gemini runner, wrap with rate limiting.
if t.Runner == "gemini" {
return executeWithRateLimit(ctx, model, prompt, func() (bool, int, error) {
return execAgent(ctx, t.Runner, model, prompt, dir, logPath)
})
}
return execAgent(ctx, t.Runner, model, prompt, dir, logPath)
}
func execAgent(ctx context.Context, runner, model, prompt, dir, logPath string) (bool, int, error) {
var cmd *exec.Cmd
switch runner {
case "codex":
cmd = exec.CommandContext(ctx, "codex", "exec", "--full-auto", prompt)
case "gemini":
args := []string{"-p", "-", "-y", "-m", model}
cmd = exec.CommandContext(ctx, "gemini", args...)
cmd.Stdin = strings.NewReader(prompt)
default: // claude
cmd = exec.CommandContext(ctx, "claude", "-p", "--model", model, "--dangerously-skip-permissions", "--output-format", "text")
cmd.Stdin = strings.NewReader(prompt)
}
cmd.Dir = dir
f, err := os.Create(logPath)
if err != nil {
return false, -1, err
}
defer f.Close()
cmd.Stdout = f
cmd.Stderr = f
if err := cmd.Run(); err != nil {
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
return false, exitCode, err
}
return true, 0, nil
}
func reportToForge(t dispatchTicket, success bool, body string) {
token := t.ForgeToken
if token == "" {
token = os.Getenv("FORGE_TOKEN")
}
if token == "" {
log.Warn("No forge token available, skipping report")
return
}
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments",
strings.TrimSuffix(t.ForgeURL, "/"), t.RepoOwner, t.RepoName, t.IssueNumber)
payload := map[string]string{"body": body}
jsonBody, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
log.Error("Failed to create request", "err", err)
return
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Error("Failed to report to Forge", "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log.Warn("Forge reported error", "status", resp.Status)
}
}
func moveToDone(paths runnerPaths, activePath, fileName string) {
donePath := filepath.Join(paths.done, fileName)
if err := os.Rename(activePath, donePath); err != nil {
log.Error("Failed to move ticket to done", "err", err)
}
}
func ensureDispatchDirs(p runnerPaths) error {
dirs := []string{p.queue, p.active, p.done, p.logs, p.jobs}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("mkdir %s failed: %w", d, err)
}
}
return nil
}
func acquireLock(lockPath string) error {
if data, err := os.ReadFile(lockPath); err == nil {
pidStr := strings.TrimSpace(string(data))
pid, _ := strconv.Atoi(pidStr)
if isProcessAlive(pid) {
return fmt.Errorf("locked by PID %d", pid)
}
log.Info("Removing stale lock", "pid", pid)
_ = os.Remove(lockPath)
}
return os.WriteFile(lockPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
}
func releaseLock(lockPath string) {
_ = os.Remove(lockPath)
}
func isProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
process, err := os.FindProcess(pid)
if err != nil {
return false
}
return process.Signal(syscall.Signal(0)) == nil
}
func pickOldestTicket(queueDir string) (string, error) {
entries, err := os.ReadDir(queueDir)
if err != nil {
return "", err
}
var tickets []string
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") && strings.HasSuffix(e.Name(), ".json") {
tickets = append(tickets, filepath.Join(queueDir, e.Name()))
}
}
if len(tickets) == 0 {
return "", nil
}
sort.Strings(tickets)
return tickets[0], nil
}

49
cmd/dispatch/ratelimit.go Normal file
View file

@ -0,0 +1,49 @@
package dispatch
import (
"context"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/ratelimit"
)
// executeWithRateLimit wraps an agent execution with rate limiting logic.
// It estimates token usage, waits for capacity, executes the runner, and records usage.
func executeWithRateLimit(ctx context.Context, model, prompt string, runner func() (bool, int, error)) (bool, int, error) {
rl, err := ratelimit.New()
if err != nil {
log.Warn("Failed to initialize rate limiter, proceeding without limits", "error", err)
return runner()
}
if err := rl.Load(); err != nil {
log.Warn("Failed to load rate limit state", "error", err)
}
// Estimate tokens from prompt length (1 token ≈ 4 chars)
estTokens := len(prompt) / 4
if estTokens == 0 {
estTokens = 1
}
log.Info("Checking rate limits", "model", model, "est_tokens", estTokens)
if err := rl.WaitForCapacity(ctx, model, estTokens); err != nil {
return false, -1, err
}
success, exitCode, runErr := runner()
// Record usage with conservative output estimate (actual tokens unknown from shell runner).
outputEst := estTokens / 10
if outputEst < 50 {
outputEst = 50
}
rl.RecordUsage(model, estTokens, outputEst)
if err := rl.Persist(); err != nil {
log.Warn("Failed to persist rate limit state", "error", err)
}
return success, exitCode, runErr
}

256
cmd/taskgit/cmd.go Normal file
View file

@ -0,0 +1,256 @@
// Package taskgit implements git integration commands for task commits and PRs.
package taskgit
import (
"bytes"
"context"
"os"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddTaskGitCommands)
}
// Style aliases from shared package.
var (
successStyle = cli.SuccessStyle
dimStyle = cli.DimStyle
)
// task:commit command flags
var (
taskCommitMessage string
taskCommitScope string
taskCommitPush bool
)
// task:pr command flags
var (
taskPRTitle string
taskPRDraft bool
taskPRLabels string
taskPRBase string
)
var taskCommitCmd = &cli.Command{
Use: "task:commit [task-id]",
Short: i18n.T("cmd.ai.task_commit.short"),
Long: i18n.T("cmd.ai.task_commit.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
taskID := args[0]
if taskCommitMessage == "" {
return cli.Err("commit message required")
}
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Get task details
task, err := client.GetTask(ctx, taskID)
if err != nil {
return cli.WrapVerb(err, "get", "task")
}
// Build commit message with optional scope
commitType := inferCommitType(task.Labels)
var fullMessage string
if taskCommitScope != "" {
fullMessage = cli.Sprintf("%s(%s): %s", commitType, taskCommitScope, taskCommitMessage)
} else {
fullMessage = cli.Sprintf("%s: %s", commitType, taskCommitMessage)
}
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
// Check for uncommitted changes
hasChanges, err := agentic.HasUncommittedChanges(ctx, cwd)
if err != nil {
return cli.WrapVerb(err, "check", "git status")
}
if !hasChanges {
cli.Println("No changes to commit")
return nil
}
// Create commit
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("create", "commit for "+taskID))
if err := agentic.AutoCommit(ctx, task, cwd, fullMessage); err != nil {
return cli.WrapAction(err, "commit")
}
cli.Print("%s %s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.commit")+":", fullMessage)
// Push if requested
if taskCommitPush {
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.Progress("push"))
if err := agentic.PushChanges(ctx, cwd); err != nil {
return cli.WrapAction(err, "push")
}
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.push", "changes"))
}
return nil
},
}
var taskPRCmd = &cli.Command{
Use: "task:pr [task-id]",
Short: i18n.T("cmd.ai.task_pr.short"),
Long: i18n.T("cmd.ai.task_pr.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
taskID := args[0]
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Get task details
task, err := client.GetTask(ctx, taskID)
if err != nil {
return cli.WrapVerb(err, "get", "task")
}
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
// Check current branch
branch, err := agentic.GetCurrentBranch(ctx, cwd)
if err != nil {
return cli.WrapVerb(err, "get", "branch")
}
if branch == "main" || branch == "master" {
return cli.Err("cannot create PR from %s branch", branch)
}
// Push current branch
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("push", branch))
if err := agentic.PushChanges(ctx, cwd); err != nil {
// Try setting upstream
if _, err := runGitCommand(cwd, "push", "-u", "origin", branch); err != nil {
return cli.WrapVerb(err, "push", "branch")
}
}
// Build PR options
opts := agentic.PROptions{
Title: taskPRTitle,
Draft: taskPRDraft,
Base: taskPRBase,
}
if taskPRLabels != "" {
opts.Labels = strings.Split(taskPRLabels, ",")
}
// Create PR
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("create", "PR"))
prURL, err := agentic.CreatePR(ctx, task, cwd, opts)
if err != nil {
return cli.WrapVerb(err, "create", "PR")
}
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.create", "PR"))
cli.Print(" %s %s\n", i18n.Label("url"), prURL)
return nil
},
}
func initGitFlags() {
// task:commit command flags
taskCommitCmd.Flags().StringVarP(&taskCommitMessage, "message", "m", "", i18n.T("cmd.ai.task_commit.flag.message"))
taskCommitCmd.Flags().StringVar(&taskCommitScope, "scope", "", i18n.T("cmd.ai.task_commit.flag.scope"))
taskCommitCmd.Flags().BoolVar(&taskCommitPush, "push", false, i18n.T("cmd.ai.task_commit.flag.push"))
// task:pr command flags
taskPRCmd.Flags().StringVar(&taskPRTitle, "title", "", i18n.T("cmd.ai.task_pr.flag.title"))
taskPRCmd.Flags().BoolVar(&taskPRDraft, "draft", false, i18n.T("cmd.ai.task_pr.flag.draft"))
taskPRCmd.Flags().StringVar(&taskPRLabels, "labels", "", i18n.T("cmd.ai.task_pr.flag.labels"))
taskPRCmd.Flags().StringVar(&taskPRBase, "base", "", i18n.T("cmd.ai.task_pr.flag.base"))
}
// AddTaskGitCommands registers the task:commit and task:pr commands under a parent.
func AddTaskGitCommands(parent *cli.Command) {
initGitFlags()
parent.AddCommand(taskCommitCmd)
parent.AddCommand(taskPRCmd)
}
// inferCommitType infers the commit type from task labels.
func inferCommitType(labels []string) string {
for _, label := range labels {
switch strings.ToLower(label) {
case "bug", "bugfix", "fix":
return "fix"
case "docs", "documentation":
return "docs"
case "refactor", "refactoring":
return "refactor"
case "test", "tests", "testing":
return "test"
case "chore":
return "chore"
case "style":
return "style"
case "perf", "performance":
return "perf"
case "ci":
return "ci"
case "build":
return "build"
}
}
return "feat"
}
// runGitCommand runs a git command in the specified directory.
func runGitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return "", cli.Wrap(err, stderr.String())
}
return "", err
}
return stdout.String(), nil
}

34
go.mod
View file

@ -5,39 +5,73 @@ go 1.25.5
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
forge.lthn.ai/core/go v0.0.0
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3
forge.lthn.ai/core/go-scm v0.0.0
github.com/mark3labs/mcp-go v0.43.2
github.com/stretchr/testify v1.11.1
)
require (
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
)
replace (

116
go.sum
View file

@ -1,15 +1,52 @@
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3 h1:6H3hjqHY0loJJe9iCofFzw6x5JDIbi6JNSL0oW2TKFE=
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3/go.mod h1:2WCSLupRyAeSpmFWM5+OPG0/wa4KMQCO8gA0hM9cUq8=
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA=
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk=
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf h1:EDKI+OM0M+l4+VclG5XuUDoYAM8yu8uleFYReeEYwHY=
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf/go.mod h1:FpUlLEX/ebyoxpk96F7ktr0vYvmFtC5Rpi9fi88UVqw=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@ -20,33 +57,68 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@ -57,8 +129,14 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -66,11 +144,19 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -81,8 +167,38 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=