Users declare artifact kinds in [[artifacts]] (id + schema_path to a JSON-schema file + llm_emitted); ConfigArtifactKind registers them at startup via createWorkflowLoader(extraKinds). New JsonSchemaValidator validates any non-built-in kind generically against its declared deriveJsonSchema(), so an LLM-emitted custom kind is checked against its shape, never trusted. Removed the dead payloadSerializer from the ArtifactKind contract. Schema source is path-to-file (not inline TOML — the hand-rolled parser can't nest).
adr-0003 §1.3 dedup: collapse runs of identical adjacent lines into `line (×N)`, order-preserving and lossless (count retained). Wired into ShellTool's default spec before HeadTail so the head/tail window holds distinct content. Safe as a default, unlike DropMatching.
- LlamaCppInferenceProvider: check HTTP status and surface the real error body instead of masking it as a ChatCompletionResponse deserialization failure
- PromptRenderer: render the live conversation layer (L1) last so the user turn is the final message (fixes Qwen 'No user query found' 500 when L3 recall is present)
- TomlWorkflowLoader: resolve stage prompts relative to the workflow file's own dir (bundle), CWD-independent; config-dir fallback for globals
- SessionOrchestrator: hard-fail a stage whose declared prompt can't resolve (was a silent user-less request)
- move healthcheck prompts next to the example workflow (examples/workflows/prompts/)
Go TUI decodes model.changed / model.list / resource.status and renders a
status-bar VRAM/GPU%/RAM gauge plus a models overlay (m key / palette 'models'):
lists configured models with the resident one marked, enter swaps (SwapModel),
c clears the pin (ClearModelPin). All three new server messages are
non-event-bearing control/gauge frames, applied immediately like
provider.status_changed.
Server addition required for the picker: ServerMessage.ModelList (model.list,
NonEventMessage) sent once in the initial snapshot from
ManagedInferenceRouter.availableModelIds()/currentModelId() — the TUI otherwise
cannot enumerate swap targets.
Completes the 5-slice model-lifecycle feature. ./gradlew check green; go build/vet clean.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 5 of 5).
Vendor-agnostic ResourceProbe (commons): NvidiaResourceProbe reads nvidia-smi
(injectable runner) for whole-device VRAM/util and /proc/<pid>/status for the
managed llama-server RSS, both fail-soft to null; UnavailableProbe for non-GPU
hosts / the static path. DefaultModelManager.currentPid() + LlamaProcess.pid
expose the managed pid. ServerMessage.ResourceStatus (resource.status,
NonEventMessage, all-nullable) pushed every 2.5s on the global stream plus one
in the initial snapshot. Live gauge only — never event-sourced (feeds no core
decision), so invariants #8/#9 hold. Main wires the NVIDIA probe on the managed
path when nvidia-smi is present, else UnavailableProbe.
Tests: csv parsing (single/multi/malformed), gpu+rss combine, fallbacks.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 4 of 5).
ManagedInferenceRouter owns a live @Volatile pin (decision D1): swap(modelId)
makes a model resident and pins it; clearPin() releases it. Pin is highest
precedence in route() (pin > stage.modelId > capability > default). New
SwapModel/ClearModelPin client messages handled in GlobalStreamHandler via
ServerModule.modelSwapper (null on the static path). ServerMessage.ModelChanged
(model.changed) mapped from ModelLoadedEvent/ModelUnloadedEvent — the swap's load
event surfaces to clients through streamGlobal, so the handler doesn't push it
directly. Tests: pin precedence + swap/clear, Model* -> ModelChanged.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 3 of 5).
StageConfig.modelId; InferenceRouter gains a backward-compatible model-aware
route() overload (default ignores modelId, so static routers and test fakes are
unaffected). New ManagedInferenceRouter (infra commons) resolves a target model
per stage (stage.modelId > capability match > default) and ensureLoaded()s it
before routing, returning the live managed provider — its id changes with the
resident model, so there is no stale health cache to invalidate on swap.
ModelManager.ensureLoaded default delegates to the idempotent load(). Main wires
the managed router on the [[models]] path; the static path keeps DefaultInferenceRouter.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 2 of 5).
[[models]] config (ModelConfig/ModelsSettings) parsed by ConfigLoader;
InfrastructureModule.createModelManager + modelConfigToDescriptor; Main.kt
managed boot path spawns the default llama-server when [[models]] is present
(static [[providers]] path preserved when absent) and kills it on shutdown.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 1 of 5).
TurboVec persists vectors via the sidecar but kept entry metadata
(sessionId/turnId/text) only in an in-memory map, so restarting with
backend=turbovec silently dropped every query hit (the id→metadata
lookup missed). Rebuild that map from the ChatTurnEvent log at startup.
- RehydratableL3MemoryStore: capability interface for stores whose
vectors persist independently of the JVM. TurboVec implements it;
InMemory deliberately does not (its vectors are volatile too).
- L3MetadataRehydrator replays ChatTurnEvents into the metadata map.
No embedder: the query path never reads entry.vector, so vectors
stay in the sidecar and metadata comes from the event log (the
source of truth, invariant #1). Short-circuits for non-rehydratable
backends before scanning the log.
- Wired into Main before the server accepts queries; no sidecar spawn.
Backfill of never-embedded history (needs the embedder) is a separate
follow-up. The in_memory non-durability warning already exists.
Tools can declare how their output is compressed before it enters the
context pack, replacing uniform handling. Open-threads 6.3 / ADR-0003.
- core:tools/compression: ToolOutputCompressor interface,
IdentityCompressor default, declarative OutputCompressionSpec +
CompressionRule (StripBlankLines, StripLeadingWhitespace,
DropMatching, HeadTail), and the pure DeclarativeCompressor engine
(regexes compiled at construction; bypassOnError passes raw on
non-zero exit; never throws on input).
- Tool gains `outputCompressor` (default IdentityCompressor). FileReadTool
strips blanks + leading whitespace; ShellTool strips blanks + head/tail.
- SessionOrchestrator applies the resolved tool's compressor on the
ToolResult -> ContextEntry path only. Raw output stays authoritative
in the event log (invariant #6); compression is a pure function of
(raw, exitCode, spec), so no new event and replay stays deterministic.
- Spec is @Serializable/config-shaped (JSON round-trip tested). No
custom-tool-from-config path exists today, so first-party tools wire
in code; TOML-declared compression rides along when that path lands.
Tier B (format-aware parsers: failures-only test output, git-log oneline)
is a documented per-tool extension behind the interface, not built here.
Add ParamRole and a defaulted Tool.paramRoles to the tool contract; built-in
tools declare which params carry effects: file_read/write/edit -> path=PATH,
shell -> argv=EXEC_COMMAND. PathContainmentRule / ExecInterpreterRule /
NetworkHostRule now inspect only the declared params when roles are present, and
fall back to the looksLikePath / leading-token / extractHost heuristic only for
un-enriched (e.g. custom) tools - so a custom FILE_WRITE tool is still
containment-checked. Shared extractParamStrings flattens String and List<*>
(argv) values. SessionOrchestrator.runPlane2Assessment resolves the tool once
and passes tool.paramRoles into the assessment input.
This retires the arg-guessing from the first-party path: a slashy edit 'content'
arg is no longer mis-flagged as a path.
DefaultMaterializingArtifactWriter and its MaterializingArtifactWriter interface
were test-only orphans after the sandbox dual-write retirement (95e79bf) - no
production wiring, never instantiated outside their own test. Delete all three.
Add healthcheck_*.txt to .gitignore (one-off generated output).
When a user sends input, embed it, query L3 across all sessions, dedup
against in-session turns, and record the retrieval as an event so replay
is deterministic (invariant #9). Hits land in RouterState; context
injection follows in a later slice.
- Add L3MemoryRetrievedEvent + L3RetrievedHit (registered in eventModule)
- RouterState.lastRetrievedMemory + reducer case; RouterTurn carries turnId
- RouterConfig.retrievalK (default 5)
- Harden L3 write path: runCatching + visible logging, cancellation
re-thrown; event append stays ahead of the L3 write
- Warn prominently when the non-durable in_memory L3 backend is selected
Adds [router.embedder] and [router.l3] sections to CorrexConfig with
backend selectors. Ships LlamaCppEmbedder that hits llama.cpp's
/embedding endpoint (handles OpenAI-compatible, simple, and array
response shapes; validates dimension). InfrastructureModule gains
createEmbedderFromConfig and createL3MemoryStoreFromConfig that
dispatch on backend value.
Defaults preserve current behavior (noop embedder + in-memory L3).
Switching to "llamacpp" / "turbovec" is a config-only change — no code
edits required. For turbovec backend, the bundled python sidecar
script is extracted from classpath to ~/.cache/correx/ on first use.
Introduces Embedder contract in core:inference with a NoopEmbedder
default (zero vector) and wires both Embedder and L3MemoryStore into
DefaultRouterFacade. Each ChatTurnEvent emission now embeds the turn
content and writes a corresponding L3MemoryEntry, so cross-session
memory is captured at the source of truth (the event emission point).
InfrastructureModule defaults to NoopEmbedder + InMemoryL3MemoryStore
so the system runs without an external embedding model or vector store
wired. The TurboVec adapter stays unwired for now; switching to it is
a configuration concern (Epic 12 config layer).
Also gitignores apps/tui/logs/ (Kotlin TUI runtime logs, pre-Go rewrite).
Defines the L3MemoryStore contract in core:router (entry, query, hit,
in-memory test default) and a new infrastructure:router:turbovec module
that adapts turbovec via a Python sidecar over stdin/stdout JSON-Lines.
Adapter manages the subprocess via ProcessBuilder with lazy start,
mutex-serialized requests, and shutdown via withTimeout. Wire-format
metadata that turbovec doesn't store (sessionId, turnId, text) is kept
in an in-memory map for now — durable persistence ships with the
upcoming ChatTurnEvent.
Not yet wired into RouterFacade, InfrastructureModule, or DI. Embedding
generation source is a separate concern.
Extract message-rendering logic from LlamaCppInferenceProvider into a
provider-agnostic PromptRenderer in core:inference. SessionOrchestrator
now serializes the rendered ChatMessage list as JSON for the prompt
artifact, so the audit trail reflects what the LLM actually saw.
Resolves the TODO at SessionOrchestrator.kt:691.
LlamaCppInferenceProvider was created with empty capabilities, causing
NoEligibleProviderException when the router requested ModelCapability.General.
FirstAvailableRoutingStrategy requires declared capabilities to cover all
requested ones — empty set satisfied nothing.
Add a DEFAULT_LLAMA_CAPABILITIES constant with General, Coding, Reasoning,
Summarization, and ToolCalling scores, and pass it through createLlamaCppProvider()
to ModelDescriptor. Existing callers get sensible defaults automatically.
This is the root cause of the 'router not connected — epic 14' placeholder:
the server-side error (capability mismatch) sent a ProtocolError instead of a
RouterResponseMessage, so the TUI never set routerConnected=true.
P3-1: Delete 17 empty Gradle modules (core:agents/observability/policies/stages,
infrastructure:scheduler/security/telemetry, all 7 plugins/*, all 3
interfaces/*). Zero source files, zero dependents, identical build.gradle
stubs. Remove from settings.gradle. Keep .adoc spec docs as roadmap refs.
P3-2: Fix docs drift — rename :core:orchestration to :core:kernel in
core-orchestration-submodule-spec.md, rewrite stale chat-transcript
modules-and-spec.md as proper doc.
(CLAUDE.md Router context isolation clarification is local-only,
file is gitignored.)
P3-3: Reduce detekt maxIssues from 999999 to 120 (actual current count ~107).
(CLAUDE.md detekt description updated locally; file is gitignored.)
P3-4: Remove stale locked worktree agent-a98d45277ce4b0040 (contained only
cosmetic test style changes and an unused kotlinx-datetime dep).
P2-1: Guard SessionId NPE with safe-call in SessionsReducer
P2-2: Remove 16 dead event classes + reducer branches; replace with live orchestration events in tests
P2-3: Standardize ChatInput divergences — guard CancelSession, single-arg ProtocolError
P2-4: Remove dead TuiToolRecord.diff and ToolDisplayStatus.REQUESTED
P2-5: Remove dead streamLive from SessionEventBridge
P2-6: Extract SessionsReducerContext data class for 6+ param method
P2-7: Replace StageId("none") sentinel with TypeId.NONE
P2-9: Add TypeId.random() factory on all type-alias IDs
P2-10: Add KDoc on schemaVersion documenting reserved-for-migration
P2-8: Verified RouterReducer string-template already correct (TypeId value class toString)
Event log integrity (sole source of truth):
- SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL +
busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the
app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under
concurrent appends (race was invisible to CI: only the in-memory store and
single-threaded :memory: tests were ever exercised).
- Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all
46 EventPayload subclasses and event-referenced enum constants, @Serializable on
RiskLevel/RiskAction. Guards the append-only log against silent corruption from
future class renames, field removals, or default-value changes.
Approval/grant security (Invariant: approvals cannot widen authority):
- Tier authorization is now a ceiling (request.tier.level <= max granted) instead
of set membership; SESSION grants bind to a specific toolName instead of matching
everything; handleCreateGrant rejects empty/over-tier/scopeless grants.
Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE,
router-panel layout, workflows behind Ctrl+W.
Tests: SqliteEventStore concurrency, serialization round-trip + discriminator
stability + unknown-key tolerance, adversarial grant scope/tier; updated existing
approval and reducer tests for the new grant semantics and StartChatSession effect.
- GlobalStreamHandler.handleCreateGrant validates scope (SESSION/STAGE)
and sends ProtocolError to client on validation failure instead of
silent log.warn + drop.
- Derive event stageId directly from GrantScope type, removing
redundant stageIdForEvent tuple.
- SessionOrchestrator integrates ApprovalEngine to check active grants
before requesting user approval for T2+ tool calls.
- ContextEntry gains EntryRole (SYSTEM/USER/ASSISTANT/TOOL) field for
proper chat message role mapping.
- RouterContextBuilder.build is now suspend; uses Tokenizer for
accurate token estimation with fallback to character-based estimate.
- LlamaCppInferenceProvider maps EntryRole to ChatMessage role instead
of heuristic layer-based inference.
- Add field to ProcessResultArtifact ("success"/"failed")
- Materialize ToolResult.Failure as a failed ProcessResult artifact
in the orchestrator, with artifact lifecycle events emitted
- Non-recoverable ToolResult.Failure now fails the stage ("FATAL:")
instead of being silently fed back as LLM context
- Fix enterStage to return Continue on non-retryable failures,
enabling back-edge transitions (fix/retry loops)
- Add ArtifactFieldEquals transition condition with flat and
nested dot-notation field access + FieldOperator enum
(eq, neq, gt, gte, lt, lte)
- Wire artifact_field_equals through ConditionFactory and
TomlWorkflowLoader (condition_field, condition_operator)
- Cache ProcessResult artifact content for evaluation context
- Add 16 tests covering all condition operators and error cases
- DefaultTransitionResolver catches condition evaluation errors
and returns Blocked instead of crashing
- Add unified diff viewer for file_write/file_edit tools across all layers:
ToolReceipt.diff → DiffUtil (LCS-based) → SandboxedToolExecutor → ToolCompleted
→ TuiToolRecord.diff → DiffViewer widget (colored +/- lines)
- Add workflow selection panel: WorkflowList server message, WorkflowListPanel
widget, ↑↓ seamlessly extends into workflow picker from session list
- Add cursor/caret support: inputCursor in TuiState, CursorLeft/CursorRight
actions and key events, AppendChar inserts at cursor, Backspace deletes before
- Colorize event history strip (green/red/yellow/blue by type) and input bar
(blue/yellow prompt, cyan session name, blue keybindings)
- Show 5 recent events instead of 3; increase event strip height to 6
- Remove lastEventAt sort so display and navigation use consistent order
- Fix approval reconnect: register pending approvals with ApprovalCoordinator
during snapshot replay so lookupSession works after reconnect
- 167 tests pass (126 TUI + 41 server)
StoredEvent gains sessionSequence: Long (per-session monotonic); existing
sequence field becomes the global monotonic cursor across the entire store.
SqliteEventStore allocates both counters inside the same BEGIN IMMEDIATE
transaction and emits to a global MutableSharedFlow after commit.
InMemoryEventStore mirrors this with an AtomicLong global counter and a
suspend-on-overflow SharedFlow. EventStore interface gains subscribeAll()
and lastGlobalSequence(). Contract tests updated and extended to cover
global-sequence and cross-session subscribeAll invariants.
- fixed the tool overlay in tui.
- fixed tool calling - added schemas.
- getting all sessionIds via event store.
- instead of sending all events on the replay - there's a new way for replaying.
- session snapshot is now a thing getting sent for previously created sessions.
- added logging to important parts.
- revert workflowId from WorkflowCompletedEvent.
fixed detekt issues where possible.
fixed disttar failing build because tools is added twice in the server module.
added workflowId where required.
fixed some tests not being recognized because of runBlocking without explicit return type.
formatting + imports.
Implements the full conversational router facade: RouterState, RouterReducer,
RouterProjector, RouterRepository, RouterContextBuilder, RouterFacade, protocol
types, WebSocket wiring, infrastructure factory, and deterministic test suite.
Also fixes spec divergences found in post-implementation review:
- Add SteeringNote domain object to core:context (epic prerequisite)
- Rename RouterFacade.handleChat → onUserInput per spec interface contract
- Add in-memory ConcurrentHashMap conversation history to DefaultRouterFacade
- Make RouterRepository.getRouterState suspend
- Rename RouterConfig.keepLast → conversationKeepLast, fix defaults (6, 4096)
- Refactor InfrastructureModule.createRouterFacade to self-assemble internally
- Fix FileReadTool: allowedPaths was dead constructor param (@SuppressUnusedParameter);
now stored as private val and enforced in validateRequest
- Disable koverVerify on modules tested via testing/ submodules or with
hardware/integration dependencies (24 modules); build gate now passes clean
Deletes 21 Module.kt scaffolding objects that were never wired into any DI
registry. Removes unused imports across 8 production and 3 test files.
Restores StageExecutor, CyclePolicyResolver, PolicyValidation, and
ReplayContractTest — these are deferred features, not dead code.
- SessionOrchestrator: wire T2/T3/T4 approval gate before tool execution;
emit OrchestrationPausedEvent/ApprovalRequestedEvent, await decision,
return rejection as ERROR context entry so LLM sees the denial;
propagate tool ERROR entries as StageExecutionResult.Failure
- SandboxedToolExecutor: remove dead code and simplify
- InfrastructureModule: minor wiring cleanup
- LlamaCppInferenceProvider / build.gradle: related build fixes
New core:artifacts-store interface + infrastructure/artifacts-cas
implementation: segment files + SQLite index, Blake3 hashing,
group-commit fsync via flushBefore, recovery tail-scan, manual
compactor, and oldest-first disk-cap evictor.
Inference events now carry promptArtifactId / responseArtifactId;
orchestrators put bytes before emitting. SqliteEventStore wraps
its txn in artifactStore.flushBefore so segment data is fsynced
before the event commit, making the crash window non-corrupting
(TailScanner re-indexes orphan tail records on reopen).
Compactor and evictor are mutually exclusive via maintenanceMutex.
Step 9 (cloud sync) deferred to a later epic.
See docs/reviews/2026-05-18-cas-steps-1-8-review.md for the final
review.