The 2026-07-16 audition run-3 burned 26 inferences in install_dependencies
because a registry 404 for a hallucinated package (@types/vite) surfaced only
as raw shell output: the model retried npm, pinned versions/flags, switched to
Yarn, and probed pnpm/network before rewriting the manifest.
packageNotFoundAdvisory deterministically parses npm/yarn/pnpm 404 output,
names the offending manifest entry, and appends a directive telling the model
to edit the manifest — not retry the installer. Wired into renderToolResult
for both the nonzero-exit Success and recoverable Failure framings. Pure over
already-recorded tool output (no new event, invariant #9 unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Existing read-loop/rejection-loop breakers key on CONSECUTIVE rounds, so a
single interleaved success resets them — the hole that let the 2026-07-16
audition run-3 thrash npm install against a hallucinated package across 57
inferences. Add repeatedToolFailureLoop: cumulative count per normalized
tool-failure signature within a stage; once a signature hits
stageFailureLoopLimit (default 6) the stage fails with STAGE_LOOP_BREAK_GATE
and the step handler routes straight to recovery (never retried in place).
Pure fold over recorded events (replay-safe, invariants #8/#9);
ToolExecutionFailedEvent lacks stageId so failures correlate via
invocationId -> ToolInvocationRequestedEvent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vikunja #169 audition result. The 5x5 controlled A/B
(docs/qa/QA-stage-prompt-audition-results-2026-07-16.md) found the curated
guidance block performance-neutral: 5/5 build success both variants, cost
differences within heavy-tailed noise. Trimmed to the single sentence the
gates don't already enforce (scaffolding-scope boundary); dropped the
read-before-write / verify-before-complete / use-exact-feedback nudges that
restate gate-enforced behavior. ON run 3 proved prose doesn't stop failure
loops — deterministic defenses do (filed Vikunja #191/#192, unblocked #78).
Vikunja #167/#168/#169.
#167 PlanGrounder (infrastructure/workflow): build-prereq + touches-scope
existence grounding, emits PlanGroundingEvaluatedEvent. Deterministic fold
over recorded workspace/plan events (invariants #8/#9).
#168 positive-pattern mining (SessionOrchestratorPlanPatterns): mine
SuccessfulPlanShape from cross-session log — a locked plan whose own
workflow later completed — and inject the closest-resembling plan shape as
L0/SYSTEM advisory context for the planning stage. Keyword-Jaccard
resemblance, no LLM/embedder, replays identically. Advisory only (#3).
#169 stage-prompt audit + CORREX_STAGE_GUIDANCE ON/OFF toggle in
SessionOrchestratorExecution. RunCommand gains --intent to seed a
freestyle session over REST.
Also: workspace verification events + concept-compiler wiring. ./gradlew
check green.
Promotes recurring validated failure->fix patterns into L3 as retrieval-on-demand
concepts. Deterministic core: ConceptCompilerProjection clusters
RetryAttempted->StageCompleted pairs by fingerprint (gate-agnostic), promotes at
N=3 cross-session validated fixes, never-contradicted. ConceptPromotedEvent is the
sole authoritative write (idempotent under replay); ConceptCompilerService appends
it + best-effort injects to L3 (non-authoritative, inv #6). Wired live in
ServerModule.start() on StageCompleted.
Design: docs/plans/2026-07-12-acr-concept-compiler.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhFXmKe4WisSSPf9LrmmTg
The tool-output framing feature (2912799f) wraps every tool result in a
'[tool exit=N]' header before it enters context. Compression still runs
(blank lines stripped); the test predated the frame and asserted the bare
compressed body. Update to the framed contract — invariant #6 unchanged.
Moves the 11 private step/recovery helpers off the concrete orchestrator into
internal extension-fun files (Step: step, executeMove, enterStage,
decideGateExhaustion; Recovery: retry/route/ticket helpers). Class keeps the
override seams (run, cancel, submitApprovalDecision), the cross-module public API
(rehydrate, resume, submitClarification, submitSteering), enrich, and
validatedArtifactContent. File-private helpers the moved funs need are promoted
to internal. Pure relocation; clears TooManyFunctions on the class. kernel +
testing:kernel green; server/cli compile clean.
Splits the 3.7k-line SessionOrchestrator into the state-owning abstract class
(fields + open/abstract seams: run, cancel, runInference, mapValidationOutcome,
estimateTokens) plus behavior-preserving internal extension-fun files grouped by
concern: Artifacts, ToolExec, Workspace, Context, RepoContext, Gates, Gates2,
Workflow, Approval, Preview. Two public members consumed cross-module
(liveClarificationRequestIds, requestPlanApproval) stay on the class. Each file
kept <=10 top-level funs; pure relocation, no logic changes.
Clears LargeClass; adds no new TooManyFunctions. kernel test + detekt green;
server/cli compile clean.
Pilot for the SessionOrchestrator god-class decomposition. Moves executeStage
(426-line stage-execution driver) out to SessionOrchestratorExecution.kt as an
internal extension fun, hoists three nested holders (RunEffectives,
ArtifactLadderOutcome, RenderedToolResult) to top-level in
SessionOrchestratorTypes.kt, and promotes class/protected members to internal so
extensions can reach them. Behavior-preserving relocation; kernel+test+server
compile green.
Tool results injected into model context are now consistently framed and
globally bounded. Success output over the floor (TOOL_RESULT_MAX_CHARS=8000)
is head/tail-truncated with a marker naming a retrieval ref; the full raw
output spills to the artifact store (CAS) and its hash is recorded on the
ToolReceipt (fullOutputHash) in the event log. Agents recover the full text
via the new read-only tool_output(ref=...) tool.
- SessionOrchestrator: frameTruncatedToolResult + renderToolResult; char-cap
head/tail so a single pathological long line can't defeat the bound.
- ToolReceipt.fullOutputHash (additive, nullable).
- ToolOutputTool (Tier T1, no fs capability) resolves ref -> full bytes.
- Wired via extraTools in Main.kt; tool_output added to ALWAYS_AVAILABLE_READ_TOOLS.
- Failure path keeps ERROR:/FATAL: prefixes (all-rejected breaker dependency).
Tests: FrameTruncatedToolResultTest, ToolOutputToolTest.
DomainEventMapper.kt had grown into a single ~280-line `when` over every
domain event type with a ~40-line flat import list ('god mapper' smell).
Split the when body by domain area into four files, each a suspend fun
returning a MapOutcome sentinel:
- SessionEventMappers.kt — session/workflow lifecycle + chat turns
- StageInferenceEventMappers.kt — stage transitions + inference lifecycle
- ToolEventMappers.kt — tool exec/assessment + review findings (+ prettyToolParams)
- LifecycleEventMappers.kt — approval/clarification/proposal/narration/artifact/model/preempt
The dispatcher chains them; MapOutcome.Emit(msg?) vs MapOutcome.Skip
preserves the null-vs-unhandled distinction (a suppressed event like
ArtifactValidating returns Emit(null), not Skip). Public entry points
(DomainEventMapper class, domainEventToServerMessage, prettyToolParams)
unchanged — no behavior change, pure maintainability. Each per-domain
import list is now local to its file. DomainEventMapperTest green.
Vikunja #31.
Weak local models reached for shell find/grep -r (unjailed, dumps
.gitignore'd trees, floods context) to answer 'does frontend/ exist?' /
'where is symbol X?'. Add two jailed, gitignore-aware read-only tools:
- GlobTool (name 'glob', T1, FILE_READ+DIRECTORY_LIST): find files by
glob pattern; survey-exempt from anti-hallucination read gates like
list_dir.
- GrepTool (name 'grep', T1, FILE_READ): regex content search, returns
path:line: text; skips gitignored, binary (NUL), and >2MB files.
Both reuse GitignoreMatcher + PathJail from the filesystem package,
share FileReadTool's jail/anchor/toggle, cap at 200 results. Registered
in ToolConfig.buildTools (fileRead block) and added to
StageConfig.ALWAYS_AVAILABLE_READ_TOOLS so every tool-granting stage can
call them. 6 tests green.
Vikunja #37.
GenerationConfig only carried temperature/top_p/max_tokens/stop/seed. Added nullable
topK/minP/repeatPenalty, serialized to the llama.cpp and OpenAI-compat request bodies
via @EncodeDefault(NEVER) so an unset knob is omitted (the model keeps its own default)
and behavior is unchanged unless the operator opts in.
Surfaced as a new [sampling] config section feeding the default stage GenerationConfig
(the main agentic loop) through TomlWorkflowLoader + ExecutionPlanCompiler; the former
hardcoded temperature=0.7/topP=1.0 stage defaults now come from config. Talkie
chat/narration keep their own generation settings.
Vikunja #46 (task 76) — sampling half.
The kernel's ReAct-loop tuning constants (max tool rounds, read/rejection-loop
nudge thresholds, feedback issue cap, repo-map top-k/files-per-dir, docs catalog
cap, clarification-round cap, review-block confidence/retry cap, default
refinement, recovery/intent route budgets) were hardcoded in SessionOrchestrator/
DefaultSessionOrchestrator. Relocated to a new OrchestrationTuning value threaded
through the orchestrator constructor, mapped from CorrexConfig.orchestration in
Main.kt, parsed in ConfigLoader, written by CorrexConfigWriter. Defaults equal the
former constants so an absent [orchestration] section reproduces prior behavior.
Startup-load (not hot-reload); pure output-truncation caps left as constants.
Vikunja #46 (task 76).
Two event-delivery back-pressure faults from the core audit (#7).
(a) streamGlobal forwarded globalFlow → a per-connection Channel with
buffer.send (SUSPEND). globalFlow is also SUSPEND-overflow, so one wedged
WebSocket client back-pressured globalFlow → append() suspended → the whole
kernel stalled. Now the collector uses trySend; on overflow it closes the
buffer with SlowClientException, tearing down that one connection (client
reconnects and re-syncs via replaySnapshot) instead of blocking append().
(b) Per-session subscriptions (extraBufferCapacity 64) used tryEmit with the
return ignored: a lagging in-process collector (e.g. LiveArtifactRepository)
silently diverged from the durable log. Both SqliteEventStore and
InMemoryEventStore now warnIfDropped() — the event is still persisted; only the
live SharedFlow drops, and we surface it. ponytail: log-only; bounded
rebuild-on-lag if divergence ever bites.
Added slf4j-api to :infrastructure:persistence (already the standard logging
dep across modules) for the warn.
Green: :infrastructure:persistence + :apps:server compile+detekt+test (the one
pre-existing ModelLifecycleWiringTest failure needs a real llama-server, fails
identically on clean checkout).
Two unbounded per-session leaks that never released after a workflow ended:
- SessionOrchestrator.artifactContentCache (full file contents, keyed
"<sessionId>:<path>") grew for the process lifetime. Added
evictArtifactContentCache(sessionId) — session-prefix key removal,
rehydrate-safe — called on both completeWorkflow and failWorkflow.
cancellations was already evicted on both terminal paths.
- NarrationSubscriber leaked a Channel + worker coroutine + lanes map entry
per session forever. closeLane() now closes the lane channel on
WorkflowCompleted/WorkflowFailed (draining the terminal narration first);
the worker self-removes from lanes when its for-loop exits on close.
Deferred: SqliteEventStore.subscriptions eviction — removing the SharedFlow
mid-life would strand LiveArtifactRepository's downstream collector (a
suspended-coroutine leak worse than the tiny empty-SharedFlow entry).
Green: :core:kernel compile+detekt, :apps:server *NarrationSubscriber* (8).
Real LLM Usage is already recorded (InferenceCompletedEvent.tokensUsed),
aggregated per-session (MetricsProjection), exposed via correx stats + server
MetricsInspectionService, and rendered in tui-go. The only remaining hardcoded
length/4 estimates were the journal context-entry budget and the compaction
threshold — both pre-injection (no LLM response exists for the journal text), so
estimation is legitimate, but they now use the real tokenizer estimateTokens()
for consistency with sibling context entries instead of length/4.
The Created->Validating->Validated triple fires unconditionally back-to-back at
three artifact-emission sites — three single-event append transactions (each its
own commit + flow-publish pass) for one logical unit. Added emitAll(sessionId,
payloads) which routes 2+ events through EventStore.appendAll (one transaction,
one publish pass) and falls back to emit() for 0/1. Same events, same order.
Adjacent-but-conditional emits (clarification/approval pause+resume) are left on
emit(): they straddle suspension points, so batching would change observable
ordering.
nextGlobalSequence()/nextSessionSequence() computed MAX+1 with two extra
SELECT round-trips, guarded only by an in-process mutex — two processes on the
same correx.db (CLI + server) could read the same MAX and collide on the unique
index. Compute both the global sequence and session_sequence as MAX+1 subqueries
inside the INSERT and RETURNING the assigned values. SQLite serializes writers,
so the subqueries are atomic across processes; the unique index is the backstop.
Also removes the two pre-SELECTs per append.
Reads (read/readFrom/lastSequence/allEvents/allSessionIds/lastGlobalSequence)
shared the single JDBC Connection with append's transaction unsynchronized.
The coroutine appendMutex only excludes append-vs-append; a read on the caller
thread could hit the connection mid-transaction (setAutoCommit(false)..commit)
and corrupt tx state — SQLite JDBC is not thread-safe. Guard every connection
access with a shared JVM monitor (synchronized(connection)) via withConnection,
so reads and the IO-thread append transaction are mutually exclusive.
runInference filtered the offered tool list with isReadOnlyMode(sessionId)
evaluated inside the per-tool .filter{} — a full log read+fold per offered
tool, per inference round (the audit's dominant O(events^2) hot path). The
flag is tool-independent, so read it once before the filter.
The per-tool-CALL re-check in dispatchToolCalls is left as-is: it is
semantically load-bearing (a read completing earlier in the same batch lifts
read-only mode for a later write), not redundant.
IdeaReader folded eventStore.allEvents() on every activeIdeas()/capturedOf()
call — a full deserializing log scan per CHAT context build. Now folds once at
construction and stays current incrementally via subscribeAll(); the fold is
idempotent so seed/live overlap needs no dedupe.
withTimeout wrapped a blocking process.waitFor() that coroutine cancellation
can't interrupt, so a hung process hung the stage until it exited on its own;
and destroyForcibly() killed only the direct child, leaving sh -c grandchildren
(the real npm/tsc) as orphans. Use process.waitFor(timeout, MILLISECONDS) for
an on-the-clock deadline, and kill via toHandle().descendants() + the parent.
An operator argv runs via `sh -c`, so ["ls","&&","rm","-rf","/"] passed an
argv[0]-only allowlist check with {ls} then executed rm. Now every command
position (argv[0] + the token after each &&/||/|/;/& separator) must be
allowlisted; shell builtins are implicit, redirect targets are treated as
filenames. Tests for the bypass, an all-allowed chain, and a redirect target.
Backups were named workingDir/${fileName}.bak — two affected paths with the
same filename in different dirs overwrote each other's backup, so failure
restore wrote the wrong content. Name by full-path hash. Latent today
(single-path tools) but a correctness landmine for any multi-path tool.
computeToolPreview/readFileIfExists resolved relative file_write/file_edit
paths against the daemon CWD, so the diff shown to the operator for approval
read the wrong file (or nothing) when server CWD != workspace_root. Thread the
bound workspaceRoot (effectives.policy.workspaceRoot) through and resolve
relative paths against it, same as the tools do.
The 'Not a directory' branch returned a bare message; models re-issued the
identical list_dir until cancel (session a60c54b0). Name the remedy: the path
is a FILE — file_read it or delete/convert it first. Regression test added.
S5 — GET /sessions re-read + re-projected every session's full log on every
call. Cache keyed on eventStore.lastGlobalSequence(); volatile pair-swap,
no lock — any append bumps the seq so a stale read is impossible.
S6 — close research + health-probe HttpClients via shutdown hook; log the
real configured host:port (was hardcoded 8080) and bind to it; System.err
-> SLF4J; WARN at startup on non-loopback bind (unauthenticated surface).
resolveApiKey precedence item N/A — no such code exists.
Both verified green in isolated worktrees (226 pass). Full local build
currently blocked by an unrelated concurrent core:config edit.
SessionEventBridge.replaySnapshot ran per WS connection and appended a repair
OrchestrationResumedEvent — a write side effect on a pure read path that two
concurrent clients could double-append (and 'open TUI' mutated replay history).
Moved to a one-shot ServerModule.repairStuckApprovalPauses() at boot; replaySnapshot
is read-only again. The state it repairs comes from a fixed pre-Feb-13 bug, so no
session newly enters it at runtime — boot coverage is sufficient.
Note: :apps:server:test not run (core:kernel broken by concurrent work); edits are
isolated and mirror the existing preRegisterPendingApprovals shape.
broadcast() wrapped each client.send in a bare runCatching with no logging or
dead-client eviction, so an approval prompt could vanish silently and the session
hang until reconnect. Now log+evict failed clients from both registries and error
when a prompt reaches 0 live clients.
Note: :apps:server:test not run (core:kernel temporarily broken by concurrent work);
change is isolated, compiled clean before the breakage, detekt passes.
S7: DomainEventMapper mapped every non-APPROVAL_PENDING pause (CLARIFICATION_PENDING,
ABANDONED_STALE) to USER_REQUESTED, so clients rendered the wrong pause state. Added
the two PauseReason enum values, mapped the real reason string, and gave the Go TUI
distinct labels.
S8: REST POST /sessions launched a run without resolving/recording a workspace, so the
session had no SessionWorkspaceBoundEvent (path containment, project profile, grants had
nothing to anchor to). Extracted the WS path's resolve+emit into ServerModule.bindWorkspace
and called it from both launchers.
S1: ApprovalCoordinator marked a request resolved before submitApprovalDecision,
so a throwing submit left it permanently unanswerable; now resolve only on success
and clear the flag on failure so the client can retry. Recorded decision also used
a hardcoded Tier.T2 regardless of the real tier — now tracked per request.
S2: streamGlobal numbered live frames from a per-connection counter starting at 1,
colliding with the snapshot's lastSessionSequence (MAX(session_sequence)) after
reconnect and causing the TUI dedup filter to drop/misorder frames; now uses each
event's own persisted sessionSequence. Approval broadcast no longer sends 0.
Threads reasoning_content across inference calls and journal renders, filters
markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks,
and tightens filesystem tool output/dir-listing behavior surfaced by prior
live QA (see project_readloop_campaign memory).
Adds the failure-ticket + recovery-routing mechanism: a deterministic
gate->capability table gates whether a stage has agency to fix its own
failure, opens a FailureTicketOpenedEvent when it doesn't, and routes to
a metadata role=recovery stage (per-stage budget, cap 2, not reset by
TransitionExecuted) instead of retrying in place. Extends salvage
decisions with a RECOVER option so the review-gate judge can also route
to recovery, unifies deterministic-gate and review-gate routing through
routeToRecovery(), and has ExecutionPlanCompiler synthesize a
write-capable recovery stage + edge for freestyle plans. Read-only tools
(file_read, list_dir) are now always available on any tool-granting
stage so a recovery stage can inspect the write-less stage's failure
without flooding context via shell ls -R.
A stage parked on operator clarification lost its modal whenever the
client disconnected or the server restarted — the questions were stranded
in the event log with no way to re-surface them (unlike approvals, which
already rehydrate).
Two composing mechanisms, mirroring the approval path:
- On reconnect: SessionEventBridge.replaySnapshot() now re-pushes the last
unanswered ClarificationRequestedEvent for a PAUSED/CLARIFICATION_PENDING
session as a ClarificationRequired message. Gated on the orchestrator
still holding a live deferred (SessionOrchestrator.liveClarificationRequestIds)
so a client never sees a modal whose answer could not be delivered.
- On restart: ServerModule.resumeAbandonedSessions() eagerly relaunches
clarification-parked sessions (approval-parked stay lazy). rehydrate+resume
re-runs the parked stage, which re-emits a fresh, live
ClarificationRequestedEvent (round budget MAX=3, 1 prior round recorded, so
it re-parks rather than silently proceeding), re-arming the deferred that
the reconnect path gates on.
pauseReason == "CLARIFICATION_PENDING" is the discriminator because the
reducer sets pendingApproval=true for every pause.
The clarification modal hard-truncated each question's head line with
clip(), so long prompts (e.g. an API question and an Architecture
question) were cut at the modal edge and unreadable. Wrap the prompt
across rows instead: wrapPrompt() greedily word-wraps with a narrower
first line (marker + header chip eat into it) and full-width
continuation lines, indented to align under the prompt.
On a real (large) codebase, a file_read of a big file dumped the whole
body into L2 uncapped, evicting the files the stage actually needed —
the ContextTruncated we saw on real-repo runs. Bound it:
- readFile: auto-truncate a read-to-EOF past MAX_READ_LINES (400) with a
note pointing at start_line/end_line; hard char backstop (20k) for
very long lines. An explicit end_line is deliberate paging, honoured
in full. No contentHash baseline on a truncated view, so the
stale-write gate forces a real read before an edit.
- listDir (single-level): cap at LIST_MAX_ENTRIES (400) with a note.
(ListDirTool's recursive walk was already capped.)