Add docs/schemas/execution_plan.json (goal/stages/edges), the
architect_freestyle prompt that instructs emitting the execution pipeline as
JSON, and register the execution_plan llm-emitted kind in both
artifacts.config.toml and sample-config.toml. Validated via
ExecutionPlanSchemaValidationTest (minimal plan passes; missing stages rejected).
Extract a SubagentRunner fun-interface (SubagentRunRequest/SubagentRunResult)
and an InSessionSubagentRunner default that delegates to the existing
executeStage path, with per-run effectives captured in the lambda closure so
the seam stays free of the internal RunEffectives type.
DefaultSessionOrchestrator.enterStage now dispatches through subagentRunner.run
instead of calling executeStage inline; effectives threading is dropped from
run/step/executeMove/enterStage/resume. ReplayOrchestrator supplies its own
runner. Behaviour is identical — RefinementLoopTest and full check stay green.
Stages declaring needs=[...] now receive each needed artifact's validated
JSON (from artifactContentCache) as an L1/USER neededArtifact context entry,
so a one-shot subagent sees its inputs.
Also emit llm-emitted artifact lifecycle events (Created/Validating/Validated)
on successful validation via emitLlmArtifacts, closing a latent gap where
verifyProduces would fail for stages producing only an llm-emitted artifact.
Events are emitted only after validation passes, preserving invariant #7.
The router/narration model behaviour was hardcoded (Main built RouterConfig()
with defaults). Surface it under the [router] section so it's tunable without a
rebuild:
[router] conversation_keep_last, retrieval_k, token_budget
[router.generation] temperature, top_p, max_tokens (chat/steering)
[router.narration] temperature, top_p, max_tokens, max_per_run
ConfigLoader parses the new sections (adds asDouble); Main maps the config-layer
RouterConfig onto the domain RouterConfig and threads narration.max_per_run into
ServerModule. All values default to the previous constants, so behaviour is
unchanged when the sections are absent. Documents the block in sample-config.toml
and adds parser tests for present/absent cases.
Live QA showed two problems with paused-approval narration:
- The narrator was told to "name the stage, outcome, and reason" but the pause
trigger only carried "APPROVAL_PENDING" with no detail — on a first-stage pause
there's no L2 history either, so it had nothing to ground on. NarrationSubscriber
now captures the latest ApprovalRequestedEvent per session (tool name, tier,
preview) and folds it into the pause instruction (preview truncated to 800
chars).
- Narration reused the 512-token chat generation config; a reasoning model spent
the whole budget "thinking" and returned empty content (finishReason=length).
Add a separate narrationGenerationConfig (maxTokens=1024) so thinking can
complete and still leave room for the one or two sentences.
Adds a subscriber test asserting the pause narration names the tool and includes
the preview.
Replace the placeholder "You are a routing assistant" one-liner with two
purpose-written prompts: a conversational prompt that frames the router as
correx's operator-facing interface to the event-sourced engine (grounded in
workflow state, no fabrication, concise, steering-aware), and a dedicated
narrator prompt for buildNarrationContext that asks for one or two present-tense
sentences naming the stage and outcome.
The longer protected frame shifted token budgets, so the two L2 eviction tests
now size their budget from the measured protected-frame + per-entry cost instead
of hard-coded magic numbers, making them robust to prompt length.
Two issues surfaced during live QA once workflows began progressing past the
first stage:
1. Narration prompts went out with no user message, so the chat template
rejected them ("No user query found in messages"). buildNarrationContext
filed the trigger instruction (a user turn) under ContextLayer.L0, and
PromptRenderer folds every L0 entry into the system message regardless of
role. Move the trigger to L1 so it renders as the user turn.
2. TransitionExecutedEvent was emitted after the next stage had already run:
executeMove called enterStage (which executes the stage) before advanceStage
(which emits the transition), so the event log showed a stage running before
the event marking entry into it. Emit the transition before entering.
Adds a regression test asserting a rendered narration prompt carries a user
message.
Tool-calling loops were rendered as all-assistant-calls-then-all-tool-results
with the original task last, causing the model to re-issue the same tool call
indefinitely. Two stacked reorderings caused it:
- DefaultContextPackBuilder grouped entries by sourceType for per-type
compression, discarding a,t,a,t interleaving.
- PromptRenderer forced L1 (the live user turn) to render last, pushing the
task after the entire transcript.
- SessionOrchestrator's tool loop re-fed currentContext.layers.values.flatten()
(grouped by layer) each round, compounding the scramble.
Add a chronological `ordinal` to ContextEntry, stamped by the builder from
input order and restored after grouping/compression (the compressor preserves
entry identity, so ordinals survive). PromptRenderer now orders non-system
messages by ordinal, with the old L1-last layer priority kept only as a
tiebreak so router chat (ordinal 0) is unchanged. The orchestrator keeps a
running ordered accumulator instead of reading back the grouped pack.
Adds builder + renderer ordering regression tests.
- Add NarrationTrigger(kind, instruction) model to core:router
- Add RouterContextBuilder.buildNarrationContext: minimal ContextPack
(system + workflow status + L2 + trigger instruction); excludes
conversationHistory and L3 recalled memory
- Add RouterFacade.narrate: emits RouterNarrationEvent with content,
latencyMs, tokensUsed; skips event on blank inference; never writes
ChatTurnEvent or l3MemoryStore.store
- Add RouterNarrationTest covering all acceptance criteria
- Add narrate default to RouterContextBuilder interface so existing
anonymous test stubs compile without a stub override
ChatTurnEvent gains nullable latencyMs and tokensUsed fields (defaults preserve
backward-compat; legacy JSON without them deserializes cleanly). emitChatTurn
accepts optional metrics; the ROUTER emit site passes inferenceResponse values
through; the USER emit site leaves both null.
Wires the workspace handshake end to end so a session's workspace is bound
from the client's cwd at connect time and is event-sourced for replay.
- Go TUI sends Hello{workingDir=os.Getwd()} as the first WS frame on connect
(protocol.go encoder + client.go connect path; golden test pins the wire
format against the Kotlin discriminator).
- Server adds ClientMessage.Hello, stashes the per-connection workingDir, and
on session start resolves it through WorkspaceResolver's trust pipeline,
emits SessionWorkspaceBoundEvent (invariant #9), and threads the resolved
workspace into OrchestrationConfig for the live run. A Hello after the first
StartSession is ignored (warn); a rejected path binds the resolver fallback.
- Replay derives the workspace from the recorded event: SessionState gains
boundWorkspace, DefaultSessionReducer fills it from SessionWorkspaceBoundEvent,
and ReplayOrchestrator uses it (Path.of only, no filesystem re-query —
invariant #8) with graceful fallback to config for pre-Phase-B logs.
Absent Hello / null resolver degrades to the prior config-workspace behavior.
A stage's allowedTools only shaped which tool definitions were offered to
the model; the dispatch path resolved returned tool calls straight from the
full registry and executed them without checking stage membership. A
hallucinated, jailbroken, or edited-transcript tool call for any registered
tool would run unchecked (violating invariant #5 / policy-is-absolute).
dispatchToolCalls now rejects any tool call whose name is not in the stage's
allowedTools (STAGE_COMPLETE_TOOL exempt), emitting ToolExecutionRejectedEvent
and feeding an error ContextEntry back to the model instead of executing.
Empty allowedTools means deny-all domain tools, consistent with offering
only STAGE_COMPLETE_TOOL at inference time.
A steering note attached to a tool approval was recorded but only
consumed by the NEXT stage's prompt build (buildSteeringNoteEntries runs
once, before the tool loop), so on a single-stage task it had no visible
effect. Two changes:
- Approve+note: capture userDecision.userSteering at the gate and inject
it as a context entry right after the tool result, so the same stage's
loop re-infers with the note and the model acts on it.
- Reject: buildSteeringNoteEntries now also emits anti-repeat feedback for
REJECTED decisions with no note, so the model does not re-propose the
identical call on the retryable re-run (a bare reject previously fed
back only "approval denied").
Integration test drives an approval with a steering note and asserts the
next inference's context carries it.
Compute the effective tool registry/executor and plane-2 WorkspacePolicy per run
from config.workspace (effectivesFor), threaded through the orchestrators; a null
workspace falls back to the boot instances byte-for-byte. Wire the concrete
WorkspaceToolRegistryProvider in Main (buildToolConfigForWorkspace). Session undo
unions the session's recorded workspace into its jail roots. (Axis 2 Phase A, tasks 3 + 7.)
Resolve a client-supplied workspace dir safely: canonicalize via toRealPath,
reject privileged locations, clamp to [tools] allowed_workspace_roots (permissive
when unset, logged), fall back to the boot default on any rejection. PathJail made
non-internal so the server can reuse its symlink-safe containment. (Axis 2 Phase A, task 4.)
Add a WorkspaceContext value type and a nullable OrchestrationConfig.workspace,
plus SessionWorkspaceBoundEvent registered in eventModule. Purely additive; no
behavior change when workspace is null. (Axis 2 Phase A, tasks 1-2.)
A stage declaring no `produces` passed verifyProduces vacuously, letting an
agent stage_complete with zero work. Gate on allowedTools: a produce-less stage
with tools available must have >=1 ToolInvocationRequestedEvent scoped to its
stageId, else Failure(retryable). Produce-less + no tools is exempt (only
stage_complete is ever offered), so pure-reasoning stages aren't bricked.
Fix broken stage-summary interpolation ($payload.stageId rendered the event's toString()). RouterL2Entry gains reason + steeringNotes; the reducer folds SteeringNoteAddedEvent into pendingSteeringNotes and harvests them into the completing stage's L2 entry, then clears (adr-0003 §4, pure field extraction, zero inference). RouterContextBuilder renders summary + reason + steering.
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).
DefaultContextPackBuilder already enforced the token budget, but SessionOrchestrator discarded the entriesDropped signal at both build sites — overflow was enforced yet unobserved. Emit ContextTruncatedEvent when entriesDropped>0, matching the router path (open-threads 6.2 contract).
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/)
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).