README gains the self-host loop section (flow diagram, config reference
rows for [loop]/[redmine.status_map]/[keyproxy]/[gitea], CLI flags,
status table); REPORT.md records phase 3a including the die list for the
bash stack it replaces. dev.sh grows a `loop` runner (repo bind-mounted,
key env passthrough). `harness loop --once` now exits 2 when the intake
scan itself fails so cron setups alert loudly; daemon mode still logs
and retries.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
12 KiB
REPORT — harness skeleton (build phase 2) — 2026-08-28
Works
- Config:
harness.tomlvia 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.exampleis 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-idfilter for chaining. Exit codes: 0 ok/no-tasks, 1 config/usage, 2 intake, 4 llm/turn. - Intake: Redmine
/issues.jsonscope query (raw filter params or saved query id), task class from a configurable custom field with default-class fallback;--demobuilds the issue from[demo](no Redmine needed). - Bounded turn: OpenAI-compatible chat via LiteLLM (stdlib http;
base_url ±
/v1normalized; Bearer auth; retry/backoff on 429/5xx/ transport; usage accounting), tool-calling loop capped atmax_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-<vertical>-<task>-<ts>.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
testingonly — 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.
- 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).
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=<vertical virtual key>
./bin/harness once --demo
Stubbed / known gaps
- Event → turn dispatch:
harness eventsstores + maps events and hands them toConductor.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).
oncetask selection is first-in-scope (--task-idto 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 eventsCLI: stdlib net/http receiver (no frameworks), runs until SIGINT/SIGTERM, graceful shutdown,-config/-listenflags. RoutesPOST /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-timehmac.Equal); redmine/discourse = shared-secret header (constant-time compare; header names configurable, defaultsX-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-Deliverywhen 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/testclean. - 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 → 200duplicate, per-provider actions correct, stub dispatch fired once per stored actionable event../dev.sh smokereproduces it end-to-end.
Stubbed:
- Event → turn dispatch (
Conductor.DispatchEventprints 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 loopCLI: the self-host daemon. Polls the/issues.jsonintake every[loop] poll_interval_secs(default 120;-intervaloverride;--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-writebackupdated_on(refreshevent). 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 LOCALtoken_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 = truecommits each REPORT file right after it lands (contents API: GET for sha → POST create / PUT update,tokenauth, 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)
- bitwarden-go wrapper +
bw:refs (unblocks secret posture). - Full permission layer: tree-sitter bash scopes, write roots, symlink checks, per-vertical allow/deny presets.
- Event → turn dispatch wiring: stored actionable events actually chain conductor iterations for the affected stack.
- Budget gate via LiteLLM spend APIs; cost line in REPORT.
- Streaming with truncation retry + turn resume.
- Loop concurrency knob (in-process limit) once load justifies it.