mavgpud.service, the Maven GPU supervisor, is an enabled systemd user unit that
binds *:8080 and restarts on kill. The correx server wanted the same port, so
qa-stack died with BindException and the QA stack never came up. Worse,
qa-stack's --stop ran `fuser -k 8080/tcp`, which killed mavgpud rather than a
correx server; systemd then restarted it straight into the port it had just
freed, so it won the race every time.
Moves the default to 8090 in the four places that have to agree: ServerConfig,
ConfigLoader's fallback, the CLI's DEFAULT_PORT, and the TUI's -port flag. A
mismatch between any two of them is a client that cannot find its own server.
The machine-local halves are not in this diff and were applied on disk:
`~/.config/correx/config.toml` pinned `port = 8080` explicitly, which overrides
the code default, and `scripts/` is gitignored so qa-stack.sh's five references
(including the --stop kill, now aimed at 8090) live only on this box.
Verified live: the server binds 8090 and answers /health while mavgpud keeps
8080. Left open in #695: mavgpud also spawns a llama-server on :10000, which is
qa-stack's router port, and qa-stack pkills that pattern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pinned schemaInstruction said only "Respond with a single JSON object
matching this schema", contradicting every role prompt that says to emit via
the emit_artifact tool. Two stated channels for one artifact, and the pinned
one won.
ResponseFormat.Json is built under exactly the condition that offers the tool
(an llmEmitted slot — see emitArtifactTool), so the tool is always available
wherever this instruction renders, and the kernel prefers it: tool-calling
models are more reliable at it and it sidesteps llama.cpp's grammar+tools
incompatibility. The instruction now names it, and keeps raw JSON as what to do
when told to stop calling tools — which is what the tools-less final pass
demands and what the executor already accepts either way
(llmArtifactOverride ?: response.text).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The role prompt was an L1/USER entry, so PromptRenderer emitted it as a user
turn behind the intent, decision journal, repo map and docs catalog, outranked
by the pinned schemaInstruction it contradicts. It is now L0/SYSTEM and folds
into the leading system message, and it sits directly after systemPrompt in
assembly so it heads the system block rather than trailing schemaEntries.
Extracted buildAgentPromptEntry so both the promptInline and prompt-path
branches build the entry one way. Guard test mutation-verified.
#416's finding 1 was wrong: the role prompt was never evictable. "agentPrompt"
is already in REQUIRED_SOURCE_TYPES, and DefaultContextPackBuilder exempts
REQUIRED entries from pruning at any layer. Layer was never the pinning
mechanism here; message placement was the whole defect.
Also pins groundingFeedback and recoveryTicket, the two feedback types that
were neither REQUIRED nor in neverDropSourceTypes. The recovery stage exists
only because of its ticket, so pruning the ticket left it nothing to repair.
Not done: reconciling the pinned schemaInstruction ("respond with JSON only")
against the role prompt's emit_artifact instruction, and live verification
across analyst/architect/role_pipeline.toml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both defects come from live session fced377e, where scaffold_frontend burned 89
turns without converging.
#419 — the stage output manifest never tracked deletions. A deletion is a
FileWrittenEvent with a null postImageHash, and stageWrittenPaths filtered that
per-event instead of per-path, so a written-then-deleted file stayed in the
manifest forever. The contract gate stamps file_exists on every entry, which made
deleting — and therefore renaming, which is delete plus write — a permanent
contract violation. That deadlocked against the build gate: it ordered "rename it
to use the '.cjs' file extension", the agent complied, and the contract gate then
failed file_exists on the .js it had just been told to remove. Keep the last
mutation per path instead. Fixed in the shared function, so the build-gate
toolchain probe, the review gate, and verification all stop seeing deleted files
too. Moved to its own file to keep the gates file under the detekt function cap.
#417 — file_edit failed 4 of 6 live calls, all "Target not found" with a correct
"did you mean" suggestion attached. flexibleMatch already tolerated indentation
drift but walked target lines against file lines positionally, so one blank line
the model dropped shifted every later index and missed the whole block. Compare
only non-blank lines and map the hit back to real file lines. Ambiguity still
fails rather than picking a match, and reindent keeps handling the writeback.
Guard tests both ways, each mutation-verified against the old logic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Store 3's read side already existed but gated on `produces execution_plan`, so the
one stage #305 is actually about — discovery — still started cold. The gate now keys
on the produced artifact kind being a plan-shape consumer (execution_plan OR
discovery), with a discovery-specific framing: the prior run's stage list read as a
checklist of surfaces this task-family touches, to inspect now rather than at stage 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bc5afa51 made unconfirmedFixEntries one-shot per RetryAttemptedEvent by folding
prior ContextAssembledEvent manifests. Correct, but it only guards the call
site — which runs ONCE per stage entry (SessionOrchestratorExecution.kt:250).
The entry it returns is folded into accumulatedEntries, and every pushBack /
tool-round rebuild rebuilds from that same list, so the hint rode along into
every turn of the stage regardless of the delivery fold. That is the reported
symptom: 7 consecutive turns in session d734e1de, one stage entry.
Drop sourceType=="unconfirmedFix" from accumulatedEntries once the first pack
is built and its delivery recorded. The fold still does the across-entries half.
./gradlew check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
Root cause: unconfirmedFixEntries() re-derived the hint on every context
build from "the latest RetryAttemptedEvent for this stage," with no guard
on whether that retry occurrence had already been delivered. A single
contradicted retry (e.g. a workflow-routing dead end, gate="stage")
therefore kept re-injecting its "Steer away from a known dead end" block
on every subsequent stage retry/rebuild, even once the model had moved on
to a materially different sub-problem — live evidence: session d734e1de,
stage scaffold_frontend, injected on 7 consecutive turns.
Fix (direction 1 from the three proposed): key delivery to the specific
RetryAttemptedEvent occurrence and check whether it was already delivered
by folding prior ContextAssembledEvent manifests (#307) for
sourceType="unconfirmedFix"/sourceId=classKey recorded after that retry's
own sessionSequence — no new mutable state, replay-safe (invariant #9).
A fresh contradicted retry now injects exactly once; later rebuilds for
the same occurrence stay silent; a NEW RetryAttemptedEvent of the same
class (a real recurrence) earns one fresh injection.
Direction 2 (match the current turn's own classKey, not "latest in
stage") turned out to be already true by construction — classKey already
comes from the single "latest" retry being matched — so the mismatch in
the evidence was purely temporal/sticky, which direction 1 resolves.
Implementing 2 separately would have been redundant; noted rather than
silently dropped.
Direction 3 (tighten the signature so routing and build failures can't
collapse): already structurally true — routing dead-ends
("no transition condition matched...") only ever reach
RetryAttemptedEvent via DefaultRetryCoordinator.shouldRetry(), which
defaults gate="stage", while every other failure path uses .decide()
with its real gate (build/contract/lint/...). Since classKey = "$gate:
$normalizedSignature", the two families can never share a classKey.
Added a regression test locking this in rather than changing the scheme.
Docstring on unconfirmedFixEntries updated to describe the one-shot
mechanism precisely instead of just asserting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
fileRepairOutcomes mapped both "no diagnostic run after this write" and
"diagnostics ran and found nothing" to emptySet(), so resolved was true for a
file that was never actually re-checked. Two consequences, both the inverse of
what #309 is for:
- the ledger told the model "done, leave it" about a file whose last write was
never verified — the precise mis-signal the tab-keeping exists to prevent;
- the loop-breaker counted unchecked writes as unresolved, so it could kill a
run terminally on the ABSENCE of evidence rather than on recorded proof the
rewrites weren't working.
Track it as a distinct `unchecked` state: the ledger says "not re-checked since
the last write — outcome unknown", and the breaker requires !unchecked.
./gradlew check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
Recovery runs freestyle for 3h+/800 events thrashing one file (22
rewrites, 9 rewrites of a single component) with no second
FailureTicketOpenedEvent ever firing, terminated only by human CANCEL.
Root cause: recovery is architected as ONE continuous ReAct loop
(deliberately generous maxToolRounds — most rounds are
re-investigation before the write), so the existing cumulative
tool-failure breaker (stage_loop_break, #78/#304) never fires: each
file_edit call itself SUCCEEDS, only the downstream lsp_diagnostics
gate keeps failing on the same file. The per-gate progress-aware
fingerprint is also unreliable here: it hashes the whole failure-reason
string, which can look "different" each round from unrelated
diagnostics elsewhere even while one file's defect never clears.
Fix — two consumers sharing one event-derived data source
(RecoveryFileLoopBreak.kt, FileRepairOutcome/fileRepairOutcomes):
correlates each FileWrittenEvent with the next LspDiagnosticsCompletedEvent
for the same path (invariant #9 — no re-observation, pure fold).
- Guard (recoveryFileLoopBreak, wired into DefaultSessionOrchestratorStep's
enterStage before the normal gate-retry machinery): once a single path has
been rewritten recoveryFileRewriteLimit times (default 3) without its
diagnostic ever going clean, escalateRecoveryLoop opens a
FailureTicketOpenedEvent (gate=recovery_loop_break, escalated=true, same
machinery every other escalation uses) and fails the workflow terminally
instead of looping again. Recovery is the last tier — there's nowhere
further to route to.
- Ledger (buildRetryFeedbackEntry in ContextFeedback.kt): each
already-written file in the trailing repair-mandate slot is now annotated
with whether rewriting it actually moved the diagnostic, e.g.
"SessionsList.tsx — written 3x. TS6133: present before AND after every
write. Re-writing has not changed the result. Change the fix or report
unresolvable." vs "MainLayout.tsx — written 2x. cleared after write 2.
done, leave it." Existing trailing-slot precedence (recoveryTicket >
retryFeedback > groundingFeedback > rejectionFeedback, #313) is untouched —
this only changes retryFeedback's rendered content, still EntryRole.USER.
FileRepairOutcome/fileRepairOutcomes/describeFileRepairOutcome and the
recovery guard functions live in a new RecoveryFileLoopBreak.kt purely to
keep ContextFeedback.kt and DefaultSessionOrchestratorRecovery.kt under
detekt's per-file function-count threshold (11) — no behavioral reason
for the split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
Recovery was dying on a stale failure cap, not on anything recovery
itself did. detectRepeatedToolFailure (stage_loop_break, #78) counted
same-signature ToolExecutionFailedEvents cumulatively across the whole
stage. A stage routed to recovery, retried by the ladder, and routed
again would re-trip the SAME pre-recovery count on its very first
post-recovery round — recovery would burn a full expensive turn and
still fail on retryExhausted=true from stale evidence.
Fix: window the fold to events after the most recent
FailureTicketOpenedEvent naming the stage (mirrors the existing
BuildPrerequisiteBootstrapAttemptedEvent windowing pattern already used
by repeatedBuildCriticalReferenceBlock). A stage returning from a
genuine repair attempt now starts the count clean; it must accumulate
stageFailureLoopLimit (default 6) NEW failures post-recovery before
re-tripping.
This does not weaken RECOVERY_ROUTE_BUDGET/INTENT_ROUTE_BUDGET: those
are charged directly off FailureTicketOpenedEvent by the reducer
(OrchestrationState.recoveryRoutes/recoveryFailureFingerprints),
independent of this fold, and are deliberately left untouched by
TransitionExecutedEvent already. A full ladder round-trip therefore
stays bounded at RECOVERY_ROUTE_BUDGET(2) + INTENT_ROUTE_BUDGET(2)
route-in/route-out cycles, each itself requiring a fresh
stageFailureLoopLimit(6) failures to re-trip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
c742656e emitted ContextAssembledEvent only at the stage's initial
contextPackBuilder.build. But the question that motivated #307 — "did the ACR
steer-away hint fire on 7 consecutive turns?" — is a per-rebuild question:
unconfirmedFixEntries recomputes on every rebuild, which is exactly the #306
bug. With one event per stage the log still couldn't answer it.
Emit at all four build sites, matching emitContextTruncationIfNeeded's existing
placement: pushBack, the tool-round rebuild, and the tools-less clean emission.
./gradlew check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
Root cause: context-assembly (unconfirmedFixEntries, promotedConceptEntries,
buildRetryFeedbackEntry, etc.) computes ContextEntry lists at stage-build time
but never records what was actually injected into a turn. The only way to
answer "did hint X fire in session Y" was decoding the CAS prompt artifact and
grepping raw JSON — the event log alone couldn't say.
Scope check: no existing event carries this (ContextTruncatedEvent only
reports drop counts, not the injected set) — added new ContextAssembledEvent
rather than extending an existing one.
Fix: ContextAssembledEvent (core/events/events/ContextEvents.kt) records ONE
manifest per stage's initial ContextPack build — sessionId, stageId,
contextPackId, and a List<ContextManifestEntry> of {sourceType, sourceId,
tokenEstimate, layer, role} pulled from the pack's actual (post-budget)
entries. No entry content — content stays a pure derived projection of the
event log (invariant #9/#6) and already lives in CAS. Registered in
eventModule (Serialization.kt). Emitted via
SessionOrchestrator.emitContextAssembled (SessionOrchestratorRepoContext.kt),
wired at the initial contextPackBuilder.build() call site in
SessionOrchestratorExecution.kt (mirrors emitContextTruncationIfNeeded).
Tests: ContextAssembledEventSerializationTest (round-trip + no-content-leak),
SessionOrchestratorIntegrationTest "stage context build emits
ContextAssembledEvent with a manifest (#307)".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
tsserver tags TS6133 (unused import) Unnecessary, but a tsconfig with
noUnusedLocals promotes it to error severity — which hard-failed whole
runs no rewrite could clear. Carry LSP DiagnosticTag through the
LspDiagnostic event and gate on untagged errors only; lint diagnostics
stay recorded and visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The system block is now for content that does not change during a run.
Anything the run mutates renders as a user message — both because a
mutating system prefix defeats prompt caching and because models
under-weight system-folded content against the trailing user turn.
PromptRenderer: role alone decides the message type; the old
`layer == L0 ||` clause is gone. That clause was silently overriding
role on four packs (InferenceSummarizer, SemanticReviewerImpl,
CapabilityGapReflectorImpl, Talkie session-naming) which are L0+USER
prompts — they were rendering as a system-only request with no user
turn at all.
Re-roled SYSTEM -> USER, all mutable within a run:
recoveryTicket, remainingDelta, groundingFeedback, rejectionFeedback
(trailing slot), plus verifiedBaseline, promotedConcept, claimedTask,
clarificationAnswer, locked steeringNote, factSheet (inline, still
L0-pinned).
Left SYSTEM (immutable): systemPrompt, operatingGuidance,
schemaInstruction, projectProfile, operatorProfile, agentInstructions,
successfulPlanShape.
Trailing-slot scarcity guard: repairMandateSourceTypes joined ALL
matches, so a recovery stage on a retry with an unmet delta would stack
three competing mandates. Now a precedence list emits exactly one
(recoveryTicket > retryFeedback > groundingFeedback > rejectionFeedback)
with remainingDelta appended as the completion signal.
ContextClassifier keys STATIC on layer alone — L0 means pinned/never
pruned regardless of role, so the re-roled L0 entries don't fall through
to FREEFORM and get token-pruned.
Also fixes a stale ContextFeedbackTest assertion (expected a CAS hash
the producer deliberately stopped emitting) and a stale initialIntent
doc comment claiming L0/SYSTEM where the code says L1/USER.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shipped Store 1 persisted repo-file descriptors in a new SqliteObservationStore
— a second, unsynchronized SQLite writer on the event-log DB (no WAL/busy_timeout,
one shared Connection across coroutines) holding a fact the log already carries. That
violates "the event log is the only source of truth" (inv #1/#8): an Observation is
just FileWrittenEvent.path + postImageHash + describe(bytes).render(), and render is a
pure function of content-addressed CAS bytes.
Replace the whole durable apparatus with an in-process memo (descriptorMemo, keyed on
repoRoot+path+contentHash) on the orchestrator, next to the existing artifactContentCache.
Rebuilt from the log on restart, disposable, no external store. Preserves the perf win
(skip CAS read + regex on repeat describes) with none of the concurrency/lock risk.
Deletes: ObservationStore/InMemoryObservationStore/SqliteObservationStore (+ tests),
InfrastructureModule.createObservationStore, the FileReadTool priming path (which never
hit anyway — it keyed on a workspace-relative path while every consumer looks up the
absolute FileWrittenEvent.path), the FileReadConfig/SessionOrchestrator plumbing, and
five orphaned build.gradle deps.
Store 2 (soft-confidence hints) unchanged — it was correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
Store 2 (Fixes): ConceptCompilerProjection already tracked the
unconfirmed(validatedFixes>=1)/falsified(contradicted)/confirmed(promoted)
lifecycle as data, but only confirmed (>=threshold) clusters were ever
delivered. unconfirmedFixEntries reactively matches the CURRENT retry's
classKey against that state and injects a soft one-shot hint (below
threshold) or steer-away (contradicted) before hard promotion — wired
into stage context build in SessionOrchestratorExecution.kt.
Store 3 (Plan-shapes): already shipped as SessionOrchestratorPlanPatterns.kt
(deterministic keyword-Jaccard over eventStore.allEvents(), no embedder
needed) — confirmed complete, no new code required.
FileReadTool hot-path extension to Store 1: a whole-file read now primes
the cross-session ObservationStore (when wired) the same way the write
path already does, so a later describeCached lookup for the same content
hash is warm even if the file was only read, not written, this session.
Adds ObservationStore (core:context) keyed on (repoRoot, path), an in-memory default and
a durable SqliteObservationStore (infrastructure:persistence), and wires it into the two
SessionOrchestratorArtifacts call sites that re-derive a SourceDescriptor from CAS bytes
on every call — a hit on matching content hash skips both the CAS read and the regex
extraction. First slice of docs/plans/2026-07-21-acr-knowledge-accretion.md (build order:
observations before fixes/plan-shapes).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
A stage (configure_ethos_theme) looped forever re-writing an identical
postcss.config.js: file_write returned "written successfully" on a byte-
identical write, and the retry-feedback entry that says WHY it failed got
truncated out of context every turn, so the model cold-started on the same
wrong idea. CAS hashes leaked into its face from two sides too.
- SandboxedToolExecutor: a write whose result is byte-identical to disk now
returns "No change: … nothing was written" (+ noop=true) instead of a false
success signal. Uses the pre-image hashes already captured for reversibility.
- DefaultContextPackBuilder: pin "retryFeedback" in neverDropSourceTypes so the
failure reason + already-written-files list survives truncation.
- ContextFeedback: drop the opaque "— CAS <hash>" from the retry entry; path only.
- tool_output withheld until the session actually spills over-cap output to CAS
(StageConfig.ALWAYS_AVAILABLE_READ_TOOLS -> on-demand via sessionHasSpilledOutput),
so a hash-eating tool isn't advertised to every stage that never spills.
- Wrap 10 long lines in kernel to bring detekt back under maxIssues (99 -> 89).
Tests: infrastructure:tools (+2 no-op cases), core:{transitions,context,kernel} green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #301 escalation event shipped unregistered — it would silently fail to
deserialize on replay, dropping the persisted write-scope grant and re-prompting
the same path every session restart (breaks replay invariant).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Small models frequently fail to comply with the WRITE_SCOPE/PATH_OUTSIDE_MANIFEST
remediation ("add its path via task_update affected_paths") and instead thrash the
same out-of-scope write call turn after turn, burning the session without ever
completing. After `tuning.escalateScopeAfterN` (default 3) consecutive rejections
of the SAME path for a scope/manifest reason, the orchestrator now reaches back to
the FIRST rejected invocation of that path (its pristine, pre-degraded arguments),
and routes it through the existing approval/pause flow instead of rejecting again.
On approval, a new WriteScopeGrantedEvent widens the effective write manifest for
that path for the rest of the session (mirroring how OutsidePathAccessGrantedEvent
already widens out-of-workspace reads) and the first attempt's write is executed.
On denial, the call is rejected as before. escalateScopeAfterN = 0 preserves the
old hard-block-forever behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeyGFXczJb8RUWGBKmkm6G
HealthMonitor's periodic poll took ~18s to notice a dead provider — long
after a retry had already re-selected it and died. Health needs to GATE
routing, not just log reactively.
- InferenceRouter.reportFailure(providerId, reason): new default-no-op
method; DefaultInferenceRouter implements it by writing Unavailable
straight into its health cache, bypassing healthCheck()/TTL entirely.
- SessionOrchestrator.runInference: on a connection-level exception
(ConnectException/SocketException/IOException or a message matching
"prematurely closed"/"connection refused"/"connection reset"), call
reportFailure immediately so the very next route() call — the retry driven
by #299 — sees the provider as down right away instead of re-selecting it
and dying again.
Pairs with #299: routing now (1) reacts to a connection drop instantly and
(2) waits/backs off for the capability to recover before declaring terminal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeyGFXczJb8RUWGBKmkm6G
A transient provider connection drop (e.g. OOM-killed llama.cpp mid-request)
collapsed into a hard NoEligibleProviderException that escaped runInference's
try/catch (the route() call sat outside it), propagated past the stage retry
loop, and landed in ServerModule's session-level catch — failing the WHOLE
session even though the failure was retryable and the provider recovered
seconds later.
- SessionOrchestrator.runInference: wrap router.route() in try/catch so
routing failures become InferenceResult.Failed and flow through the normal
retryable/backoff/exhaustion machinery instead of escaping.
- DefaultInferenceRouter.route: distinguish "capability never configured on
any provider" (fail fast, waiting can't help) from "configured but
currently unhealthy" (bounded wait/backoff — default 3 attempts x 2s —
re-checking health before declaring NoEligibleProvider terminal).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeyGFXczJb8RUWGBKmkm6G
file_edit failed en masse for small models on two ergonomics traps:
- replace() rejected calls that sent `content` (the append param) instead of
`replacement` — intent unambiguous; now accepted as an alias.
- exact-string match died on leading-whitespace drift (model can't reproduce
indentation). Added whitespace-flexible line matching + replacement reindent,
wired into both the pre-exec validation gate and replace().
Also made the WRITE_SCOPE / PATH_OUTSIDE_MANIFEST block messages emit a literal
copy-pasteable task_update(id=..., affected_paths=[...]) call and warn against
action=block — the exact wrong turn models kept taking (18x in one session).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rdo9fe7SujNVeyZA8YkpkD
At the terminal boundary (repair ladder spent, or a non-recoverable gate
exhaustion), run exactly one tool-free diagnostic inference per terminal
failure fingerprint over recorded facts only. A validated — materially new,
confident, recovery-stage-available — RecoveryProposal routes once into the
existing recovery stage via the ticket machinery, bypassing the spent route
budget but bounded by a one-diagnosis-per-fingerprint dedupe so no loop is
possible. Otherwise the run stays terminal FAILED (safe degrade when no
diagnoser is wired).
- New PostFailureDiagnosedEvent + nested RecoveryProposal (registered in
eventModule); every observation/proposal/decision/route recorded for replay.
- PostFailureDiagnoser seam (nullable, mirrors SalvageJudge) + DiagnosisInput
built from the event log only (no fresh workspace observation).
- diagnosisMinConfidence tuning knob.
- Hooked at both terminal boundaries: routeToRecovery ladder-exhausted and
decideGateExhaustion.
Tests: RecoveryRoutingTest (route-once-then-bounded, low-confidence stays
terminal), EventsTest serialization round-trip (proposal + null).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move intent, repo map, docs catalog, decision journal and relevant-files
context entries to EntryRole.USER so they no longer fold into the single
leading SYSTEM block — that block stays pure policy/schema. Omit L3 repo-map
retrieval on repair retries (the transcript already carries the evidence).
Add "initialIntent" to REQUIRED_SOURCE_TYPES.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildRetryFeedbackEntry was L1/SYSTEM, so PromptRenderer folded it into the
leading system block — far from the assistant/tool transcript and weaker than
the original stage task. Flip it to USER role and give the renderer an explicit
trailing repair-mandate slot (a sourceType set, extensible for recovery later):
repair mandates are lifted out of the inline flow and emitted once as the final
message, after the tool evidence and the steering anchor. retryFeedback is
already in REQUIRED_SOURCE_TYPES so it stays unprunable. Golden renderer test
proves the final message is the repair USER mandate and leading system no longer
carries it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A successful file_write/file_edit echoes the whole file body (+ diff) back in
its tool result — up to ~30k chars, which pushed the traced Gemma4 request past
its context window. The write already happened and its full output is durable in
the event log/CAS; the model only needs a receipt that it landed. Replace each
exit=0 write result with a one-line receipt (path + elision marker) in the
context builder; reads, gate output, and nonzero-exit writes stay verbatim.
Derived-only pass over the transcript — authoritative events are untouched, so
it's replay-safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files written by earlier stages this session aren't in the session-start
repo map and were never embedded, so semantic retrieval is structurally
blind to them. Overlay each stage's FileWrittenEvent post-images as
deterministic hits (score 1.0) leading the semantic hits, deduped by path
— no reindex, no embed. Descriptors derive via the comment-free
sourcedesc describe() so agent-written content can't inject prose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A rejected tool call produced one generic, context-free warning per rejection
("a previous tool call was rejected — choose a different approach"), repeated
and session-scoped. With no tool, args, tier, or reason, it read to the model
as noise rather than a correction, and could not tell it which call to avoid.
Replace with a single stage-scoped entry (buildRejectionFeedbackEntry) joining
each rejected ApprovalDecisionResolvedEvent back to its ApprovalRequestedEvent
by requestId: tool name, args preview, tier, and the operator's reason. Scoped
to the stage whose calls were declined; steering notes on a decision are kept
separately. Pure (events, stageId) like buildRetryFeedbackEntry, so unit-tested
directly. Keeps sourceType "rejectionFeedback" (REQUIRED bucket) unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage agents were spending most of their turn budget re-discovering files
already known from prior stages/attempts. Root cause: nothing durable carries
acquired knowledge across handoffs and retries. First four slices of the fix:
- core:sourcedesc — new dependency-free module: describe(path, bytes) derives
comment-free structural navigation metadata (module, bounded symbols, bounded
imports, versioned format). Deliberately non-prose: descriptors are derived
from agent-writable files and rendered into successor-stage context, so
comments/docstrings/literals are excluded to close a prompt-injection channel.
CAS post-image hash stays authoritative; descriptor is disposable navigation.
- kernel retry-repair state (ContextFeedback): on retry, name the authoritative
CAS images of files this stage already wrote so the agent patches them instead
of re-reading to rediscover them.
- kernel file-written manifest (SessionOrchestratorArtifacts): each produced
file surfaced with its authoritative CAS image plus a comment-free structural
descriptor (via core:sourcedesc, over recorded CAS bytes — replay-safe).
- apps/server RepoMapIndexer: route the injected repo-map descriptor through the
comment-free describe(). Previously scraped leading comments, which were
embedded into L3 and surfaced verbatim to successor stages — an injection
channel from one stage into the next. Structural facts (module + imports +
symbols) remain as the retrieval signal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run 771c0b96 marked COMPLETE with a frontend that did not build. Two
verification holes let a broken import through:
1. A MODULE build_expectation delegates to LSP and the execution gate
returned Success trusting it — but tsserver failed to initialize every
stage, so empty diagnostics read as clean. runExecutionGate now only
trusts the MODULE->LSP short-circuit when the LSP run actually ran
(new lspDiagnosticsSkipped projection); on skip it falls through to the
real build command.
2. ExecutionPlanCompiler disabled the terminal whole-project auto build gate
whenever ANY stage declared a build_expectation — so a MODULE (typecheck-
only) declaration removed the real `npm run build` floor. Now only a real
whole-project build (PROJECT/TESTS) suppresses the auto gate; MODULE/NONE
do not. Extracted autoGateStages() helper. Two new compiler tests.
core:kernel + infrastructure:workflow tests green; no new detekt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Discovery-stage clarifications could only be answered over WebSocket
(ClientMessage.ClarificationResponse), so the curl-based headless QA
driver parked forever at discovery.
Add POST /sessions/{id}/clarify mirroring approveStageRoute. The server
resolves the live (stageId, requestId) from the session id via
SessionOrchestrator.pendingClarificationFor() — the newest still-live,
unanswered ClarificationRequestedEvent from the event log — so a curl
caller need not know the requestId. Empty answers = free-text skip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diagnosed from session 508c8d58 (frontend freestyle run, WorkflowFailed):
three independent weak-model-hostile gates, none a model-capability problem.
- shell: split a collapsed single-string command line (["npm create vite …"])
into tokens instead of rejecting it as a "collapsed array". The model
reliably re-emits this shape; rejecting looped bootstrap_frontend until
stage_loop_break. JSON-escape mangles (quotes/commas in argv[0]) stay rejected.
- recovery: a stage_loop_break route now gets a "Stuck-loop ticket", not the
"Contract arbitration ticket". The arbitration prompt told the model to read
and reconcile "the files named below" — but a tool-syntax loop names zero
files, sending the recovery agent grepping the repo for 40+ turns until the
repair ladder exhausted.
- plan lint: H3 (unreferenced_prompt_artifact) demoted from hard failure to
soft finding, and seeds excluded from it. It word-matches artifact IDs in
free prose and cannot tell a forgotten dep from a descriptive mention, so as
a hard gate it burned architect retries on words it couldn't reword away
(analysis/dod). Hard tier is now deterministic graph facts only (H1/H2).
Tests: ShellToolTest, PlanLinterTest, RecoveryRoutingTest green; detekt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ly2mMnt9TCZbvhcC1JfuV
Two failed web-ui freestyle runs dead-ended on the write manifest. The
scaffold_frontend stage declared writes=[frontend/package.json,
frontend/vite.config.ts], so every other file the scaffold produced
(tsconfig, src/main.tsx, index.html, App.tsx) was BLOCKED as
PATH_OUTSIDE_MANIFEST with no agent-facing escape hatch — unlike WRITE_SCOPE,
which advertises task_update. Retry exhausted -> WorkflowFailed.
- ManifestContainmentRule: a write already inside the active task's
affected_paths is allowed even when the stage manifest is narrower. The
task scope is the agent-widenable, recorded authority (see WriteScopeRule);
the stage manifest is a planner hint that defers to it. Block message now
names the remedy (widen affected_paths via task_update).
- architect_freestyle prompt: a scaffold/generator stage must declare its
`writes` as a covering directory glob (frontend/**), not enumerate files,
and use ** not * — frontend/* does not cover frontend/src/main.tsx.
core:toolintent green (78 tests), detekt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A plan that failed grounding used to dead-end — every gate rejection in
FreestyleDriver.lockAndRun was terminal, so a legitimate grounding catch
(e.g. a stage declaring a PROJECT build with no manifest) left the run stuck
with no retry.
lockAndRun is now a gate loop: on a grounding rejection with retries left it
re-runs the planning workflow from the architect stage (rerunArchitect), which
emits a corrected plan, then re-gates. Other gate failures — and grounding once
maxGroundingRetries is spent — stay terminal.
- FreestyleDriver: gate loop + rerunArchitect/maxGroundingRetries seams;
groundPlan returns findings (String?) instead of Boolean; post-grounding
tail extracted to lockAndRunGrounded.
- DefaultSessionOrchestrator.runFrom(startStage) + emitWorkflowStarted(startStage);
run() delegates to it. Lets the re-run enter directly at architect.
- buildGroundingFeedbackEntry (ContextFeedback) injects the already-recorded
PlanGroundingEvaluatedEvent findings into the architect's L1 context on re-run;
wired in SessionOrchestratorExecution.
- Main: rerunArchitect lambda (rehydrate -> runFrom(architect) -> rehydrate).
The architect stage-entry approval gate already reuses a prior APPROVED decision
(alreadyApproved), so the re-run does not re-prompt the operator — added a
FreestyleApprovalGateTest regression guard proving runFrom(architect) with a
seeded approval emits no second request and runs straight through.
Tests: FreestyleDriverTest retry-then-lock + exhaustion->reject(source=grounding);
FreestyleApprovalGateTest reuse-approval guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This branch's uncommitted WIP, committed together (entangled at file level).
Distinct pieces of work:
Freestyle QA fixes (this session):
- FileEditTool: pre-validate replace anchor in validateRequest — reject a
missing/ambiguous target BEFORE the approval gate, mirroring read/write's
file-not-found / read-before-write pre-checks. Shared not-found/ambiguous
messages between validate and execute so they can't drift.
- PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded,
repoMapPaths is "unknown" not "empty workspace" — skip scope grounding
(which proves a path ABSENT) so real paths (apps/server/**) aren't falsely
rejected. Build-manifest check still runs.
- FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a
session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the
COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted).
- ServerModule: resolve project-memory workspace root from the session's bound
workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing
the workspace-binding divergence (correx vs empty scratch dir). Retire tracked
in Vikunja #266.
- LaunchRegistrationRaceTest: join registered jobs before asserting launchCount
— computeIfAbsent returns the Job immediately but the fire-and-forget launch
body lagged awaitAll (the 49-vs-50 flake).
ACR concept-compiler experiment (pre-existing WIP on this branch):
- ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts),
LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace,
config surface, workflow prompts/schemas, orchestrator advance-don't-rerun.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JournalCompactionService and TierContextSummarizer wrapped their event emit
in artifactStore.flushBefore { }. But emit -> SqliteEventStore.append already
calls flushBefore internally, re-acquiring CasArtifactStore's non-reentrant
Mutex -> the coroutine parks forever (no CPU, no thread, no exception, no
terminal event). Only fires once the journal crosses the compaction threshold,
i.e. exactly on long runs.
Emit directly; append()'s own flushBefore still fsyncs artifacts before the
referencing event is persisted, so durability ordering is preserved. Adds a
regression test with a lock-holding fake store (the prior fake took no lock,
which is why the deadlock escaped tests).
Vikunja #244.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG7sGEbVJQncHJtsbPFJZm
HeadTail now accepts an optional salience regex + cap: middle lines matching
it (error|fail|exception|panic|traceback|✗, case-insensitive) survive
truncation in place instead of being silently dropped, so a decisive error
buried in the middle of a long build/test log still reaches the model-facing
context entry. ShellTool's outputCompressor spec wires this in; the raw
build-gate receipt path is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an MCP host layer so external MCP servers (AST/LSP code-intelligence,
package resolvers, etc.) can be mounted at startup and surface their tools/list
as first-class Correx tools named mcp__<server>__<tool>. Each MCP tool is a
Tool+ToolExecutor, so it rides the normal ToolExecutor path and inherits tier
gating, receipts, and event-recorded execution (#5) with zero special-casing;
replay reads the recorded receipt rather than re-calling the server (#8/#9).
- McpProtocol/McpStdioClient/McpTool/McpMounter in infrastructure:tools
(stdio JSON-RPC 2.0, tools/* slice only). Capabilities empty; safety via
default T2 tier since external side effects are opaque.
- [[mcp]] config (id/command/env/tier) + array-of-tables parsing.
- Main wires mounted servers into extraTools (main + per-workspace paths) with
a shutdown hook; a server that fails to start is logged and skipped.
- Tests: in-process fake transport (handshake/list/call/error + tool mapping),
[[mcp]] parser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
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.