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.
This commit is contained in:
2026-07-07 01:01:12 +04:00
parent 79e2c38a88
commit 597a26d551
6 changed files with 185 additions and 11 deletions
@@ -43,5 +43,13 @@ data class StageConfig(
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
val semanticReview: Boolean = false,
// Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's
// terminal stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
// file-writing stage as the generic `file_written` kind (never a typed code kind) and rarely sets
// build_expectation, so a plan that scaffolds real code would otherwise never hit an execution gate.
// When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the
// session actually wrote a code module (decided at run time from the FileWritten manifest + path→kind
// inference), so a docs-only plan is left untouched. Explicit build_expectation always wins.
val autoBuildGate: Boolean = false,
val metadata: Map<String, String> = emptyMap(),
)