Run operator-configured static-analysis commands (compiler/detekt/formatters) as a
deterministic harness step on the producing stage, before the LLM reviewer. A stage
declares `static_analysis = [commands]`; after it produces its artifact, each command
runs in the workspace root and the results are recorded in a StaticAnalysisCompletedEvent
(invariant #9 env observation — recorded live, folded on replay, never re-run). A
non-clean command fails the stage retryably with its output fed back verbatim (§2:
compiler/test output is ground truth), so the implementer fixes it and only static-clean
code ever reaches the reviewer. This makes §5's "reviewer context excludes static findings"
mechanical — the findings are resolved upstream, so the reviewer can't waste inference on
what detekt catches for free; no output-parsing, no context plumbing.
- StaticAnalysisCompletedEvent + StaticAnalysisFinding (core:events) + registration.
- StaticAnalysisRunner seam + ProcessStaticAnalysisRunner (whitespace-split argv, stderr
merged, timeout→nonzero exit) in core:kernel; wired (nullable) into OrchestratorEngines
and the server. Null runner / no workspace root → logged no-op, never blocks the run.
- StageConfig.staticAnalysis + `static_analysis` TOML field; runStaticAnalysis folded into
a runPostStageGates sequence (produces → grounding → echo → static analysis).
- Documented `static_analysis` on the role_pipeline implementer stage (commented; commands
are workspace-specific). The reviewer prompt half shipped in 3467826.
New brief_echo stage before the planner in role_pipeline: the model restates
the analyst brief as a structured artifact, and a deterministic gate
(checkBriefEcho, opt-in via brief_echo=true) diffs the restatement against the
original brief. On divergence — dropped requirements (Jaccard coverage < 0.34)
or hallucinated files — it emits BriefEchoMismatchEvent and fails the stage
retryably, so a misread brief never reaches plan generation.
The diff (BriefEchoDiff) is a pure function of two recorded artifacts, so it is
replay-safe and records no observation; the event fires only on mismatch, for
audit. Symbols are recorded but non-blocking in v1. Mirrors the groundBrief-
References gate. role_pipeline only; freestyle_planning wiring is a follow-up.
Runtime install (like research): copy brief_echo.json + brief_echo.md and the
[[artifacts]] block into ~/.config/correx.
Flip §A live health TUI pane to pending-live-QA (fd67a6c), register the QA
gate in §F, add docs/qa/QA-health-tui-pane.md. Note that §4 health-checks is
now feature-complete in code (3 probes + pane) — retire as a unit once QA passes.
Surface the health subsystem (llama/event-store/disk probes) in the TUI,
mirroring the session-stats pane: ClientMessage.GetHealthChecks ->
ServerMessage.HealthChecks (health.checks, reuses HealthReport) ->
StreamQueries.healthChecks via HealthInspectionService; Go OverlayHealth on
key H + palette 'health checks', pull-based fetch, per-subject status with
degraded loud. Empty/disabled -> visible fallback (no blank/lie). Go+Kotlin
golden tests pin the wire contract field-for-field. Observability spec §4/§3.
Third HealthProbe (after disk + llama): times a cheap lastGlobalSequence()
read as an event-store responsiveness heartbeat — DEGRADED on throw or when
latency exceeds eventStoreLatencyWarnMs (default 500). Read-only by design
(no probe writes into the source-of-truth log; Invariant #1). Plugs into the
existing HealthMonitor; no new events. Observability spec §4.
Also wraps a long line in LlamaServerHealthProbeTest to clear a detekt
MaxLineLength finding introduced in aaf896d.
Second HealthProbe (after disk-watermark): liveness via GET /health on the
configured llama base URL + tokens/sec degradation watch derived from recorded
InferenceCompletedEvents (windowed average vs configurable threshold, opt-in via
llamaTpsWarnBelow). Plugs into the existing HealthMonitor edge-detection loop;
no new events. Observability spec §4.
Synthesize the five 2026-06-13 design specs and session memory into a
single outstanding-work list. Header rules: resolved items move to
RETRO.md; every finished feature gets a live-QA plan before it counts as
resolved. Add docs/qa/TEMPLATE.md as the observable-evidence QA form.
OverlayIdeas (opened with I, global/cross-session) lists the idea board in the
artifact-viewer shell: ↑↓ to move, x/d to remove (sends DiscardIdea, optimistic
drop), esc to close. Pull-based via ListIdeas -> idea.list.
ListIdeas -> idea.list (StreamQueries folds the board from the log, replay-neutral)
and DiscardIdea -> append IdeaDiscardedEvent landed in the capturing session, then
reply with the refreshed board. IdeaDto on the wire.
The rubber-duck path emits a fenced {"ideas":[…]} block; Ideas.parse strips it
from the chat turn and onUserInput records an IdeaCapturedEvent each (CHAT only).
IdeaReader folds eventStore.allEvents() into the active cross-session board, and
DefaultRouterContextBuilder injects a capped "## Ideas" entry — router-only, so
workflow stages never see it. Extended SYSTEM_PROMPT option (d) to capture ideas.
IdeaCapturedEvent(ideaId, sessionId, text, timestampMs) + IdeaDiscardedEvent
(ideaId, sessionId) tombstone, registered in eventModule. The cross-session idea
board is rebuilt from these via eventStore.allEvents(); discard hides without
deleting (invariant #1).
Render a workflow.proposed frame as a single-select picker with a manual-answer
slot: ↑↓/jk to choose, enter to launch, e for custom, esc to peek away. Picking
a candidate sends StartSession (and focuses the launched session); the custom
slot continues the conversation via ChatInput. Reuses the modal helpers from the
clarification view.
Map WorkflowProposedEvent to a workflow.proposed ServerMessage carrying the
candidates + original request. No new ClientMessage: a pick reuses StartSession
(launch the chosen workflow seeded with the request) and the manual-answer slot
reuses ChatInput.
The triage system prompt now appends a fenced json propose_workflows block;
WorkflowProposals.parse strips it from the visible chat turn and onUserInput
records a WorkflowProposedEvent (CHAT only). Candidate ids are validated against
the registered workflows (invariant #7) so an invented id never reaches the
operator. Bumped a token-budget test for the larger prompt.
ProposedWorkflow + WorkflowProposedEvent(sessionId, proposalId, prompt,
candidates, originalRequest), registered in the polymorphic eventModule.
Records the router's triage suggestion of candidate workflows as a
non-authoritative fact the operator acts on.
Replace the inline "Q:"-prefixed-summary convention with a structured
`questions` array of {prompt, options?, multiSelect?, header?} objects that
rides in the analysis artifact (allowed by additionalProperties:true, skipped
by JsonSchemaValidator). The kernel parses it to drive the producer-exit
clarification loop; the TUI renders it as an interactive form.
Render stage clarification questions as an AskUserQuestion-style form:
header chip, prompt, selectable option chips, and a free-text custom slot.
Arrow/hjkl nav, space to select, e for custom, enter to submit. Submit
guards on all-answered and sends ClarificationResponse; the parked stage
re-runs with the answers in context.
Surfaces the clarification loop over the wire: ServerMessage.ClarificationRequired
(clarification.required, carrying the structured questions) is mapped from
ClarificationRequestedEvent; ClientMessage.ClarificationResponse carries the
operator's answers and is routed to orchestrator.submitClarification on both
the global and session streams. Reuses the core ClarificationQuestion/Answer
types as the wire shape. Round-trip tests for both directions.
When a stage produces an LLM artifact carrying a non-empty questions
array, the orchestrator parks the run, records ClarificationRequested,
awaits the operator's answers (submitClarification seam), records
ClarificationAnswered, injects the answered Q&A as an L2 USER entry, and
re-runs the same stage so the questions are resolved by the role that
asked them. Runs on a dedicated pendingClarifications map and the
enterStage success-branch loop, so a clarification round never consumes
the failure-retry budget; capped at three rounds. Integration test covers
the happy path and the cap.
Adds the vocabulary for a stage raising open questions to the operator:
ClarificationQuestion/ClarificationAnswer value types, the
ClarificationRequested/ClarificationAnswered events (registered in the
event module), a ClarificationRequestId, and ClarificationQuestions.parse
— a lenient, fence-tolerant reader that pulls an optional questions array
out of an LLM artifact (shared by orchestrator and server).
When given a directory path, file_read now returns its entries (sorted,
sub-directories suffixed '/') instead of failing. The tool description
and path-param schema advertise this so the model discovers it rather
than giving up after a missing-file probe. Generalises file_read into a
'read this path' primitive; the existing PathJail/Plane-2 containment
already covers directories.
The schema declared sources as an array of objects, which correx's flat
JsonSchema model can't represent (JsonSchemaProperty has no nested
properties). The strict config loader rejected it at startup, silently
disabling the source_dossier artifact kind. Flatten sources to an array
of strings (URL-prefixed per-source notes), update the gather prompt to
match, and add ResearchSchemaLoadTest to guard every shipped research
schema against the strict loader.
[tools.research].enabled now defaults true, matching shell/file tools. Safe because
web_fetch is T2 — nothing leaves the machine without operator approval regardless of
the flag; the flag only controls whether the tools are registered. web_search (T1) hits
only the configured SearXNG endpoint.
Makes the research feature runnable end-to-end, off by default.
- config: [tools.research] (enabled, searxng_url, max_results, max_fetch_bytes).
- registration: web_search/web_fetch are built into BOTH the default and per-workspace
tool registries when research.enabled, sharing one HTTP client threaded from Main
(none built on the static path). Egress stays harness-enforced: web_fetch is T2
(operator-approved) and the existing NetworkHostRule still applies.
- workflow: examples/workflows/research.toml — decompose → gather → report, with the
three artifact schemas and prompts. Fan-out (search per sub-question, fetch per source)
runs as repeated tool calls inside the gather stage (Correx has no parallel agents);
per-source synthesis into the dossier is the compression step, so the report stage
consumes summaries, never raw pages. ResearchWorkflowTest validates the graph contract.
To run: set [tools.research].enabled, register the 3 [[artifacts]], copy research.toml +
prompts + schemas into the workflows dir, start SearXNG. Launch like any workflow (the
T2 fetch approval surfaces as an approval card; the report opens in the artifact viewer).
Follow-ups (noted, not blocking): batch fetch-approval at the source-list level (§3),
a dedicated SourceFetched/LowQualityExtraction event (quality + content hash are already
in tool-result metadata), dynamic per-session egress allowlist, and the web approval client (§6).
The research feature is "no new architecture" (spec §1) — composition of existing
mechanisms. The only genuinely new code is the two web tools + the HTML→markdown
extractor (spec marks them "(new)"), and those belong with ShellTool/FileReadTool
in :infrastructure:tools, not in a separate module.
Moves extract/ + web/ from the short-lived :infrastructure:research module into
:infrastructure:tools (packages com.correx.infrastructure.tools.{extract,web}); adds
jsoup + ktor-client there; removes the module from settings.gradle. No behaviour change
— 44 tests green.
Searches the self-hosted SearXNG JSON API: local-first, no API keys, no third-party
telemetry. Hits only the configured endpoint, so unlike web_fetch there is no
arbitrary-host egress to police — T1. Returns the top-N results as a readable list
(title / url / snippet) with the result URLs in metadata so the fetch stage can act on
them and the operator can approve the source list rather than each URL.
Tested with Ktor MockEngine (query encoding, result parsing/cap, empty + error paths).
Completes the §3 "two tools" pair; with slice-1 extraction this is the deterministic
+ network-tool foundation of the research workflow.
The first tool that punches through the sandbox to the network. T2, declares
NETWORK_ACCESS with url marked NETWORK_TARGET, so egress is enforced by the harness
(NetworkHostRule, plane 2) — not tool courtesy. Header-first guards (§3): content-type
checked before the body is read (binary/media rejected at the header via the extractor's
shared content-type policy); declared Content-Length capped; the read itself is bounded
so a server lying about length can't blow the cap (default 10 MB).
On success returns the extracted markdown plus metadata for citation lineage + replay:
url, content_sha256 (over the raw bytes), fetched_bytes, extractor_version, quality.
Consumes slice-1's HtmlMarkdownExtractor. Tested with Ktor MockEngine.
NOT yet registered in the live tool registry: with an empty networkAllowedHosts the
host rule allows everything, so exposing a fetch tool before the egress allowlist
(SearXNG endpoint + approved-source hosts, per-session) is wired would be an open-egress
hole. Registration + egress config land with the research workflow slice.
First slice of the research workflow: the pure, zero-inference extraction pipeline
that the WebFetchTool will consume. New :infrastructure:research module (jsoup).
HtmlMarkdownExtractor: content-type dispatch (HTML extracted; JSON/text pass
through; binary/media/pdf rejected at the header), then structural strip
(MainContentSelector: drop script/style/nav/footer/aside/form + class/id blocklist)
+ density extraction (text − link-text score, semantic <article>/<main> preferred)
+ DOM→markdown (MarkdownRenderer/Table/Inline: headings, lists incl. nested, tables,
fenced code, blockquotes, links, emphasis). Output below the min-length threshold is
flagged LOW_QUALITY so the workflow can route around dead sources (SPAs, paywalls).
extractorVersion ("html-md-1") is pinned on every result so replay reads recorded
artifacts and never re-extracts — the embedding-hash discipline (§5, ADR-0000 §10).
Next slices: WebFetchTool + egress policy + CAS wiring (emits the fetch +
LowQualityExtraction events), then WebSearchTool/SearXNG, workflow graph, synthesis.
Health states are events. A HealthMonitor polls registered HealthProbes on an
interval and appends HealthDegraded/HealthRestored only on a status transition
(hysteresis), so health is observable, replayable, and renderable like every
other fact in the log. The probe reads nondeterministic environment state and
records it at observe-time (Invariant #9); HealthProjection rebuilds current
health by replay, never re-probing (Invariant #8). Monitor is live-only, started
in ServerModule.start alongside narration; seeded from the projection so a
restart does not re-emit a degraded the log already records.
First concrete probe: DiskWatermarkProbe — event-log + CAS size and growth rate,
warning before the 2am surprise. Read surface mirrors `correx stats`:
GET /health/checks replays the system session and `correx health` renders it.
Health events ride the reserved __system__ session, filtered from operator
session listings.
LLAMA_SERVER (liveness + tokens/sec trend) and EVENT_STORE (append/fsync latency)
probes plug into the same HealthProbe framework as follow-ups.
The reviewer prompt told the model to judge "the analysis requirements," but
the reviewer stage only needed [patch, impl_plan] — it never actually received
the analysis artifact, so the acceptance criteria it was meant to check against
were never in its context. A reliability gate checking against criteria it
can't see is theater.
- reviewer now needs `analysis`, so the diff is judged against concrete,
pre-stated acceptance criteria (§5 narrow question) rather than whole files
against taste; the §3 minItems enforcement guarantees those criteria are
non-empty.
- reviewer.md operationalizes §5's two principles: evaluate the diff against
each requirement (findings must map to a requirement/plan-step/defect), and
don't re-report what deterministic tools already catch (compiler/detekt/tests
— trust verification, point at failures, don't re-derive them).
This is the workflow-level realization of §5. The full static-first
infrastructure (running static tools as a pipeline step and mechanically
excluding their findings from reviewer context) needs a static-findings event
representation and is deferred. §6 critic calibration needs runtime history
before it can say anything (spec ordering #5).
Local analysts hallucinate file paths the same as planners. A stage can now
opt in with `ground_references = true`: after it produces its brief, every
workspace-relative file path the brief named is checked for existence, and a
path that doesn't exist fails the stage (retryable) with the misses fed back
("Reference only files you have read") so the model re-grounds.
Grounding probes the filesystem directly rather than the repo map — the map
is recency-ranked and depth/extension-limited, not a complete existence
oracle, so "absent from the map" can't be a hard failure. Existence is a
nondeterministic observation, so the result is recorded as a
BriefGroundingCheckedEvent (invariant #9); replay reads it back and never
re-probes.
- BriefGroundingCheckedEvent + GroundingReference, registered in eventModule
- BriefReferenceExtractor: pure, deterministic pull of relative file-path
references from a brief artifact JSON (needs a '/' and a dotted extension;
skips modules, bare names, URLs, absolute/parent paths — so coarse
"subsystem" mentions the brief is allowed to make aren't false-flagged)
- SessionOrchestrator.groundBriefReferences on the post-verifyProduces path,
gated by stage metadata; uses the existing worldProbe
- TomlWorkflowLoader `ground_references` → metadata; role_pipeline analyst
opts in
Scoped to file references with a '/' + extension (not bare basenames, which
a depth-limited probe can't resolve), so a flagged miss is high-confidence.
The symbol-grounding half of §3 is left for a real symbol index.
Scope creep — an implementer writing files it never declared — is the
dominant local-model failure that compiler/tests don't catch. A stage can
now declare a `writes` manifest (workspace-relative globs); a FILE_WRITE
that resolves inside the workspace but outside the declared set is raised
as PATH_OUTSIDE_MANIFEST → BLOCK, so the model gets the violation as tool
feedback and retries within scope (the §2 bounded-retry signal).
Implemented as a plane-2 rule beside PathContainmentRule (same effect-based
dispatch on FILE_WRITE, same symlink-safe WorldProbe resolution, same
recorded observations so replay reads them back — invariant #9). An empty
manifest is unrestricted; out-of-workspace targets stay PathContainmentRule's
concern, so the two rules don't double-flag.
- StageConfig.writeManifest + TomlWorkflowLoader `writes` parsing
- ToolCallAssessmentInput.writeManifest, threaded from the active stage via
SessionOrchestrator.runPlane2Assessment
- new ManifestContainmentRule, registered in the server assessor
- shared candidatePathStrings extracted so both path rules judge the same
target set
- role_pipeline.toml documents the option on the implementer stage
The dedicated ManifestViolationEvent the spec names is not added: the
violation is already recorded replayably in ToolCallAssessedEvent (issue +
observations + BLOCK) and surfaced in the TUI rationale band. A first-class
event is worth adding only once reconciliation consumes it.
The JSON-schema artifact validator enforced `required` keys but not array
contents, so a model could emit `"requirements": []` (or `"steps": []`,
`"components": []`) and pass — a misread brief sailing through as a valid
artifact. That empty-list gap is the hole under role-reliability §3's
keystone ("force the analyst to define done; verifiability propagates
downstream").
- JsonSchemaProperty gains `minItems` and `items` (recursive), letting a
schema declare a required-non-empty / element-typed array.
- JsonSchemaValidator checks array length >= minItems and each element's
declared item type (with index in the message). Pure + deterministic;
single-return to stay within the detekt ReturnCount budget.
- Role-pipeline schemas tightened: analysis.requirements, design.components,
impl_plan.steps → minItems 1 + items string. Prompt-free — these arrays
were already `required`; this only forbids them being empty.
§4 (force a non-empty `alternativesConsidered` on the architect) is
deliberately NOT taken: it contradicts the standing "don't raise
alternatives unnecessarily" preference the architect prompt already encodes.
Layer 1 of tui-requirements §5: a no-LLM analyzer that shell-lexes a
proposed command, classifies binaries against a known-command table, and
mechanically flags the constructs that widen blast radius — sudo/doas,
recursive delete, pipe-to-shell (skipping wrappers so `| sudo sh` counts),
redirects outside the workspace, network binaries, base64/eval, and
unparseable input (itself a red flag). Pure + replay-stable: same preview
+ workspaceRoot → same analysis, no environment reads.
The approval band renders command approvals as a card — worst-first flag
chips over the reconstructed command line, each token colored by severity
(T0–T4 ramp). ^x opens a scrollable fullscreen view so a long command is
never truncated. Diff and plain-preview paths are unchanged.
Server: computeToolPreview now renders the shell tool's argv as a full,
shell-quoted command line instead of the 200-char JSON-args fallback,
honouring §3's "full untruncated command". The TUI parser accepts both
the rendered line and the older `{"argv":[...]}` shape for replay.
Layer 2 (model annotation) deliberately deferred per the spec.
The TUI overflowed and became unreadable on narrow terminals: the status bar and
footer spilled far past the edge (justify never truncated), the two-column main
split shrank the events panel to ~20 cols, and the stats overlay wrapped mid-word.
- justify is now width-safe: shrinks the left segment first (the right holds the
high-signal connection clock / active spinner), clamps the right only as a last
resort, ANSI-aware so it never slices escape codes. No line can exceed the width.
- Main area collapses below narrowWidth (96): single full-width session list when
idle; output stacked over the live event strip in-session (the strip is too
valuable to drop — the constraint is width, not height).
- Compact chrome when slim: status drops workspace/gauge/provider-suffix and uses a
short clock ("◷ 3s") + bare spinner; footer uses a reduced hint set and drops the
"Soft·blue" badge; approval action row tightens its gaps so decision keys stay
visible.
- Overlays get near-full-width on slim terminals; stats pane uses compact formats +
per-line clipping so it never wraps.
Tests: no rendered line exceeds the terminal width at 40–160 cols (incl. the stats
overlay); narrow in-session main stacks output+events.
Implements the load-bearing TUI-requirements §2 hard requirements:
- Last-event clock: "last event Ns ago" always visible in the status bar while
in-session, updating every frame tick. Turns warn-colored when an active session
has gone quiet past the stale threshold — the "thinking vs hung" tell. The
WebSocket connection indicator (●/◌/○) already covers immediate drop visibility.
- Unknown-event raw fallback: applyServer now surfaces any unrecognized,
session-scoped event type as a raw row in the event stream instead of silently
dropping it (the frontend must not lie by omission). Recognized-but-unrendered
types (router.response, protocol_error) get an explicit no-op so the default
branch means genuinely unknown.
Tests: agoLabel formatting, unknown-event surfaces-as-row, known-ignored
no-spurious-row. Preview "session" frame exercises the clock.
Deferred: the full Go↔Kotlin per-event-type render matrix (§2 last bullet) — a
larger fixture effort than the core behaviors landed here.
Implements observability-spec §2 + §3-tier-1: metrics as a pure fold over the
event log, no third pillar, no OTel. Every metric is replayable over the full
historical log because it derives from events other contexts already emit — no
new EventPayload subclasses, so no serialization registry change.
- MetricsProjection: Projection<MetricsState> folding inference/tool/approval/
stage/workflow events into raw sums; approval request→decision latency is
correlated in-fold via a transient pending-request map.
- MetricsInspectionService: rebuilds via DefaultEventReplayer, derives read-side
ratios (token throughput, avg approval wait, session wall-time accounting split
into inference / tool / approval-wait / other) into MetricsReport.
- GET /sessions/{id}/metrics route, mirroring the replay endpoint.
- `correx stats <sessionId>` CLI client (--json passthrough), mirroring replay.
- Tests: seeded-event fold correctness with controlled timestamps (7) + CLI
render smoke tests (4).
Provides a reusable builder that packages the event-log-in → derived-state-out pattern for deterministic tests. Implements fixed clock and deterministic event ID generation to ensure byte-identical event JSON across test runs with same payloads.
Acceptance criteria met:
- Two harnesses fed same payloads yield byte-identical stored event JSON
- Fixed clock ensures timestamp determinism (2026-01-01T00:00:00Z)
- Event IDs are deterministically generated (event-0, event-1, etc.)
- Supports both givenEvents (append) and rebuild (replay via projection)