@guanlin4924/devflow-pi 0.1.86 → 0.1.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +199 -9
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +15 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +49 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_adapter.rb +83 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +19 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checkpoint_meta.rb +46 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +27 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +32 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +44 -41
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +5 -4
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +15 -4
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +15 -9
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_preflight.py +238 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_run.py +234 -32
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +63 -11
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +5 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +40 -31
  20. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +11 -5
  21. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +6 -6
  22. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +16 -1
  23. package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +3 -1
  24. package/plugins/devflow-quality/agents/code-reviewer.md +7 -1
  25. package/plugins/devflow-quality/skills/code-review/SKILL.md +10 -0
  26. package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +37 -0
  27. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/SKILL.md +327 -0
  28. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/assets/output-template.md +213 -0
  29. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/bug-rules.md +9 -0
  30. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/coverage-checklist.md +63 -0
  31. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/exception-checklist.md +37 -0
  32. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/api-test.md +58 -0
  33. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/boundary-test.md +151 -0
  34. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/common-test.md +49 -0
  35. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/compatibility-test.md +105 -0
  36. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/data-test.md +67 -0
  37. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/exception-test.md +109 -0
  38. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/function-test.md +65 -0
  39. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/integration-test.md +36 -0
  40. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/performance-test.md +100 -0
  41. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/scenario-test.md +63 -0
  42. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/security-test.md +55 -0
  43. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/ui-test.md +49 -0
  44. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/usability-test.md +46 -0
  45. package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/writing-guide.md +111 -0
  46. package/src/constants.ts +1 -1
@@ -7,6 +7,7 @@ pretend that a source excerpt alone proves runtime behaviour.
7
7
  from __future__ import annotations
8
8
 
9
9
  import hashlib
10
+ import hmac
10
11
  import json
11
12
  import os
12
13
  from pathlib import Path
@@ -36,6 +37,29 @@ def canonical(value: Any) -> str:
36
37
  return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
37
38
 
38
39
 
40
+ def verified_review_binding(request: dict) -> tuple[bool, str]:
41
+ """A completed reviewer task, not the caller, owns the submitted report."""
42
+ binding = request.get("core_review_binding")
43
+ secret = os.environ.get("DEVFLOW_REVIEW_BINDING_KEY", "")
44
+ if not isinstance(binding, dict) or not secret:
45
+ return False, "missing Core-bound independent reviewer task"
46
+ fields = ("agent", "feature", "contract_id", "task_call_id", "report_sha256", "signature")
47
+ if any(not isinstance(binding.get(field), str) or not binding[field] for field in fields):
48
+ return False, "incomplete Core reviewer task binding"
49
+ target = request.get("target")
50
+ if binding["agent"] != "devflow-code-reviewer" or not isinstance(target, dict) or binding["feature"] != target.get("feature"):
51
+ return False, "reviewer identity or feature does not match the report"
52
+ unsigned_report = {key: value for key, value in request.items() if key != "core_review_binding"}
53
+ report_hash = digest(canonical(unsigned_report).encode())
54
+ if not hmac.compare_digest(report_hash, binding["report_sha256"]):
55
+ return False, "review report differs from the completed reviewer task"
56
+ unsigned_binding = {key: binding[key] for key in fields if key != "signature"}
57
+ expected = hmac.new(secret.encode(), canonical(unsigned_binding).encode(), hashlib.sha256).hexdigest()
58
+ if not hmac.compare_digest(expected, binding["signature"]):
59
+ return False, "invalid Core reviewer task signature"
60
+ return True, ""
61
+
62
+
39
63
  def safe_file(root: Path, relative: str) -> Path:
40
64
  if not isinstance(relative, str) or not relative or Path(relative).is_absolute() or "\x00" in relative:
41
65
  raise ValueError("path must be a nonempty project-relative path")
@@ -136,6 +160,10 @@ def db_open(root: Path) -> sqlite3.Connection:
136
160
  id INTEGER PRIMARY KEY, chain_id TEXT, status TEXT NOT NULL, detail_json TEXT NOT NULL,
137
161
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
138
162
  );
163
+ CREATE TABLE IF NOT EXISTS reviewer_tasks (
164
+ task_call_id TEXT PRIMARY KEY, chain_id TEXT NOT NULL, round INTEGER NOT NULL,
165
+ report_sha256 TEXT NOT NULL
166
+ );
139
167
  """)
140
168
  return db
141
169
 
@@ -221,6 +249,35 @@ def verify_core_result(root: Path, feature: str, evidence: Any, expected_status:
221
249
  return False, f"unreadable Core acceptance result: {exc}"
222
250
 
223
251
 
252
+ def verify_core_impact(root: Path, feature: str, evidence: dict, impact: str, trigger: str) -> tuple[bool, str]:
253
+ """A failed TC alone does not establish the reviewer's causal/impact label."""
254
+ try:
255
+ suite = read_json(safe_file(root, f".planning/requirements/{feature}/tests.json"))
256
+ node = next((n for n in suite.get("nodes", []) if isinstance(n, dict) and n.get("id") == evidence.get("tc_id")), None)
257
+ data = node.get("data", {}) if node else {}
258
+ if data.get("review_impact") == impact and data.get("review_trigger") == trigger:
259
+ return True, ""
260
+ return False, "Core frozen TC does not confirm the claimed failure trigger and business impact"
261
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
262
+ return False, f"Core frozen impact cannot be verified: {exc}"
263
+
264
+
265
+ def declared_change_paths(root: Path, feature: str) -> tuple[set[str], str, str]:
266
+ relative = f".planning/features/{feature}/变更清单.md"
267
+ source = safe_file(root, relative)
268
+ declarations = re.findall(r"^\s*-\s*\*\*文件\*\*\s*[::]\s*(.+)$", source.read_text(encoding="utf-8"), re.MULTILINE)
269
+ paths = set()
270
+ for declaration in declarations:
271
+ matches = re.findall(r"`([^`]+)`", declaration)
272
+ if len(matches) != 1 or declaration.strip() != f"`{matches[0]}`":
273
+ raise ValueError("ambiguous change-list file declaration")
274
+ safe_file(root, matches[0])
275
+ paths.add(matches[0])
276
+ if not paths:
277
+ raise ValueError("change list has no unambiguous source file declarations")
278
+ return paths, relative, digest(source.read_bytes())
279
+
280
+
224
281
  def verify_coverage(root: Path, feature: str, required: list[str], coverage_evidence: Any) -> tuple[set[str], list[str]]:
225
282
  if not isinstance(coverage_evidence, dict):
226
283
  return set(), ["Each review surface and scenario needs a distinct Core TC binding"]
@@ -241,7 +298,10 @@ def verify_coverage(root: Path, feature: str, required: list[str], coverage_evid
241
298
  suite = read_json(safe_file(root, f".planning/requirements/{feature}/tests.json"))
242
299
  node = next((n for n in suite.get("nodes", []) if isinstance(n, dict) and n.get("id") == ref["tc_id"]), None)
243
300
  types = {"scenario:normal": {"api", "page"}, "scenario:unauthorized": {"permission"}, "scenario:failure_rollback": {"resilience", "boundary"}, "scenario:compatibility": {"migration"}}
244
- if not node or node.get("kind") != "test_case" or node.get("status") != "confirmed" or node.get("data", {}).get("test_type") not in types.get(tag, set()):
301
+ data = node.get("data", {}) if node else {}
302
+ explicit = tag in data.get("review_coverage_tags", []) if isinstance(data.get("review_coverage_tags"), list) else False
303
+ typed = data.get("test_type") in types.get(tag, set())
304
+ if not node or node.get("kind") != "test_case" or node.get("status") != "confirmed" or not (explicit or typed):
245
305
  issues.append(f"{tag}: frozen TC type cannot prove this coverage obligation; request human confirmation")
246
306
  continue
247
307
  covered.add(tag)
@@ -256,10 +316,139 @@ def suite_has_failures(root: Path, feature: str) -> bool:
256
316
  return True
257
317
 
258
318
 
319
+ def gate_status(root: Path, feature: str, contract_id: str | None = None) -> dict:
320
+ """Read only the Core ledger; prose reports never confer approval."""
321
+ root = root.resolve(strict=True)
322
+ if not contract_id:
323
+ return {"status": "human_confirmation", "reason": "Current Core review contract identity is required"}
324
+ try:
325
+ contract = read_json(safe_file(root, f".planning/contracts/{feature}/{contract_id}.json"))
326
+ if contract.get("origin") != "core_execution_contract" or contract.get("step") != "code-review" or contract.get("feature") != feature or contract.get("contract_id") != contract_id:
327
+ return {"status": "core_contract_conflict", "reason": "Review contract identity is not a frozen Core code-review contract"}
328
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
329
+ from review_preflight import load_state
330
+ state = load_state(safe_file(root, ".planning/STATE.md"))
331
+ graph = read_json(safe_file(root, f".planning/requirements/{feature}/governance.json"))
332
+ workflow = safe_file(root, contract["workflow_path"])
333
+ if (state.get("current_feature") != feature or state.get("current_step") != "code-review"
334
+ or state.get("revision") != contract.get("state_revision")
335
+ or graph.get("revision") != contract.get("graph_revision")
336
+ or digest(workflow.read_bytes()) != contract.get("workflow_fingerprint")):
337
+ return {"status": "stale_evidence", "reason": "Review contract differs from current Core state, graph, or workflow"}
338
+ except (OSError, ValueError, KeyError, TypeError, ImportError, json.JSONDecodeError) as exc:
339
+ return {"status": "human_confirmation", "reason": f"Current Core review contract cannot be verified: {exc}"}
340
+ database = root / ".planning" / "review-runs" / "review-runs.sqlite"
341
+ if not database.is_file() or database.is_symlink():
342
+ return {"status": "human_confirmation", "reason": "No Core review round exists"}
343
+ db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
344
+ try:
345
+ rows = db.execute("SELECT result_json FROM rounds ORDER BY created_at DESC, rowid DESC").fetchall()
346
+ finally:
347
+ db.close()
348
+ for (raw,) in rows:
349
+ result = json.loads(raw)
350
+ task = result.get("reviewer_task", {})
351
+ if task.get("feature") != feature:
352
+ continue
353
+ if task.get("contract_id") != contract_id:
354
+ continue
355
+ report_path = next((item for item in contract.get("allowed_paths", []) if isinstance(item, str) and item.endswith("/REVIEW.md")), None)
356
+ if not report_path:
357
+ return {"status": "core_contract_conflict", "reason": "Core contract has no REVIEW.md output"}
358
+ try:
359
+ if safe_file(root, report_path).read_text(encoding="utf-8") != review_markdown(result):
360
+ return {"status": "core_contract_conflict", "reason": "REVIEW.md differs from Core review round"}
361
+ except (OSError, ValueError):
362
+ return {"status": "core_contract_conflict", "reason": "Core-derived REVIEW.md is missing"}
363
+ if task.get("agent") != "devflow-code-reviewer":
364
+ return {"status": "core_contract_conflict", "reason": "Review round lacks independent reviewer"}
365
+ fixed = result.get("fixed_versions", {})
366
+ index_generation = fixed.get("code_index_generation")
367
+ if index_generation is not None:
368
+ try:
369
+ snapshot = read_json(safe_file(root, ".planning/code-index/SNAPSHOT.json"))
370
+ if snapshot.get("generation") != index_generation:
371
+ return {"status": "stale_evidence", "reason": "Code-index generation changed after review", "chain_id": result.get("chain_id")}
372
+ except (OSError, ValueError, json.JSONDecodeError):
373
+ return {"status": "stale_evidence", "reason": "Code-index snapshot unavailable after review", "chain_id": result.get("chain_id")}
374
+ for relative, expected in fixed.get("source", {}).items():
375
+ try:
376
+ if digest(safe_file(root, relative).read_bytes()) != expected:
377
+ return {"status": "stale_evidence", "reason": f"Source changed after review: {relative}", "chain_id": result.get("chain_id")}
378
+ except (OSError, ValueError):
379
+ return {"status": "stale_evidence", "reason": f"Source unavailable after review: {relative}", "chain_id": result.get("chain_id")}
380
+ for authority in fixed.get("authority", []):
381
+ try:
382
+ if digest(safe_file(root, authority["authority_path"]).read_bytes()) != authority["authority_sha256"]:
383
+ return {"status": "stale_evidence", "reason": "Authority changed after review", "chain_id": result.get("chain_id")}
384
+ except (OSError, ValueError, KeyError):
385
+ return {"status": "stale_evidence", "reason": "Authority unavailable after review", "chain_id": result.get("chain_id")}
386
+ for relative, expected in fixed.get("core_results", {}).items():
387
+ try:
388
+ if digest(safe_file(root, relative).read_bytes()) != expected:
389
+ return {"status": "stale_evidence", "reason": f"Core test evidence changed after review: {relative}", "chain_id": result.get("chain_id")}
390
+ except (OSError, ValueError):
391
+ return {"status": "stale_evidence", "reason": f"Core test evidence unavailable: {relative}", "chain_id": result.get("chain_id")}
392
+ plan = fixed.get("change_plan", {})
393
+ try:
394
+ if not plan or digest(safe_file(root, plan["path"]).read_bytes()) != plan["sha256"]:
395
+ return {"status": "stale_evidence", "reason": "Core change-list scope changed after review", "chain_id": result.get("chain_id")}
396
+ except (OSError, ValueError, KeyError, TypeError):
397
+ return {"status": "stale_evidence", "reason": "Core change-list scope unavailable", "chain_id": result.get("chain_id")}
398
+ status = result.get("status")
399
+ verdict = "pass" if status == "clean" and result.get("automatic_release_allowed") else ("terminate_task" if status == "terminated_p0" else "repair")
400
+ return {"status": verdict, "chain_id": result.get("chain_id"), "round": result.get("round"), "evidence_chain": {"reviewer_task": task, "fixed_versions": fixed, "coverage": result.get("coverage")}, "open_findings": [f for f in result.get("findings", []) if f.get("state") != "resolved"], "next_action": "advance" if verdict == "pass" else ("stop_current_task" if verdict == "terminate_task" else "repair_and_re_review")}
401
+ return {"status": "human_confirmation", "reason": "No Core review round exists for this feature and contract"}
402
+
403
+
404
+ def review_markdown(result: dict) -> str:
405
+ lines = ["# Code Review", "", "> Core-derived view; the review ledger is authoritative.", "", f"- Chain: `{result['chain_id']}`", f"- Round: {result['round']}", f"- Decision: `{result['status']}`", f"- Reviewer task: `{result['reviewer_task']['task_call_id']}`", "", "## Open findings", ""]
406
+ for finding in result.get("findings", []):
407
+ if finding.get("state") != "resolved":
408
+ source = finding.get("source", {})
409
+ lines.append(f"- `{finding['finding_id']}` {finding['severity']}: {source.get('path')}:{source.get('line')} — {finding.get('actual', '')}")
410
+ if lines[-1] == "":
411
+ lines.append("- None")
412
+ lines.extend(["", "## Next action", "", "Advance to the next workflow step." if result.get("status") == "clean" else "Repair findings and request a new independent review.", ""])
413
+ return "\n".join(lines)
414
+
415
+
416
+ def render_review(root: Path, feature: str, contract_id: str) -> dict:
417
+ root = root.resolve(strict=True)
418
+ contract = read_json(safe_file(root, f".planning/contracts/{feature}/{contract_id}.json"))
419
+ path = next((item for item in contract.get("allowed_paths", []) if isinstance(item, str) and item.endswith("/REVIEW.md")), None)
420
+ if not path:
421
+ return {"status": "core_contract_conflict", "reason": "Core contract has no REVIEW.md output"}
422
+ database = root / ".planning/review-runs/review-runs.sqlite"
423
+ if not database.is_file():
424
+ return {"status": "human_confirmation", "reason": "No Core review round exists"}
425
+ db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
426
+ try:
427
+ rows = db.execute("SELECT result_json FROM rounds ORDER BY created_at DESC, rowid DESC").fetchall()
428
+ finally:
429
+ db.close()
430
+ for (raw,) in rows:
431
+ result = json.loads(raw)
432
+ task = result.get("reviewer_task", {})
433
+ if task.get("feature") == feature and task.get("contract_id") == contract_id:
434
+ destination = root / path
435
+ if destination.is_symlink() or not destination.resolve().is_relative_to(root):
436
+ return {"status": "core_contract_conflict", "reason": "Unsafe review output path"}
437
+ destination.parent.mkdir(parents=True, exist_ok=True)
438
+ temporary = destination.with_name(destination.name + ".core-tmp")
439
+ if temporary.exists() or temporary.is_symlink():
440
+ return {"status": "core_contract_conflict", "reason": "Review output temporary path is occupied"}
441
+ temporary.write_text(review_markdown(result), encoding="utf-8")
442
+ os.replace(temporary, destination)
443
+ return {"status": "rendered", "path": path, "chain_id": result["chain_id"], "round": result["round"]}
444
+ return {"status": "human_confirmation", "reason": "No Core review round exists for this contract"}
445
+
446
+
259
447
  def run(root: Path, request: dict) -> dict:
260
448
  root = root.resolve(strict=True)
261
449
  if not isinstance(request, dict):
262
450
  raise ValueError("request must be an object")
451
+ bound, binding_issue = verified_review_binding(request)
263
452
  if "round" in request or "findings" in request or "severity" in request:
264
453
  raise ValueError("Core assigns rounds and findings; caller-supplied authority fields are forbidden")
265
454
  target = request.get("target")
@@ -275,6 +464,9 @@ def run(root: Path, request: dict) -> dict:
275
464
  raise ValueError("review_plan.questions must be nonempty")
276
465
  if not all(isinstance(q, str) and q.strip() for q in plan["questions"]):
277
466
  raise ValueError("review questions must be strings")
467
+ review_questions = request.get("review_questions", [])
468
+ if not isinstance(review_questions, list) or not all(isinstance(q, str) and q.strip() for q in review_questions):
469
+ raise ValueError("review_questions must be a list of nonempty strings")
278
470
  surfaces, scenarios = plan.get("surfaces"), plan.get("required_scenarios")
279
471
  if not isinstance(surfaces, list) or not set(surfaces) <= SURFACES or not isinstance(scenarios, list) or not set(scenarios) <= SCENARIOS:
280
472
  raise ValueError("review plan uses an unsupported surface or scenario")
@@ -284,6 +476,14 @@ def run(root: Path, request: dict) -> dict:
284
476
  if not isinstance(expectations, list):
285
477
  raise ValueError("expectations must be a list")
286
478
  accepted, questions = validate_authority(root, target["feature"], expectations)
479
+ try:
480
+ declared_paths, change_plan_path, change_plan_hash = declared_change_paths(root, target["feature"])
481
+ missing_paths = declared_paths - set(paths)
482
+ if missing_paths:
483
+ questions.append("Review target omits change-list files: " + ", ".join(sorted(missing_paths)))
484
+ except (OSError, ValueError, UnicodeError) as exc:
485
+ declared_paths, change_plan_path, change_plan_hash = set(), None, None
486
+ questions.append(f"Core change-list review scope cannot be verified: {exc}")
287
487
  chain_id = request.get("chain_id")
288
488
  if chain_id is not None and (not isinstance(chain_id, str) or not chain_id.startswith("rev_")):
289
489
  raise ValueError("invalid chain_id")
@@ -311,11 +511,14 @@ def run(root: Path, request: dict) -> dict:
311
511
  raise ValueError("three completed review rounds exhausted")
312
512
  else:
313
513
  round_no = 1
314
- if questions:
315
- result = response("awaiting_human_confirmation", chain_id, round_no, questions=questions)
514
+ if questions or review_questions:
515
+ result = response("awaiting_human_confirmation", chain_id, round_no, questions=[*questions, *review_questions])
316
516
  db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
317
517
  db.commit()
318
518
  return result
519
+ binding = request.get("core_review_binding") if bound else None
520
+ if bound and db.execute("SELECT 1 FROM reviewer_tasks WHERE task_call_id=?", (binding["task_call_id"],)).fetchone():
521
+ raise ValueError("reviewer task was already consumed by another review round")
319
522
  if not chain_id:
320
523
  # A new ID cannot erase an unresolved P0 for the same target and authority.
321
524
  for old_id, raw in db.execute("SELECT c.chain_id,r.result_json FROM chains c JOIN rounds r ON r.chain_id=c.chain_id AND r.round=(SELECT MAX(round) FROM rounds WHERE chain_id=c.chain_id) WHERE c.target_key=? AND c.authority_key=?", (target_key, authority_key)):
@@ -370,6 +573,10 @@ def run(root: Path, request: dict) -> dict:
370
573
  valid, reason = verify_core_result(root, target["feature"], item.get("runtime_evidence"), "failed", authority_node_id)
371
574
  if not valid:
372
575
  evidence_issues.append(f"{evidence['path']}:{evidence['line']}: {reason}")
576
+ else:
577
+ causal, reason = verify_core_impact(root, target["feature"], item["runtime_evidence"], item["impact"], item.get("trigger"))
578
+ if not causal:
579
+ evidence_issues.append(f"{evidence['path']}:{evidence['line']}: {reason}; human confirmation required")
373
580
  fingerprint = digest(canonical([item["expectation_id"], evidence["path"], evidence["line"], item["impact"], item.get("trigger")]).encode())[:20]
374
581
  findings.append({"finding_id": "F-" + fingerprint, "expectation_id": item["expectation_id"], "severity": IMPACTS[item["impact"]], "severity_source": "reviewer_claim", "impact": item["impact"], "source": evidence, "actual": item.get("actual"), "trigger": item.get("trigger"), "reproduction": item.get("reproduction"), "improvement": item.get("improvement"), "confidence": "source_and_core_tc_verified_causality_unverified" if item.get("runtime_evidence") else "source_verified_inference", "epistemic_status": "reviewer_interpretation", "verified_boundaries": ["approved_core_graph_expectation", "source_line_and_hash", "core_tc_identity_and_status"] if item.get("runtime_evidence") else ["approved_core_graph_expectation", "source_line_and_hash"]})
375
582
  gaps = sorted(set(required) - covered)
@@ -390,13 +597,12 @@ def run(root: Path, request: dict) -> dict:
390
597
  evidence_issues.append(f"{old_id}: source unchanged; cannot resolve by removing a report observation")
391
598
  else:
392
599
  regression = request.get("regression_evidence", {}).get(old_id)
393
- try:
394
- if not isinstance(regression, dict) or digest(safe_file(root, regression.get("path")).read_bytes()) != regression.get("sha256"):
395
- evidence_issues.append(f"{old_id}: missing verified regression evidence")
396
- else:
397
- findings.append({**old, "state": "resolved"})
398
- except (OSError, ValueError, TypeError):
399
- evidence_issues.append(f"{old_id}: missing verified regression evidence")
600
+ authority_node_id = next((e["node_id"] for e in accepted if e["id"] == old.get("expectation_id")), None)
601
+ valid, reason = verify_core_result(root, target["feature"], regression, "passed", authority_node_id)
602
+ if not valid:
603
+ evidence_issues.append(f"{old_id}: missing Core passed regression TC: {reason}")
604
+ else:
605
+ findings.append({**old, "state": "resolved"})
400
606
  for rel, start_hash in versions.items():
401
607
  if digest(safe_file(root, rel).read_bytes()) != start_hash:
402
608
  result = response("stale_evidence", chain_id, round_no, questions=[f"Source changed during review: {rel}"])
@@ -424,33 +630,24 @@ def run(root: Path, request: dict) -> dict:
424
630
  return result
425
631
  open_findings = [f for f in findings if f["state"] != "resolved"]
426
632
  summary = {p: sum(f["severity"] == p for f in open_findings) for p in ("P0", "P1", "P2", "P3")}
427
- if open_findings:
428
- # Source and TC hashes establish identity, not that the reviewer's
429
- # causal story or impact category is true. Until Core has a
430
- # verifiable independent-review attestation, these are proposals,
431
- # not completed rounds that can terminate a task after three tries.
432
- result = response(
433
- "awaiting_human_confirmation", chain_id, round_no,
434
- questions=["Independent reviewer identity and business causality/impact require confirmation"],
435
- proposed_findings=open_findings,
436
- proposed_risk_summary=summary,
437
- proposed_improvement_plan=[{"finding_id": f["finding_id"], "severity": f["severity"], "action": f["improvement"]} for f in open_findings],
438
- coverage={"covered": sorted(covered), "gaps": gaps},
439
- )
633
+ if not bound:
634
+ result = response("awaiting_human_confirmation", chain_id, round_no, questions=[binding_issue], proposed_findings=open_findings, proposed_risk_summary=summary, coverage={"covered": sorted(covered), "gaps": gaps})
440
635
  db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
441
636
  db.commit()
442
637
  return result
443
- if not summary["P0"] and not summary["P1"]:
638
+ if not open_findings:
444
639
  reasons = [*evidence_issues]
445
640
  if suite_has_failures(root, target["feature"]):
446
641
  reasons.append("The same Core acceptance run contains failed or blocked TCs; clean cannot be inferred from selected passed cases")
447
- reasons.append("No Core-verifiable independent reviewer identity or causal semantic attestation is available; clean needs human confirmation")
448
- result = response("awaiting_human_confirmation", chain_id, round_no, questions=reasons, coverage={"covered": sorted(covered), "gaps": gaps})
449
- db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
450
- db.commit()
451
- return result
452
- status = "terminated_p0" if round_no == 3 and summary["P0"] else ("findings" if summary["P0"] or summary["P1"] else "clean")
453
- result = response(status, chain_id, round_no, fixed_versions={"source": versions, "authority": accepted, "code_index_generation": index_discovery.get("generation")}, index_discovery=index_discovery, coverage={"covered": sorted(covered), "gaps": gaps, "index_gap": index_discovery.get("reason") if index_discovery["status"] != "located" else None}, findings=findings, risk_summary=summary, improvement_plan=[{"finding_id": f["finding_id"], "severity": f["severity"], "action": f["improvement"]} for f in sorted(open_findings, key=lambda x: x["severity"])], all_p0_resolved=summary["P0"] == 0, severity_source="reviewer_claim", semantic_causality_verified=False, automatic_release_allowed=False, termination_scope="review_chain_only" if status == "terminated_p0" else None, history=[{"round": r, "status": json.loads(raw)["status"]} for r, raw in db.execute("SELECT round,result_json FROM rounds WHERE chain_id=? ORDER BY round", (chain_id,))])
642
+ if reasons:
643
+ result = response("awaiting_human_confirmation", chain_id, round_no, questions=reasons, coverage={"covered": sorted(covered), "gaps": gaps})
644
+ db.execute("INSERT INTO events(chain_id,status,detail_json) VALUES(?,?,?)", (chain_id, result["status"], canonical(result)))
645
+ db.commit()
646
+ return result
647
+ status = "terminated_p0" if round_no == 3 and summary["P0"] else ("findings" if open_findings else "clean")
648
+ core_results = {rel: digest(safe_file(root, rel).read_bytes()) for rel in (f".planning/requirements/{target['feature']}/tests.json", f".planning/test-runs/{target['feature']}/acceptance-results.json")}
649
+ result = response(status, chain_id, round_no, fixed_versions={"source": versions, "authority": accepted, "core_results": core_results, "change_plan": {"path": change_plan_path, "sha256": change_plan_hash, "declared_paths": sorted(declared_paths)}, "code_index_generation": index_discovery.get("generation")}, index_discovery=index_discovery, coverage={"covered": sorted(covered), "gaps": gaps, "index_gap": index_discovery.get("reason") if index_discovery["status"] != "located" else None}, findings=findings, risk_summary=summary, improvement_plan=[{"finding_id": f["finding_id"], "severity": f["severity"], "action": f["improvement"]} for f in sorted(open_findings, key=lambda x: x["severity"])], all_p0_resolved=summary["P0"] == 0, severity_source="independent_reviewer_core_evidence_bound", semantic_causality_verified=True, automatic_release_allowed=status == "clean", termination_scope="current_task_only" if status == "terminated_p0" else None, reviewer_task={key: binding[key] for key in ("agent", "feature", "contract_id", "task_call_id", "report_sha256")}, history=[{"round": r, "status": json.loads(raw)["status"]} for r, raw in db.execute("SELECT round,result_json FROM rounds WHERE chain_id=? ORDER BY round", (chain_id,))])
650
+ db.execute("INSERT INTO reviewer_tasks(task_call_id,chain_id,round,report_sha256) VALUES(?,?,?,?)", (binding["task_call_id"], chain_id, round_no, binding["report_sha256"]))
454
651
  db.execute("INSERT INTO rounds(chain_id,round,result_json) VALUES(?,?,?)", (chain_id, round_no, canonical(result)))
455
652
  if status == "terminated_p0":
456
653
  db.execute("UPDATE chains SET terminal=? WHERE chain_id=?", (status, chain_id))
@@ -466,7 +663,12 @@ def run(root: Path, request: dict) -> dict:
466
663
  if __name__ == "__main__":
467
664
  try:
468
665
  project = Path(sys.argv[1] if len(sys.argv) > 1 else os.getcwd())
469
- print(canonical(run(project, json.load(sys.stdin))))
666
+ if len(sys.argv) > 3 and sys.argv[2] == "--gate":
667
+ print(canonical(gate_status(project.resolve(strict=True), sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else None)))
668
+ elif len(sys.argv) > 4 and sys.argv[2] == "--render":
669
+ print(canonical(render_review(project, sys.argv[3], sys.argv[4])))
670
+ else:
671
+ print(canonical(run(project, json.load(sys.stdin))))
470
672
  except (ValueError, OSError, json.JSONDecodeError, sqlite3.Error, StaleEvidence) as error:
471
673
  print(canonical({"status": "error", "error": str(error)}))
472
674
  sys.exit(2)
@@ -14,6 +14,7 @@ require_relative "checker_registry"
14
14
  require_relative "requirement_coverage"
15
15
  require_relative "workflow_gate_contract"
16
16
  require_relative "parallel_run"
17
+ require_relative "checkpoint_meta"
17
18
 
18
19
  module WorkflowStateValidator
19
20
  REQUIRED_STATE_FIELDS = %w[
@@ -29,6 +30,32 @@ module WorkflowStateValidator
29
30
  TERMINAL_ARTIFACT_STATUSES = %w[approved completed passed].freeze
30
31
  NON_TERMINAL_ARTIFACT_STATUSES = %w[planned generated pending_rerun stale failed].freeze
31
32
  TERMINAL_WORKFLOW_STATUSES = %w[completed failed cancelled].freeze
33
+ # Core quality checks are advisory by default. Keep only structural and
34
+ # write-safety failures hard; AI review decides whether business-quality
35
+ # drift, stale metadata, or incomplete generated evidence should pause work.
36
+ SOFT_GATE_MODE = ENV.fetch("DEVFLOW_SOFT_GATES", "1") != "0"
37
+ HARD_VALIDATION_CODES = %w[
38
+ invalid_control_path
39
+ missing_state_file
40
+ missing_state_front_matter
41
+ invalid_state_yaml
42
+ missing_state_field
43
+ invalid_workflow_config
44
+ workflow_step_unknown
45
+ phase_ownership_conflict
46
+ ].freeze
47
+
48
+ def self.hard_validation_code?(code)
49
+ value = code.to_s
50
+ return true if HARD_VALIDATION_CODES.include?(value)
51
+ return true if value.start_with?("artifact_registry_", "parallel_", "PARALLEL_")
52
+ return true if value.start_with?("duplicate_", "unknown_", "missing_tracked_step")
53
+ return true if value.start_with?("step_in_", "completed_", "terminal_", "non_completed_")
54
+ return true if value.start_with?("waiting_checkpoint_")
55
+ return true if %w[missing_required_output output_not_registered].include?(value)
56
+
57
+ false
58
+ end
32
59
 
33
60
  module_function
34
61
 
@@ -89,7 +116,7 @@ module WorkflowStateValidator
89
116
  end
90
117
  validate_dependencies(issues, step_by_id, completed_ids)
91
118
  validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
92
- validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
119
+ validate_waiting_checkpoint(issues, root, state, workflow, step_by_id, artifact_by_path, strict_artifact_status)
93
120
  validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
94
121
  graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
95
122
  governed_steps = %w[requirement-baseline api-contract architecture-feasibility integration-verify change-plan execute run-acceptance-tests]
@@ -112,6 +139,15 @@ module WorkflowStateValidator
112
139
  end
113
140
  end
114
141
 
142
+ if SOFT_GATE_MODE
143
+ issues.each do |entry|
144
+ next unless entry["severity"] == "error"
145
+ next if hard_validation_code?(entry["code"])
146
+
147
+ entry["severity"] = "warning"
148
+ entry["soft_gate"] = true
149
+ end
150
+ end
115
151
  ok = issues.none? { |entry| entry["severity"] == "error" }
116
152
  result(ok, state_path, workflow_path, registry_path, issues)
117
153
  end
@@ -357,15 +393,26 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
357
393
 
358
394
  declared.each do |step_id, checker_ids|
359
395
  Array(checker_ids).each do |checker_id|
360
- next if CheckerRegistry.known?(checker_id)
361
-
362
- issues << issue(
363
- "error",
364
- "unknown_checker_id",
365
- "Workflow references a checker that is not in the canonical checker registry",
366
- step: step_id,
367
- checker_id: checker_id
368
- )
396
+ unless CheckerRegistry.known?(checker_id)
397
+ issues << issue(
398
+ "error",
399
+ "unknown_checker_id",
400
+ "Workflow references a checker that is not in the canonical checker registry",
401
+ step: step_id,
402
+ checker_id: checker_id
403
+ )
404
+ next
405
+ end
406
+
407
+ unless CheckerRegistry.executable?(checker_id)
408
+ issues << issue(
409
+ "error",
410
+ "checker_execution_adapter_missing",
411
+ "Workflow references a checker without a Core execution adapter",
412
+ step: step_id,
413
+ checker_id: checker_id
414
+ )
415
+ end
369
416
  end
370
417
  end
371
418
  end
@@ -391,7 +438,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
391
438
  end
392
439
  end
393
440
 
394
- def validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
441
+ def validate_waiting_checkpoint(issues, root, state, workflow, step_by_id, artifact_by_path, strict_artifact_status)
395
442
  return unless state["status"] == "waiting_checkpoint"
396
443
 
397
444
  step = step_by_id[state["current_step"]]
@@ -405,6 +452,11 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
405
452
  end
406
453
 
407
454
  validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, true)
455
+ checkpoint_id = state["checkpoint"] || step["checkpoint"]
456
+ checkpoint = workflow.fetch("checkpoints", {})[checkpoint_id]
457
+ CheckpointMeta.validate(root: root, state: state, checkpoint: checkpoint).each do |entry|
458
+ issues << issue("error", entry.fetch("code"), entry.fetch("message"), step: step["id"], checkpoint: checkpoint_id, **entry.reject { |key, _| %w[code message].include?(key) })
459
+ end
408
460
  end
409
461
 
410
462
  # An in-progress step may have written its output before its complete-step
@@ -73,7 +73,11 @@ module WorkflowGateContract
73
73
  step_checkers.each do |step_id, checker_ids|
74
74
  errors << error("CHECKER_STEP_REF_NOT_FOUND", "checker declaration references an unknown step", step: step_id) unless step_by_id.key?(step_id.to_s)
75
75
  Array(checker_ids).each do |checker_id|
76
- errors << error("UNKNOWN_CHECKER_ID", "workflow references an unknown checker", step: step_id, checker: checker_id) unless CheckerRegistry.known?(checker_id)
76
+ if !CheckerRegistry.known?(checker_id)
77
+ errors << error("UNKNOWN_CHECKER_ID", "workflow references an unknown checker", step: step_id, checker: checker_id)
78
+ elsif !CheckerRegistry.executable?(checker_id)
79
+ errors << error("CHECKER_EXECUTION_ADAPTER_MISSING", "workflow references a checker without a Core execution adapter", step: step_id, checker: checker_id)
80
+ end
77
81
  end
78
82
  end
79
83
  end