@guanlin4924/devflow-pi 0.1.86 → 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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +199 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +15 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +49 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_adapter.rb +83 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +19 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checkpoint_meta.rb +46 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +10 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +32 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +44 -41
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +5 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +15 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_preflight.py +238 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/review_run.py +234 -32
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +63 -11
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +5 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +40 -31
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +11 -5
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +6 -6
- package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +16 -1
- package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +3 -1
- package/plugins/devflow-quality/agents/code-reviewer.md +7 -1
- package/plugins/devflow-quality/skills/code-review/SKILL.md +10 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/SKILL.md +37 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/SKILL.md +327 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/assets/output-template.md +213 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/bug-rules.md +9 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/coverage-checklist.md +63 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/exception-checklist.md +37 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/api-test.md +58 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/boundary-test.md +151 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/common-test.md +49 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/compatibility-test.md +105 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/data-test.md +67 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/exception-test.md +109 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/function-test.md +65 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/integration-test.md +36 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/performance-test.md +100 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/scenario-test.md +63 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/security-test.md +55 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/ui-test.md +49 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/test-type-slots/usability-test.md +46 -0
- package/plugins/devflow-quality/skills/workflow-gen-test-guide/legacy/references/writing-guide.md +111 -0
- 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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
428
|
-
|
|
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
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
status = "terminated_p0" if round_no == 3 and summary["P0"] else ("findings" if
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|