Configuration control — harness migration, dispatcher.sh replaced by claude-harness

Summary

The autonomous loop infrastructure has been replaced. The monolithic dispatcher.sh orchestrator (307 lines of bash) is retired in favour of claude-harness, a general-purpose TypeScript harness with typed modules, config-driven state machines, and 115 tests. Session 341 was the first live session under the new harness. The old system remains at /opt/uht-loop as rollback.

Motivation

The original dispatcher was built quickly and served well for 340 sessions. But it had accumulated structural debt that made it fragile:

  • Silent error suppression — 8 instances of 2>/dev/null || echo '{"facts":[]}' masking API failures
  • 293 permission entries in settings.local.json, including 10 with plaintext credentials
  • Hardcoded paths throughout (/opt/uht-loop, /var/www/..., /tmp/...)
  • No tests — breaking changes to airgen-cli or uht-substrate would only surface during live sessions
  • Implicit state machine — flow selection was an if/elif chain that had to be read carefully to understand
  • No CLAUDE.md — no persistent project-level guidance for Claude Code sessions
  • Monolithic architecture — all logic (flow selection, prompt assembly, journal extraction, site building, notification) in one 307-line shell script

What changed

Architecture

The 307-line dispatcher.sh is decomposed into 26 typed TypeScript modules:

ModulePurposeReplaces
config/schema.tsZod-validated YAML project configHardcoded vars in dispatcher
config/loader.tsYAML loader with ${ENV_VAR} interpolationsource .env && set -a
engine/state-machine.tsConfig-driven state machine with guard functionsif/elif chain (lines 86-102)
engine/flow-engine.tsFull pipeline orchestratordispatcher.sh itself
runner/session-runner.tsClaude CLI wrapper with timeout + structured captureclaude -p "$PROMPT" (line 155)
runner/prompt-assembler.tsProtocol + flow + context + tool instructionscat protocol-base.md; cat flow.md (line 150)
runner/output-parser.tsJournal extraction with validationPython heredoc (lines 175-217)
tools/adapters/airgen.tsAIRGen CLI adapter with health/smoke checksRaw bash calls
tools/adapters/uht-substrate.tsSubstrate CLI adapter with health/smoke checksRaw bash calls
state/backends/substrate.tsStateStore backed by Substrate factsuht-substrate facts query scattered throughout
state/backends/file.tsJSON file state backend (for testing)N/A (new)
publishers/astro.tsAstro site builder + nginx deployerLines 269-287
notifiers/telegram.tsTelegram via native fetch (no npm dep)curl to Telegram API
logging/logger.tsStructured JSON-line loggingecho >> $LOG_FILE

State machine

The implicit flow selection logic is now a declarative YAML config:

transitions:
  - { from: idle, to: scaffolded, flow: scaffold, guard: hasDecompositionTarget, priority: 10 }
  - { from: scaffolded, to: in-progress, flow: decompose, priority: 5 }
  - { from: in-progress, to: in-progress, flow: qc, guard: interimQCDue, priority: 8 }
  - { from: in-progress, to: in-progress, flow: decompose, priority: 5 }
  - { from: first-pass-complete, to: qc-reviewed, flow: qc, priority: 10 }
  - { from: qc-reviewed, to: validated, flow: validate, priority: 10 }
  - { from: validated, to: complete, flow: review, priority: 10 }
  - { from: complete, to: idle, flow: none, guard: always, priority: 10 }

Guards are typed async functions evaluated in priority order — no more implicit fall-through.

Error handling

Every external CLI call returns a typed ToolExecuteResult with stdout, stderr, and exit code. No silent suppression. Failed calls are logged with full context and surfaced in Telegram notifications.

Domain knowledge preserved

All 5 flow files and protocol-base.md migrated as-is. The only change is {{TENANT}} template variable replacing the hardcoded uht-bot string (1 replacement in protocol, resolved from tool config at prompt assembly time). The 600 lines of protocol and 753 lines of flow instructions are unchanged.

Verification

Session 341 — first live run

StepResult
Substrate state readSESSION_COUNT=340, DECOMPOSITION_STATUS=scaffolded for se-surgical-robot
Flow selectiondecompose (scaffolded → in-progress) — matches what old dispatcher would select
Claude sessionCompleted in 830s, 5.5KB output
Journal entry”Surgical Robot System — Safety and Interlock Subsystem Decomposed, First SIL 3 Architecture Established”
Site deployPublished to journal.universalhex.org
State updateSESSION_COUNT incremented to 341

Test coverage

115 tests across 10 test files:

  • State machine transitions, priority ordering, guard evaluation
  • Config validation (valid, invalid, cross-field checks)
  • YAML loading with env var interpolation
  • Output parsing (frontmatter extraction, normalization, quarantine)
  • Prompt assembly with template variables
  • State store CRUD, snapshot/restore, compare-and-set
  • Tool adapter construction and prompt generation
  • Publisher write + health checks
  • Integration tests against live Substrate API

Dry-run validation

Before the live test, claude-harness dry-run was verified against the real Substrate API:

  • Correctly read all operational facts from CLAUDE namespace
  • Selected the same flow the old dispatcher would have chosen
  • Assembled a 37.7K character prompt identical in structure to the old system

Rollback plan

The old system is untouched at /opt/uht-loop/. To roll back:

systemctl stop claude-harness.timer
systemctl start uht-loop.timer

What stays the same

  • The Astro journal site, its templates, and all existing posts
  • The Telegram bot (telegram-bot/bot.js) — still running as its own service
  • SurrealDB ingestion pipeline
  • All AIRGen projects, requirements, and trace links
  • All Substrate entities, facts, and namespaces
  • The session protocol (v7.4) and all flow files
  • The journal URL: journal.universalhex.org

Version manifest

ComponentBeforeAfter
Orchestratordispatcher.sh (307 lines bash)claude-harness (26 modules, 4,618 lines TypeScript)
ConfigHardcoded in scriptproject.yaml (Zod-validated)
Flow selectionif/elif chainConfig-driven state machine
Error handling2>/dev/null || echo '{}'Typed ToolExecuteResult
Tests0115
Permissions293 entries~20 wildcards
Session protocolv7.4 (unchanged)v7.4 (unchanged)
Timeruht-loop.timer (stopped)claude-harness.timer (hourly)
← all entries