Commit 07a0a27edc47d5cd63a3244209fe1011085b056c

Authored by bboonnee
1 parent e3baa491

Give Planner/Coder/Validator real, machine-checkable acceptance criteria

Root cause traced (docs/implementation-gap-analysis.md): the Benchmark
document Validator relies on for its verdict was frequently a narrative
about the work session ("The planner workflow completed and produced a
Benchmark defining 18 scenarios...") instead of the actual scenarios --
found via a real skill-detail-page task's benchmark.md, then confirmed
present since the earliest real self-hosting runs. Validator's own
judgment was, in practice, resting on thin, sometimes-vacuous prose.

Planner's work graph (brd->prd->sdd->benchmark) extends to
benchmark->test_dataset->test_script: test_dataset/test_script are always
present as nodes, but the generating subagent judges per-goal whether a
real dataset/script applies (many goals, e.g. UI wiring, legitimately
don't have one -- replying 'N/A: <reason>' is a real, expected answer, not
a failure). test_script gets a genuinely mechanical verify_fn (real
py_compile/node --check, not LLM judgment) -- the one node type where an
objective check is actually possible. All four document prompts also gained
an explicit anti-narration instruction (only 'brd' had a weaker version of
this before).

Coder's implementation prompt now receives the real benchmark/dataset/
script and must make the Planner's real script pass for real, not just
whatever tests it invents on its own.

Validator gets its own 3-node Work Graph (validator_benchmark ->
validator_test_dataset -> validator_test_script), generated independently
from SDD/PRD -- deliberately NOT reusing Coder's or Planner's own copies,
same reasoning as a held-out eval set vs. the dev set a model was tuned
against. The resulting script, if real, is actually executed against the
current repository state; a non-zero real exit code force-overrides the
verdict to FAIL in code (user-confirmed hard gate: the script is not up
for LLM debate on a failure, only on a pass can subjective judgment add
INCONCLUSIVE/risk notes).

schema.py: artifact.artifact_type's CHECK constraint gains the 5 new
types, plus migrate_expand_artifact_types() -- SQLite has no ALTER-based
constraint migration, so this is the standard rebuild-copy-swap dance,
applied for real to the live ~/.graphlemon/graphlemon.db (42 rows,
verified byte-identical count before/after, integrity_check clean).

skills/qa-task-validation.md and skills/project-sdd-planning.md already
gained a standing principle from this same incident: verify cross-task
data dependencies against real system state, not seeded fixtures alone
(see prior commit d7035fc) -- this commit is the mechanism-level half of
the same fix, not just the prose-guidance half.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/e2e_pipeline.py
... ... @@ -48,6 +48,7 @@ attempt, then `ConsistencyCheckFailedError` if it&#39;s still contradictory).
48 48 import json
49 49 import os
50 50 import subprocess
  51 +import tempfile
51 52 import uuid
52 53  
53 54 from src import (
... ... @@ -76,28 +77,63 @@ _PRD_CONSISTENCY_PROMPT = (
76 77 "contradiction, and list each one in issues.\n\nPRD:\n\n{prd_content}"
77 78 )
78 79  
  80 +_NO_NARRATION = (
  81 + "The `content` field must contain ONLY the raw document/code text itself -- nothing "
  82 + "else. Do not include any sentence describing what you did, what workflow ran, how "
  83 + "the process went, or that you completed a task. A machine parses `content` directly "
  84 + "into a file with no human review; any narrative text becomes permanently embedded in "
  85 + "a document that's supposed to look like a real, standalone deliverable. Start "
  86 + "directly with the content itself (e.g. a Markdown document starts with its own `#` "
  87 + "heading, not a sentence about producing it)."
  88 +)
  89 +
79 90 _DOC_PROMPTS = {
80 91 "brd": (
81 92 "Write a BRD (Business Requirement Document) for this goal, following your own "
82 93 "SOUL.md methodology: {goal_text}\n"
83   - "Reply with the document content (Markdown) via the required schema -- no preamble, "
84   - "no meta-commentary about what you're about to do."
  94 + "Reply with the document content (Markdown) via the required schema. " + _NO_NARRATION
85 95 ),
86 96 "prd": (
87 97 "Here is the approved BRD for goal '{goal_text}':\n\n{brd_content}\n\n"
88 98 "Write the PRD (Product Requirement Document) based on this BRD. "
89   - "Reply with the document content (Markdown) via the required schema."
  99 + "Reply with the document content (Markdown) via the required schema. " + _NO_NARRATION
90 100 ),
91 101 "sdd": (
92 102 "Here is the approved PRD for goal '{goal_text}':\n\n{prd_content}\n\n"
93 103 "Write the SDD (System Design Document) based on this PRD, including a Development "
94   - "Unit breakdown. Reply with the document content (Markdown) via the required schema."
  104 + "Unit breakdown. Reply with the document content (Markdown) via the required schema. "
  105 + + _NO_NARRATION
95 106 ),
96 107 "benchmark": (
97 108 "Here is the approved SDD for goal '{goal_text}':\n\n{sdd_content}\n\n"
98 109 "Write the Benchmark/acceptance-test document based on this SDD: concrete test "
99   - "scenarios (happy/boundary/failure) with observable pass criteria. "
100   - "Reply with the document content (Markdown) via the required schema."
  110 + "scenarios (happy/boundary/failure) with observable pass criteria for each one -- not "
  111 + "just a one-line label per scenario, spell out the actual expected input/behavior/"
  112 + "output for each. Reply with the document content (Markdown) via the required schema. "
  113 + + _NO_NARRATION
  114 + ),
  115 + "test_dataset": (
  116 + "Here is the approved Benchmark for goal '{goal_text}':\n\n{benchmark_content}\n\n"
  117 + "Judge whether this goal has a natural, concrete test dataset (specific inputs with "
  118 + "known-correct outputs, fixture data, or QA pairs the Benchmark's scenarios can be "
  119 + "instantiated against) -- many goals (UI wiring, config/plumbing changes) don't have "
  120 + "one, and that's fine. If it does not apply, reply via the required schema with "
  121 + "content starting exactly with 'N/A: ' followed by a one-sentence reason. If it does "
  122 + "apply, reply with the real dataset itself (e.g. JSON/CSV/a table of concrete cases) "
  123 + "via the required schema. " + _NO_NARRATION
  124 + ),
  125 + "test_script": (
  126 + "Here is the approved Benchmark for goal '{goal_text}':\n\n{benchmark_content}\n\n"
  127 + "Here is the test dataset (may be 'N/A' if this goal has none):\n\n{test_dataset_content}\n\n"
  128 + "Write a real, runnable test script that mechanically checks the Benchmark's "
  129 + "scenarios (using the dataset above if it applies) -- a script a machine can execute "
  130 + "and get a real pass/fail exit code from, not prose describing what should be tested. "
  131 + "Use whatever language/framework fits this specific project's own conventions (don't "
  132 + "invent a new one). If genuinely no mechanical script can meaningfully check this goal "
  133 + "(e.g. a pure judgment call with no automatable check), reply via the required schema "
  134 + "with content starting exactly with 'N/A: ' followed by a one-sentence reason -- this "
  135 + "should be rare, prefer writing a real script whenever a real check is possible. "
  136 + "Reply with the real script content via the required schema. " + _NO_NARRATION
101 137 ),
102 138 }
103 139  
... ... @@ -107,8 +143,21 @@ _CODE_OUTPUT_SCHEMA = {&quot;type&quot;: &quot;object&quot;, &quot;required&quot;: [&quot;response&quot;]}
107 143 _CODER_PROMPT = (
108 144 "Implement this goal per the approved SDD below. Write real code and real tests, run the "
109 145 "tests yourself, and only stop once they pass (or once you've exhausted reasonable attempts "
110   - "and can explain why not). Reply via the required schema with a `response` field describing "
111   - "what you did and the real test commands/output. Goal: {goal_text}\n\nSDD:\n\n{sdd_content}"
  146 + "and can explain why not).\n\n"
  147 + "The Planner also produced a Benchmark, a test dataset, and a test script for this goal "
  148 + "(test dataset/script may be 'N/A' if this goal has none -- that's expected, not an error). "
  149 + "If the test script is real (not N/A): materialize it as a real file under this project's "
  150 + "own test conventions (match how existing tests in this repo are named/located), and you "
  151 + "MUST make it pass for real before you're done -- it is not optional extra credit, it's "
  152 + "part of this goal's acceptance bar. Write your own additional tests too if the Planner's "
  153 + "script doesn't cover everything the SDD requires; the Planner's script is a floor, not a "
  154 + "ceiling.\n\n"
  155 + "Reply via the required schema with a `response` field describing what you did and the "
  156 + "real test commands/output (including the Planner's own test script's real output). "
  157 + "Goal: {goal_text}\n\nSDD:\n\n{sdd_content}\n\n"
  158 + "Benchmark:\n\n{benchmark_content}\n\n"
  159 + "Test dataset:\n\n{test_dataset_content}\n\n"
  160 + "Test script:\n\n{test_script_content}"
112 161 )
113 162  
114 163 _VALIDATOR_PROMPT = (
... ... @@ -119,6 +168,77 @@ _VALIDATOR_PROMPT = (
119 168 "Goal: {goal_text}\n\nBenchmark:\n\n{benchmark_content}"
120 169 )
121 170  
  171 +# 2026-08-06: Validator gets its OWN benchmark/test_dataset/test_script,
  172 +# generated independently from SDD/PRD -- NOT reusing the Planner's/Coder's
  173 +# own copies. If the Coder could game the Planner's test_script (e.g.
  174 +# hard-code outputs for the specific dataset it developed against without
  175 +# solving the general problem), re-running that SAME script proves nothing
  176 +# -- this is the "held-out eval set vs. the dev/train set" distinction, not
  177 +# a redundant re-check of the same artifact.
  178 +_VALIDATOR_DOC_PROMPTS = {
  179 + "validator_benchmark": (
  180 + "You are independently defining YOUR OWN acceptance criteria for this goal, informed "
  181 + "by the SDD/PRD below -- do NOT copy or assume the Planner's own Benchmark, form your "
  182 + "own independent judgment of what 'done' means, from a different angle if you can. "
  183 + "Concrete test scenarios (happy/boundary/failure) with observable pass criteria for "
  184 + "each one -- spell out actual expected input/behavior/output, not one-line labels. "
  185 + "Reply with the document content (Markdown) via the required schema. " + _NO_NARRATION
  186 + + "\n\nGoal: {goal_text}\n\nSDD:\n\n{sdd_content}\n\nPRD:\n\n{prd_content}"
  187 + ),
  188 + "validator_test_dataset": (
  189 + "Here is your own independent Benchmark for goal '{goal_text}':"
  190 + "\n\n{validator_benchmark_content}\n\n"
  191 + "Judge whether this goal has a natural, concrete test dataset. If not, reply via the "
  192 + "required schema with content starting exactly with 'N/A: ' plus a one-sentence reason. "
  193 + "If so, produce your OWN independent dataset -- don't assume what the Coder actually "
  194 + "used, you have no visibility into that and shouldn't need it. Reply via the required "
  195 + "schema. " + _NO_NARRATION
  196 + ),
  197 + "validator_test_script": (
  198 + "Here is your own independent Benchmark:\n\n{validator_benchmark_content}\n\n"
  199 + "Here is your own test dataset (may be 'N/A'):\n\n{validator_test_dataset_content}\n\n"
  200 + "Write a real, runnable test script that mechanically checks your own Benchmark's "
  201 + "scenarios against the ACTUAL current implementation in this repository's working "
  202 + "directory -- run real commands, inspect real files/behavior, don't assume anything "
  203 + "about what was implemented. This script will be executed for real, and if it exits "
  204 + "non-zero the verdict is FAIL regardless of any other judgment -- make sure it actually "
  205 + "tests the real thing, not a strawman. If genuinely no mechanical script is possible for "
  206 + "this goal, reply via the required schema with content starting exactly with 'N/A: ' "
  207 + "plus a one-sentence reason -- this should be rare. " + _NO_NARRATION
  208 + ),
  209 +}
  210 +
  211 +_VALIDATOR_MANIFEST = {
  212 + "nodes": [
  213 + {"node_id": "validator_benchmark", "artifact_type": "validator_benchmark", "depends_on": []},
  214 + {
  215 + "node_id": "validator_test_dataset", "artifact_type": "validator_test_dataset",
  216 + "depends_on": ["validator_benchmark"],
  217 + },
  218 + {
  219 + # depends on BOTH -- its own prompt needs
  220 + # {validator_benchmark_content} directly, not just what
  221 + # survives into validator_test_dataset_content (same reasoning
  222 + # as the Planner's own test_script node, see
  223 + # work_graph_plan._normalize_graph_manifest).
  224 + "node_id": "validator_test_script", "artifact_type": "validator_test_script",
  225 + "depends_on": ["validator_test_dataset", "validator_benchmark"],
  226 + },
  227 + ],
  228 + "edges": [],
  229 +}
  230 +
  231 +_VALIDATOR_VERDICT_PROMPT = (
  232 + "Give your final verdict for this goal, informed by your own independent Benchmark below "
  233 + "and the REAL execution result of your own test script (if you wrote one -- 'N/A' means you "
  234 + "judged no mechanical script was possible, form your judgment from the Benchmark alone in "
  235 + "that case). Reply with a verdict line 'VERDICT: PASS' or 'VERDICT: FAIL' or "
  236 + "'VERDICT: INCONCLUSIVE' followed by your reasoning and, if FAIL, a root_cause_category "
  237 + "(planning|coder|skill_defect) with detail.\n\n"
  238 + "Goal: {goal_text}\n\nYour own Benchmark:\n\n{validator_benchmark_content}\n\n"
  239 + "Your own test script's REAL execution result:\n\n{script_execution_result}"
  240 +)
  241 +
122 242  
123 243 def _planner_prompt_builder(goal_text, node_artifact_types):
124 244 """node_artifact_types: dict[node_id] -> artifact_type, read off the
... ... @@ -137,6 +257,27 @@ def _planner_prompt_builder(goal_text, node_artifact_types):
137 257 return build_prompt
138 258  
139 259  
  260 +def _validator_prompt_builder(goal_text, sdd_content, prd_content, node_artifact_types):
  261 + """Same shape as _planner_prompt_builder, but the FIRST node
  262 + (validator_benchmark) needs sdd_content/prd_content from OUTSIDE
  263 + validator's own tiny graph -- those aren't a dependency within this
  264 + graph, they come from the Planner's already-completed one. Passed
  265 + unconditionally into every node's kwargs; .format() only consumes the
  266 + placeholders each template actually references, extra kwargs are
  267 + harmless."""
  268 + def build_prompt(node, outputs):
  269 + node_id = node["node_id"]
  270 + artifact_type = node_artifact_types.get(node_id, node_id)
  271 + content_kwargs = {"sdd_content": sdd_content, "prd_content": prd_content}
  272 + for dep_id in (node.get("depends_on") or []):
  273 + dep_output = outputs.get(dep_id)
  274 + if dep_output is not None:
  275 + dep_type = node_artifact_types.get(dep_id, dep_id)
  276 + content_kwargs[dep_type + "_content"] = dep_output["content"]
  277 + return _VALIDATOR_DOC_PROMPTS[artifact_type].format(goal_text=goal_text, **content_kwargs)
  278 + return build_prompt
  279 +
  280 +
140 281 def _coerce_bool(value):
141 282 """A real call (2026-08-02) returned `"consistent": "false"` as a JSON
142 283 STRING, not a boolean, even with `"type": "boolean"` in the schema --
... ... @@ -209,6 +350,89 @@ def _prd_consistency_verify_fn(node_artifact_types, target_folder_path):
209 350 return verify_fn
210 351  
211 352  
  353 +def _is_na_content(content):
  354 + """test_dataset/test_script are allowed to legitimately not apply to a
  355 + given goal -- '_DOC_PROMPTS' asks for content starting with 'N/A: ' in
  356 + that case. That's a pass, not something to compile-check."""
  357 + return content.strip().upper().startswith("N/A")
  358 +
  359 +
  360 +def _detect_script_language(content):
  361 + """Tries Python first, then Node/JS (the two languages this project's
  362 + own tests are ever written in -- see CLAUDE.md's Testing constraints).
  363 + Returns (language_or_None, detail) -- language is 'python'/'javascript'
  364 + on success, None if neither compiles (detail then holds both compilers'
  365 + real error output). Shared by check_script_compiles (the verify_fn,
  366 + which only needs a yes/no) and run_test_script_for_real (which also
  367 + needs to know HOW to actually invoke the script)."""
  368 + with tempfile.TemporaryDirectory(prefix="graphlemon-script-check-") as tmpdir:
  369 + py_path = os.path.join(tmpdir, "candidate.py")
  370 + with open(py_path, "w", encoding="utf-8") as f:
  371 + f.write(content)
  372 + py_result = subprocess.run(
  373 + ["python3", "-m", "py_compile", py_path], capture_output=True, text=True
  374 + )
  375 + if py_result.returncode == 0:
  376 + return "python", None
  377 +
  378 + js_path = os.path.join(tmpdir, "candidate.js")
  379 + with open(js_path, "w", encoding="utf-8") as f:
  380 + f.write(content)
  381 + js_result = subprocess.run(["node", "--check", js_path], capture_output=True, text=True)
  382 + if js_result.returncode == 0:
  383 + return "javascript", None
  384 +
  385 + return None, (
  386 + "content does not compile as Python or Node/JS -- must be a real, runnable script, "
  387 + "not a narrative description.\npython3 -m py_compile:\n{}\nnode --check:\n{}".format(
  388 + py_result.stderr, js_result.stderr
  389 + )
  390 + )
  391 +
  392 +
  393 +def check_script_compiles(content):
  394 + """Mechanical (not LLM-judged) check that `content` is real, syntactically
  395 + valid code, not narrative prose masquerading as a script -- this is the
  396 + one node type where a genuinely objective check is possible (2026-08-06
  397 + design: see docs/implementation-gap-analysis.md's Benchmark-narration
  398 + incident). Returns (ok, detail)."""
  399 + if _is_na_content(content):
  400 + return True, None
  401 + language, detail = _detect_script_language(content)
  402 + return language is not None, detail
  403 +
  404 +
  405 +def _test_script_verify_fn(node_artifact_types, script_artifact_type="test_script"):
  406 + """work_graph_orchestrate.run_graph's verify_fn for a test_script node
  407 + -- mechanical compile check, not LLM judgment. On failure the same
  408 + reflecting/retry-once mechanism the PRD-consistency check already uses
  409 + kicks in (see run_graph's own docstring). `script_artifact_type` is
  410 + parameterized so this same function backs both the Planner's
  411 + 'test_script' node and the Validator's own 'validator_test_script' node."""
  412 + def verify_fn(node, output):
  413 + if node_artifact_types.get(node["node_id"]) != script_artifact_type:
  414 + return True, None
  415 + return check_script_compiles(output["content"])
  416 + return verify_fn
  417 +
  418 +
  419 +def _combined_planner_verify_fn(node_artifact_types, target_folder_path):
  420 + """Dispatches to whichever of the per-node-type verify_fns actually
  421 + applies -- run_graph only accepts one verify_fn, and each node type's
  422 + check is independent (a node is at most one artifact_type)."""
  423 + prd_check = _prd_consistency_verify_fn(node_artifact_types, target_folder_path)
  424 + script_check = _test_script_verify_fn(node_artifact_types)
  425 +
  426 + def verify_fn(node, output):
  427 + artifact_type = node_artifact_types.get(node["node_id"])
  428 + if artifact_type == "prd":
  429 + return prd_check(node, output)
  430 + if artifact_type == "test_script":
  431 + return script_check(node, output)
  432 + return True, None
  433 + return verify_fn
  434 +
  435 +
212 436 def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300):
213 437 """Architecture §11 criterion 1: a real Planner Work Graph
214 438 (c_work_graph_plan -> per-node c_work_graph_execute/c_work_node_run via
... ... @@ -226,9 +450,16 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300):
226 450 outputs = work_graph_orchestrate.run_graph(
227 451 conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest,
228 452 _planner_prompt_builder(goal_text, node_artifact_types), _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA,
229   - verify_fn=_prd_consistency_verify_fn(node_artifact_types, target_folder_path),
  453 + verify_fn=_combined_planner_verify_fn(node_artifact_types, target_folder_path),
230 454 )
231 455  
  456 + # test_dataset/test_script aren't necessarily Markdown (a script is real
  457 + # code, a dataset could be JSON/CSV/anything) -- .txt is a deliberately
  458 + # generic container; Coder materializes it into a properly-named,
  459 + # properly-placed real file under the target project's own test
  460 + # conventions when it actually uses it, that's not this write step's job.
  461 + _ARTIFACT_FILE_EXT = {"test_dataset": "txt", "test_script": "txt"}
  462 +
232 463 artifact_ids = {}
233 464 for node in work_graph_orchestrate.topological_order(graph_manifest):
234 465 node_id = node["node_id"]
... ... @@ -238,16 +469,21 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300):
238 469 for dep_id in (node.get("depends_on") or [])
239 470 if node_artifact_types.get(dep_id) in artifact_ids
240 471 ]
  472 + ext = _ARTIFACT_FILE_EXT.get(artifact_type, "md")
241 473 result = artifact_write.write_artifact(
242 474 conn, target_folder_path, artifact_type, task_id, run_id,
243   - ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type), outputs[node_id]["content"],
  475 + ".graphlemon/runs/{}/{}.{}".format(task_id, artifact_type, ext), outputs[node_id]["content"],
244 476 depends_on_artifact_ids=deps,
245 477 )
246 478 artifact_ids[artifact_type] = result["artifact_id"]
247 479 return {"run_id": run_id, "artifact_ids": artifact_ids}
248 480  
249 481  
250   -def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, timeout=1200):
  482 +def implement_code(
  483 + conn, task_id, sdd_content, goal_text, target_folder_path, timeout=1200,
  484 + benchmark_content="N/A: not provided", test_dataset_content="N/A: not provided",
  485 + test_script_content="N/A: not provided",
  486 +):
251 487 """Architecture §11 criterion 2: a real Coder call via c_work_node_run
252 488 (work_graph_orchestrate.run_graph with a synthetic single-node
253 489 manifest -- see module docstring for why there's no real Coder graph
... ... @@ -255,6 +491,12 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti
255 491 code_diff (the diff itself) and test_report (the Coder's own response
256 492 text, which per its migrated SOUL.md is required to include real test
257 493 commands + real output) artifacts.
  494 +
  495 + 2026-08-06: benchmark_content/test_dataset_content/test_script_content
  496 + (default 'N/A: not provided' for callers that predate this, e.g. a
  497 + resumed task from before this change) are threaded into _CODER_PROMPT
  498 + so the Coder must make the Planner's real test script pass for real,
  499 + not just write whatever tests it invents on its own.
258 500 """
259 501 before_sha = _current_commit(target_folder_path)
260 502 run_id = str(uuid.uuid4())
... ... @@ -268,7 +510,10 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti
268 510  
269 511 outputs = work_graph_orchestrate.run_graph(
270 512 conn, run_id, "coder", "coder-implementation", target_folder_path, manifest,
271   - lambda node, outputs: _CODER_PROMPT.format(goal_text=goal_text, sdd_content=sdd_content),
  513 + lambda node, outputs: _CODER_PROMPT.format(
  514 + goal_text=goal_text, sdd_content=sdd_content, benchmark_content=benchmark_content,
  515 + test_dataset_content=test_dataset_content, test_script_content=test_script_content,
  516 + ),
272 517 _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, timeout=timeout,
273 518 )
274 519 response = outputs["implement"]["response"]
... ... @@ -288,6 +533,19 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti
288 533 return {"code_diff": diff_result["artifact_id"], "test_report": report_result["artifact_id"]}
289 534  
290 535  
  536 +def _parse_verdict_line(response):
  537 + verdict = "INCONCLUSIVE"
  538 + for line in response.splitlines():
  539 + stripped = line.strip().upper()
  540 + if stripped.startswith("VERDICT:"):
  541 + for candidate in ("PASS", "FAIL", "INCONCLUSIVE"):
  542 + if candidate in stripped:
  543 + verdict = candidate
  544 + break
  545 + break
  546 + return verdict
  547 +
  548 +
291 549 def validate_implementation(task_id, benchmark_content, goal_text, target_folder_path, timeout=1200):
292 550 """Architecture §11 criterion 3: real Validator call, parses the
293 551 'VERDICT: PASS|FAIL|INCONCLUSIVE' line this Unit's prompt asks for.
... ... @@ -296,22 +554,161 @@ def validate_implementation(task_id, benchmark_content, goal_text, target_folder
296 554 controllers (c_bug_record_create/p_validator_root_cause_trace only),
297 555 so the simpler flat trigger_org_node call is already faithful to what
298 556 this REQ actually requires.
  557 +
  558 + **2026-08-06**: kept as-is (still the simpler flat call, no Validator's
  559 + own Work Graph) for callers that only need this original REQ-04-scoped
  560 + behavior -- run_task_lifecycle now uses
  561 + validate_implementation_with_own_graph() instead, which wraps this same
  562 + prompt/parsing shape but adds the independent benchmark/dataset/script
  563 + generation and the real-script-execution hard gate. See that function's
  564 + own docstring and docs/implementation-gap-analysis.md for why (a
  565 + Benchmark that's only ever prose an LLM narrates over instead of
  566 + substantive content isn't a real acceptance anchor).
299 567 """
300 568 response = org_node_trigger.trigger_org_node(
301 569 "validator",
302 570 _VALIDATOR_PROMPT.format(goal_text=goal_text, benchmark_content=benchmark_content),
303 571 target_folder_path, timeout=timeout,
304 572 )
305   - verdict = "INCONCLUSIVE"
306   - for line in response.splitlines():
307   - stripped = line.strip().upper()
308   - if stripped.startswith("VERDICT:"):
309   - for candidate in ("PASS", "FAIL", "INCONCLUSIVE"):
310   - if candidate in stripped:
311   - verdict = candidate
312   - break
313   - break
314   - return {"verdict": verdict, "raw_response": response}
  573 + return {"verdict": _parse_verdict_line(response), "raw_response": response}
  574 +
  575 +
  576 +def _script_file_extension(language):
  577 + return {"python": "py", "javascript": "js"}.get(language, "txt")
  578 +
  579 +
  580 +def run_test_script_for_real(content, target_folder_path, task_id, node_label, timeout=120):
  581 + """Materializes `content` as a real file under .graphlemon/runs/<task_id>/
  582 + and actually executes it with cwd=target_folder_path, so it runs against
  583 + the REAL current state of the repository (whatever the Coder actually
  584 + did), not a sandboxed guess. Returns
  585 + {"ran": bool, "exit_code": int|None, "stdout": str, "stderr": str, "note": str}.
  586 +
  587 + `ran=False` covers both legitimate N/A content and the (should be rare,
  588 + since the generating verify_fn already gated this) case where the
  589 + content still doesn't compile -- both are "nothing to hard-gate on",
  590 + the caller decides what INCONCLUSIVE-vs-something-else means for each.
  591 + """
  592 + if _is_na_content(content):
  593 + return {"ran": False, "exit_code": None, "stdout": "", "stderr": "", "note": content}
  594 +
  595 + language, detail = _detect_script_language(content)
  596 + if language is None:
  597 + return {
  598 + "ran": False, "exit_code": None, "stdout": "", "stderr": "",
  599 + "note": "script does not compile, cannot execute: " + detail,
  600 + }
  601 +
  602 + script_path = os.path.join(
  603 + target_folder_path, ".graphlemon", "runs", task_id,
  604 + "{}.{}".format(node_label, _script_file_extension(language)),
  605 + )
  606 + os.makedirs(os.path.dirname(script_path), exist_ok=True)
  607 + with open(script_path, "w", encoding="utf-8") as f:
  608 + f.write(content)
  609 +
  610 + interpreter = "python3" if language == "python" else "node"
  611 + try:
  612 + result = subprocess.run(
  613 + [interpreter, script_path], cwd=target_folder_path, capture_output=True, text=True, timeout=timeout,
  614 + )
  615 + except subprocess.TimeoutExpired as e:
  616 + return {
  617 + "ran": True, "exit_code": None, "stdout": e.stdout or "", "stderr": e.stderr or "",
  618 + "note": "timed out after {}s".format(timeout),
  619 + }
  620 + return {
  621 + "ran": True, "exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr,
  622 + "note": "exit code {}".format(result.returncode),
  623 + }
  624 +
  625 +
  626 +def _format_script_execution_result(result):
  627 + if not result["ran"]:
  628 + return "Not executed ({}).".format(result["note"])
  629 + return (
  630 + "Executed for real. Exit code: {}\nstdout:\n{}\nstderr:\n{}"
  631 + ).format(result["exit_code"], result["stdout"][-4000:], result["stderr"][-4000:])
  632 +
  633 +
  634 +def validate_implementation_with_own_graph(
  635 + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path, timeout=1200,
  636 +):
  637 + """2026-08-06: Validator's own Work Graph (validator_benchmark ->
  638 + validator_test_dataset -> validator_test_script), independently
  639 + generated from SDD/PRD -- NOT reusing the Planner's/Coder's own
  640 + benchmark/dataset/script (see _VALIDATOR_DOC_PROMPTS's module comment
  641 + for why: a held-out eval set, not a re-run of the dev set the Coder
  642 + already developed against). The resulting test_script, if real (not
  643 + N/A), is then actually EXECUTED against the current repository state --
  644 + a non-zero real exit code force-overrides the verdict to FAIL in code,
  645 + not just by asking the LLM nicely (user-confirmed 2026-08-06: script is
  646 + the hard gate, subjective judgment can only add INCONCLUSIVE/risk notes
  647 + on top of a passing script, never override a failing one to PASS).
  648 +
  649 + Returns {"verdict", "raw_response", "artifact_ids", "script_execution"}.
  650 + """
  651 + run_id = str(uuid.uuid4())
  652 + conn.execute(
  653 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  654 + "VALUES (?, ?, 'validator', ?, 'running')",
  655 + (run_id, task_id, json.dumps(_VALIDATOR_MANIFEST)),
  656 + )
  657 + conn.commit()
  658 +
  659 + node_artifact_types = {n["node_id"]: n["artifact_type"] for n in _VALIDATOR_MANIFEST["nodes"]}
  660 + outputs = work_graph_orchestrate.run_graph(
  661 + conn, run_id, "validator", "validator-independent-verification", target_folder_path, _VALIDATOR_MANIFEST,
  662 + _validator_prompt_builder(goal_text, sdd_content, prd_content, node_artifact_types),
  663 + _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA,
  664 + verify_fn=_test_script_verify_fn(node_artifact_types, script_artifact_type="validator_test_script"),
  665 + timeout=timeout,
  666 + )
  667 +
  668 + artifact_ids = {}
  669 + for node in work_graph_orchestrate.topological_order(_VALIDATOR_MANIFEST):
  670 + node_id = node["node_id"]
  671 + artifact_type = node_artifact_types[node_id]
  672 + deps = [
  673 + artifact_ids[node_artifact_types[dep_id]]
  674 + for dep_id in (node.get("depends_on") or [])
  675 + if node_artifact_types.get(dep_id) in artifact_ids
  676 + ]
  677 + ext = "txt" if artifact_type != "validator_benchmark" else "md"
  678 + result = artifact_write.write_artifact(
  679 + conn, target_folder_path, artifact_type, task_id, run_id,
  680 + ".graphlemon/runs/{}/{}.{}".format(task_id, artifact_type, ext), outputs[node_id]["content"],
  681 + depends_on_artifact_ids=deps,
  682 + )
  683 + artifact_ids[artifact_type] = result["artifact_id"]
  684 +
  685 + script_content = outputs["validator_test_script"]["content"]
  686 + script_execution = run_test_script_for_real(
  687 + script_content, target_folder_path, task_id, "validator_test_script", timeout=timeout,
  688 + )
  689 +
  690 + response = org_node_trigger.trigger_org_node(
  691 + "validator",
  692 + _VALIDATOR_VERDICT_PROMPT.format(
  693 + goal_text=goal_text,
  694 + validator_benchmark_content=outputs["validator_benchmark"]["content"],
  695 + script_execution_result=_format_script_execution_result(script_execution),
  696 + ),
  697 + target_folder_path, timeout=timeout,
  698 + )
  699 + verdict = _parse_verdict_line(response)
  700 +
  701 + # Hard gate (user-confirmed 2026-08-06): a real non-zero exit from
  702 + # validator's OWN test script is not up for LLM debate -- force FAIL
  703 + # regardless of what the verdict line said. A passing/not-run script
  704 + # leaves the LLM's own judgment (including INCONCLUSIVE) untouched.
  705 + if script_execution["ran"] and script_execution["exit_code"] not in (0, None):
  706 + verdict = "FAIL"
  707 +
  708 + return {
  709 + "verdict": verdict, "raw_response": response,
  710 + "artifact_ids": artifact_ids, "script_execution": script_execution,
  711 + }
315 712  
316 713  
317 714 def _current_commit(repo_path):
... ... @@ -334,13 +731,32 @@ def run_task_lifecycle(conn, task_id, target_folder_path, goal_text):
334 731 plan_result = plan_documents(conn, task_id, goal_text, target_folder_path)
335 732 artifact_ids = plan_result["artifact_ids"]
336 733 sdd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["sdd"])
  734 + prd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["prd"])
337 735 benchmark_content = _read_artifact_content(conn, target_folder_path, artifact_ids["benchmark"])
  736 + # test_dataset/test_script are new 2026-08-06 nodes -- guard with .get
  737 + # so a task resumed from an artifact set produced before this change
  738 + # (no test_dataset/test_script artifact_ids) still works, falling back
  739 + # to implement_code's own "not provided" defaults.
  740 + test_dataset_content = (
  741 + _read_artifact_content(conn, target_folder_path, artifact_ids["test_dataset"])
  742 + if "test_dataset" in artifact_ids else "N/A: not provided"
  743 + )
  744 + test_script_content = (
  745 + _read_artifact_content(conn, target_folder_path, artifact_ids["test_script"])
  746 + if "test_script" in artifact_ids else "N/A: not provided"
  747 + )
338 748  
339 749 org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, _READY_PROMPT)
340   - code_artifact_ids = implement_code(conn, task_id, sdd_content, goal_text, target_folder_path)
  750 + code_artifact_ids = implement_code(
  751 + conn, task_id, sdd_content, goal_text, target_folder_path,
  752 + benchmark_content=benchmark_content, test_dataset_content=test_dataset_content,
  753 + test_script_content=test_script_content,
  754 + )
341 755  
342 756 org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, _READY_PROMPT)
343   - verdict = validate_implementation(task_id, benchmark_content, goal_text, target_folder_path)
  757 + verdict = validate_implementation_with_own_graph(
  758 + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path,
  759 + )
344 760  
345 761 return {
346 762 "task_id": task_id,
... ...
src/schema.py
... ... @@ -161,7 +161,9 @@ CREATE TABLE work_node_execution (
161 161 CREATE TABLE artifact (
162 162 artifact_id TEXT PRIMARY KEY,
163 163 artifact_type TEXT NOT NULL CHECK (artifact_type IN (
164   - 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report'
  164 + 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',
  165 + 'test_dataset', 'test_script',
  166 + 'validator_benchmark', 'validator_test_dataset', 'validator_test_script'
165 167 )),
166 168 task_id TEXT NOT NULL REFERENCES task(task_id),
167 169 produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
... ... @@ -351,3 +353,48 @@ def create_schema(conn):
351 353 from U-034, PH5) + indexes on an open connection."""
352 354 conn.executescript(SCHEMA_SQL)
353 355 conn.commit()
  356 +
  357 +
  358 +def migrate_expand_artifact_types(conn):
  359 + """2026-08-06: adds test_dataset/test_script/validator_benchmark/
  360 + validator_test_dataset/validator_test_script to artifact.artifact_type's
  361 + CHECK enum on an ALREADY-POPULATED database -- SQLite has no
  362 + `ALTER TABLE ... MODIFY CONSTRAINT`, so this is the standard "create a
  363 + new table with the wanted constraint, copy every row, drop the old
  364 + table, rename" dance. No other table has a foreign key into `artifact`
  365 + (checked), so this doesn't need to touch anything beyond the table
  366 + itself and its one index.
  367 +
  368 + Idempotent and safe to call unconditionally (including against a
  369 + freshly create_schema()'d database, which already has the expanded
  370 + constraint) -- checks the live constraint text first and no-ops if it's
  371 + already expanded, so callers never need to special-case "is this a
  372 + fresh or an upgraded database".
  373 + """
  374 + row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='artifact'").fetchone()
  375 + if row is None or "'test_dataset'" in row[0]:
  376 + return
  377 + conn.executescript(
  378 + """
  379 + CREATE TABLE artifact_new (
  380 + artifact_id TEXT PRIMARY KEY,
  381 + artifact_type TEXT NOT NULL CHECK (artifact_type IN (
  382 + 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',
  383 + 'test_dataset', 'test_script',
  384 + 'validator_benchmark', 'validator_test_dataset', 'validator_test_script'
  385 + )),
  386 + task_id TEXT NOT NULL REFERENCES task(task_id),
  387 + produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
  388 + file_path TEXT NOT NULL,
  389 + git_commit_sha TEXT NOT NULL,
  390 + depends_on_artifact_ids TEXT,
  391 + validation_status TEXT NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending', 'passed', 'failed')),
  392 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  393 + );
  394 + INSERT INTO artifact_new SELECT * FROM artifact;
  395 + DROP TABLE artifact;
  396 + ALTER TABLE artifact_new RENAME TO artifact;
  397 + CREATE UNIQUE INDEX ux_artifact_task_type_commit ON artifact(task_id, artifact_type, git_commit_sha);
  398 + """
  399 + )
  400 + conn.commit()
... ...
src/task_execution.py
... ... @@ -74,6 +74,20 @@ def _artifact_content(conn, target_folder_path, task_id, artifact_type):
74 74 return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])
75 75  
76 76  
  77 +def _artifact_content_or_default(conn, target_folder_path, task_id, artifact_type, default):
  78 + """Like _artifact_content, but for the 2026-08-06 test_dataset/
  79 + test_script nodes -- a task resumed from before this feature existed
  80 + (or any other reason those two specific artifacts are missing) should
  81 + still be resumable, just without them, not hard-fail."""
  82 + row = conn.execute(
  83 + "SELECT artifact_id FROM artifact WHERE task_id = ? AND artifact_type = ? ORDER BY created_at DESC LIMIT 1",
  84 + (task_id, artifact_type),
  85 + ).fetchone()
  86 + if row is None:
  87 + return default
  88 + return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])
  89 +
  90 +
77 91 def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
78 92 """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues
79 93 from wherever `task.status` indicates real work last completed, instead
... ... @@ -93,18 +107,33 @@ def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
93 107  
94 108 doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES)
95 109 sdd_content = _artifact_content(conn, target_folder_path, task_id, "sdd")
  110 + prd_content = _artifact_content(conn, target_folder_path, task_id, "prd")
96 111 benchmark_content = _artifact_content(conn, target_folder_path, task_id, "benchmark")
  112 + # 2026-08-06 nodes -- see _artifact_content_or_default's own docstring
  113 + # for why these two specifically fall back instead of hard-failing.
  114 + test_dataset_content = _artifact_content_or_default(
  115 + conn, target_folder_path, task_id, "test_dataset", "N/A: not provided"
  116 + )
  117 + test_script_content = _artifact_content_or_default(
  118 + conn, target_folder_path, task_id, "test_script", "N/A: not provided"
  119 + )
97 120  
98 121 if status == "planning_brd_prd_sdd":
99 122 _check_not_paused(conn, task_id)
100 123 org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
101   - code_artifact_ids = e2e_pipeline.implement_code(conn, task_id, sdd_content, goal_text, target_folder_path)
  124 + code_artifact_ids = e2e_pipeline.implement_code(
  125 + conn, task_id, sdd_content, goal_text, target_folder_path,
  126 + benchmark_content=benchmark_content, test_dataset_content=test_dataset_content,
  127 + test_script_content=test_script_content,
  128 + )
102 129 else: # status == "coding" -- Coder already ran
103 130 code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES)
104 131  
105 132 _check_not_paused(conn, task_id)
106 133 org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
107   - verdict = e2e_pipeline.validate_implementation(task_id, benchmark_content, goal_text, target_folder_path)
  134 + verdict = e2e_pipeline.validate_implementation_with_own_graph(
  135 + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path,
  136 + )
108 137  
109 138 return {
110 139 "task_id": task_id, "doc_artifact_ids": doc_artifact_ids,
... ...
src/test_e2e_pipeline.py
... ... @@ -23,6 +23,7 @@ Run: python3 -m unittest src.test_e2e_pipeline -v
23 23 """
24 24  
25 25 import json
  26 +import os
26 27 import subprocess
27 28 import tempfile
28 29 import unittest
... ... @@ -30,7 +31,11 @@ from unittest.mock import patch
30 31  
31 32 from src import e2e_pipeline, org_node_trigger, project_scaffold, schema, work_graph_execute
32 33  
33   -_FAKE_DOCS = {"brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake"}
  34 +_FAKE_DOCS = {
  35 + "brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake",
  36 + "test_dataset": "N/A: no natural dataset for this fake goal",
  37 + "test_script": "assert True # fake but real, compilable script",
  38 +}
34 39  
35 40 _FAKE_GRAPH_MANIFEST = {
36 41 "nodes": [
... ... @@ -38,11 +43,15 @@ _FAKE_GRAPH_MANIFEST = {
38 43 {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]},
39 44 {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]},
40 45 {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]},
  46 + {"node_id": "test_dataset", "artifact_type": "test_dataset", "depends_on": ["benchmark"]},
  47 + {"node_id": "test_script", "artifact_type": "test_script", "depends_on": ["test_dataset"]},
41 48 ],
42 49 "edges": [
43 50 {"from": "brd", "to": "prd", "edge_type": "needs_judgment"},
44 51 {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"},
45 52 {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"},
  53 + {"from": "benchmark", "to": "test_dataset", "edge_type": "needs_judgment"},
  54 + {"from": "test_dataset", "to": "test_script", "edge_type": "needs_judgment"},
46 55 ],
47 56 }
48 57  
... ... @@ -71,7 +80,7 @@ class PipelineTestBase(unittest.TestCase):
71 80  
72 81  
73 82 class PlanDocumentsTest(PipelineTestBase):
74   - def test_produces_four_artifacts_with_correct_lineage(self):
  83 + def test_produces_six_artifacts_with_correct_lineage(self):
75 84 self.conn.execute(
76 85 "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
77 86 "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', "
... ... @@ -86,11 +95,12 @@ class PlanDocumentsTest(PipelineTestBase):
86 95 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
87 96 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
88 97 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
  98 + {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]},
89 99 ]
90 100 plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name)
91 101  
92 102 artifact_ids = plan_result["artifact_ids"]
93   - self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark"})
  103 + self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"})
94 104 prd_deps = json.loads(
95 105 self.conn.execute(
96 106 "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", (artifact_ids["prd"],)
... ... @@ -122,12 +132,21 @@ class RunEndToEndTest(PipelineTestBase):
122 132 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
123 133 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
124 134 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
  135 + {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]},
125 136 {"response": "Implemented. Ran `pytest`: 3 passed."},
  137 + # Validator's OWN independent benchmark/dataset/script (2026-08-06) --
  138 + # deliberately different content from the Planner's, to also prove
  139 + # nothing here accidentally reuses the Planner's copies.
  140 + {"content": "# Validator's own Benchmark\nfake"},
  141 + {"content": "N/A: validator judged no dataset needed either"},
  142 + {"content": "assert True # validator's own real, compilable script"},
126 143 ]
127 144 result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏")
128 145  
129 146 self.assertEqual(result["verdict"]["verdict"], "PASS")
130   - self.assertEqual(set(result["doc_artifact_ids"]), {"brd", "prd", "sdd", "benchmark"})
  147 + self.assertEqual(
  148 + set(result["doc_artifact_ids"]), {"brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"}
  149 + )
131 150 self.assertEqual(set(result["code_artifact_ids"]), {"code_diff", "test_report"})
132 151  
133 152 task_row = self.conn.execute(
... ... @@ -138,15 +157,19 @@ class RunEndToEndTest(PipelineTestBase):
138 157 all_artifacts = self.conn.execute(
139 158 "SELECT COUNT(*) FROM artifact WHERE task_id = ?", (result["task_id"],)
140 159 ).fetchone()[0]
141   - self.assertEqual(all_artifacts, 6) # brd, prd, sdd, benchmark, code_diff, test_report
  160 + # brd, prd, sdd, benchmark, test_dataset, test_script, code_diff, test_report,
  161 + # validator_benchmark, validator_test_dataset, validator_test_script
  162 + self.assertEqual(all_artifacts, 11)
142 163  
143   - # planner and coder each got their own work_graph_run (not sharing one)
  164 + # planner, coder, AND validator (2026-08-06: validator now has its own
  165 + # real work_graph_run too, not a flat direct call) each got their own
  166 + # work_graph_run (not sharing one)
144 167 run_org_nodes = {
145 168 row[0] for row in self.conn.execute(
146 169 "SELECT org_node FROM work_graph_run WHERE task_id = ?", (result["task_id"],)
147 170 ).fetchall()
148 171 }
149   - self.assertEqual(run_org_nodes, {"planner", "coder"})
  172 + self.assertEqual(run_org_nodes, {"planner", "coder", "validator"})
150 173  
151 174  
152 175 class PrdConsistencyVerifyFnTest(unittest.TestCase):
... ... @@ -202,6 +225,92 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase):
202 225 self.assertEqual(args[0], "/tmp/proj-1")
203 226  
204 227  
  228 +class CheckScriptCompilesTest(unittest.TestCase):
  229 + """2026-08-06: the one mechanical (not LLM-judged) verify_fn -- real,
  230 + unmocked subprocess calls to py_compile/node --check, no fixtures to
  231 + fake since this is testing an actual compiler's real opinion."""
  232 +
  233 + def test_na_content_passes_without_compiling(self):
  234 + ok, detail = e2e_pipeline.check_script_compiles("N/A: this goal has no automatable check")
  235 + self.assertTrue(ok)
  236 + self.assertIsNone(detail)
  237 +
  238 + def test_valid_python_passes(self):
  239 + ok, detail = e2e_pipeline.check_script_compiles("def test_thing():\n assert 1 + 1 == 2\n")
  240 + self.assertTrue(ok)
  241 +
  242 + def test_valid_javascript_passes(self):
  243 + ok, detail = e2e_pipeline.check_script_compiles("function testThing() { return 1 + 1; }\n")
  244 + self.assertTrue(ok)
  245 +
  246 + def test_narrative_prose_fails_both(self):
  247 + # this is the exact failure mode that motivated this check -- a
  248 + # narrative sentence about a script, not a real script.
  249 + ok, detail = e2e_pipeline.check_script_compiles(
  250 + "The graphlemon planner Work Node workflow completed successfully and produced "
  251 + "a test script covering 18 scenarios across four areas."
  252 + )
  253 + self.assertFalse(ok)
  254 + self.assertIn("py_compile", detail)
  255 + self.assertIn("node --check", detail)
  256 +
  257 + def test_syntactically_broken_python_and_js_fails(self):
  258 + ok, detail = e2e_pipeline.check_script_compiles("def broken(:\n this is not valid anything\n")
  259 + self.assertFalse(ok)
  260 +
  261 +
  262 +class TestScriptVerifyFnTest(unittest.TestCase):
  263 + def test_non_test_script_node_passes_through_without_compiling(self):
  264 + node_artifact_types = {"brd": "brd"}
  265 + verify_fn = e2e_pipeline._test_script_verify_fn(node_artifact_types)
  266 + ok, detail = verify_fn({"node_id": "brd"}, {"content": "not even close to code {{{"})
  267 + self.assertTrue(ok)
  268 + self.assertIsNone(detail)
  269 +
  270 + def test_test_script_node_with_broken_content_fails(self):
  271 + node_artifact_types = {"script": "test_script"}
  272 + verify_fn = e2e_pipeline._test_script_verify_fn(node_artifact_types)
  273 + ok, detail = verify_fn({"node_id": "script"}, {"content": "this is narrative, not code, no way"})
  274 + self.assertFalse(ok)
  275 +
  276 + def test_test_script_node_with_real_code_passes(self):
  277 + node_artifact_types = {"script": "test_script"}
  278 + verify_fn = e2e_pipeline._test_script_verify_fn(node_artifact_types)
  279 + ok, detail = verify_fn({"node_id": "script"}, {"content": "assert True\n"})
  280 + self.assertTrue(ok)
  281 +
  282 +
  283 +class CombinedPlannerVerifyFnTest(unittest.TestCase):
  284 + """Dispatches to the right per-node-type check -- both real checks
  285 + (PRD consistency and script-compiles) are exercised elsewhere in
  286 + isolation; this only tests the dispatch itself."""
  287 +
  288 + def test_prd_node_dispatches_to_prd_check(self):
  289 + node_artifact_types = {"n1": "prd"}
  290 + verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1")
  291 + with patch.object(work_graph_execute, "write_workflow_script"), \
  292 + patch.object(
  293 + work_graph_execute, "execute_work_graph",
  294 + return_value={"consistent": True, "issues": []},
  295 + ) as mock_execute:
  296 + ok, detail = verify_fn({"node_id": "n1"}, {"content": "a prd"})
  297 + self.assertTrue(ok)
  298 + mock_execute.assert_called_once()
  299 +
  300 + def test_test_script_node_dispatches_to_compile_check(self):
  301 + node_artifact_types = {"n1": "test_script"}
  302 + verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1")
  303 + ok, detail = verify_fn({"node_id": "n1"}, {"content": "not code at all, just words"})
  304 + self.assertFalse(ok)
  305 +
  306 + def test_other_node_types_pass_through(self):
  307 + node_artifact_types = {"n1": "test_dataset"}
  308 + verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1")
  309 + ok, detail = verify_fn({"node_id": "n1"}, {"content": "anything at all"})
  310 + self.assertTrue(ok)
  311 + self.assertIsNone(detail)
  312 +
  313 +
205 314 class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase):
206 315 """2026-08-03 regression: a real self-hosting run showed _check_prd_
207 316 consistency() calling execute_work_graph() directly against
... ... @@ -280,5 +389,131 @@ class ValidateImplementationTest(unittest.TestCase):
280 389 self.assertEqual(result["verdict"], "INCONCLUSIVE")
281 390  
282 391  
  392 +class RunTestScriptForRealTest(unittest.TestCase):
  393 + """2026-08-06: real, unmocked execution -- these are testing what an
  394 + actual interpreter actually does, same reasoning as CheckScriptCompilesTest."""
  395 +
  396 + def setUp(self):
  397 + self.tmp = tempfile.TemporaryDirectory()
  398 + self.addCleanup(self.tmp.cleanup)
  399 +
  400 + def test_na_content_is_not_executed(self):
  401 + result = e2e_pipeline.run_test_script_for_real(
  402 + "N/A: no automatable check for this goal", self.tmp.name, "task-1", "validator_test_script"
  403 + )
  404 + self.assertFalse(result["ran"])
  405 + self.assertIsNone(result["exit_code"])
  406 +
  407 + def test_real_passing_python_script(self):
  408 + result = e2e_pipeline.run_test_script_for_real(
  409 + "import sys\nsys.exit(0)\n", self.tmp.name, "task-1", "validator_test_script"
  410 + )
  411 + self.assertTrue(result["ran"])
  412 + self.assertEqual(result["exit_code"], 0)
  413 +
  414 + def test_real_failing_python_script(self):
  415 + result = e2e_pipeline.run_test_script_for_real(
  416 + "import sys\nsys.exit(1)\n", self.tmp.name, "task-1", "validator_test_script"
  417 + )
  418 + self.assertTrue(result["ran"])
  419 + self.assertEqual(result["exit_code"], 1)
  420 +
  421 + def test_script_runs_with_real_cwd_so_it_sees_the_real_repo(self):
  422 + with open(os.path.join(self.tmp.name, "marker.txt"), "w") as f:
  423 + f.write("real file")
  424 + script = (
  425 + "import os, sys\n"
  426 + "sys.exit(0 if os.path.exists('marker.txt') else 1)\n"
  427 + )
  428 + result = e2e_pipeline.run_test_script_for_real(script, self.tmp.name, "task-1", "validator_test_script")
  429 + self.assertEqual(result["exit_code"], 0)
  430 +
  431 + def test_materializes_a_real_file_under_graphlemon_runs(self):
  432 + e2e_pipeline.run_test_script_for_real(
  433 + "import sys\nsys.exit(0)\n", self.tmp.name, "task-42", "validator_test_script"
  434 + )
  435 + expected = os.path.join(self.tmp.name, ".graphlemon", "runs", "task-42", "validator_test_script.py")
  436 + self.assertTrue(os.path.exists(expected))
  437 +
  438 +
  439 +class ValidateImplementationWithOwnGraphTest(unittest.TestCase):
  440 + """2026-08-06: the safety-critical piece -- a real non-zero exit from
  441 + Validator's own script must force FAIL no matter what the LLM's verdict
  442 + line says (user-confirmed hard gate)."""
  443 +
  444 + def setUp(self):
  445 + self.tmp = tempfile.TemporaryDirectory()
  446 + self.addCleanup(self.tmp.cleanup)
  447 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  448 + self.conn = schema.connect(":memory:")
  449 + schema.create_schema(self.conn)
  450 + project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False)
  451 + self.conn.execute(
  452 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  453 + "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', 'validating', 'validator')"
  454 + )
  455 + self.conn.commit()
  456 +
  457 + def tearDown(self):
  458 + self.conn.close()
  459 +
  460 + def _run(self, script_content, verdict_line):
  461 + with patch.object(work_graph_execute, "write_workflow_script"), \
  462 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
  463 + patch.object(org_node_trigger, "trigger_org_node", return_value=verdict_line):
  464 + mock_execute.side_effect = [
  465 + {"content": "# Validator's own Benchmark"},
  466 + {"content": "N/A: no dataset needed"},
  467 + {"content": script_content},
  468 + ]
  469 + return e2e_pipeline.validate_implementation_with_own_graph(
  470 + self.conn, "task-1", "sdd content", "prd content", "goal text", self.tmp.name,
  471 + )
  472 +
  473 + def test_llm_says_pass_and_script_really_passes_stays_pass(self):
  474 + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good")
  475 + self.assertEqual(result["verdict"], "PASS")
  476 + self.assertTrue(result["script_execution"]["ran"])
  477 + self.assertEqual(result["script_execution"]["exit_code"], 0)
  478 +
  479 + def test_llm_says_pass_but_script_really_fails_is_force_overridden_to_fail(self):
  480 + # the exact case the user's hard-gate rule exists for: a script
  481 + # that objectively fails must not be argued away by the LLM's own
  482 + # verdict line.
  483 + result = self._run("import sys\nsys.exit(1)\n", "VERDICT: PASS\nlooks good to me")
  484 + self.assertEqual(result["verdict"], "FAIL")
  485 +
  486 + def test_llm_says_fail_and_script_fails_stays_fail(self):
  487 + result = self._run("import sys\nsys.exit(1)\n", "VERDICT: FAIL\nreal bug found")
  488 + self.assertEqual(result["verdict"], "FAIL")
  489 +
  490 + def test_na_script_leaves_llm_judgment_untouched(self):
  491 + result = self._run("N/A: no mechanical check possible", "VERDICT: INCONCLUSIVE\nneeds human review")
  492 + self.assertEqual(result["verdict"], "INCONCLUSIVE")
  493 + self.assertFalse(result["script_execution"]["ran"])
  494 +
  495 + def test_writes_three_validator_artifacts_with_lineage(self):
  496 + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good")
  497 + self.assertEqual(
  498 + set(result["artifact_ids"]),
  499 + {"validator_benchmark", "validator_test_dataset", "validator_test_script"},
  500 + )
  501 + script_deps = json.loads(
  502 + self.conn.execute(
  503 + "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?",
  504 + (result["artifact_ids"]["validator_test_script"],),
  505 + ).fetchone()[0]
  506 + )
  507 + self.assertIn(result["artifact_ids"]["validator_benchmark"], script_deps)
  508 + self.assertIn(result["artifact_ids"]["validator_test_dataset"], script_deps)
  509 +
  510 + def test_creates_its_own_work_graph_run_row(self):
  511 + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good")
  512 + run_row = self.conn.execute(
  513 + "SELECT org_node FROM work_graph_run WHERE task_id = 'task-1'"
  514 + ).fetchone()
  515 + self.assertEqual(run_row[0], "validator")
  516 +
  517 +
283 518 if __name__ == "__main__":
284 519 unittest.main()
... ...
src/test_schema.py
... ... @@ -206,6 +206,117 @@ class ArtifactUniqueConstraintTest(ConstraintTestBase):
206 206 with self.assertRaises(sqlite3.IntegrityError):
207 207 self._insert_artifact("artifact-2")
208 208  
  209 + def test_new_test_artifact_types_accepted(self):
  210 + for artifact_type in (
  211 + "test_dataset", "test_script",
  212 + "validator_benchmark", "validator_test_dataset", "validator_test_script",
  213 + ):
  214 + self.conn.execute(
  215 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  216 + "file_path, git_commit_sha) VALUES (?, ?, 'task-1', 'run-1', 'x.md', 'sha-1')",
  217 + ("artifact-" + artifact_type, artifact_type),
  218 + )
  219 + self.conn.commit()
  220 +
  221 +
  222 +class MigrateExpandArtifactTypesTest(unittest.TestCase):
  223 + """2026-08-06: real upgrade path for an already-populated database whose
  224 + artifact table still has the pre-expansion CHECK constraint."""
  225 +
  226 + def setUp(self):
  227 + self.conn = schema.connect(":memory:")
  228 + # Simulate a pre-migration database: create the OLD schema by hand
  229 + # (not schema.create_schema(), which already has the new constraint).
  230 + old_sql = schema.SCHEMA_SQL.replace(
  231 + "'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',\n"
  232 + " 'test_dataset', 'test_script',\n"
  233 + " 'validator_benchmark', 'validator_test_dataset', 'validator_test_script'",
  234 + "'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report'",
  235 + )
  236 + self.conn.executescript(old_sql)
  237 + self.conn.commit()
  238 + self.conn.execute(
  239 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  240 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  241 + )
  242 + self.conn.execute(
  243 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  244 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
  245 + )
  246 + self.conn.execute(
  247 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  248 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'planning')"
  249 + )
  250 + self.conn.execute(
  251 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  252 + "file_path, git_commit_sha) VALUES ('art-1', 'brd', 'task-1', 'run-1', 'brd.md', 'sha-1')"
  253 + )
  254 + self.conn.commit()
  255 +
  256 + def tearDown(self):
  257 + self.conn.close()
  258 +
  259 + def test_old_check_constraint_rejects_new_type_before_migration(self):
  260 + with self.assertRaises(sqlite3.IntegrityError):
  261 + self.conn.execute(
  262 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  263 + "file_path, git_commit_sha) VALUES ('art-2', 'test_script', 'task-1', 'run-1', 'x', 'sha-2')"
  264 + )
  265 +
  266 + def test_migration_preserves_existing_rows_and_accepts_new_types(self):
  267 + schema.migrate_expand_artifact_types(self.conn)
  268 +
  269 + row = self.conn.execute("SELECT artifact_type FROM artifact WHERE artifact_id = 'art-1'").fetchone()
  270 + self.assertEqual(row[0], "brd")
  271 +
  272 + self.conn.execute(
  273 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  274 + "file_path, git_commit_sha) VALUES ('art-2', 'test_script', 'task-1', 'run-1', 'x', 'sha-2')"
  275 + )
  276 + self.conn.commit()
  277 + count = self.conn.execute("SELECT COUNT(*) FROM artifact").fetchone()[0]
  278 + self.assertEqual(count, 2)
  279 +
  280 + def test_unique_index_still_enforced_after_migration(self):
  281 + schema.migrate_expand_artifact_types(self.conn)
  282 + self.conn.commit()
  283 + with self.assertRaises(sqlite3.IntegrityError):
  284 + self.conn.execute(
  285 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  286 + "file_path, git_commit_sha) VALUES ('art-dup', 'brd', 'task-1', 'run-1', 'brd.md', 'sha-1')"
  287 + )
  288 +
  289 + def test_idempotent_on_a_database_already_migrated(self):
  290 + schema.migrate_expand_artifact_types(self.conn)
  291 + schema.migrate_expand_artifact_types(self.conn) # must not raise / must not duplicate
  292 + count = self.conn.execute("SELECT COUNT(*) FROM artifact").fetchone()[0]
  293 + self.assertEqual(count, 1)
  294 +
  295 + def test_noop_on_a_freshly_created_already_expanded_database(self):
  296 + fresh = schema.connect(":memory:")
  297 + try:
  298 + schema.create_schema(fresh)
  299 + schema.migrate_expand_artifact_types(fresh) # must not raise
  300 + fresh.execute(
  301 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  302 + "VALUES ('p', 'P', '/tmp/p', 1, 'active')"
  303 + )
  304 + fresh.execute(
  305 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  306 + "VALUES ('t', 'p', 'g', 'goal', 'queued', 'planner')"
  307 + )
  308 + fresh.execute(
  309 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  310 + "VALUES ('r', 't', 'planner', '{}', 'planning')"
  311 + )
  312 + fresh.execute(
  313 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  314 + "file_path, git_commit_sha) VALUES ('a', 'test_script', 't', 'r', 'x', 'sha')"
  315 + )
  316 + fresh.commit()
  317 + finally:
  318 + fresh.close()
  319 +
209 320  
210 321 class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase):
211 322 def setUp(self):
... ...
src/test_task_execution.py
... ... @@ -76,18 +76,27 @@ class ResumeFromCodingTest(TaskExecutionTestBase):
76 76  
77 77 def test_skips_to_validator_only(self):
78 78 self._insert_task("task-1", status="coding", assigned_org_node="coder")
  79 + self._write_artifact("task-1", "prd", "# PRD content")
79 80 self._write_artifact("task-1", "sdd", "# SDD content")
80 81 self._write_artifact("task-1", "benchmark", "# Benchmark content")
81 82 self._write_artifact("task-1", "code_diff", "diff content")
82 83 self._write_artifact("task-1", "test_report", "report content")
83 84  
84 85 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  86 + patch.object(work_graph_execute, "write_workflow_script"), \
  87 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
85 88 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
  89 + # validator's OWN benchmark/test_dataset/test_script graph (2026-08-06)
  90 + mock_execute.side_effect = [
  91 + {"content": "# Validator's own Benchmark"},
  92 + {"content": "N/A: no dataset needed"},
  93 + {"content": "import sys\nsys.exit(0)\n"},
  94 + ]
86 95 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
87 96  
88 97 self.assertEqual(result["verdict"]["verdict"], "PASS")
89 98 self.assertEqual(result["code_artifact_ids"]["code_diff"], "art-code_diff")
90   - # only the validator's real call happened -- no coder call
  99 + # only the validator's final-verdict real call happened -- no coder call
91 100 mock_trigger.assert_called_once()
92 101 self.assertEqual(mock_trigger.call_args[0][0], "validator")
93 102 row = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()
... ... @@ -110,8 +119,15 @@ class ResumeFromPlanningTest(TaskExecutionTestBase):
110 119  
111 120 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
112 121 patch.object(work_graph_execute, "write_workflow_script"), \
113   - patch.object(work_graph_execute, "execute_work_graph", return_value={"response": "done, tests pass"}), \
  122 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
114 123 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
  124 + mock_execute.side_effect = [
  125 + {"response": "done, tests pass"}, # coder's implement node
  126 + # validator's OWN benchmark/test_dataset/test_script graph (2026-08-06)
  127 + {"content": "# Validator's own Benchmark"},
  128 + {"content": "N/A: no dataset needed"},
  129 + {"content": "import sys\nsys.exit(0)\n"},
  130 + ]
115 131 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
116 132  
117 133 self.assertEqual(result["verdict"]["verdict"], "PASS")
... ... @@ -140,6 +156,7 @@ class PauseMidResumeTest(TaskExecutionTestBase):
140 156  
141 157 def test_pause_before_validator_stops_cleanly(self):
142 158 self._insert_task("task-1", status="coding", assigned_org_node="coder")
  159 + self._write_artifact("task-1", "prd", "# PRD content")
143 160 self._write_artifact("task-1", "sdd", "# SDD content")
144 161 self._write_artifact("task-1", "benchmark", "# Benchmark content")
145 162 self._write_artifact("task-1", "code_diff", "diff content")
... ... @@ -259,13 +276,21 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
259 276 conn.close()
260 277  
261 278 def test_real_thread_completes_and_closes_task(self):
  279 + self._write_artifact("task-1", "prd", "# PRD")
262 280 self._write_artifact("task-1", "sdd", "# SDD")
263 281 self._write_artifact("task-1", "benchmark", "# Benchmark")
264 282 self._write_artifact("task-1", "code_diff", "diff")
265 283 self._write_artifact("task-1", "test_report", "report")
266 284  
267 285 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  286 + patch.object(work_graph_execute, "write_workflow_script"), \
  287 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
268 288 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"):
  289 + mock_execute.side_effect = [
  290 + {"content": "# Validator's own Benchmark"},
  291 + {"content": "N/A: no dataset needed"},
  292 + {"content": "import sys\nsys.exit(0)\n"},
  293 + ]
269 294 thread = task_execution.start_task_execution_in_background(
270 295 self.db_path, "task-1", self.repo.name, "a goal",
271 296 )
... ... @@ -277,13 +302,24 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
277 302 self.assertEqual(status, "done")
278 303  
279 304 def test_generic_failure_marks_blocked(self):
  305 + self._write_artifact("task-1", "prd", "# PRD")
280 306 self._write_artifact("task-1", "sdd", "# SDD")
281 307 self._write_artifact("task-1", "benchmark", "# Benchmark")
282 308 self._write_artifact("task-1", "code_diff", "diff")
283 309 self._write_artifact("task-1", "test_report", "report")
284 310  
285 311 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  312 + patch.object(work_graph_execute, "write_workflow_script"), \
  313 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
286 314 patch.object(org_node_trigger, "trigger_org_node", side_effect=RuntimeError("boom")):
  315 + # validator's own benchmark/dataset/script graph completes fine --
  316 + # the failure this test cares about is the FINAL verdict call
  317 + # (org_node_trigger.trigger_org_node), which happens after.
  318 + mock_execute.side_effect = [
  319 + {"content": "# Validator's own Benchmark"},
  320 + {"content": "N/A: no dataset needed"},
  321 + {"content": "import sys\nsys.exit(0)\n"},
  322 + ]
287 323 thread = task_execution.start_task_execution_in_background(
288 324 self.db_path, "task-1", self.repo.name, "a goal",
289 325 )
... ...
src/test_work_graph_plan.py
... ... @@ -142,6 +142,8 @@ class NormalizeGraphManifestTest(unittest.TestCase):
142 142 {"node_id": "prd", "artifact_type": "prd"},
143 143 {"node_id": "sdd", "artifact_type": "sdd"},
144 144 {"node_id": "benchmark", "artifact_type": "benchmark"},
  145 + {"node_id": "test_dataset", "artifact_type": "test_dataset"},
  146 + {"node_id": "test_script", "artifact_type": "test_script"},
145 147 ],
146 148 "edges": [],
147 149 }
... ... @@ -151,6 +153,11 @@ class NormalizeGraphManifestTest(unittest.TestCase):
151 153 self.assertEqual(by_type["prd"]["depends_on"], ["brd"])
152 154 self.assertEqual(by_type["sdd"]["depends_on"], ["prd"])
153 155 self.assertEqual(by_type["benchmark"]["depends_on"], ["sdd"])
  156 + self.assertEqual(by_type["test_dataset"]["depends_on"], ["benchmark"])
  157 + # test_script needs both -- its own prompt references
  158 + # {benchmark_content} directly, not just what survives into
  159 + # test_dataset_content (see work_graph_plan._normalize_graph_manifest).
  160 + self.assertEqual(by_type["test_script"]["depends_on"], ["test_dataset", "benchmark"])
154 161  
155 162 def test_canonical_chain_enforcement_uses_real_node_ids_not_artifact_types(self):
156 163 # node_id and artifact_type can differ (e.g. real LLM output naming
... ... @@ -162,6 +169,8 @@ class NormalizeGraphManifestTest(unittest.TestCase):
162 169 {"node_id": "step-2", "artifact_type": "prd"},
163 170 {"node_id": "step-3", "artifact_type": "sdd"},
164 171 {"node_id": "step-4", "artifact_type": "benchmark"},
  172 + {"node_id": "step-5", "artifact_type": "test_dataset"},
  173 + {"node_id": "step-6", "artifact_type": "test_script"},
165 174 ],
166 175 "edges": [],
167 176 }
... ... @@ -170,6 +179,8 @@ class NormalizeGraphManifestTest(unittest.TestCase):
170 179 self.assertEqual(by_type["prd"]["depends_on"], ["step-1"])
171 180 self.assertEqual(by_type["sdd"]["depends_on"], ["step-2"])
172 181 self.assertEqual(by_type["benchmark"]["depends_on"], ["step-3"])
  182 + self.assertEqual(by_type["test_dataset"]["depends_on"], ["step-4"])
  183 + self.assertEqual(by_type["test_script"]["depends_on"], ["step-5", "step-4"])
173 184  
174 185 def test_partial_canonical_set_does_not_trigger_enforcement(self):
175 186 # only brd/prd/sdd present, no benchmark -- not a recognized
... ...
src/work_graph_plan.py
... ... @@ -41,7 +41,10 @@ GRAPH_MANIFEST_SCHEMA = {
41 41 "required": ["node_id", "artifact_type", "depends_on"],
42 42 "properties": {
43 43 "node_id": {"type": "string"},
44   - "artifact_type": {"type": "string", "enum": ["brd", "prd", "sdd", "benchmark"]},
  44 + "artifact_type": {
  45 + "type": "string",
  46 + "enum": ["brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"],
  47 + },
45 48 "depends_on": {"type": "array", "items": {"type": "string"}},
46 49 },
47 50 },
... ... @@ -62,13 +65,14 @@ GRAPH_MANIFEST_SCHEMA = {
62 65 }
63 66  
64 67 _PROMPT_TEMPLATE = (
65   - "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, and "
66   - "Benchmark artifacts for this goal: {goal_text}\n"
67   - "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, "
68   - "benchmark) and edges expressing their dependency chain (sdd depends on prd "
69   - "depends on brd, benchmark depends on sdd). Every edge needs edge_type: "
70   - "'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for anything "
71   - "requiring the agent to actually reason about content.\n"
  68 + "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, Benchmark, "
  69 + "Test Dataset, and Test Script artifacts for this goal: {goal_text}\n"
  70 + "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, benchmark, "
  71 + "test_dataset, test_script) and edges expressing their dependency chain (sdd depends "
  72 + "on prd depends on brd, benchmark depends on sdd, test_dataset depends on benchmark, "
  73 + "test_script depends on test_dataset). Every edge needs edge_type: 'zero_token' for "
  74 + "pure aggregation/reduce steps, 'needs_judgment' for anything requiring the agent to "
  75 + "actually reason about content.\n"
72 76 "End your reply with the graph_manifest as a single fenced ```json code block "
73 77 "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key."
74 78 )
... ... @@ -102,7 +106,7 @@ def _extract_graph_manifest(text):
102 106 return None
103 107  
104 108  
105   -_CANONICAL_DOC_CHAIN = ("brd", "prd", "sdd", "benchmark")
  109 +_CANONICAL_DOC_CHAIN = ("brd", "prd", "sdd", "benchmark", "test_dataset", "test_script")
106 110  
107 111  
108 112 def _normalize_graph_manifest(manifest):
... ... @@ -158,6 +162,19 @@ def _normalize_graph_manifest(manifest):
158 162 if prev_id not in cur_node["depends_on"]:
159 163 cur_node["depends_on"].append(prev_id)
160 164  
  165 + # 2026-08-06: test_script's own prompt needs {benchmark_content}
  166 + # directly (not just what survives into test_dataset_content, which
  167 + # can be a terse 'N/A: ...' with none of the original scenario
  168 + # detail) -- the plain linear chain above only gives it
  169 + # test_dataset as an immediate dependency, so add benchmark as a
  170 + # second one. e2e_pipeline._planner_prompt_builder already threads
  171 + # every *immediate* dependency's content through generically, this
  172 + # is the one canonical node that genuinely needs two.
  173 + benchmark_id = by_artifact_type["benchmark"]["node_id"]
  174 + script_node = by_artifact_type["test_script"]
  175 + if benchmark_id not in script_node["depends_on"]:
  176 + script_node["depends_on"].append(benchmark_id)
  177 +
161 178 return manifest
162 179  
163 180  
... ...