// Command harness is the MOPAC harness CLI. v0 surface: `harness once`, // `harness events`. package main import ( "context" "errors" "flag" "fmt" "net/http" "os" "os/signal" "syscall" "time" "ukrrs.com/mopac/harness/internal/config" "ukrrs.com/mopac/harness/internal/events" "ukrrs.com/mopac/harness/internal/loop" ) const usage = `MOPAC harness (v0) Usage: harness once [-config PATH] [-dry-run] [-demo] [-task-id ID] harness events [-config PATH] [-listen ADDR] once runs ONE conductor iteration and exits (chain by re-invoking; no daemon): intake (Redmine scope, or the [demo] issue) -> plan/model routing -> bounded turn via LiteLLM -> REPORT file writeback. events runs the webhook receiver until SIGINT/SIGTERM: Redmine/Discourse/ Gitea webhooks are verified, normalized, stored append-only (dedup by provider event id) and handed to the conductor (dispatch stub for now). Routes: POST /hooks/{redmine,discourse,gitea}, GET /healthz. Flags: -config PATH config file (default $HARNESS_CONFIG or ./harness.toml) -dry-run intake + plan only; no LLM call, no REPORT -demo run the [demo] issue instead of Redmine intake -task-id ID run only the task/issue with this id -listen ADDR events: bind address (overrides [events] listen) Exit codes: 0 ok (including "no tasks in scope") 1 usage / config / routing / writeback error 2 intake error 4 llm / turn error ` func main() { os.Exit(run(os.Args[1:])) } func run(args []string) int { if len(args) == 0 { fmt.Fprint(os.Stderr, usage) return 1 } switch args[0] { case "help", "-h", "--help": fmt.Print(usage) return 0 case "once": return runOnce(args[1:]) case "events": return runEvents(args[1:]) default: fmt.Fprintf(os.Stderr, "harness: unknown command %q\n\n%s", args[0], usage) return 1 } } func runOnce(args []string) int { fs := flag.NewFlagSet("once", flag.ContinueOnError) cfgPath := fs.String("config", "", "config file path") dryRun := fs.Bool("dry-run", false, "intake + plan only, no LLM call") demo := fs.Bool("demo", false, "use the [demo] issue instead of Redmine") taskID := fs.String("task-id", "", "run only this task id") if err := fs.Parse(args); err != nil { return 1 } if fs.NArg() > 0 { fmt.Fprintf(os.Stderr, "harness: unexpected argument %q\n", fs.Arg(0)) return 1 } if *cfgPath == "" { *cfgPath = os.Getenv("HARNESS_CONFIG") } if *cfgPath == "" { *cfgPath = "harness.toml" } cfg, err := config.Load(*cfgPath) if err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) return 1 } conductor, err := loop.New(cfg, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) return 1 } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() _, err = conductor.Once(ctx, loop.OnceOpts{ DryRun: *dryRun, Demo: *demo, TaskID: *taskID, }) if err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) switch { case errors.Is(err, loop.ErrIntake): return 2 case errors.Is(err, loop.ErrLLM): return 4 default: return 1 } } return 0 } func runEvents(args []string) int { fs := flag.NewFlagSet("events", flag.ContinueOnError) cfgPath := fs.String("config", "", "config file path") listen := fs.String("listen", "", "bind address (overrides [events] listen)") if err := fs.Parse(args); err != nil { return 1 } if fs.NArg() > 0 { fmt.Fprintf(os.Stderr, "harness: unexpected argument %q\n", fs.Arg(0)) return 1 } if *cfgPath == "" { *cfgPath = os.Getenv("HARNESS_CONFIG") } if *cfgPath == "" { *cfgPath = "harness.toml" } cfg, err := config.Load(*cfgPath) if err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) return 1 } // The conductor is the event dispatcher (stub until phase 3 wiring). conductor, err := loop.New(cfg, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) return 1 } srv, err := events.NewServer(cfg.Events, conductor, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) return 1 } defer srv.Close() if *listen == "" { *listen = cfg.Events.Listen } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() httpSrv := &http.Server{ Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, } go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = httpSrv.Shutdown(shutdownCtx) }() fmt.Printf("harness: events receiver on %s (state: %s)\n", *listen, cfg.Events.StateDir) err = httpSrv.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { fmt.Fprintf(os.Stderr, "harness: %v\n", err) return 1 } fmt.Printf("harness: events receiver stopped\n") return 0 }