@guanlin4924/devflow-pi 0.1.95 → 0.1.97

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 (49) hide show
  1. package/README.md +42 -2
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +679 -91
  4. package/plugins/devflow-core/runtime/hooks/hook_common.py +245 -0
  5. package/plugins/devflow-core/runtime/hooks/index_gate.py +18 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +98 -2
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  24. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  25. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  26. package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
  27. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  28. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  29. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  30. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  31. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  34. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  35. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  36. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  37. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  38. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +3 -1
  39. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  40. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  41. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +4 -2
  42. package/src/cli.ts +10 -0
  43. package/src/constants.ts +1 -1
  44. package/src/doctor.ts +9 -0
  45. package/src/mcp/auth.ts +103 -0
  46. package/src/mcp/core.ts +102 -0
  47. package/src/mcp/server.ts +193 -0
  48. package/src/mcp/types.ts +5 -0
  49. package/src/startup-briefing.ts +109 -0
@@ -711,6 +711,251 @@ def tool_touches_control_store(event: dict[str, Any], root: Path) -> tuple[bool,
711
711
  return False, []
712
712
 
713
713
 
714
+ # MCP authorization and audit data are user-local security state. This guard
715
+ # is deliberately narrower than DEVFLOW_HOME: only mcp.json and mcp-audit/
716
+ # are protected, and the decision is independent of soft-gate policy.
717
+ def path_is_mcp_protected(raw: str, cwd: Path) -> bool:
718
+ candidate = Path(_expand_shell_path(str(raw)))
719
+ if not candidate.is_absolute():
720
+ candidate = cwd / candidate
721
+ lexical = Path(os.path.normpath(str(candidate)))
722
+ resolved = Path(os.path.realpath(str(candidate)))
723
+ home = devflow_home()
724
+ config = home / "mcp.json"
725
+ audit = home / "mcp-audit"
726
+ for item in (lexical, resolved):
727
+ if item == config or _under(item, audit):
728
+ return True
729
+ return False
730
+
731
+
732
+ def _analyze_mcp_command(command: str, cwd: Path, depth: int = 0) -> bool:
733
+ tokens = _shell_tokens(command)
734
+ for segment in _segments(tokens):
735
+ index = 0
736
+ while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
737
+ index += 1
738
+ if index >= len(segment):
739
+ continue
740
+ program = os.path.basename(segment[index]).lower()
741
+ operands = segment[index + 1:]
742
+ if program in SHELL_PROGRAMS and depth < 3:
743
+ for pos, operand in enumerate(operands):
744
+ if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
745
+ if _analyze_mcp_command(operands[pos + 1], cwd, depth + 1):
746
+ return True
747
+ ignored: set[int] = set()
748
+ if program in {"echo", "printf"}:
749
+ ignored.update(range(len(operands)))
750
+ elif program in {"rg", "grep", "ag", "ack"}:
751
+ seen_pattern = False
752
+ pos = 0
753
+ while pos < len(operands):
754
+ operand = operands[pos]
755
+ if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
756
+ ignored.add(pos + 1)
757
+ pos += 2
758
+ continue
759
+ if operand.startswith("--regexp=") or (not operand.startswith("-") and not seen_pattern):
760
+ ignored.add(pos)
761
+ seen_pattern = True
762
+ pos += 1
763
+ elif program == "git":
764
+ for pos, operand in enumerate(operands):
765
+ if operand in {"-m", "--message"} and pos + 1 < len(operands):
766
+ ignored.add(pos + 1)
767
+ previous = ""
768
+ for pos, operand in enumerate(operands):
769
+ if previous in REDIRECT_OPERATORS and path_is_mcp_protected(operand, cwd):
770
+ return True
771
+ if pos in ignored:
772
+ previous = operand
773
+ continue
774
+ if operand in REDIRECT_OPERATORS or operand.startswith("-"):
775
+ previous = operand
776
+ continue
777
+ if path_is_mcp_protected(operand, cwd):
778
+ return True
779
+ previous = operand
780
+ return False
781
+
782
+
783
+ def tool_touches_mcp_config(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
784
+ cwd = Path(str(event.get("cwd") or root)).expanduser()
785
+ blocked = [raw for raw in _raw_tool_paths(event) if path_is_mcp_protected(raw, cwd)]
786
+ if blocked:
787
+ return True, sorted(dict.fromkeys(blocked))
788
+ command = command_text(event)
789
+ if command and _analyze_mcp_command(command, cwd):
790
+ return True, ["<shell command>"]
791
+ return False, []
792
+
793
+
794
+ # Run artifacts are user-local evidence, not agent-readable source. Keep this
795
+ # guard separate from the control-store guard: it is always on and its only
796
+ # read exception is the Core-produced result.json summary.
797
+ def _run_root(cwd: Path) -> Path:
798
+ # macOS commonly exposes /var as a symlink to /private/var; use the
799
+ # canonical cwd so lexical and realpath checks compare in one namespace.
800
+ return Path(os.path.realpath(str(cwd))) / ".devflow" / "runs"
801
+
802
+
803
+ def _run_relative(raw: str, cwd: Path, anchor: Path | None = None) -> tuple[Path, Path, Path]:
804
+ # The protected root is anchored at the project root, never at the current
805
+ # shell cwd, so `cd .devflow && cat runs/...` still resolves into it.
806
+ expanded = _expand_shell_path(raw)
807
+ cwd_text = str(cwd)
808
+ expanded = re.sub(r"\$\{PWD\}|\$PWD\b", cwd_text, expanded)
809
+ candidate = Path(expanded.replace("\\", "/"))
810
+ if not candidate.is_absolute():
811
+ candidate = cwd / candidate
812
+ lexical = Path(os.path.normpath(str(candidate)))
813
+ resolved = Path(os.path.realpath(str(candidate)))
814
+ return lexical, resolved, _run_root(anchor or cwd)
815
+
816
+
817
+ def _under_run(path: Path, run_root: Path) -> bool:
818
+ return _under(path, run_root)
819
+
820
+
821
+ def path_is_run_artifact(raw: str, cwd: Path, tool: dict[str, Any] | str) -> bool:
822
+ """Return whether a tool path enters .devflow/runs.
823
+
824
+ ``result.json`` is readable only by pure-read tools. Glob may list the
825
+ runs directory itself; every other path, including result.json writes and
826
+ symlink aliases, remains protected.
827
+ """
828
+ lexical, resolved, run_root = _run_relative(raw, cwd)
829
+ paths = (lexical, resolved)
830
+ tool_event = tool if isinstance(tool, dict) else {"tool_name": tool}
831
+ pure_read = is_pure_read_tool(tool_event)
832
+ name = tool_name(tool_event)
833
+ for item in paths:
834
+ if not _under_run(item, run_root):
835
+ continue
836
+ relative = item.relative_to(run_root).parts
837
+ if name == "glob" and len(relative) == 0:
838
+ continue
839
+ if pure_read and len(relative) == 3 and relative[-1] == "result.json":
840
+ continue
841
+ return True
842
+ return False
843
+
844
+
845
+ RUN_FILE_PROGRAMS = {
846
+ "cat", "cp", "mv", "rm", "tee", "touch", "truncate", "install", "rsync", "scp",
847
+ "tar", "unzip", "zip", "base64", "xxd", "strings", "od", "hexdump", "file", "stat",
848
+ "head", "tail", "less", "more", "sed", "awk", "nl", "bat", "rg", "grep", "find", "fd",
849
+ "ls", "tree", "du", "open", "sha256sum", "shasum", "md5sum", "md5", "cksum", "chmod",
850
+ "chown", "unlink", "shred", "dd", "gzip", "gunzip", "bzip2", "xz", "python", "python3",
851
+ "node", "bun", "deno", "ruby", "perl", "php", "lua",
852
+ }
853
+
854
+
855
+ def _token_is_run_target(token: str, cwd: Path, anchor: Path | None = None) -> bool:
856
+ if not token or token in REDIRECT_OPERATORS or token.startswith("-"):
857
+ return False
858
+ lexical, resolved, run_root = _run_relative(token, cwd, anchor)
859
+ return _under_run(lexical, run_root) or _under_run(resolved, run_root)
860
+
861
+
862
+ def _analyze_run_command(command: str, cwd: Path, depth: int = 0, anchor: Path | None = None) -> bool:
863
+ anchor = anchor or cwd
864
+ tokens = _shell_tokens(command)
865
+ current_cwd = cwd
866
+ for segment in _segments(tokens):
867
+ index = 0
868
+ while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
869
+ index += 1
870
+ if index >= len(segment):
871
+ continue
872
+ program = os.path.basename(segment[index]).lower()
873
+ operands = segment[index + 1:]
874
+ if program in SHELL_PROGRAMS and depth < 3:
875
+ for pos, operand in enumerate(operands):
876
+ if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
877
+ if _analyze_run_command(operands[pos + 1], current_cwd, depth + 1, anchor):
878
+ return True
879
+ if program == "cd" and operands:
880
+ if _token_is_run_target(operands[-1], current_cwd, anchor):
881
+ return True
882
+ candidate = Path(_expand_shell_path(operands[-1]))
883
+ current_cwd = candidate if candidate.is_absolute() else current_cwd / candidate
884
+ current_cwd = Path(os.path.normpath(str(current_cwd)))
885
+ continue
886
+ # Any shell redirection is a real filesystem access, even when the
887
+ # command producing the bytes is otherwise harmless (e.g. echo).
888
+ previous = ""
889
+ for operand in operands:
890
+ if previous in REDIRECT_OPERATORS and _token_is_run_target(operand, current_cwd, anchor):
891
+ return True
892
+ previous = operand
893
+ # Inline interpreters can open arbitrary files. Reject the source
894
+ # text conservatively whenever it names the protected tree.
895
+ if program in {"python", "python3", "node", "bun", "deno", "ruby", "perl", "php"}:
896
+ for pos, operand in enumerate(operands):
897
+ if operand in {"-c", "-e", "--eval"} and pos + 1 < len(operands):
898
+ if ".devflow/runs" in operands[pos + 1]:
899
+ return True
900
+ if any(operand.startswith(prefix) for prefix in ("--eval=", "-e=")) and ".devflow/runs" in operand.split("=", 1)[1]:
901
+ return True
902
+
903
+ # Every non-option operand is a potential filesystem target, regardless
904
+ # of the executable. Only documented text positions are exempt.
905
+ ignored: set[int] = set()
906
+ if program in {"echo", "printf"}:
907
+ ignored.update(range(len(operands)))
908
+ elif program in {"rg", "grep", "ag", "ack"}:
909
+ seen_pattern = False
910
+ pos = 0
911
+ while pos < len(operands):
912
+ operand = operands[pos]
913
+ if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
914
+ ignored.add(pos + 1)
915
+ pos += 2
916
+ continue
917
+ if operand.startswith("--regexp="):
918
+ ignored.add(pos)
919
+ elif not operand.startswith("-") and not seen_pattern:
920
+ ignored.add(pos)
921
+ seen_pattern = True
922
+ pos += 1
923
+ elif program == "git":
924
+ for pos, operand in enumerate(operands):
925
+ if operand in {"-m", "--message"} and pos + 1 < len(operands):
926
+ ignored.add(pos + 1)
927
+ elif operand.startswith("--message=") or operand.startswith("-m="):
928
+ ignored.add(pos)
929
+
930
+ for pos, operand in enumerate(operands):
931
+ if pos in ignored or operand in REDIRECT_OPERATORS:
932
+ continue
933
+ target = operand
934
+ if operand.startswith("--") and "=" in operand:
935
+ target = operand.split("=", 1)[1]
936
+ if not target or target.startswith("-"):
937
+ continue
938
+ if _token_is_run_target(target, current_cwd, anchor):
939
+ return True
940
+ return False
941
+
942
+
943
+ def command_touches_run_artifacts(command: str, cwd: Path | None = None) -> bool:
944
+ """Deny shell access to real run-artifact paths, not textual mentions."""
945
+ return _analyze_run_command(command, cwd or Path.cwd())
946
+
947
+
948
+ def tool_touches_run_artifacts(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
949
+ cwd = Path(str(event.get("cwd") or root)).expanduser()
950
+ blocked = [raw for raw in _raw_tool_paths(event) if path_is_run_artifact(raw, cwd, event)]
951
+ if blocked:
952
+ return True, sorted(dict.fromkeys(blocked))
953
+ command = command_text(event)
954
+ if command and command_touches_run_artifacts(command, cwd):
955
+ return True, ["<shell command>"]
956
+ return False, []
957
+
958
+
714
959
  def evidence_observed(event: dict[str, Any], root: Path, artifacts: list[str]) -> list[str]:
715
960
  name = tool_name(event)
716
961
  if not (any(marker in name for marker in READ_TOOL_MARKERS) or is_command_tool(event)):
@@ -15,8 +15,10 @@ from hook_common import (
15
15
  read_event,
16
16
  record_prechange_fingerprints,
17
17
  required_evidence,
18
+ tool_touches_mcp_config,
18
19
  tool_requests_protected_source,
19
20
  tool_touches_control_store,
21
+ tool_touches_run_artifacts,
20
22
  )
21
23
 
22
24
 
@@ -40,6 +42,13 @@ def main() -> int:
40
42
  event = read_event()
41
43
  root = project_root(event)
42
44
  # Hard protection, evaluated before any soft/strict policy handling.
45
+ mcp_blocked, mcp_paths = tool_touches_mcp_config(event, root)
46
+ if mcp_blocked:
47
+ return deny(
48
+ "MCP_CONFIG_PROTECTED: $DEVFLOW_HOME/mcp.json and $DEVFLOW_HOME/mcp-audit/ are reserved for "
49
+ "Devflow MCP authorization and audit records; ordinary agent file and shell access is denied "
50
+ "regardless of DEVFLOW_SOFT_GATES. Blocked target: " + ", ".join(mcp_paths) + "."
51
+ )
43
52
  control_blocked, control_paths = tool_touches_control_store(event, root)
44
53
  if control_blocked:
45
54
  return deny(
@@ -49,6 +58,15 @@ def main() -> int:
49
58
  "Canonical acceptance-results*.json may be read with the Read/Grep file tools. "
50
59
  f"Use devflow_approval(action=\"propose\"|\"list\") for approvals. Blocked target: {', '.join(control_paths)}."
51
60
  )
61
+ run_blocked, run_paths = tool_touches_run_artifacts(event, root)
62
+ if run_blocked:
63
+ return deny(
64
+ "RUN_ARTIFACTS_PROTECTED: .devflow/runs/ contains trace, screenshots, runtime logs and other "
65
+ "user-local evidence that Agent tools may not read, write or access through bash. Trace and screenshots "
66
+ "are for the user to inspect locally with Playwright in a terminal; Agent failure analysis must read "
67
+ "the Core-produced result.json summary instead. Only pure-read access to a feature execution's "
68
+ "result.json is allowed. Blocked target: " + ", ".join(run_paths) + "."
69
+ )
52
70
  policy = load_policy(root)
53
71
  session_id = ensure_session(root, event)
54
72
  required = required_evidence(root, policy)
@@ -5,4 +5,4 @@ description: Render validated canonical wireframe JSON into deterministic Markdo
5
5
 
6
6
  # Wireframe Render
7
7
 
8
- Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG, page SVGs under `wireframes/screens/<screen-id>/<platform>/default.svg`, and the optional static gallery under the wireframe output directory. The `wireframes/screens` directory is a required render output and is covered by the execution contract. The `canonical.meta.json` file is created later by the Core approval path and is not an input to this step. Never read proposal YAML as a substitute, mutate canonical JSON, write approval, or modify workflow state.
8
+ Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG, one SVG per declared state under `wireframes/screens/<screen-id>/<platform>/<state>.svg` (`default.svg` is always present), and the optional static gallery under the wireframe output directory. The `wireframes/screens` directory is a required render output and is covered by the execution contract. The `canonical.meta.json` file is created later by the Core approval path and is not an input to this step. Never read proposal YAML as a substitute, mutate canonical JSON, write approval, or modify workflow state.
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import contextlib
7
+ import fcntl
6
8
  import hashlib
7
9
  import json
8
10
  import os
@@ -35,6 +37,8 @@ ALLOWED_BINDING_MODES = {"automated": ("automated", "manual", "blocked"), "manua
35
37
  RESULT_ORIGINS = {"command": "project_test_command", "http": "core_http_adapter", playwright_adapter.ADAPTER_KIND: playwright_adapter.RESULT_ORIGIN}
36
38
  DEFAULT_RUN_RETENTION_DAYS = 30
37
39
  DEFAULT_UNREFERENCED_RUN_TTL_DAYS = 7
40
+ MAX_REPORT_ERRORS = 50
41
+ MAX_REPORT_ERROR_CHARS = 500
38
42
 
39
43
 
40
44
  def utc_now() -> str:
@@ -427,7 +431,36 @@ def run_command_case(root: Path, argv: list[str]) -> tuple[int, str]:
427
431
  return 127, f"command could not start: {error}"
428
432
 
429
433
 
434
+ @contextlib.contextmanager
435
+ def acceptance_run_lock(root: Path, feature: str):
436
+ """Hold the feature write lock for the complete run, including result writes."""
437
+ lock_path = root / ".devflow/planning/test-runs" / feature / ".run.lock"
438
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
439
+ handle = lock_path.open("a+")
440
+ try:
441
+ try:
442
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
443
+ except BlockingIOError:
444
+ yield False
445
+ return
446
+ yield True
447
+ finally:
448
+ try:
449
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
450
+ finally:
451
+ handle.close()
452
+
453
+
430
454
  def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
455
+ if write:
456
+ with acceptance_run_lock(root, feature) as acquired:
457
+ if not acquired:
458
+ return {"ok": False, "feature": feature, "reason": "acceptance_run_in_progress", "errors": []}
459
+ return _run_locked(root, feature, write)
460
+ return _run_locked(root, feature, write)
461
+
462
+
463
+ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
431
464
  validation, suite, bindings = validate(root, feature)
432
465
  if not validation["ok"]:
433
466
  return validation
@@ -447,7 +480,12 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
447
480
  source = cases[tc_id]
448
481
  strategy = source.get("evidence_strategy")
449
482
  surface, legacy = derive_surface(source)
450
- common = {"tc_id": tc_id, "mode": mode, "execution_surface": surface, "legacy_unclassified": legacy, "tc_sha256": tc_sha256(source)}
483
+ # Feature-development TCs historically omitted this field; the shared
484
+ # contract defines that legacy omission as requirement-origin. A
485
+ # regression suite is frozen only after Core has required it explicitly.
486
+ oracle_source = source.get("oracle_source") or "requirement"
487
+ common = {"tc_id": tc_id, "mode": mode, "execution_surface": surface, "legacy_unclassified": legacy,
488
+ "oracle_source": oracle_source, "tc_sha256": tc_sha256(source)}
451
489
  frozen_mode = source.get("automation_mode")
452
490
  if mode != frozen_mode:
453
491
  common["downgrade"] = {"from": frozen_mode, "to": mode}
@@ -509,6 +547,8 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
509
547
  passed = sum(1 for item in results if item.get("status") == "passed")
510
548
  failed = sum(1 for item in results if item.get("status") == "failed")
511
549
  blocked = sum(1 for item in results if item.get("status") == "blocked")
550
+ oracle_source_counts = {source: sum(1 for item in results if (item.get("oracle_source") or "requirement") == source)
551
+ for source in ("requirement", "contract", "invariant", "characterization")}
512
552
  write_json(meta_path, {
513
553
  "schema_version": 1,
514
554
  "feature": feature,
@@ -518,6 +558,7 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
518
558
  "acceptance_tc_legacy_unclassified": sum(1 for item in results if item.get("legacy_unclassified")),
519
559
  "acceptance_tc_downgraded": sum(1 for item in results if item.get("downgrade")),
520
560
  "acceptance_tc_dsl_unapproved": sum(1 for item in results if item.get("dsl_approval") == "none"),
561
+ "oracle_source_counts": oracle_source_counts,
521
562
  "acceptance_suite_graph_revision": payload["test_suite"].get("graph_revision"),
522
563
  "acceptance_results_fingerprint": file_fingerprint(result_path),
523
564
  "built_by": "core_acceptance_test_runner",
@@ -527,6 +568,55 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
527
568
  "errors": [] if all_passed else ["one or more frozen TC cases failed or are blocked"]}
528
569
 
529
570
 
571
+ def _report_error(value: Any) -> dict[str, Any]:
572
+ text = str(value)
573
+ # Reports are safe summaries: do not expose project or run locations from gate diagnostics.
574
+ text = re.sub(r"(?:[A-Za-z]:)?[^\s,;()]+(?:/|\\)[^\s,;()]+", "<path>", text)
575
+ text = re.sub(r"(?:^|\s)(?:/|~/?|\.\.?/)[^\s,;()]+", " <path>", text)
576
+ truncated = len(text) > MAX_REPORT_ERROR_CHARS
577
+ return {"message": text[:MAX_REPORT_ERROR_CHARS], "truncated": True} if truncated else {"message": text, "truncated": False}
578
+
579
+
580
+ def _result_summary(entry: dict[str, Any]) -> dict[str, Any]:
581
+ approval = entry.get("dsl_approval")
582
+ if isinstance(approval, dict):
583
+ approval = "approved" if approval.get("status") == "approved" else "none"
584
+ elif approval not in {"approved", "none"}:
585
+ approval = None
586
+ return {key: entry.get(key) for key in (
587
+ "tc_id", "status", "mode", "adapter_kind", "execution_surface", "oracle_source", "error_code"
588
+ )} | {"dsl_approval": approval, "legacy_unclassified": bool(entry.get("legacy_unclassified")),
589
+ "downgrade": entry.get("downgrade")}
590
+
591
+
592
+ def report(root: Path, feature: str) -> dict[str, Any]:
593
+ _, _, result_path = paths(root, feature)
594
+ if not result_path.is_file():
595
+ return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
596
+ try:
597
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
598
+ except (OSError, json.JSONDecodeError):
599
+ return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
600
+ if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
601
+ return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
602
+ entries = [entry for entry in payload["results"] if isinstance(entry, dict)]
603
+ statuses = {status: sum(1 for entry in entries if entry.get("status") == status)
604
+ for status in ("passed", "failed", "blocked")}
605
+ oracle_source_counts = {source: sum(1 for entry in entries if (entry.get("oracle_source") or "requirement") == source)
606
+ for source in ("requirement", "contract", "invariant", "characterization")}
607
+ counts = {"passed": statuses["passed"], "failed": statuses["failed"], "blocked": statuses["blocked"],
608
+ "legacy_unclassified": sum(bool(entry.get("legacy_unclassified")) for entry in entries),
609
+ "downgraded": sum(bool(entry.get("downgrade")) for entry in entries),
610
+ "oracle_source": oracle_source_counts}
611
+ gate = validate_results(root, feature)
612
+ raw_errors = gate.get("errors", []) if isinstance(gate, dict) else []
613
+ return {"ok": True, "feature": feature,
614
+ "gate": {"ok": bool(gate.get("ok")), "reason": gate.get("reason"),
615
+ "errors": [_report_error(error) for error in raw_errors[:MAX_REPORT_ERRORS]]},
616
+ "executed_at": payload.get("executed_at"), "test_suite": payload.get("test_suite"),
617
+ "counts": counts, "results": [_result_summary(entry) for entry in entries]}
618
+
619
+
530
620
  def run_evidence_expired(root: Path, feature: str, today: date | None = None) -> bool:
531
621
  """True only once release-closeout completed and the post-closeout retention window has elapsed.
532
622
 
@@ -610,6 +700,10 @@ def result_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str,
610
700
  """Re-verify one recorded result against the CURRENT binding, frozen TC, approvals and evidence files."""
611
701
  errors: list[str] = []
612
702
  surface, _legacy = derive_surface(source)
703
+ frozen_oracle_source = source.get("oracle_source") or "requirement"
704
+ recorded_oracle_source = entry.get("oracle_source")
705
+ if recorded_oracle_source != frozen_oracle_source:
706
+ errors.append(f"recorded oracle_source {recorded_oracle_source!r} does not match the frozen TC oracle_source {frozen_oracle_source!r}")
613
707
  mode = binding.get("mode")
614
708
  if entry.get("mode") != mode:
615
709
  errors.append(f"recorded mode {entry.get('mode')!r} does not match the current binding mode {mode!r}")
@@ -764,7 +858,7 @@ def gc_runs(root: Path, feature: str, write: bool, retention_days: int = DEFAULT
764
858
 
765
859
  def main() -> int:
766
860
  parser = argparse.ArgumentParser()
767
- parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "gc-runs"))
861
+ parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "report", "gc-runs"))
768
862
  parser.add_argument("--feature", required=True)
769
863
  parser.add_argument("--write", action="store_true")
770
864
  parser.add_argument("--retention-days", type=int, default=DEFAULT_RUN_RETENTION_DAYS)
@@ -785,6 +879,8 @@ def main() -> int:
785
879
  result = run(root, args.feature, args.write) if args.write else {"ok": False, "errors": ["--write is required for run"]}
786
880
  elif args.action == "gc-runs":
787
881
  result = gc_runs(root, args.feature, args.write, max(args.retention_days, 0), max(args.unreferenced_ttl_days, 0))
882
+ elif args.action == "report":
883
+ result = report(root, args.feature)
788
884
  else:
789
885
  result = validate_results(root, args.feature)
790
886
  print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -7,6 +7,7 @@
7
7
  require "json"
8
8
  require "open3"
9
9
  require "optparse"
10
+ require "yaml"
10
11
  require_relative "control_io"
11
12
  require_relative "requirement_graph"
12
13
 
@@ -46,6 +47,10 @@ begin
46
47
  lock = DevflowControlIO.acquire_project_lock(root) if command == "freeze"
47
48
  graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
48
49
  cases = test_nodes(graph)
50
+ state_path = File.join(root, ".devflow/planning/STATE.md")
51
+ state_front = File.file?(state_path) ? File.read(state_path).match(/\A---\n(.*?)\n---\n/m)&.[](1) : nil
52
+ state = state_front ? YAML.safe_load(state_front, permitted_classes: [], aliases: false) : {}
53
+ regression = state.is_a?(Hash) && state["current_flow"].to_s == "regression-acceptance"
49
54
 
50
55
  if command == "inspect"
51
56
  view = RequirementGraph.view_payload(graph, "tests")
@@ -82,7 +87,7 @@ begin
82
87
  else
83
88
  matrix = api_test_matrix_check(root, options[:feature], graph)
84
89
  result = if matrix["ok"]
85
- RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record], confirm_prerequisites: options[:confirm_prerequisites])
90
+ RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record], confirm_prerequisites: options[:confirm_prerequisites], regression: regression)
86
91
  else
87
92
  { "ok" => false, "reason" => "api_test_matrix_incomplete", "message" => Array(matrix["errors"]).join("; "), "matrix" => matrix }
88
93
  end
@@ -565,6 +565,11 @@ HARD_FAILURE_REASONS = %w[
565
565
  dsl_not_approved
566
566
  environment_profile_not_approved
567
567
  runtime_integrity_error
568
+ acceptance_test_suite_freeze_failed
569
+ acceptance_test_suite_freeze_attestation_invalid
570
+ acceptance_test_suite_freeze_attestation_failed
571
+ acceptance_test_suite_candidate_binding_failed
572
+ acceptance_test_suite_freeze_recovery_failed
568
573
  ].freeze
569
574
  $devflow_advisories = []
570
575
 
@@ -724,7 +729,9 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
724
729
  raise DevflowControlIO::InvalidControlPath, "candidate test suite pin missing from CP-3 binding check" unless candidate_suite.is_a?(Hash)
725
730
  raise DevflowControlIO::InvalidControlPath, "frozen test suite pin missing from CP-3 freeze" unless frozen_suite.is_a?(Hash)
726
731
  binding_hash = candidate_check["binding_sha256"]
727
- raise DevflowControlIO::InvalidControlPath, "test binding hash missing from CP-3 binding check" unless binding_hash.is_a?(String) && !binding_hash.empty?
732
+ unless candidate_check["regression"] == true || (binding_hash.is_a?(String) && !binding_hash.empty?)
733
+ raise DevflowControlIO::InvalidControlPath, "test binding hash missing from CP-3 binding check"
734
+ end
728
735
 
729
736
  path, relative = test_suite_freeze_attestation_path(root, feature)
730
737
  payload = {
@@ -735,9 +742,9 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
735
742
  "state_revision" => state_revision,
736
743
  "candidate_test_suite" => candidate_suite,
737
744
  "frozen_test_suite" => frozen_suite,
738
- "test_binding_sha256" => binding_hash,
739
745
  "frozen_case_ids" => Array(freeze_result["frozen_case_ids"]).sort
740
746
  }
747
+ payload["test_binding_sha256"] = binding_hash unless candidate_check["regression"] == true
741
748
  DevflowControlIO.atomic_create(path, JSON.pretty_generate(payload) + "\n")
742
749
  DevflowControlIO.register_control_artifact(root, {
743
750
  "path" => relative, "type" => "test-suite-freeze-attestation", "feature" => feature,
@@ -746,6 +753,26 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
746
753
  payload
747
754
  end
748
755
 
756
+ def regression_freeze_attestation_check(graph, feature, attestation_path)
757
+ begin
758
+ attestation = JSON.parse(File.read(attestation_path))
759
+ rescue JSON::ParserError, Errno::ENOENT => error
760
+ return { "ok" => false, "errors" => ["invalid regression freeze attestation: #{error.message}"] }
761
+ end
762
+ current_view = graph.dig("views", "tests")
763
+ cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
764
+ errors = []
765
+ errors << "attestation schema_version is invalid" unless attestation["schema_version"] == 1
766
+ errors << "attestation origin is invalid" unless attestation["origin"] == "core_acceptance_test_suite_freeze"
767
+ errors << "attestation feature does not match" unless attestation["feature"] == feature
768
+ errors << "attestation checkpoint is invalid" unless attestation["checkpoint"] == "CP-3"
769
+ errors << "attestation frozen suite does not match the graph" unless attestation["frozen_test_suite"] == frozen_test_suite_pin("tests_view" => current_view)
770
+ frozen_ids = attestation["frozen_case_ids"]
771
+ expected_ids = cases.map { |node| node["id"] }.sort
772
+ errors << "attestation does not cover exactly the frozen TC cases" unless frozen_ids.is_a?(Array) && frozen_ids.map(&:to_s).sort == expected_ids
773
+ { "ok" => errors.empty?, "errors" => errors }
774
+ end
775
+
749
776
  def cp3_freeze_provenance?(graph, state_revision)
750
777
  expected = "CP-3: state revision #{state_revision} approved"
751
778
  cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
@@ -754,6 +781,10 @@ def cp3_freeze_provenance?(graph, state_revision)
754
781
  end
755
782
  end
756
783
 
784
+ def regression_workflow?(state, steps)
785
+ state["current_flow"].to_s == "regression-acceptance" || steps.any? { |step| step["id"] == "regression-test-design" }
786
+ end
787
+
757
788
  # Return from a completed failure branch using its declared return_to contract.
758
789
  # The caller must still explicitly confirm the returned checkpoint before any
759
790
  # subsequent source-flow step can proceed.
@@ -1111,13 +1142,18 @@ result =
1111
1142
  # inside the checkpoint transition so the user does not have to
1112
1143
  # approve an extra model-issued dialog and a Skill cannot freeze its
1113
1144
  # own candidates early.
1114
- if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step| step["id"] == "acceptance-test-design" }
1145
+ if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step| %w[acceptance-test-design regression-test-design].include?(step["id"]) }
1115
1146
  feature = state.fetch("current_feature")
1116
1147
  graph, = RequirementGraph.load_graph(root, feature)
1148
+ regression = regression_workflow?(state, steps)
1117
1149
  attestation_path, = test_suite_freeze_attestation_path(root, feature)
1118
1150
 
1119
1151
  if File.exist?(attestation_path)
1120
- frozen_check = test_task_binding_check(root, feature, "frozen")
1152
+ frozen_check = if regression
1153
+ regression_freeze_attestation_check(graph, feature, attestation_path)
1154
+ else
1155
+ test_task_binding_check(root, feature, "frozen")
1156
+ end
1121
1157
  unless frozen_check["ok"]
1122
1158
  result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1123
1159
  else
@@ -1128,7 +1164,11 @@ result =
1128
1164
  # attestation is published. Recover only if each TC proves that
1129
1165
  # this exact CP-3 already froze it; never use this path to bless
1130
1166
  # a generic confirmed suite.
1131
- recovery_check = test_task_binding_check(root, feature, "recovery")
1167
+ recovery_check = if regression
1168
+ { "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
1169
+ else
1170
+ test_task_binding_check(root, feature, "recovery")
1171
+ end
1132
1172
  unless recovery_check["ok"]
1133
1173
  result = fail_result("acceptance_test_suite_freeze_recovery_failed", "test_suite" => recovery_check)
1134
1174
  else
@@ -1139,7 +1179,11 @@ result =
1139
1179
  }
1140
1180
  begin
1141
1181
  write_test_suite_freeze_attestation(root, feature, current_revision, recovery_check, recovered)
1142
- frozen_check = test_task_binding_check(root, feature, "frozen")
1182
+ frozen_check = if regression
1183
+ regression_freeze_attestation_check(graph, feature, attestation_path)
1184
+ else
1185
+ test_task_binding_check(root, feature, "frozen")
1186
+ end
1143
1187
  if frozen_check["ok"]
1144
1188
  actions << "acceptance_test_suite_freeze_attestation_recovered"
1145
1189
  else
@@ -1160,7 +1204,11 @@ result =
1160
1204
  result = fail_result("execution_surface_mismatch", "execution_surface" => surface_check) unless surface_check["ok"]
1161
1205
  end
1162
1206
  if matrix_check["ok"] && surface_check["ok"]
1163
- candidate_check = test_task_binding_check(root, feature, "candidate")
1207
+ candidate_check = if regression
1208
+ { "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
1209
+ else
1210
+ test_task_binding_check(root, feature, "candidate")
1211
+ end
1164
1212
  unless candidate_check["ok"]
1165
1213
  result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
1166
1214
  else
@@ -1169,6 +1217,7 @@ result =
1169
1217
  graph,
1170
1218
  "CP-3: state revision #{current_revision} approved",
1171
1219
  confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
1220
+ regression: regression,
1172
1221
  persist: false
1173
1222
  )
1174
1223
  unless frozen["ok"]
@@ -1313,7 +1362,14 @@ result =
1313
1362
  pending_freeze.fetch("candidate_check"),
1314
1363
  pending_freeze.fetch("frozen")
1315
1364
  )
1316
- frozen_check = test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
1365
+ frozen_check = if pending_freeze.dig("candidate_check", "regression") == true
1366
+ regression_freeze_attestation_check(
1367
+ pending_freeze.fetch("graph"), pending_freeze.fetch("feature"),
1368
+ test_suite_freeze_attestation_path(root, pending_freeze.fetch("feature")).first
1369
+ )
1370
+ else
1371
+ test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
1372
+ end
1317
1373
  unless frozen_check["ok"]
1318
1374
  result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1319
1375
  end