harness: LiteLLM client, Redmine intake, REPORT writeback, gated bash tool

OpenAI-compatible chat client for the LiteLLM proxy (base_url normalization,
Bearer auth, retry/backoff on 429/5xx/transport, usage accounting) - stdlib
http only. Intake lists issues in scope from /issues.json with the task
class read from a configurable custom field. Writeback lands each turn as
REPORT-<vertical>-<task>-<ts>.md plus REPORT-latest.md (atomic rename) with
model/tier/token telemetry. The bash tool ports maki's permission semantics
without tree-sitter: segment-by-segment compound-command checks, deny beats
allow, word-boundary "cmd *" matching, $()/backtick/subshell denied, output
truncation, per-command timeout with process-group cleanup.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-28 19:20:57 -05:00
parent 591d345371
commit aefa73aa90
12 changed files with 1230 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
// Package intake turns released scope into TASK records: Redmine issues in
// scope today; local inbox files later.
package intake
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.knownelement.com/reachableceo/MOPAC/harness/internal/config"
"git.knownelement.com/reachableceo/MOPAC/harness/internal/task"
)
// RedmineClient lists issues in the configured scope via /issues.json.
type RedmineClient struct {
cfg config.RedmineConfig
key string
http *http.Client
limit int
}
// NewRedmineClient builds a client from config and a resolved API key.
func NewRedmineClient(cfg config.RedmineConfig, key string) *RedmineClient {
return &RedmineClient{
cfg: cfg,
key: key,
http: &http.Client{Timeout: 30 * time.Second},
limit: cfg.Limit,
}
}
// ListTasks returns the issues in scope, in the order Redmine returns them.
// The task class comes from the configured custom field, falling back to the
// configured default class.
func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) {
q := c.cfg.ScopeQuery
if q == "" && c.cfg.ScopeQueryID > 0 {
q = "query_id=" + strconv.Itoa(c.cfg.ScopeQueryID)
}
if !strings.Contains(q, "limit=") && c.limit > 0 {
q += "&limit=" + strconv.Itoa(c.limit)
}
url := strings.TrimSuffix(c.cfg.URL, "/") + "/issues.json?" + q
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("X-Redmine-API-Key", c.key)
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("redmine request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("redmine read: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("redmine HTTP %d: %s", resp.StatusCode, truncate(string(raw), 300))
}
var payload struct {
Issues []struct {
ID int `json:"id"`
Subject string `json:"subject"`
Description string `json:"description"`
CustomFields []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"custom_fields"`
} `json:"issues"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("redmine decode: %w", err)
}
tasks := make([]task.Task, 0, len(payload.Issues))
for _, is := range payload.Issues {
class := c.cfg.DefaultClass
for _, cf := range is.CustomFields {
if strings.EqualFold(cf.Name, c.cfg.ClassField) && cf.Value != "" {
class = cf.Value
}
}
prompt := is.Description
if prompt == "" {
prompt = is.Subject
}
tasks = append(tasks, task.Task{
ID: strconv.Itoa(is.ID),
Subject: is.Subject,
Prompt: prompt,
Class: class,
Source: "redmine",
})
}
return tasks, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}