feat(context): reasoning-thread continuity, L3 doc filtering, and gate hardening

Threads reasoning_content across inference calls and journal renders, filters
markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks,
and tightens filesystem tool output/dir-listing behavior surfaced by prior
live QA (see project_readloop_campaign memory).
This commit is contained in:
2026-07-10 11:13:43 +04:00
parent f51a8dada4
commit 3d5e05c1fb
32 changed files with 742 additions and 85 deletions
@@ -41,7 +41,7 @@ object PlanLinter {
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
PlanLintResult(
hardFailures = unproducedNeeds(graph, seeds) + trapStates(graph),
hardFailures = unproducedNeeds(graph, seeds) + trapStates(graph) + unreferencedPromptArtifacts(graph, seeds),
softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph),
)
@@ -89,6 +89,35 @@ object PlanLinter {
}
}
/**
* H3: a stage's inline prompt mentions an artifact ID that is produced by the plan or
* provided by a seed, but the stage does not declare it in its `needs`. The stage will
* not have access to that artifact at runtime, so any reference in the prompt is a dangling
* assumption that will confuse or stall the model — force the architect to either add the
* artifact to `needs` or reword the prompt.
*/
private fun unreferencedPromptArtifacts(graph: WorkflowGraph, seeds: Set<String>): List<PlanLintFinding> {
val produced = graph.stages.values.flatMap { it.produces }.map { it.name.value }.toSet() + seeds
return graph.stages.entries.flatMap { (id, stage) ->
val prompt = briefOf(stage)
val needIds = stage.needs.map { it.value }.toSet()
val ownProduces = stage.produces.map { it.name.value }.toSet()
produced.filter { prodId ->
prompt.isNotBlank() &&
prodId.length >= 2 && // skip single-char IDs (false-positive risk with substring matching)
Regex("\\b${Regex.escape(prodId)}\\b").containsMatchIn(prompt) &&
prodId !in needIds &&
prodId !in ownProduces
}.map { prodId ->
PlanLintFinding(
code = "unreferenced_prompt_artifact",
stageId = id.value,
detail = "prompt mentions '$prodId' but it is not declared in needs",
)
}
}
}
/** S1: more stages than the ceiling. */
private fun stageCount(graph: WorkflowGraph): List<PlanLintFinding> =
if (graph.stages.size > STAGE_CEILING) {
@@ -92,6 +92,37 @@ class PlanLinterTest {
assertTrue(result.passed, "analysis is a planning-phase seed, not an unproduced need")
}
@Test
fun `a stage mentioning its own produced artifact in the prompt is not a failure`() {
val g = graph(
stages = mapOf(
"a" to stage(produces = listOf("plan"), prompt = "write the plan artifact"),
"b" to stage(needs = listOf("plan"), prompt = "build"),
),
edges = listOf("a" to "b", "b" to "done"),
start = "a",
)
val result = PlanLinter.lint(g)
assertTrue(result.passed, "expected pass, hard=${result.hardFailures}")
}
@Test
fun `a prompt mentioning another stage's artifact not in needs is a hard failure`() {
val g = graph(
stages = mapOf(
"a" to stage(produces = listOf("plan"), prompt = "write the plan artifact"),
"b" to stage(produces = listOf("report"), prompt = "use plan to build"),
),
edges = listOf("a" to "b", "b" to "done"),
start = "a",
)
val result = PlanLinter.lint(g)
assertFalse(result.passed)
val h = result.hardFailures.single { it.code == "unreferenced_prompt_artifact" }
assertEquals("b", h.stageId)
assertTrue(h.detail.contains("plan"))
}
@Test
fun `a cycle with no exit is a trap-state hard failure`() {
val g = graph(