Commit Graph

523 Commits

Author SHA1 Message Date
kami bfd925b518 feat(tools): glob + grep read-only workspace search tools
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.
2026-07-12 18:33:36 +04:00
kami d89d4e32a9 feat(inference): operator-tunable sampling knobs (top_k/min_p/repeat_penalty) for stage requests
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.
2026-07-12 17:54:25 +04:00
kami b2c7bbe401 feat(config): relocate orchestration loop/threshold/budget constants to [orchestration] config
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).
2026-07-12 17:45:54 +04:00
kami 8d7c827ebb fix(server,persistence): stop slow WS client stalling kernel; log dropped emits (#53)
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).
2026-07-12 17:11:41 +04:00
kami 3559ea67ef fix(kernel,server): evict per-session caches on workflow termination (#54)
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).
2026-07-12 17:05:34 +04:00
kami 97e56b9275 refactor(kernel): use real tokenizer for journal budgeting estimates (#58)
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.
2026-07-12 13:24:50 +04:00
kami 530b118b67 perf(kernel): batch the artifact-lifecycle event triple via appendAll (#59)
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.
2026-07-12 13:22:05 +04:00
kami 65d5e9de83 fix(persistence): assign event sequences atomically inside INSERT (#56)
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.
2026-07-12 13:19:50 +04:00
kami 759828c0f5 fix(persistence): serialize SqliteEventStore reads with append transaction (#52)
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.
2026-07-12 13:17:38 +04:00
kami bb73bc9371 perf(kernel): hoist read-only check out of per-tool filter (#51)
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.
2026-07-12 13:14:58 +04:00
kami da7fe61b77 perf(talkie): fold idea board once, keep live via subscribeAll (#55)
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.
2026-07-12 13:12:35 +04:00
kami 1fd878eb9b fix(approvals): a REJECTED decision must not satisfy the stage approval gate on retry 2026-07-12 13:08:42 +04:00
kami 8c45650e21 fix(artifacts): rebuild LiveArtifactRepository from event log on cold access 2026-07-12 13:05:52 +04:00
kami b93729008d fix(infra): single-read file_read (no double disk I/O for hash) + bail health poll on dead process 2026-07-12 12:55:12 +04:00
kami 363d880a69 fix(l3): persistent sidecar reader, kill-on-desync, wire persistPath save/load 2026-07-12 12:52:07 +04:00
kami 3ff9c8281a fix(inference): single ContentNegotiation config + firstOrNull on empty choices 2026-07-12 12:49:46 +04:00
kami 46fbd597c3 fix(tools): atomic file_edit writes + bound the patch subprocess 2026-07-12 12:48:30 +04:00
kami c2336ae6f7 fix(tools): don't follow redirects in web_fetch (SSRF) + drop ArrayList<Byte> boxing 2026-07-12 12:46:37 +04:00
kami ddf2c014f1 fix(shell): interruptible timeout + kill the whole process tree (#62)
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.
2026-07-12 12:42:04 +04:00
kami 44e15ea1b7 fix(shell): validate every command position against allowlist, not just argv[0] (#61)
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.
2026-07-12 12:40:42 +04:00
kami 8ef957cd53 fix(sandbox): unique backup names to prevent same-basename collision (#64)
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.
2026-07-12 12:38:27 +04:00
kami cf856742b1 fix(approval): resolve preview paths against workspace root, not server CWD (#57)
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.
2026-07-12 12:37:04 +04:00
kami d0ab027353 fix(list_dir): file-vs-dir mismatch gives an actionable hint (#45)
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.
2026-07-12 12:34:37 +04:00
kami 25ab4faac9 server: S5 memoize listSessionSummaries + S6 Main.kt hygiene
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.
2026-07-12 12:28:19 +04:00
kami df4dd5b155 fix(server): move stuck-approval-pause repair from WS connect to boot (S3)
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.
2026-07-12 12:22:04 +04:00
kami 979e2c3a16 fix(approval): log + evict on failed approval broadcast (S4)
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.
2026-07-12 12:19:16 +04:00
kami caeb9f0868 fix(server): correct pause-reason labels (S7) + bind workspace on REST launch (S8)
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.
2026-07-12 12:16:34 +04:00
kami e2f44387e1 fix(approval,stream): resolve-after-submit + real tier (S1), persisted sessionSequence on live frames (S2)
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.
2026-07-12 12:11:49 +04:00
kami 15248cae8a feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events
- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
  as intent-holder, holds initial intent, reconciles cross-file contract disputes)
  with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
2026-07-11 23:56:52 +04:00
kami 3d5e05c1fb feat(context): reasoning-thread continuity, L3 doc filtering, and gate hardening
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).
2026-07-10 11:13:43 +04:00
kami f51a8dada4 feat(recovery): route failed write-less stages to recovery instead of futile retry
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.
2026-07-08 10:28:53 +04:00
kami d6bada6f10 feat(clarification): rehydrate pending questions on reconnect + restart
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.
2026-07-07 18:30:09 +04:00
kami 8cfc590c7e fix(tui): soft-wrap clarification question prompts
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.
2026-07-07 18:29:56 +04:00
kami c849fd9921 feat(tools): cap file_read output so one read can't flood L2
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.)
2026-07-07 14:59:09 +04:00
kami 82c55ec9c7 feat(context): doc catalog — frontmatter/H1 descriptor as read-on-demand index
Docs were indexed as up to 40 h1-h3 headings per .md (a TOC flood) and
force-fed or excluded wholesale by a binary docs gate. Replace with a
compact, always-on catalog the agent reads on demand.

- RepoMapIndexer: .md now yields ONE descriptor (frontmatter
  description/summary/title -> first # H1 -> first prose line), not a
  heading dump. Replay-safe (still List<String> symbols).
- SessionOrchestrator: always-on '## Docs available (file_read to open)'
  catalog — top-N docs by recency as 'path — descriptor', hard-capped at
  DOCS_CATALOG_MAX. One line each, so it stays present without the
  poisoning the docs gate suppressed. Source layout still excludes doc
  content for non-doc prompts.
2026-07-07 14:52:49 +04:00
kami a8c496e909 fix(toolintent): exempt list_dir from reference-exists + rejection-loop breaker
Greenfield discovery/analyst stages thrashed on plane-2 REFERENCE_EXISTS
rejections: the model listed a not-yet-created dir (e.g. frontend/), got
hard-BLOCKED every round, and burned MAX_TOOL_ROUNDS without progress.

- New ToolCapability.DIRECTORY_LIST; ListDirTool declares it alongside
  FILE_READ. ReferenceExistsRule now exempts directory enumeration (a
  listing of an absent dir truthfully reports empty; only hallucinated
  file *reads* still fail loud). Dispatch stays on capability, not name.
- Stage-agnostic rejection-loop breaker in the tool loop: after 3 rounds
  where every tool call is rejected (BLOCKED/ERROR) with no success,
  force the model to produce its output. Covers JSON-emitting stages the
  read-loop breaker (file_written-only) never protected.
2026-07-07 14:09:03 +04:00
kami efb6eb0334 feat(validation): capability-gap reflection rung (#30 part 2)
Bounded LLM "are you sure?" pass over the capability gaps part 1 detects, run
once at plan-lock before the operator is asked to approve — honest mistakes
self-correct without escalation; only a genuine tool need reaches the human.

- CapabilityGapReflector: fun-interface seam in core:kernel (plain-data in/out,
  no infrastructure:workflow dep), mirroring SemanticReviewer/SalvageJudge.
- CapabilityGapReflectorImpl (apps/server): one InferenceRouter call for the
  whole gap batch, no per-gap loop, no retry; runCatching degrades to a safe
  RESOLVED-advisory default on any failure (unroutable/timeout/malformed JSON) —
  a broken pass can never manufacture a NEEDS_TOOL escalation or a grant.
- CapabilityGapReflectedEvent(verdict RESOLVED|NEEDS_TOOL) recorded per gap and
  registered in eventModule; replay reads the event, never re-invokes inference
  (invariants #7/#8/#9).
- FreestyleDriver.lockAndRun: RESOLVED is advisory (recorded, plan untouched —
  invariant #3); NEEDS_TOOL is appended to the existing requestPlanApproval
  preview so the operator decides — no auto-grant anywhere (#4/#5).
- reflector nullable -> degrades to part-1 behavior when unwired.

Verified: ./gradlew :core:events:test :core:kernel:test :infrastructure:workflow:test :apps:server:test green.
2026-07-07 13:45:00 +04:00
kami 41ed6414c6 feat(guardrails): steering channel + shell-in-file rule + capability-gap detector
Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the
in-flight branch WIP they were built on top of (reasoning_content capture,
operator/project profile editor, write-jail workspaceRoot fix) — the tree is
interdependent (SessionOrchestrator references reasoningArtifactId from the WIP)
and does not compile as separable subsets, so it lands as one commit.

Guardrails:
- #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler ->
  orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context
  fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where
  steering typed off an approval gate was silently dropped.
- #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a
  file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool
  description now advertises auto-mkdir of parent dirs. Basename-allowlist so the
  extensionless case is caught; scripts/Makefiles/multiline exempt.
- #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage
  intent -> implied ToolCapability, compares to granted tools, emits advisory
  CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails
  the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2.

Verified: ./gradlew check green (whole tree).
2026-07-07 13:27:59 +04:00
kami 879672a47d feat(recovery): failure-ticket routing + review-gate RECOVER + return-to-sender
Retry-agency invariant: a stage may only retry a gate it has the capability
to change. A write-less stage failing a build/contract/static gate (e.g.
freestyle final_verification with allowedTools=[shell]) can never fix it, so
retrying in place is futile until the budget drains (Vikunja #41).

Instead: open a FailureTicketOpenedEvent (category + requiredCapability derived
deterministically from the gate id, no LLM) and route to a recovery stage that
holds the capability, bounded by a small per-stage route budget.

- Slice 2: SalvageDecision.RECOVER (ternary CONTINUE/RECOVER/FAIL) lets the
  review-gate salvage judge hand off to recovery. Shared routeToRecovery()
  unifies the deterministic agency guard and the judge on one destination;
  decideGateExhaustion now returns StepResult?.
- Return-to-sender: recovery's exit is dynamic (recoveryReturnMove) — it goes
  back to the exact ticket-origin stage to re-run its gate, so a write-less
  gate anywhere in the graph is handled without skipping intervening stages.
  The synthesized recovery->terminal edge is now only a no-ticket fallback.
- Freestyle wiring: ExecutionPlanCompiler injectRecovery flag (Main passes
  true) synthesizes a write-capable recovery stage; ticket evidence reaches it
  via buildRecoveryTicketEntry.
- Read-only tools always present: StageConfig.effectiveAllowedTools adds
  file_read/list_dir to any tool-granting stage (fixes the verifier reaching
  for `shell ls -R` to inspect the tree).

Tests: RecoveryRoutingTest (deterministic gate, no static return edge — proves
dynamic return) + GateRetryBudgetExhaustionTest RECOVER case + two compiler
tests. All green; detekt clean.
2026-07-07 12:05:26 +04:00
kami 597a26d551 feat(validation): auto build-gate on terminal freestyle stage (Gate 4 floor)
Closes the COMPLETED-lie where a freestyle stage passes by producing a
schema-valid artifact regardless of whether its build actually succeeded:
a verify stage could run 'npm run build', watch it fail, then self-attest a
build_verified artifact and transition to done. Tool failures never failed
the stage.

The compiler now flags the terminal stage autoBuildGate=true when no stage
declares an explicit build_expectation. Code-ness is decided at runtime, not
compile time (the declared kind is always the generic file_written; only the
written paths reveal code): runExecutionGate folds the FileWritten manifest via
sessionProducedBuildTarget() and promotes to a PROJECT build when a build
manifest (package_json/gradle_module) or an imports_resolve-bearing path was
written, then runs the real command from .correx/project.toml [commands]. A
non-clean exit fails the stage retryably; a docs-only plan is left untouched.

Live-proven (run 3, session 28812b44): gate fired on the real
'npm --prefix frontend run build', exit 2 on a bad tsconfig, RetryAttempted
instead of a false COMPLETED.

Modules green: transitions+workflow 67, kernel 60.
2026-07-07 01:01:12 +04:00
kami 79e2c38a88 feat(retry): per-gate retry budgets + progress-aware charging + hybrid salvage
Replaces the single shared per-stage retryCount (reset on TransitionExecuted,
shared across all post-stage gates) with a per-gate budget. Motivated by run
2e468f9f, where a promising freestyle run was terminally FAILED because the
contract gate burned all 3 shared retries on one trivial miss before the
semantic-review gate ever ran.

- StageExecutionResult.Failure gains a `gate` id; each gate tags its failures.
- OrchestrationState gains gateRetryBudgets / gateFailureFingerprints /
  gateSalvageUsed (all rebuilt from events, reset per-stage on TransitionExecuted).
- FailureFingerprint normalizes+hashes the failure reason; RetryCoordinator.decide
  charges a gate's budget only when the fingerprint is unchanged (no progress),
  so a moving run is never penalised. Returns Retry | Exhausted.
- Hybrid exhaustion: deterministic gates fail; the review gate consults a
  SalvageJudge (LLM, or a deterministic allow-one-reset fallback), recorded as
  RetrySalvageDecidedEvent (invariant #9). CONTINUE resets the gate budget once;
  a second exhaustion is terminal.
- Review gate is now the sole authority on review-retry termination; the old
  REVIEW_BLOCK_RETRY_CAP is repurposed as a high absolute backstop only.
- ServerModule escaped-exception path now records a truthful terminal
  WorkflowFailed (real stage/reason/retryExhausted) via recordUnhandledFailure.

Tests: DefaultRetryCoordinatorTest (fingerprint charging) +
GateRetryBudgetExhaustionTest (deterministic exhaust / review CONTINUE-then-
terminal / review FAIL / null-judge fallback) + reducer coverage.
2026-07-06 19:20:46 +04:00
kami 9f12c87bb1 feat(validation): plan-compile gate + semantic-review gate (staged verification #19/#20)
Plan-compile gate (#19): PlanCompilationCheck seam (null-on-replay),
runPlanCompileGate guards a produced execution_plan slot and compiles its
cached content via ExecutionPlanCompiler, emitting PlanCompileCheckedEvent.
On failure the architect gets a retryable hand-back and self-corrects;
exhaustion terminates the dead-end workflow instead of shipping an
uncompilable plan.

Semantic-review gate (#20): SemanticReviewer seam + StageConfig.semanticReview
opt-in (TOML semantic_review + freestyle plan JSON). runReviewGate reads the
FileWritten manifest (never blind context), asks a review-capable model for
PR-comment findings, and is advisory — blocks retryably only on FAIL plus a
correctness finding >=0.7 confidence, capped at 2 blocks so a stubborn stage
completes advisory-only rather than trapping. SemanticReviewerImpl talks to
the provider directly; any reviewer error degrades to PASS. REVIEW_MAX_TOKENS
is 8192: gemma is a reasoning model and 1024 truncated it mid-reasoning before
it reached the JSON verdict (empty content -> parse degraded to PASS).

Both gates live-QA'd on the real repo: plan-compile passed on a freestyle
web-UI scaffold; semantic review caught all 3 planted bugs in a maxOf() and
exercised the block->retry->cap safety valve end-to-end.
2026-07-06 13:11:16 +04:00
kami 28e9e698a1 feat(inference): capture reasoning_content on responses (WIP, provider side)
Adds a nullable `reasoning` field to InferenceResponse and maps the
llama.cpp / OpenAI-compat `reasoning_content` field into it, so local models
that emit their chain-of-thought have it captured. Provider/model side only —
the emit-site event field (reasoningArtifactId) + CAS storage in the
orchestrator are still pending (tracked in Vikunja Correx #4).
2026-07-06 01:26:01 +04:00
kami ff1a0ffdad feat(validation): staged-verification funnel — contract + execution gates
Gate 2 (contract) and Gate 4 (execution) of the 4-gate staged-verification
design, plus the two gate-quality fixes found in live QA.

- Gate 2: KindContractTable (kind→assertions registry, universal floor,
  additive project overrides) + KindInference (path→kind, pure/replay-safe)
  + ContractAssertion model; ContractGateEvaluatedEvent (registered);
  ContractAssertionEvaluator seam + FileSystemContractEvaluator (FS/TEXT,
  COMPILER skipped); runContractGate in runPostStageGates — a stage that
  produces file_written artifacts must have every declared+written file
  exist/non-empty/parse, else retryable hand-back with per-assertion feedback.
- Option A: ExecutionPlanCompiler puts concrete (non-glob) plan `writes` into
  StageConfig.expectedFiles, so a declared-but-unwritten file fails file_exists.
- Gate 4: BuildExpectation enum (none|module|project|tests) + plan field +
  closed-set compiler validation; runExecutionGate resolves the alias against
  the bound project profile commands and runs it as a deterministic gate.
- Live-QA fixes: react_entry kind so main.tsx/index.tsx aren't required to
  export a default component (was an unwinnable false-positive); JSONC
  tolerance (allowComments/allowTrailingComma) so tsconfig.json passes valid_json.

Seams are null on the replay harness (no-op) — recorded events are truth (#9).
2026-07-06 01:25:51 +04:00
kami 33ea44d8cc feat(context): required/optional bucket split, budget ceilings, grounded handoffs
Context rework from the 2026-07-05 context-poisoning diagnosis (session
6ca236c4 read-loop):

- ContextBucket on ContextEntry: REQUIRED (task, constraints, needed
  artifacts, failure feedback) is never pruned/compressed/dropped; the
  build fails fast (RequiredContextOverflowException -> non-retryable
  stage failure) if required alone exceeds the stage budget.
- OptionalCeilings: per-sourceType token caps for the optional block
  (repoMap 1200, relevantFiles 1600, journal 800, profiles 400/600,
  vocabulary 400), tuned for the 9-26B tier at 16k; enforced by
  truncation (journal keeps its tail, others their head).
- Repo map is now a structural 'what exists' listing (alphabetical,
  per-directory, no recency ranking, no symbols); .md/docs paths are
  gated behind an explicit docs mention in the stage prompt — retrieval
  hits remain the only other path for docs to enter context.
- file_written needs render as a manifest of ALL files the producing
  stage wrote (projected from ArtifactContentStored -> ToolInvocation
  -> FileWritten events), fixing the last-write-wins collapse; content
  stays lazy behind file_read.
- Decision journal folds a stage's RETRY records into one summary line
  once a later transition leaves that stage (render-time only).
- Dedupe retrieved-file lines in buildRelevantFilesEntry.
2026-07-05 18:05:02 +04:00
kami f90f2ab39d feat(validation): artifact-emission repair ladder + empty-schema fix
Deterministic repair pipeline (extraction, classification, policy, gated
LLM-repair rung) with ArtifactRepairAttempted/Failed events. Also fix
JsonSchemaValidator: a schema with no declared properties describes 'any
object' — unknown-property enforcement now only applies against a declared
shape (empty-properties schemas were rejecting every real artifact).
2026-07-05 18:04:36 +04:00
kami 24f3b843bb fix(talkie): label narration actions 'in this run', not 'this stage'
The actions line is session-wide, but the old 'Actions taken this stage' label
made a literal narrator (Qwen2.5-1.5B) contradict itself ('no files read this
stage'). Relabel to match reality.
2026-07-03 14:33:02 +04:00
kami bc5fedf99b fix(talkie): surface narration actions in the user turn, session-wide
Live run showed the actions were in the prompt but the 1.2B narrator ignored
them: they sat in a system-role entry (small models under-weight system context)
and were stage-scoped to the pausing stage, which usually isn't the one that ran
the tools. Fold the recent actions into the USER trigger instruction instead, and
take the last N tool calls across the whole run rather than per-stage. Narration
now names the concrete actions ("reviewing server directories and reading the
specified file") instead of generic filler.
2026-07-03 14:03:24 +04:00
kami 1c027e8189 feat(talkie): ground narration in the stage's actual tool actions
Narration was generic ("Stage X completed. Summarise.") because nothing about
what the stage actually did reached the narrator — the L2 summary is a
placeholder and StageCompletedEvent carries no output. narrate() now reads the
stage's ToolInvocationRequested events from the log and feeds the narrator a
concrete 'Actions taken this stage: file_read(path), ...' entry, and the system
prompt asks it to prefer those concrete actions over restating the stage name.
2026-07-03 13:40:00 +04:00
kami 0c8a7e88f6 docs(config): rename sample [router] sections to [talkie] 2026-07-03 13:26:48 +04:00