`harness loop` polls the /issues.json intake on an interval (default
120s, --once for cron-style single scans), runs one bounded turn per
new/updated issue — deduped by issue id + updated_on in an append-only
loop.jsonl — then writes the REPORT back as a Redmine journal note,
transitions status per [redmine.status_map] (In Progress -> Done by
name, resolved via /issue_statuses.json), and refreshes the dedup marker
to the post-writeback updated_on so its own notes never re-trigger it.
Turns are sequential (v0); failed turns are recorded, not retried, so a
down proxy cannot hot-loop the poll. The optional Gitea REPORT commit
([gitea] commit_reports, off) rides the same flow. No slot files, no
doorbell screens, no queue scripts — the bash middle layer is replaced,
not wrapped.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
MOPAC harness
The MOPAC harness is a headless agent conductor written in Go: it pulls a task
from Redmine (or a demo issue), routes it to the right model through LiteLLM,
runs ONE bounded turn with an allow-listed bash tool, and writes the result as
a REPORT file. There is no daemon and no TUI — callers chain turns by
re-invoking harness once. It is the execution core for the org's vertical
stacks: humans and other agents interact with a stack only through Redmine
(SoR), Discourse (docs) and Gitea (code), never by attaching to the loop.
Status: 2026-08-28 — skeleton + event receiver; MVP demo path live (single
GLM turn through LiteLLM to REPORT, proven live 2026-08-28); harness events webhook receiver smoke-proven on LAN port 4100 the same day.
Quickstart
All dev work happens inside a Docker builder (host stays toolchain-free);
docker pull of the builder is pre-authorized. Commands below were verified
on 2026-08-28 from a fresh clone.
Build and test
dev.sh routes every compile/vet/test path through the digest-pinned
builder container (the host stays toolchain-free):
./dev.sh check # = go build + go vet + go test, all inside the builder
The equivalent raw command:
docker run --rm -v "$PWD:/h" -w /h \
-u "$(id -u):$(id -g)" -e HOME=/tmp \
golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514 \
sh -c 'go build -o bin/harness ./cmd/harness && go vet ./... && go test ./...'
(that digest = golang:1.26-bookworm; alpine lacks bash, which the exec
tool's tests need.)
Expected output (tail):
ok ukrrs.com/mopac/harness/internal/config
ok ukrrs.com/mopac/harness/internal/intake
ok ukrrs.com/mopac/harness/internal/llm
ok ukrrs.com/mopac/harness/internal/loop
ok ukrrs.com/mopac/harness/internal/models
ok ukrrs.com/mopac/harness/internal/tools
ok ukrrs.com/mopac/harness/internal/writeback
(The module path is ukrrs.com/mopac/harness; the repo lives at
git.knownelement.com/ukrrs/MOPAC.)
Configure
cp harness.toml.example harness.toml
harness.toml is gitignored. It holds no secrets — only key refs
(env:NAME, file:PATH, literal:VALUE; bw: is reserved for the
bitwarden wrapper, build phase 3) that are resolved at runtime and redacted
from every error path.
Dry-run (no LLM call, no REPORT)
./bin/harness once --dry-run --demo
Expected output:
harness: intake: demo issue from harness.toml [demo]
harness: task demo-1 (demo): "MVP demo: GLM self-description" class=primary -> mopac-primary -> glm-5.3
PLAN (dry-run)
vertical: demo
task: [demo-1] MVP demo: GLM self-description (source demo)
routing: class "primary" -> tier mopac-primary -> model glm-5.3
tools: bash (allow=18 deny=7 default=deny timeout=60s)
bound: max 8 rounds
harness: dry-run complete, no LLM call made
Live demo turn (the MVP bar)
HARNESS_LITELLM_KEY=<vertical virtual key> ./bin/harness once --demo
The model's reply lands verbatim in reports/REPORT-latest.md with model /
tier / class / tokens / rounds telemetry. Without the key the run fails fast
and clean (verified):
harness: litellm key: environment variable HARNESS_LITELLM_KEY is not set
exit code 1 (config error). The with-key path was proven live on 2026-08-28:
the [demo] prompt "tell me about yourself" routed to glm-5.3 via tier
mopac-primary and the GLM self-description landed as the REPORT.
Webhook receiver (harness events)
Redmine / Discourse / Gitea webhooks punch the harness through the event
receiver. Configure [events] in harness.toml (secrets as env refs — see
harness.toml.example), then:
HARNESS_REDMINE_WEBHOOK_SECRET=... HARNESS_DISCOURSE_WEBHOOK_SECRET=... \
HARNESS_GITEA_WEBHOOK_SECRET=... ./dev.sh events
publishes port 4100 on the host LAN (override with -listen). Routes:
| Route | Verification | Maps to |
|---|---|---|
POST /hooks/redmine |
shared-secret header (default X-Redmine-Webhook-Secret) |
dispatch_turn on issue update/note |
POST /hooks/discourse |
shared-secret header (default X-Discourse-Webhook-Secret) |
respond_turn on post reply |
POST /hooks/gitea |
HMAC-SHA256 X-Gitea-Signature over the raw body |
pipeline_step on PR approved/merged |
GET /healthz |
none | liveness |
Unsigned or unverified deliveries get 401; verified ones are normalized to
one internal event record, appended to state/events/events.jsonl (0600,
deduped by provider event id: X-Gitea-Delivery / X-Discourse-Event-Id,
payload-digest fallback), and handed to the conductor (turn dispatch is a
stub until phase 3 wiring). End-to-end smoke, including the 401/200 paths
and the JSONL record, driven with python urllib (curl is banned on host):
./dev.sh smoke
Help
./bin/harness help
Prints the full usage block reproduced under CLI reference.
Architecture
flowchart TD
S(["harness once"]) --> I["INTAKE<br/>Redmine /issues.json scope query<br/>or the [demo] issue"]
I -->|"task + class"| RT["ROUTING<br/>class -> tier -> concrete model<br/>[models] + [models.classes]"]
RT --> T["BOUNDED TURN<br/>OpenAI-compatible chat via LiteLLM<br/>gated bash tool, max_rounds cap"]
T --> W["REPORT<br/>reports/REPORT-<vertical>-<task>-<ts>.md<br/>+ REPORT-latest.md, atomic write"]
W --> E(["exit 0"])
E -.->|"re-invoke to chain the next turn"| S
One conductor iteration per process: INTAKE resolves the released scope (a
Redmine query, or the [demo] issue with --demo) into a task with a class;
ROUTING maps the class through the config-only tier table to a concrete proxy
model; the bounded TURN drives an OpenAI-compatible chat loop through LiteLLM
(retry/backoff on 429/5xx/transport, usage accounting) with a tool-calling
loop capped at max_rounds, where the only tool today is an allow-listed
bash exec whose gate denials feed back to the model as tool results instead
of failing the turn; WRITEBACK persists the final assistant reply as a REPORT
file plus REPORT-latest.md (atomic tmp+rename). On a mid-turn LLM failure
after content exists, a partial REPORT is still written with the error as the
stop reason. Chaining is by re-invocation — there is no daemon.
The event path is a second door into the same loop:
flowchart LR
RM["Redmine webhook"] --> V["VERIFY<br/>shared-secret / HMAC headers"]
DC["Discourse webhook"] --> V
GT["Gitea webhook"] --> V
V -->|401 unverified| X(["rejected"])
V --> N["NORMALIZE<br/>one Event record: source, kind, actor,<br/>subject id, digest, received-at"]
N --> S["STORE<br/>state/events/events.jsonl, append-only,<br/>dedup by provider event id"]
S -->|"dispatch_turn / respond_turn / pipeline_step"| C(["conductor (dispatch stub)"])
Verification rejects unsigned and unverified deliveries with 401 (no detail beyond "unverified"); secret material and header values never reach logs or the event log — only normalized fields and a payload digest do.
CLI reference
Subcommands (from harness help):
| Subcommand | Purpose |
|---|---|
harness help |
print usage (also -h, --help) |
harness once |
run ONE conductor iteration, then exit |
harness events |
run the webhook receiver until SIGINT/SIGTERM |
Flags for once:
| Flag | Meaning |
|---|---|
-config PATH |
config file (default $HARNESS_CONFIG, then ./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 |
Flags for events:
| Flag | Meaning |
|---|---|
-config PATH |
config file (default $HARNESS_CONFIG, then ./harness.toml) |
-listen ADDR |
bind address (overrides [events] listen) |
Exit codes:
| Code | Meaning |
|---|---|
| 0 | ok (including "no tasks in scope") |
| 1 | usage / config / routing / writeback error |
| 2 | intake error |
| 4 | llm / turn error |
Configuration
Every harness.toml section (see harness.toml.example, load-tested so it
cannot rot):
| Section | Keys | Meaning |
|---|---|---|
| top level | vertical |
vertical/stack identity for this harness instance |
work_root |
where the bash tool executes (default .) |
|
report_dir |
where REPORT files land (default reports) |
|
[loop] |
max_rounds |
max LLM round trips per turn, tool calls included (default 8) |
[redmine] |
url, key_ref |
SoR issues endpoint + auth key ref |
scope_query / scope_query_id |
released-scope filter: raw /issues.json params, or a saved query id (scope_query wins when both are set) |
|
class_field, default_class |
custom field carrying the task class; fallback for issues without it (default Class / primary) |
|
limit |
max issues fetched per intake (default 50) | |
[litellm] |
base_url, key_ref |
OpenAI-compatible proxy endpoint + key ref |
timeout_secs, max_retries |
per-request timeout (default 120) and retry count (default 2) | |
[models] |
tier keys, default_tier |
tier alias → concrete proxy model (see below) |
[models.classes] |
class keys | task class → tier alias (see below) |
[tools.bash] |
enabled, timeout_secs, max_output_bytes |
bash gate on/off, per-command timeout (default 60s), output truncation (default 100000) |
default |
verdict for commands matching no rule: allow or deny (org preset: deny) |
|
allow, deny |
rule lists; deny beats allow; cmd * word-boundary, pfx* raw prefix, pfx/** path prefix, * universal; compound commands checked segment by segment; command substitution and subshells always denied |
|
[demo] |
id, subject, prompt, class |
the issue --demo runs instead of Redmine intake |
[events] |
listen |
receiver bind address (default :4100; publish via docker -p) |
state_dir |
event log dir (default state/events); events.jsonl created 0600 |
|
[events.redmine] |
secret_ref, secret_header |
shared-secret ref + header name (default X-Redmine-Webhook-Secret) |
[events.discourse] |
secret_ref, secret_header |
shared-secret ref + header name (default X-Discourse-Webhook-Secret) |
[events.gitea] |
secret_ref |
HMAC secret ref; verification is always X-Gitea-Signature (hex HMAC-SHA256 of the raw body) |
Key refs accepted anywhere a *_ref appears: env:NAME, file:PATH,
literal:VALUE (last resort), and bw:REF (reserved; errors until the
bitwarden wrapper lands in build phase 3).
Model routing
Static, config-only, no heuristics: a task's class maps to a tier alias, the tier alias maps to the concrete model actually sent to the proxy. Unknown class = hard error naming the config section.
Tiers ([models]):
| Tier | Concrete model | Role |
|---|---|---|
mopac-study |
glm-4.7-flash |
flash tier |
mopac-code |
glm-5.2 |
flagship |
mopac-review |
glm-5-turbo |
mid |
mopac-primary |
glm-5.3 |
default / flagship+ |
mopac-vision |
glm-4.6v |
vision when needed |
Classes ([models.classes]):
| Task class | Tier | Concrete model |
|---|---|---|
study |
mopac-study |
glm-4.7-flash |
read |
mopac-study |
glm-4.7-flash |
code |
mopac-code |
glm-5.2 |
architecture |
mopac-code |
glm-5.2 |
review |
mopac-review |
glm-5-turbo |
summarize |
mopac-review |
glm-5-turbo |
writeback |
mopac-review |
glm-5-turbo |
vision |
mopac-vision |
glm-4.6v |
primary |
mopac-primary |
glm-5.3 |
| (no class) | default_tier = mopac-primary |
glm-5.3 |
Status
Sourced from REPORT.md — keep both in sync.
| Area | State | Detail |
|---|---|---|
| Config | Works | TOML-subset parser, defaults + validation, secret refs only, redacted errors |
| Model routing v0 | Works | tier + class maps; concrete model resolved before the request leaves |
| Conductor single-shot | Works | harness once chainable; --dry-run makes zero LLM calls (test-asserted) |
| Redmine intake | Works | /issues.json scope query, class custom field, --demo fallback |
| Bounded turn | Works | LiteLLM chat, retry/backoff, tool loop capped at max_rounds, partial REPORT on mid-turn failure |
| Exec tool | Works | allow-listed bash, segment-wise compound checks, timeout + truncation |
| REPORT writeback | Works | timestamped file + REPORT-latest.md, atomic, full telemetry |
| Event receiver | Works | harness events: verify (HMAC/shared-secret) → normalize → append-only JSONL with provider-id dedup → action mapping; smoke-proven on LAN port 4100 |
| Event → turn dispatch | Stubbed | conductor DispatchEvent prints what it would do; wiring is phase 3 |
| Tests | Works | table-driven, stdlib only; build/vet/test clean on go1.26 |
| Redmine note writeback | Stubbed | REPORT is file-only today |
| Budget/semaphore gate | Stubbed | tokens in REPORT, no cost/spend enforcement yet |
bw: key refs |
Stubbed | error until the bitwarden wrapper (phase 3) |
| Streaming + turn resume | Stubbed | retry is request-level today |
| Session persistence/repair | Stubbed | not started |
| Write confinement | Stubbed | declared roots + symlink checks land with the permission layer |
| Task ordering | Stubbed | first-in-scope; chain with --task-id until status transitions land |
| Local inbox intake | Stubbed | not started |
| Next (phase 3) | Next | bitwarden wrapper, full permission layer, Redmine note writeback + status transitions, budget gate via LiteLLM spend APIs, streaming with resume |
Docs and links
- DESIGN.md — design spec (hard rules, build order, org model)
- REPORT.md — current build status
- docs/PORTING-NOTES-crush.md — sessions/MCP/provider study of the crush agent
- docs/PORTING-NOTES-maki.md — permission parsing and token-reduction study of maki
- docs/PORTING-NOTES-secrets.md — Bitwarden secrets study; feeds the bitwarden-go tool
- Repository: https://git.knownelement.com/ukrrs/MOPAC
- Sibling tool repos (spec seeds): mopac-keyproxy, mopac-bitwarden-go
- SoR: Redmine project MOPAC; docs: Discourse
License
AGPLv3 — see LICENSE.