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).
This commit is contained in:
2026-07-07 13:27:59 +04:00
parent 879672a47d
commit 41ed6414c6
43 changed files with 1592 additions and 94 deletions
+5
View File
@@ -9,6 +9,11 @@
# All four are llm_emitted: the stage's model emits JSON, which is validated against the schema
# (never trusted) before the artifact counts as produced (invariant #7).
[[artifacts]]
id = "discovery"
schema_path = "schemas/discovery.json"
llm_emitted = true
[[artifacts]]
id = "analysis"
schema_path = "schemas/analysis.json"
+19 -1
View File
@@ -1,5 +1,16 @@
id = "freestyle_planning"
start = "analyst"
start = "discovery"
# discovery runs before the analyst: it vets the request against the real repo and either clears
# it to plan or parks on a batched list of operator questions (grounds contradictions too, e.g. a
# request naming an endpoint the server doesn't expose). Read-only; owns clarification exclusively.
[[stages]]
id = "discovery"
prompt = "prompts/discovery.md"
produces = [{ name = "discovery", kind = "discovery" }]
allowed_tools = ["file_read", "list_dir", "shell"]
token_budget = 16384
max_retries = 2
# analyst writes no files, but it owns task framing: task_search/task_context (read-only) find
# existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write)
@@ -24,6 +35,13 @@ produces = [{ name = "execution_plan", kind = "execution_plan" }]
token_budget = 16384
max_retries = 2
[[transitions]]
id = "discovery-to-analyst"
from = "discovery"
to = "analyst"
condition_type = "artifact_validated"
condition_artifact_id = "discovery"
[[transitions]]
id = "analyst-to-architect"
from = "analyst"
+3 -6
View File
@@ -22,11 +22,8 @@ Emit your result as the `analysis` artifact (JSON, schema provided):
- `requirements`: concrete requirements / acceptance criteria, one per line.
- `affected_areas`: files, modules, or subsystems likely involved, one per line.
If the request is genuinely ambiguous in a way you cannot resolve by reading the code — a missing
decision or a fork only the user can settle — add a `questions` array. Each entry is an object:
`prompt` (required, the question in full), `options` (optional array of suggested answers when the
answer is a choice among known alternatives), `multiSelect` (optional, default false), and `header`
(optional 12 word label). Omit `questions` (or leave it empty) when there is nothing to ask — the
common case. The user answers in a form and their answers return to you for a re-run.
Always produce the analysis — it is your single exit. Resolving operator forks and open questions
is the **Discovery** stage's responsibility, upstream of you; treat the request as settled and do
not ask the user anything here. Anything you cannot answer, answer by reading the code.
Keep it factual and grounded in what you actually read. Do not propose a solution yet.
@@ -26,32 +26,8 @@ Produce the `analysis` artifact by calling the **`emit_artifact`** tool with the
Call `emit_artifact` once you have read enough — do not write the JSON as a plain message.
If — and only if — something genuinely blocks a plan (an ambiguous goal, a missing decision, a
fork only the user can resolve), add a `questions` array. Each entry is an object:
- `prompt` (required): the question, in full.
- `options` (optional): an array of suggested answers as strings. Offer these whenever the
answer is a choice among known alternatives.
- `multiSelect` (optional, default false): true if more than one option may apply.
- `header` (optional): a 12 word label for the question (e.g. "Scope", "Stack").
Greenfield or new-surface work (a new UI, app, or module) almost always hides such a fork even
when the high-level goal is clear: the build tool, styling approach, state/routing libraries, or
component library are choices only the user can pin, and a placeholder instruction does not
resolve them. Ask about stack/tooling in that case rather than guessing.
Example:
```json
{
"summary": "...",
"requirements": ["..."],
"affected_areas": ["..."],
"questions": [
{"prompt": "Which frontend stack should the UI target?",
"options": ["React", "Vue", "Svelte"], "header": "Stack"}
]
}
```
Ask nothing you can answer yourself by reading the code. Omit `questions` entirely (or use an
empty array) when there is nothing to ask — that is the common case. The user answers in a form;
their answers come back to you and you re-run with them in context. Do not design or plan yet.
Always produce the analysis — this is your single exit. Open questions and operator forks are the
**Discovery** stage's job, and it has already run before you: any ambiguity or contradiction the
user needed to resolve was raised and answered upstream, and those answers are in the decision
history above. Treat the request as settled, ground your requirements in what you actually read,
and do not ask the user anything. Do not design or plan yet.
@@ -79,6 +79,12 @@ Emit a JSON object that validates against the `execution_plan` schema:
decompose tasks here — task creation is out of scope for the plan.
- Keep stages small and single-responsibility. Prefer more stages over large monolithic
prompts.
- **Scaffolding uses `file_write`, never a network installer.** A stage that sets up a project
writes `package.json`, config files, and sources directly with `file_write` — it does not
shell out to `npm create vite`, `create-react-app`, `npx`, or any `create`/`init` scaffolder.
Those fetch remote code and prompt on stdin; the run is unattended (no TTY) so they hang, and
`shell` rejects them outright. `shell` in a scaffold stage is for `npm install` / `npm run
build` on files that already exist, not for generating the project.
**edges** — describe every transition between stages. Rules:
- `from` and `to` must each be a declared stage `id` or the literal string `"done"`.
+56
View File
@@ -0,0 +1,56 @@
You are **Discovery** — the first role in a build pipeline, running *before* the analyst. Your
sole job is to decide whether the request is clear and grounded enough to plan against, or
whether it needs the operator to resolve something first. You do **not** analyse, design, or
implement — you vet the request against the real repository and either clear it or ask.
Read-only tools: `file_read` (also lists a directory's entries when given a directory path),
`ls`, `grep`, `cat`, `find`. Use them — do not ask about things you can settle by reading the
code.
Two checks, both grounded in what you actually read:
1. **Underspecification.** Is a fork left open that only the operator can settle — a missing
decision, an ambiguous goal, a choice among real alternatives? Greenfield or new-surface work
(a new UI, app, or module) almost always hides one: build tool, styling approach,
state/routing libraries, or component library are the operator's to pin, and a placeholder
instruction does not resolve them.
2. **Contradiction with the repo.** Verify concrete claims in the request against the actual
tree. If the request assumes something the code contradicts, ask instead of faithfully
building the wrong thing. Example: a request says "connect to `ws://localhost:8080/ws`" but
the server only exposes `/stream` — flag it and ask, rather than implementing the wrong
endpoint.
Emit the `discovery` artifact by calling **`emit_artifact`** with:
- `ready`: `true` when the request is clear and grounded enough to hand to the analyst; `false`
when you are raising questions.
- `questions`: the open questions (empty when `ready` is true). Batch **all** of them into this
one list — do not ask one at a time. Each entry is an object:
- `prompt` (required): the question, in full.
- `options` (optional): suggested answers as strings, whenever the answer is a choice among
known alternatives.
- `multiSelect` (optional, default false): true if more than one option may apply.
- `header` (optional): a 12 word label (e.g. "Scope", "Stack", "Endpoint").
**Default to proceeding.** For a clear, well-grounded request this stage is pure overhead —
emit `{"ready": true, "questions": []}` and let the analyst take over. Only ask when a genuine
fork or contradiction blocks planning. Do not nag, and do not re-ask what the operator has
already answered in the decision history above.
Example (needs input):
```json
{
"ready": false,
"questions": [
{"prompt": "Which frontend stack should the UI target?",
"options": ["React", "Vue", "Svelte"], "header": "Stack"},
{"prompt": "The request says connect to ws://localhost:8080/ws, but the server exposes only /stream. Which is correct?",
"options": ["/stream (current server)", "/ws (add it to the server)"], "header": "Endpoint"}
]
}
```
Example (clear — the common case):
```json
{ "ready": true, "questions": [] }
```
+21 -1
View File
@@ -18,6 +18,8 @@
#
# Requires these artifact kinds in ~/.config/correx/config.toml (schemas under docs/schemas/):
# [[artifacts]]
# id = "discovery"; schema_path = "schemas/discovery.json"; llm_emitted = true
# [[artifacts]]
# id = "analysis"; schema_path = "schemas/analysis.json"; llm_emitted = true
# [[artifacts]]
# id = "design"; schema_path = "schemas/design.json"; llm_emitted = true
@@ -27,7 +29,18 @@
# Prompt files (prompts/*.md, relative to this workflow) must exist for a real run.
id = "role_pipeline"
start = "analyst"
start = "discovery"
# 0. Vet the request against the real repo before any analysis: park on a batched list of
# operator questions (underspecification or a claim the code contradicts) or clear it to
# proceed. Read-only; owns clarification exclusively so the analyst always emits an analysis.
[[stages]]
id = "discovery"
prompt = "prompts/discovery.md"
produces = [{ name = "discovery", kind = "discovery" }]
allowed_tools = ["file_read", "list_dir", "shell"]
token_budget = 16384
max_retries = 2
# 1. Understand the request and the relevant code. Read-only.
# ground_references: every workspace-relative file path the analysis names is checked
@@ -93,6 +106,13 @@ max_retries = 2
# --- forward edges ---
[[transitions]]
id = "discovery-to-analyst"
from = "discovery"
to = "analyst"
condition_type = "artifact_validated"
condition_artifact_id = "discovery"
[[transitions]]
id = "analyst-to-architect"
from = "analyst"