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).
Uncommitted work from the session-robustness-and-dox branch sweep
(docs/qa/QA-session-robustness-and-dox.md), verified alongside the
compression/context fixes:
- SandboxedToolExecutor: validate tool args centrally before dispatch. A
malformed/missing-arg call becomes a recoverable ERROR: (surfaced with the
tool's arg schema so the model can correct + retry) instead of stranding
the stage with no artifact. + validation test.
- PlanLinter: seed artifacts (analysis) produced by the planning phase count
as available producers, so a plan stage that `needs` them isn't flagged as
an unproduced-need; H1 unproduced-needs + trap-state checks. + tests.
- DefaultSessionOrchestrator: live-QA robustness fixes (event-tail /
per-stage budget + retry handling).
- workflow prompts/configs: DOX AGENTS.md alignment + freestyle/task/role
prompt tweaks.
- SessionOrchestratorIntegrationTest: coverage for the above.
- FreestylePlanningWorkflowTest: allow list_dir in analyst tools (follows the
list_dir wiring in 968cbfa).
- QA plan doc for the branch sweep.
role_pipeline.toml is now analyst → architect → decomposer → loop(claim →
implement → review) → done. The decomposer (require_task_decompose) breaks
the design into a task graph; the implementer stage claims the next ready
task on entry (claim_task = true, kernel-driven) with writes auto-scoped to
the task's affected_paths and propose_scope as the operator-approved widen
valve. The loop is gated by the tasks_ready predicate.
Loop precedence rides the resolver's by-id transition ordering (review-1/2/3)
to avoid composite all_of/not conditions in the flat TOML schema: changes →
implementer, approved+tasks_ready → implementer, else → done.
TomlWorkflowLoader gains a claim_task stage field (→ claimTask metadata) and
extracts the metadata build into StageSection.toMetadata. RolePipelineWorkflowTest
rewritten for the tasked graph.
Two coupled gaps from tracing a real run:
1. Freestyle implements in phase 2 via stages compiled from the architect's
execution_plan (ExecutionPlanCompiler sets allowedTools = stage.tools), so the
static allow-lists never reach it and architect_freestyle.md banned every tool
but the file four. Teach the architect to thread an analysis-referenced task
through the plan: implementing stages get task_context/task_update and claim +
submit_for_review; the final/review stage completes it. No task referenced → no
task tools, and the plan never creates one.
2. Give the analyst task_create so the work is framed as a tracked item up front
(role_pipeline + freestyle). "Read-only" for the analyst means it writes no
files; a task is an event-log entry, not a file write — task_create is T2, so
opening one is approval-gated. The analyst names the new id in the analysis so
the implementer claims it and the reviewer completes it; the implementer now
creates only as a fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tool availability is a strict per-stage allowlist, so the task doctrine was
inert in role_pipeline — no stage granted the tools. Add them where they fit
each stage's tier and role, and point the stage prompts at the lifecycle:
- analyst (read-only): task_search/task_context to find related or duplicate
work and ground the analysis.
- implementer: full set — claim before working, submit_for_review once
verification passes, create one if the work warrants tracking.
- reviewer: task_context to judge against the task's own criteria, task_update
to complete it on an approved verdict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reviewer-reliability tracks, both deterministic and unit-verified (no model/network).
§B-§5 — static_check stage seam. The StaticAnalysisRunner + StaticFindingsRecordedEvent +
reviewer-context filter already existed; what was missing was a stage that runs the tool and
emits the event. Added:
- StaticCheckStageExecutor: reads stage metadata (static_tool/static_argv), runs the configured
command via StaticAnalysisRunner, returns a StaticFindingsRecordedEvent. No-op (empty findings)
when no runner is wired or no command is configured — safe to carry unconfigured.
- A deterministic-stage seam in DefaultSessionOrchestrator.enterStage: any stage with
metadata["stage_type"] == "static_check" is run by the executor instead of the LLM subagent,
then advances on its (unconditional) exit edge.
- TomlWorkflowLoader: stage_type/static_tool/static_argv fields → StageConfig.metadata.
- role_pipeline.toml: a static_check stage between implementer and reviewer (no-op until
static_argv + a CommandRunner are set; activation is live-QA-gated, see StaticAnalysisRunner doc).
§B-§6 — critique-outcome producer. The CritiqueFinding type + CriticCalibrationProjection existed
but nothing fed them. Added:
- CritiqueFindingsRecordedEvent (+ CritiqueVerdict): the producing side — a critic's findings +
verdict for one review iteration, carrying modelHash for per-model calibration.
- CritiqueOutcomeCorrelator: pure loop-resolution logic deciding UPHELD (fixed between rounds) /
DISMISSED (persisted into an approved final) / INCONCLUSIVE (open at a non-approved terminal),
per critic (role + modelHash) and per finding id.
- A hook in completeWorkflow/failWorkflow that correlates recorded findings into
CritiqueOutcomeCorrelatedEvents at loop resolution — no-op when none recorded, idempotent.
(LLM-side finding emission stays a separate model-gated activation.)
Tests: StaticCheckStageExecutorTest (4), StaticCheckStageTest integration (1, fake runner →
event + transition), CritiqueOutcomeCorrelatorTest (8), CritiqueCalibrationWiringTest integration
(1, seeded findings → outcomes at completion), updated RolePipelineWorkflowTest. Full suites for
core:events/kernel/critique, infrastructure:workflow, testing:integration green; detekt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
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.