# REPORT — harness skeleton (build phase 2) — 2026-08-28 ## Works - **Config**: `harness.toml` via a stdlib-only TOML-subset parser (tables, bare keys incl. hyphens, strings/ints/bools, single- and multi-line arrays; trailing commas tolerated; anything richer fails loudly with line numbers). Defaults + validation; secrets are refs only (`env:NAME` / `file:PATH` / `literal:VALUE`; `bw:` reserved) and redacted from every error path. `harness.toml.example` is tracked and load-tested so it can never rot; real configs are gitignored. - **Model routing v0**: `[models]` tier map + `[models.classes]` class map; requests go out with the CONCRETE model resolved from the map (mopac-study→glm-4.7-flash, mopac-code→glm-5.2, mopac-review→glm-5-turbo, mopac-primary→glm-5.3, + mopac-vision→glm-4.6v). No heuristic code; unknown class = hard error naming the config section. - **Conductor single-shot**: `harness once` = intake → routing → bounded turn → REPORT, then exit 0. Chainable, no daemon. `--dry-run` = intake + plan (resolved model, tool bounds) with ZERO LLM calls (test-asserted). `--task-id` filter for chaining. Exit codes: 0 ok/no-tasks, 1 config/usage, 2 intake, 4 llm/turn. - **Intake**: Redmine `/issues.json` scope query (raw filter params or saved query id), task class from a configurable custom field with default-class fallback; `--demo` builds the issue from `[demo]` (no Redmine needed). - **Bounded turn**: OpenAI-compatible chat via LiteLLM (stdlib http; base_url ± `/v1` normalized; Bearer auth; retry/backoff on 429/5xx/ transport; usage accounting), tool-calling loop capped at `max_rounds`; gate denials feed back to the model as tool results and are counted, not fatal. On mid-turn LLM failure after content exists, a partial REPORT is still written with the error as stop reason. - **Exec tool**: allow-listed bash. maki-derived scope semantics without tree-sitter: compound commands split segment-by-segment (`&&`/`||`/`;`/ `|`, quote-aware), `cmd *` word-boundary, `pfx*` raw prefix, `pfx/**` path prefix, `*` universal; deny beats allow; `$()`/backticks/subshells always denied (headless has no prompt channel). Per-command timeout with process-group cleanup on unix; output truncation. - **Writeback**: `REPORT---.md` + `REPORT-latest.md` (atomic tmp+rename) with model/tier/class/tokens/rounds/denied/stop telemetry per the DESIGN auditability bar. - **Tests**: table-driven, stdlib `testing` only — TOML subset (valid doc + 9 error cases), config defaults/validation/key refs (incl. leak check), routing decisions, scope matcher + gate (14 cases) + exec (timeout, truncation, deny), writeback, Redmine intake (httptest), LLM client (auth/model/retry/4xx/empty-choices), and loop end-to-end against a scripted fake OpenAI server (demo turn, dry-run zero-call, tool round-trip with message-shape assertions, denial counting, round limit, error-class mapping). `go build ./...`, `go vet ./...`, `go test ./...` all clean on go1.26.7; every intermediate commit builds standalone. ## MVP demo bar status `harness once --demo` is wired end-to-end: prompt "tell me about yourself" → LiteLLM (glm-5.3 via mopac-primary) → GLM self-description → REPORT. Proven against a scripted fake in tests. The live proxy (http://192.168.3.78:4001) is reachable from this host and answers 401 without a key, so the live run needs exactly one thing: export HARNESS_LITELLM_KEY= ./bin/harness once --demo ## Stubbed / known gaps - Event → turn dispatch: `harness events` stores + maps events and hands them to `Conductor.DispatchEvent`, which is a printing stub (phase 3). - Budget/semaphore GATE (LiteLLM spend APIs, class-aware slots) and cost in REPORT (tokens only today). - `bw:` key refs error until the bitwarden wrapper (phase 3). - Streaming + turn resume on truncation — retry is request-level today. - Session persistence + read-time repair (crush notes §2) — not started. - Write confinement to declared roots + symlink-aware canonicalization; redirects currently match by segment text (phase 3 permission layer). - `once` task selection is first-in-scope (`--task-id` to chain); the loop's dedup + status map supersede this for self-hosted runs. - Local inbox intake (DESIGN core-loop step 1, second half) — not started. ## Build phase 2b — events receiver (`mopac events`) — 2026-08-28 Works: - **`harness events` CLI**: stdlib net/http receiver (no frameworks), runs until SIGINT/SIGTERM, graceful shutdown, `-config` / `-listen` flags. Routes `POST /hooks/{redmine,discourse,gitea}` + `GET /healthz`; GET on a hook = 405, unknown path = 404, oversized body (> 1 MiB) = 413. - **Verification, deny-first**: gitea = hex HMAC-SHA256 of the raw body in `X-Gitea-Signature` (constant-time `hmac.Equal`); redmine/discourse = shared-secret header (constant-time compare; header names configurable, defaults `X-Redmine-Webhook-Secret` / `X-Discourse-Webhook-Secret`). Unsigned/unverified = 401 with a single generic error body; rejection logs name the failure class only, never header values or secrets. - **Normalization**: one internal Event (source, kind, actor, canonical subject id `redmine:issue:42` / `discourse:topic:7` / `gitea:pr:ukrrs/MOPAC#5`, title, repo, provider event id, payload sha256 digest, received-at) — tolerant extraction across known payload variants (redmine_webhooks + flat shapes, Discourse headers + payload, Gitea action/pull_request/issue shapes incl. closed+merged → pr_merged). - **Action mapping (DESIGN)**: redmine issue update/note/journal → `dispatch_turn`; discourse post reply → `respond_turn`; gitea PR approved/merged → `pipeline_step`; everything else stored-but-ignored. - **Persistence**: append-only `state/events/events.jsonl` (0600, dir 0700), dedup by provider event id (`X-Gitea-Delivery`, `X-Discourse-Event-Id`, `X-Redmine-Delivery` when present; payload digest fallback). Dedup index rebuilt from the file at startup (torn tail line skipped), so replays across restarts still dedup — at-least-once delivery, exactly-once reaction. - **Config**: `[events]` listen/state_dir + per-source secret refs (`env:`/`file:`/`literal:`, resolved at startup; server refuses to start with zero secrets). Full validation, ref values never echoed. - **dev.sh**: every build/vet/test/run path routes through the digest-pinned builder `golang@sha256:e8c859f...` (= golang:1.26-bookworm; alpine has no bash for the exec tool's tests). - **Tests**: table-driven — signature verification (7 HMAC + 5 shared secret cases), normalization (13 payload/shape cases + action map), store dedup/restart/torn-tail/permissions, end-to-end httptest (401/400/ 200 paths, replay dedup, ignore-not-dispatched, oversized body, log/JSONL secret-leak assertions). Docker `build/vet/test` clean. - **Smoke (live, LAN port 4100)**: containerized receiver driven by host python3 urllib (curl banned on host): unsigned → 401, bad HMAC → 401, wrong shared secret → 401, valid → 200 `stored` + JSONL line, replay → 200 `duplicate`, per-provider actions correct, stub dispatch fired once per stored actionable event. `./dev.sh smoke` reproduces it end-to-end. Stubbed: - Event → turn dispatch (`Conductor.DispatchEvent` prints the would-be action; real wiring lands in phase 3 per plan). - Cross-host/event forwarding (T1 replication later; JSONL is local-only). ## Build phase 3a — self-host core (`harness loop`) — 2026-08-28 ~22:00 Charles (21:30): "get to be self hosting asap — smallest core that can bootstrap itself (redmine/gitea interaction fine, discourse later)". The harness now drives ITSELF: Redmine is the SoR, the loop is the worker, no bash middle layer. Works: - **`harness loop` CLI**: the self-host daemon. Polls the `/issues.json` intake every `[loop] poll_interval_secs` (default 120; `-interval` override; `--once` = single scan for cron-style setups; `--dry-run` = scan + print, zero LLM calls, zero state writes — test-asserted). - **Dispatch discipline**: one bounded turn per issue not yet processed at its current `updated_on`, strictly sequential (v0 — the concurrency slot-file cap of the old stack is replaced by "one at a time"; a knob can come later). The dedup marker is written BEFORE the turn, so a failed turn is recorded and never retried (update the issue to re-release it) — a down proxy cannot hot-loop the poll. - **State**: append-only `state/loop/loop.jsonl` (0600, dir 0700), one JSON line per action (dispatch / report / note / status / commit / refresh / error), index rebuilt at startup (torn tail skipped). Restart keeps exactly-once-reaction semantics — test-asserted across a new Conductor instance. - **Redmine note writeback** (was the phase-2 stub): after the turn, the REPORT body is POSTed onto the issue as a journal note (PUT `/issues/{id}.json`), so the SoR carries the result, not just the filesystem. - **Status transitions**: `[redmine.status_map]` (quoted TOML keys, e.g. `"In Progress" = "Done"`); names resolve to ids via `/issue_statuses.json` (fetched once, cached, case-insensitive); unmapped statuses are left alone. - **Self-retrigger prevention**: the loop's own note/status writes bump `updated_on`; after writeback the loop re-fetches the issue and advances its dedup marker to the post-writeback `updated_on` (`refresh` event). If that refresh call fails, the JSONL records the error and one redundant re-dispatch may follow (visible, bounded). - **`mpk:` key refs** (`[keyproxy]`): resolved through the ukrrs/mopac-keyproxy hop — POST `/v1/resolve`, bearer token from a LOCAL `token_ref` (no recursion), 60s in-memory cache, transport errors query-string-stripped so a misconfigured hop cannot echo the token. `env:`/`file:`/`literal:` refs untouched: the loop runs with or without keyproxy up. All hosts in harness.toml, none in code. - **Gitea REPORT commit** (optional, off by default): `[gitea] commit_reports = true` commits each REPORT file right after it lands (contents API: GET for sha → POST create / PUT update, `token` auth, branch from config). - **Module rename**: `git.knownelement.com/reachableceo/MOPAC/harness` → `ukrrs.com/mopac/harness` (the doc-rot finding folded in; build + vet + test verified in the Docker builder). - **Tests**: loop e2e against a fake Redmine (mutable state: notes bump updated_on like the real SoR) + fake OpenAI server — dispatch/note/ status happy path, dedup on rescan, re-dispatch on PMO issue update, failed turn recorded without hot-loop, dry-run zero-trace, restart dedup survival, status-map miss, gitea create/update/auth shapes, keyproxy resolver (cache hit, auto-prefix, 401/404, local refs untouched), writeback unit tests for PUT body shapes. Replaced by this turn (die list for the PMO report; deletion pending Charles' sign-off): `/home/_crossfeed/tooling/agent-stack/semaphore.sh` + slot-file cap; `~/.coordinate/scripts/queue-next.sh`, `queue-after.sh`, `pmo-heartbeat.sh`; the dispatch-turn.sh screen doorbell (already dead — it targets screen `reachableceo-PMO`, the live screen is `RCEO-PMO`, so the message was silently dropped). ## Next chunk (phase 3) 1. bitwarden-go wrapper + `bw:` refs (unblocks secret posture). 2. Full permission layer: tree-sitter bash scopes, write roots, symlink checks, per-vertical allow/deny presets. 3. Event → turn dispatch wiring: stored actionable events actually chain conductor iterations for the affected stack. 4. Budget gate via LiteLLM spend APIs; cost line in REPORT. 5. Streaming with truncation retry + turn resume. 6. Loop concurrency knob (in-process limit) once load justifies it.