@guanlin4924/devflow-pi 0.1.85 → 0.1.88

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 +211 -19
  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 +10 -1
  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
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only Core review preflight. Never runs an AI review or changes project files."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+
12
+
13
+ def sha256(path: Path) -> str:
14
+ return hashlib.sha256(path.read_bytes()).hexdigest()
15
+
16
+
17
+ def within(root: Path, relative: str) -> Path:
18
+ if not isinstance(relative, str) or not relative or Path(relative).is_absolute() or "\x00" in relative:
19
+ raise ValueError("invalid relative path")
20
+ path = (root / relative).resolve()
21
+ if not path.is_relative_to(root):
22
+ raise ValueError("path escapes project")
23
+ return path
24
+
25
+
26
+ def load_workflow(path: Path) -> dict:
27
+ command = ["ruby", "-ryaml", "-rjson", "-e",
28
+ "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0)), permitted_classes: [], aliases: false))",
29
+ str(path)]
30
+ result = subprocess.run(command, text=True, capture_output=True, timeout=15, check=False)
31
+ if result.returncode:
32
+ raise ValueError(f"workflow cannot be read: {result.stderr.strip()}")
33
+ return json.loads(result.stdout)
34
+
35
+
36
+ def load_state(path: Path) -> dict:
37
+ command = ["ruby", "-ryaml", "-rjson", "-e",
38
+ "front = File.read(ARGV.fetch(0)).match(/\\A---\\n(.*?)\\n---/m); "
39
+ "abort 'STATE front matter missing' unless front; "
40
+ "puts JSON.generate(YAML.safe_load(front[1], permitted_classes: [], aliases: false))",
41
+ str(path)]
42
+ result = subprocess.run(command, text=True, capture_output=True, timeout=15, check=False)
43
+ if result.returncode:
44
+ raise ValueError(f"STATE cannot be read: {result.stderr.strip()}")
45
+ return json.loads(result.stdout)
46
+
47
+
48
+ def preflight(root: Path, feature: str, step: str, contract_path: str,
49
+ obligations: list[dict], review_agent: str | None = None,
50
+ review_contract_path: str | None = None, stage: str = "completion") -> dict:
51
+ root = root.resolve()
52
+ evidence, issues = [], []
53
+ if stage not in {"entry", "completion"}:
54
+ return {"status": "human_confirmation", "scope": "snapshot_preflight_only_not_gate_approval",
55
+ "evidence": [], "issues": [{"code": "INVALID_STAGE"}]}
56
+ try:
57
+ frozen_path = within(root, contract_path)
58
+ frozen = json.loads(frozen_path.read_text(encoding="utf-8"))
59
+ command = ["ruby", str(Path(__file__).with_name("execution_contract.rb")),
60
+ "inspect", "--feature", feature, "--step", step, "--json"]
61
+ run = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=15, check=False)
62
+ current_result = json.loads(run.stdout)
63
+ if run.returncode or not current_result.get("ok"):
64
+ return {"status": "human_confirmation", "evidence": evidence,
65
+ "issues": [{"code": "CURRENT_TRUTH_UNAVAILABLE", "detail": current_result.get("message", run.stderr)}]}
66
+ current = current_result["contract"]
67
+ evidence.extend([
68
+ {"kind": "state_graph", "state_revision": current["state_revision"],
69
+ "graph_revision": current["graph_revision"]},
70
+ {"kind": "contract", "path": contract_path, "sha256": sha256(frozen_path),
71
+ "contract_id": frozen.get("contract_id")},
72
+ ])
73
+ if frozen != current:
74
+ return {"status": "human_confirmation", "evidence": evidence,
75
+ "issues": [{"code": "STALE_EXECUTION_CONTRACT",
76
+ "detail": "frozen contract differs from current Core snapshot; recreate and retry"}]}
77
+ if frozen.get("feature") != feature or frozen.get("step") != step:
78
+ issues.append({"code": "CONTRACT_TARGET_MISMATCH", "detail": "contract feature/step mismatch"})
79
+ workflow_path = within(root, current["workflow_path"])
80
+ if sha256(workflow_path) != current["workflow_fingerprint"]:
81
+ return {"status": "human_confirmation", "evidence": evidence,
82
+ "issues": [{"code": "STALE_WORKFLOW_SNAPSHOT"}]}
83
+ workflow = load_workflow(workflow_path)
84
+ state = load_state(within(root, ".planning/STATE.md"))
85
+ if state.get("revision") != current["state_revision"] or state.get("current_feature") != feature or state.get("current_step") != step:
86
+ return {"status": "human_confirmation", "evidence": evidence,
87
+ "issues": [{"code": "STALE_STATE_SNAPSHOT"}]}
88
+ step_definition = next((item for item in workflow.get("steps", [])
89
+ if isinstance(item, dict) and item.get("id") == step), None)
90
+ if step_definition is None:
91
+ raise ValueError("current workflow step unavailable")
92
+ render = lambda value: value.replace("{feature}", feature).replace("{phase}", str(state["current_phase"]))
93
+ derived = []
94
+ for raw in step_definition.get("inputs", []):
95
+ if isinstance(raw, str):
96
+ relative, required = raw, True
97
+ elif isinstance(raw, dict) and isinstance(raw.get("path"), str):
98
+ relative = raw["path"]
99
+ required = (raw.get("required", True) and raw.get("required_at_entry", True)) if stage == "entry" else raw.get("required_at_completion", raw.get("required", True))
100
+ else:
101
+ issues.append({"code": "INVALID_WORKFLOW_INPUT", "detail": "unrecognized input obligation"})
102
+ continue
103
+ if required and relative != ".planning/execution-contract.md":
104
+ derived.append({"path": render(relative), "producer": "existing"})
105
+ for item in step_definition.get("outputs", []):
106
+ if isinstance(item, dict) and item.get("required") is True and isinstance(item.get("path"), str):
107
+ derived.append({"path": render(item["path"]), "producer": "step"})
108
+ meta = step_definition.get("meta_output")
109
+ if isinstance(meta, dict) and isinstance(meta.get("path"), str):
110
+ derived.append({"path": render(meta["path"]), "producer": "step"})
111
+ evidence.append({"kind": "workflow", "path": current["workflow_path"],
112
+ "sha256": current["workflow_fingerprint"],
113
+ "required_obligation_count": len(derived),
114
+ "checker_ids": current.get("checker_ids", [])})
115
+ if step == "code-review":
116
+ graph_relative = f".planning/requirements/{feature}/governance.json"
117
+ graph_path = within(root, graph_relative)
118
+ if not graph_path.is_file():
119
+ issues.append({"code": "CONFIRMED_EXPECTATIONS_UNAVAILABLE", "path": graph_relative})
120
+ else:
121
+ graph = json.loads(graph_path.read_text(encoding="utf-8"))
122
+ stale = {entry.get("node_id") for entry in graph.get("stale", []) if isinstance(entry, dict)}
123
+ relevant = [node for node in graph.get("nodes", [])
124
+ if isinstance(node, dict) and node.get("kind") in {"requirement", "rule", "boundary", "acceptance"}]
125
+ unconfirmed = [node.get("id", "unknown") for node in relevant
126
+ if node.get("status") != "confirmed" or node.get("id") in stale
127
+ or not isinstance(node.get("confirmation"), dict)
128
+ or node["confirmation"].get("origin") != "core"
129
+ or not node["confirmation"].get("evidence")]
130
+ if graph.get("feature") != feature or graph.get("revision") != current["graph_revision"] or not relevant:
131
+ issues.append({"code": "CONFIRMED_EXPECTATIONS_UNAVAILABLE", "path": graph_relative})
132
+ elif unconfirmed:
133
+ issues.append({"code": "UNCONFIRMED_EXPECTATIONS", "path": graph_relative,
134
+ "node_ids": unconfirmed,
135
+ "detail": "Core review scope is not available; confirm or scope these nodes before task dispatch"})
136
+ else:
137
+ evidence.append({"kind": "confirmed_expectations", "path": graph_relative,
138
+ "sha256": sha256(graph_path), "graph_revision": graph["revision"],
139
+ "node_ids": [node["id"] for node in relevant]})
140
+ if not derived and not current.get("checker_ids"):
141
+ issues.append({"code": "NO_REVIEW_OBLIGATIONS", "detail": "Core workflow defines no review obligations"})
142
+ supplied = {item.get("path"): item for item in obligations if isinstance(item, dict)}
143
+ for item in derived:
144
+ supplement = supplied.get(item["path"], {})
145
+ if supplement.get("producer") not in (None, item["producer"]):
146
+ issues.append({"code": "OBLIGATION_PRODUCER_MISMATCH", "path": item["path"]})
147
+ item.update({key: value for key, value in supplement.items() if key == "sha256"})
148
+ obligations = derived + [item for item in obligations if not isinstance(item, dict) or item.get("path") not in {d["path"] for d in derived}]
149
+ allowed = set(current.get("allowed_paths", []))
150
+ for index, obligation in enumerate(obligations):
151
+ if not isinstance(obligation, dict) or not isinstance(obligation.get("path"), str):
152
+ issues.append({"code": "INVALID_OBLIGATION", "index": index})
153
+ continue
154
+ relative = obligation["path"]
155
+ try:
156
+ path = within(root, relative)
157
+ except ValueError as error:
158
+ issues.append({"code": "INVALID_OBLIGATION_PATH", "index": index, "detail": str(error)})
159
+ continue
160
+ producer = obligation.get("producer")
161
+ if producer == "existing":
162
+ if not path.is_file():
163
+ issues.append({"code": "MISSING_INPUT_FACT", "index": index, "path": relative})
164
+ elif obligation.get("sha256") is not None and obligation.get("sha256") != sha256(path):
165
+ issues.append({"code": "STALE_INPUT_FACT", "index": index, "path": relative})
166
+ else:
167
+ evidence.append({"kind": "input", "path": relative, "sha256": sha256(path)})
168
+ elif producer == "step":
169
+ if relative not in allowed:
170
+ issues.append({"code": "UNREACHABLE_OBLIGATION", "index": index, "path": relative,
171
+ "detail": "step contract does not allow producing this path"})
172
+ else:
173
+ evidence.append({"kind": "producer", "path": relative, "step": step,
174
+ "contract_id": current["contract_id"]})
175
+ if path.is_file():
176
+ evidence.append({"kind": "output_snapshot", "path": relative, "sha256": sha256(path)})
177
+ elif stage == "completion":
178
+ issues.append({"code": "MISSING_REVIEW_OUTPUT", "index": index, "path": relative})
179
+ else:
180
+ issues.append({"code": "UNKNOWN_PRODUCER", "index": index, "path": relative})
181
+ if step == "code-review" and review_agent not in (None, "devflow-code-reviewer"):
182
+ issues.append({"code": "REVIEW_AGENT_MISMATCH", "agent": review_agent,
183
+ "detail": "code-review requires the independent devflow-code-reviewer"})
184
+ if step == "code-review":
185
+ review_agent = "devflow-code-reviewer"
186
+ if review_agent:
187
+ reviewer_contract = current
188
+ if review_contract_path:
189
+ review_path = within(root, review_contract_path)
190
+ reviewer_contract = json.loads(review_path.read_text(encoding="utf-8"))
191
+ evidence.append({"kind": "review_contract", "path": review_contract_path,
192
+ "sha256": sha256(review_path)})
193
+ if reviewer_contract.get("feature") != feature or reviewer_contract.get("graph_revision") != current.get("graph_revision"):
194
+ issues.append({"code": "STALE_REVIEW_CONTRACT"})
195
+ # A separate step cannot be inspected while STATE points at the execution
196
+ # step. A supplied JSON file alone is not Core authority.
197
+ issues.append({"code": "REVIEW_CONTRACT_UNVERIFIED",
198
+ "detail": "separate review contract needs a Core-verified step transition"})
199
+ if review_agent not in reviewer_contract.get("allowed_agents", []) or reviewer_contract.get("tool_budget", 0) < 1:
200
+ issues.append({"code": "REVIEW_AGENT_UNREACHABLE", "agent": review_agent,
201
+ "detail": "review agent is not allowed or task budget is insufficient"})
202
+ except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
203
+ return {"status": "human_confirmation", "evidence": evidence,
204
+ "issues": [{"code": "PREFLIGHT_INPUT_UNAVAILABLE", "detail": str(error)}]}
205
+ conflict_codes = {"CONTRACT_TARGET_MISMATCH", "UNREACHABLE_OBLIGATION", "REVIEW_AGENT_MISMATCH",
206
+ "REVIEW_AGENT_UNREACHABLE", "STALE_REVIEW_CONTRACT"}
207
+ status = ("core_contract_conflict" if any(issue["code"] in conflict_codes for issue in issues)
208
+ else "human_confirmation" if issues else "ready")
209
+ return {"status": status, "scope": "snapshot_preflight_only_not_gate_approval",
210
+ "evidence": evidence, "issues": issues}
211
+
212
+
213
+ def main() -> int:
214
+ parser = argparse.ArgumentParser(description=__doc__)
215
+ parser.add_argument("--root", type=Path, default=Path.cwd())
216
+ parser.add_argument("--feature", required=True)
217
+ parser.add_argument("--step", required=True)
218
+ parser.add_argument("--contract", required=True, help="project-relative frozen contract JSON")
219
+ parser.add_argument("--obligations", required=True, help="JSON array: {path, producer: existing|step, sha256?}")
220
+ parser.add_argument("--review-agent")
221
+ parser.add_argument("--review-contract", help="separate project-relative reviewer contract JSON")
222
+ parser.add_argument("--stage", choices=["entry", "completion"], default="completion")
223
+ args = parser.parse_args()
224
+ try:
225
+ obligations = json.loads(args.obligations)
226
+ if not isinstance(obligations, list):
227
+ raise ValueError("obligations must be an array")
228
+ result = preflight(args.root.resolve(), args.feature, args.step, args.contract,
229
+ obligations, args.review_agent, args.review_contract, args.stage)
230
+ except ValueError as error:
231
+ result = {"status": "human_confirmation", "evidence": [],
232
+ "issues": [{"code": "INVALID_INPUT", "detail": str(error)}]}
233
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
234
+ return 0 if result["status"] == "ready" else 2
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main())
@@ -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)