diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 95f237ca..e0b2a88d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -285,6 +285,28 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { ) } +/** + * The stage's role prompt — `prompts/.md` or an inline `promptInline` — as its system prompt. + * + * #416: L0/SYSTEM, so PromptRenderer folds it into the leading system message rather than rendering it + * as a user turn arriving behind the intent, decision journal, repo map and docs catalog, outranked by + * the pinned `schemaInstruction` it contradicts. The renderer reserves the system block for content + * that does not change during a run — "prompts, guidance, profiles" — which is exactly this. + * + * Layer choice is not what pins it: `agentPrompt` is in REQUIRED_SOURCE_TYPES, so it was already exempt + * from pruning at L1 too. + */ +fun buildAgentPromptEntry(text: String, stageId: StageId, tokenEstimate: Int): ContextEntry = + ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L0, + content = text, + sourceType = "agentPrompt", + sourceId = stageId.value, + tokenEstimate = tokenEstimate, + role = EntryRole.SYSTEM, + ) + // CLAUDE.md / AGENTS.md injected as L0 standing context (feat/backlog-burndown). fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry { val content = instructions.content diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index f6ec2785..7cc4b953 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -151,6 +151,13 @@ internal val REQUIRED_SOURCE_TYPES = setOf( // #290: original intent stays unprunable via the REQUIRED bucket now that it renders as // L1/USER instead of relying on the old L0/SYSTEM never-drop placement. "initialIntent", + // The recovery stage is entered by kernel routing and exists ONLY because of its ticket, so + // pruning the ticket leaves it with nothing to repair. Grounding findings are the same shape: + // the architect was handed its plan back, and without them it re-emits the identical plan. + // Both are single latest-state entries (lastOrNull), so pinning adds two entries, not two per + // retry, and both clear themselves — groundingFeedback on a PASS verdict, the ticket on close. + "groundingFeedback", + "recoveryTicket", ) // HTTP statuses that are transient despite being 4xx (F-002 retry classification). diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index 90ba26f2..b4403a0a 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -105,6 +105,13 @@ internal suspend fun SessionOrchestrator.executeStage( // Known-good workspace invariant (design 2026-07-15 seam 2): tell the stage whether the last // green build is still valid for the current workspace state, or stale and needing re-verification. val verifiedBaseline = verifiedBaselineEntries(sessionId) + // #416: the stage role prompt IS the stage's system prompt, so it renders L0/SYSTEM and folds into + // the leading system message. It used to be L1/USER, which made it a user turn arriving behind the + // intent, decision journal, repo map and docs catalog — outranked by the schemaInstruction that + // contradicts it, while PromptRenderer's own rule reserves the system block for exactly this kind of + // content ("prompts, guidance, profiles" — what does not change during a run). Pinning is unchanged: + // agentPrompt is in REQUIRED_SOURCE_TYPES, so it was never prunable at either layer. + // // A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt // SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The // recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries @@ -115,19 +122,7 @@ internal suspend fun SessionOrchestrator.executeStage( } else { stageConfig.metadata["promptInline"] ?.takeIf { it.isNotBlank() } - ?.let { text -> - listOf( - ContextEntry( - id = ContextEntryId(UUID.randomUUID().toString()), - layer = ContextLayer.L1, - content = text, - sourceType = "agentPrompt", - sourceId = stageId.value, - tokenEstimate = estimateTokens(text), - role = EntryRole.USER, - ), - ) - } + ?.let { text -> listOf(buildAgentPromptEntry(text, stageId, estimateTokens(text))) } ?: stageConfig.metadata["prompt"] ?.let { path -> val resolvedText = runCatching { promptResolver.resolve(path) } @@ -143,17 +138,7 @@ internal suspend fun SessionOrchestrator.executeStage( "[SessionOrchestrator] stage=${stageId.value}: " + "declared prompt '$path' could not be resolved", ) - listOf( - ContextEntry( - id = ContextEntryId(UUID.randomUUID().toString()), - layer = ContextLayer.L1, - content = text, - sourceType = "agentPrompt", - sourceId = stageId.value, - tokenEstimate = estimateTokens(text), - role = EntryRole.USER, - ), - ) + listOf(buildAgentPromptEntry(text, stageId, estimateTokens(text))) } ?: emptyList() } @@ -280,11 +265,17 @@ internal suspend fun SessionOrchestrator.executeStage( val remainingDeltaEntries = remainingDeltaResults ?.let { buildRemainingDeltaEntry(contractFailureItems(it)) } ?.let { listOf(it) } ?: emptyList() + // #416: promptEntries sits directly after systemPrompt. System entries render in (layer, ordinal) + // order and the builder stamps ordinals by position, so this puts the role mandate at the head of + // the system block — adjacent to the generic preamble it extends, and ahead of the schema + // instruction. It used to trail schemaEntries, which is how a pinned "respond with JSON only" + // ended up outranking the role's own emit_artifact instruction. var accumulatedEntries = stampBuckets( - systemPrompt + operatingGuidance + promotedConcepts + successfulPlanShapes + verifiedBaseline + + systemPrompt + promptEntries + operatingGuidance + promotedConcepts + successfulPlanShapes + + verifiedBaseline + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries + journalEntries + repoMapEntries + claimedTaskEntries + - needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + + needsEntries + schemaEntries + vocabularyEntries + steeringEntries + rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries + recoveryTicketEntries + unconfirmedFixHints + remainingDeltaEntries, ) diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index 54c375b2..2b7484e5 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -26,6 +26,7 @@ import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId import com.correx.core.events.events.RepoKnowledgeHit import com.correx.core.kernel.orchestration.buildAgentInstructionsEntry +import com.correx.core.kernel.orchestration.buildAgentPromptEntry import com.correx.core.kernel.orchestration.buildArtifactKindVocabularyEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildRelevantFilesEntry @@ -194,6 +195,19 @@ class ContextFeedbackTest { assertEquals("projectProfile", entry.sourceType) } + @Test + fun `stage role prompt renders as the system prompt, not a user turn`() { + // #416: as L1/USER this arrived as a user message behind the intent, journal, repo map and docs + // catalog, outranked by the pinned schemaInstruction that contradicts it. Live session fced377e: + // discovery never acknowledged its role and drifted into implementation. + val entry = buildAgentPromptEntry("You discover. You do not implement.", StageId("discovery"), 7) + assertEquals(ContextLayer.L0, entry.layer) + assertEquals(EntryRole.SYSTEM, entry.role) + assertEquals("agentPrompt", entry.sourceType) + assertEquals("discovery", entry.sourceId) + assertEquals("You discover. You do not implement.", entry.content) + } + @Test fun `agent instructions render as single L0 entry`() { val entry = buildAgentInstructionsEntry(