forgexa-cli 1.76.2__tar.gz → 1.77.0__tar.gz

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 (31) hide show
  1. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/agent_core.py +170 -0
  4. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/daemon.py +76 -4
  5. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/runtime_evidence.py +155 -0
  6. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/pyproject.toml +1 -1
  8. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/README.md +0 -0
  9. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/_build_config.py +0 -0
  10. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/_local_bind.py +0 -0
  11. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/aws_evidence.py +0 -0
  13. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/aws_logs_evidence.py +0 -0
  14. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/main.py +0 -0
  15. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/py.typed +0 -0
  16. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli/zcode_protocol.py +0 -0
  17. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  18. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  19. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  20. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli.egg-info/requires.txt +0 -0
  21. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  22. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/setup.cfg +0 -0
  23. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_auth_and_runtime_commands.py +0 -0
  24. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_autoupgrade.py +0 -0
  25. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_check_command.py +0 -0
  26. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
  27. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_local_bind_commands.py +0 -0
  28. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_runtime_credentials.py +0 -0
  29. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_session_credentials.py +0 -0
  30. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_silent_install.py +0 -0
  31. {forgexa_cli-1.76.2 → forgexa_cli-1.77.0}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.76.2
3
+ Version: 1.77.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.76.2"
2
+ __version__ = "1.77.0"
@@ -103,6 +103,176 @@ def missing_planned_task_files(task_input: dict, changed_files: list[str]) -> li
103
103
  )
104
104
 
105
105
 
106
+ def extract_implementation_audit(text: str) -> dict | None:
107
+ """Read the last complete audit envelope from mixed assistant output."""
108
+ decoder = json.JSONDecoder()
109
+ offset = 0
110
+ report = None
111
+ report_end = 0
112
+ while offset < len(text):
113
+ start = text.find("{", offset)
114
+ if start < 0:
115
+ break
116
+ try:
117
+ value, end = decoder.raw_decode(text, start)
118
+ except json.JSONDecodeError:
119
+ offset = start + 1
120
+ continue
121
+ if isinstance(value, dict) and "implementation_audit" in value:
122
+ report = value["implementation_audit"] if isinstance(value["implementation_audit"], dict) else None
123
+ report_end = end
124
+ offset = end
125
+ if '"implementation_audit"' in text[report_end:]:
126
+ return None
127
+ return report
128
+
129
+
130
+ def redact_implementation_audit(contract: dict, report: object) -> dict | None:
131
+ """Keep contract-owned status and hashed evidence without local free text."""
132
+ from hashlib import sha256
133
+
134
+ if not assess_implementation_audit(contract, report)["report_valid"]:
135
+ return None
136
+ summary = {"audit_id": contract["audit_id"], "task_checks": [], "acceptance_checks": []}
137
+ for key in ("task_checks", "acceptance_checks"):
138
+ for row in report[key]:
139
+ evidence = []
140
+ for reference in row["evidence"][:20]:
141
+ digest = reference.removeprefix("local-evidence-sha256:")
142
+ evidence.append(reference if digest != reference and len(digest) == 64
143
+ and all(character in "0123456789abcdef" for character in digest)
144
+ else "local-evidence-sha256:" + sha256(reference.encode()).hexdigest())
145
+ check = {"status": row["status"], "evidence": evidence}
146
+ if key == "task_checks":
147
+ check["task_id"] = row["task_id"]
148
+ else:
149
+ check.update(index=row["index"], task_ids=row["task_ids"])
150
+ if row["status"] != "covered":
151
+ check.update(description="Local implementation issue remains unresolved; inspect local task evidence.", files=[])
152
+ if row.get("plan_revision"):
153
+ check["plan_revision"] = {
154
+ "reason": "Local evidence conflicts with the approved plan",
155
+ "proposal": "Inspect the source in the requirement workspace and revise analysis and tasks together",
156
+ }
157
+ summary[key].append(check)
158
+ return summary
159
+
160
+
161
+ def assess_implementation_audit(contract: dict, report: object) -> dict:
162
+ """Validate implementation coverage independently of later runtime acceptance."""
163
+ issues: list[str] = []
164
+ findings: list[dict] = []
165
+ result = {
166
+ "audit_id": contract.get("audit_id"), "report_valid": False,
167
+ "node_complete": False, "status": "incomplete", "issues": issues,
168
+ "findings": findings, "followup_task_ids": [], "report": report,
169
+ "revision_requests": [],
170
+ }
171
+ if (
172
+ not isinstance(contract.get("audit_id"), str) or not contract["audit_id"]
173
+ or not isinstance(report, dict) or report.get("audit_id") != contract["audit_id"]
174
+ ):
175
+ issues.append("Implementation audit is missing or belongs to another remediation task")
176
+ return result
177
+ if set(report) - {"audit_id", "task_checks", "acceptance_checks"}:
178
+ issues.append("Implementation audit contains unsupported fields")
179
+ expected = {task["id"] for task in contract.get("tasks") or []}
180
+ subject = contract.get("subject_task_id")
181
+ if not expected or subject not in expected:
182
+ issues.append("Implementation audit has no approved task baseline")
183
+ task_checks = report.get("task_checks")
184
+ acceptance_checks = report.get("acceptance_checks")
185
+ if not isinstance(task_checks, list) or not isinstance(acceptance_checks, list):
186
+ issues.append("Implementation audit requires task_checks and acceptance_checks lists")
187
+ return result
188
+ checked: dict[str, str] = {}
189
+ checked_criteria: set[int] = set()
190
+ criterion_count = len(contract.get("acceptance_criteria") or [])
191
+ for kind, rows in (("task", task_checks), ("acceptance", acceptance_checks)):
192
+ for row in rows:
193
+ if not isinstance(row, dict):
194
+ issues.append(f"Invalid implementation {kind} check")
195
+ continue
196
+ evidence = row.get("evidence")
197
+ status = row.get("status")
198
+ if (
199
+ not isinstance(status, str) or status not in {"covered", "gap", "blocked", "unverified"}
200
+ or not isinstance(evidence, list) or not evidence
201
+ or any(not isinstance(item, str) or not item.strip() for item in evidence)
202
+ ):
203
+ issues.append(f"Implementation {kind} check requires an explicit status and concrete evidence")
204
+ continue
205
+ if kind == "task":
206
+ task_id = row.get("task_id")
207
+ if not isinstance(task_id, str) or task_id not in expected or task_id in checked:
208
+ issues.append("Implementation audit has unknown or duplicate task IDs")
209
+ continue
210
+ checked[task_id] = status
211
+ related = [task_id]
212
+ else:
213
+ index = row.get("index")
214
+ related = row.get("task_ids")
215
+ if type(index) is int and not 1 <= index <= criterion_count:
216
+ issues.append(
217
+ f"Acceptance index {index} is outside the frozen baseline 1-{criterion_count}; "
218
+ "report the baseline conflict instead of dropping or renumbering requirements"
219
+ )
220
+ continue
221
+ if (
222
+ type(index) is not int or not 1 <= index <= criterion_count or index in checked_criteria
223
+ or not isinstance(related, list) or not related
224
+ or any(not isinstance(task_id, str) or task_id not in expected for task_id in related)
225
+ ):
226
+ issues.append("Implementation audit has invalid acceptance criterion or task references")
227
+ continue
228
+ checked_criteria.add(index)
229
+ if status != "covered":
230
+ description = row.get("description")
231
+ paths = row.get("files") or []
232
+ if not isinstance(description, str) or not description.strip() or not isinstance(paths, list):
233
+ issues.append("Implementation gaps require an actionable description and file list")
234
+ continue
235
+ if any(
236
+ not isinstance(path, str) or not path.strip()
237
+ or PurePosixPath(path).is_absolute() or ".." in PurePosixPath(path).parts
238
+ for path in paths
239
+ ):
240
+ issues.append("Implementation gap paths must be repository-relative")
241
+ continue
242
+ revision = row.get("plan_revision")
243
+ if revision is not None:
244
+ if not isinstance(revision, dict) or any(
245
+ not isinstance(revision.get(key), str) or not revision[key].strip()
246
+ for key in ("reason", "proposal")
247
+ ):
248
+ issues.append("Plan revision requests require evidence, reason and a concrete proposal")
249
+ continue
250
+ result["revision_requests"].extend({
251
+ "task_id": task_id, "reason": revision["reason"], "proposal": revision["proposal"],
252
+ "evidence": evidence, "files": paths,
253
+ } for task_id in related)
254
+ findings.extend({
255
+ "task_id": task_id, "status": status, "description": description,
256
+ "files": paths, "evidence": evidence,
257
+ **({"plan_revision": revision} if revision is not None else {}),
258
+ } for task_id in related)
259
+ required = expected if contract.get("final_verification") else {subject}
260
+ if required - checked.keys():
261
+ issues.append("Implementation audit omitted required approved tasks")
262
+ if contract.get("final_verification") and len(checked_criteria) != criterion_count:
263
+ issues.append("Implementation audit omitted acceptance criteria")
264
+ result["report_valid"] = not issues
265
+ result["node_complete"] = (
266
+ not issues and checked.get(subject) == "covered"
267
+ and not any(finding["task_id"] == subject for finding in findings)
268
+ )
269
+ result["followup_task_ids"] = sorted({finding["task_id"] for finding in findings})
270
+ result["status"] = "complete" if not issues and not findings else "incomplete"
271
+ if result["report_valid"] and result["revision_requests"]:
272
+ result["status"] = "plan_revision_required"
273
+ return result
274
+
275
+
106
276
  _WORKSPACE_INSTRUCTION_PATHS = (
107
277
  "AGENTS.md",
108
278
  "CLAUDE.md",
@@ -1234,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
1234
1234
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1235
1235
  # Kept in sync with pyproject.toml version via bump-version.sh.
1236
1236
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1237
- DAEMON_VERSION = "1.76.2"
1237
+ DAEMON_VERSION = "1.77.0"
1238
1238
 
1239
1239
 
1240
1240
  def _detect_client_type() -> str:
@@ -2641,6 +2641,14 @@ def _read_local_bind_shared_output_snapshot(task_id: str) -> dict | None:
2641
2641
 
2642
2642
  def _prepare_local_bind_completion(task: "TaskInfo", result: "TaskResult") -> None:
2643
2643
  """Attach the automatic private snapshot before stripping normal local output."""
2644
+ result.metrics.pop("implementation_audit", None)
2645
+ audit_contract = (task.input_data or {}).get("implementation_audit_contract")
2646
+ if isinstance(audit_contract, dict):
2647
+ audit_report = next((item.get("data") for item in reversed(result.observations)
2648
+ if item.get("type") == "implementation_audit"), None)
2649
+ summary = agent_core.redact_implementation_audit(audit_contract, audit_report)
2650
+ if summary is not None:
2651
+ result.metrics["implementation_audit"] = summary
2644
2652
  if _MANAGED_WSL_POLICY is not None:
2645
2653
  result.shared_local_output = None
2646
2654
  if _local_bind_output_sharing_enabled(task.local_workspace_binding_id):
@@ -9904,6 +9912,9 @@ class ServerConnection:
9904
9912
  "local_fresh_branch_reset": {
9905
9913
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
9906
9914
  },
9915
+ "acceptance_verification": {
9916
+ "protocol_version": runtime_evidence.DEFERRED_ACCEPTANCE_VERIFICATION_PROTOCOL_VERSION,
9917
+ },
9907
9918
  "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True, "image_agents": ["claude"]},
9908
9919
  },
9909
9920
  "server_url": self.server_url,
@@ -10324,6 +10335,9 @@ class ServerConnection:
10324
10335
  "local_fresh_branch_reset": {
10325
10336
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
10326
10337
  },
10338
+ "acceptance_verification": {
10339
+ "protocol_version": runtime_evidence.DEFERRED_ACCEPTANCE_VERIFICATION_PROTOCOL_VERSION,
10340
+ },
10327
10341
  "local_evidence": {"protocol_version": 1, "structured_summary": True},
10328
10342
  "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True, "image_agents": ["claude"]},
10329
10343
  },
@@ -13404,6 +13418,18 @@ class RuntimeDaemon:
13404
13418
  result.git = {}
13405
13419
 
13406
13420
  if result.status == "success":
13421
+ acceptance = result.metrics.get("acceptance_verification") or {}
13422
+ if acceptance.get("status") == "pending_final_qa":
13423
+ await reporter.report_progress(
13424
+ task.task_id, 94, "acceptance_pending_final_qa",
13425
+ execution_attempt_id=task.execution_attempt_id,
13426
+ output_lines=[
13427
+ "[daemon] Verification assets are prepared; acceptance remains pending "
13428
+ "post-deployment automated or human verification: "
13429
+ + ", ".join(acceptance["criteria"]),
13430
+ "[daemon] No browser acceptance or release approval is claimed.",
13431
+ ],
13432
+ )
13407
13433
  if is_local_bind:
13408
13434
  # Restricted commit/push adapter for the user's real
13409
13435
  # directory: never `git add -A` sensitive paths, never
@@ -13852,6 +13878,25 @@ class RuntimeDaemon:
13852
13878
  req_type = input_data.get("requirement_type", "feature")
13853
13879
  verification_work_item = bool(input_data.get("verification_work_item"))
13854
13880
 
13881
+ audit_contract = input_data.get("implementation_audit_contract")
13882
+ if isinstance(audit_contract, dict):
13883
+ agent_id = str(result.metrics.get("actual_agent") or getattr(task, "agent_type", "") or "")
13884
+ audit_report = agent_core.extract_implementation_audit(_daemon_extract_agent_output(agent_id, result.stdout))
13885
+ result.observations = [
13886
+ observation for observation in result.observations
13887
+ if observation.get("type") != "implementation_audit"
13888
+ ]
13889
+ if isinstance(audit_report, dict):
13890
+ result.observations.append({"type": "implementation_audit", "data": audit_report})
13891
+ audit_result = agent_core.assess_implementation_audit(audit_contract, audit_report)
13892
+ if audit_result["report_valid"] and audit_result["revision_requests"]:
13893
+ result.status = "failed"
13894
+ result.failure_code = "implementation_plan_revision_required"
13895
+ result.error = "Approved analysis and task plan require revision before implementation can continue"
13896
+ issues.append(result.error)
13897
+ elif not audit_result["report_valid"] or not audit_result["node_complete"]:
13898
+ issues.extend(audit_result["issues"] or ["The selected approved task still has implementation gaps"])
13899
+
13855
13900
  if node_type == "coding" or (
13856
13901
  node_type == "fix" and input_data.get("task") == "phase_remediation_fix"
13857
13902
  ):
@@ -14319,6 +14364,7 @@ class RuntimeDaemon:
14319
14364
  ))
14320
14365
 
14321
14366
  # --- coverage-matrix.json validation ---
14367
+ result.metrics.pop("acceptance_verification", None)
14322
14368
  if req_type == "task":
14323
14369
  result.metrics.pop("acceptance_criteria_manual_review_required", None)
14324
14370
  cm_path = base / "coverage-matrix.json"
@@ -14361,16 +14407,38 @@ class RuntimeDaemon:
14361
14407
  "coverage_matrix_invalid"
14362
14408
  )
14363
14409
  else:
14364
- uncovered = [ac for ac in ac_list if ac["status"] != "covered"]
14410
+ pending, contract_issues = (
14411
+ runtime_evidence.validate_deferred_acceptance_criteria(
14412
+ _input, cm_data, _tc_data,
14413
+ )
14414
+ )
14415
+ issues.extend(contract_issues)
14416
+ if pending:
14417
+ contract = _input["deferred_acceptance_verification"]
14418
+ result.metrics["acceptance_verification"] = {
14419
+ "status": "pending_final_qa",
14420
+ "analysis_revision_id": contract["analysis_revision_id"],
14421
+ "criteria": [ac["id"] for ac in ac_list if ac["id"] in pending],
14422
+ }
14423
+ uncovered = [
14424
+ ac for ac in ac_list
14425
+ if ac["status"] != "covered" and ac["id"] not in pending
14426
+ ]
14365
14427
  if uncovered:
14366
- ids = ", ".join(ac["id"] for ac in uncovered[:5])
14428
+ ids = ", ".join(ac["id"] for ac in uncovered[:20])
14429
+ if len(uncovered) > 20:
14430
+ ids += f" (+{len(uncovered) - 20} more)"
14367
14431
  issues.append(
14368
14432
  "Uncovered acceptance criteria in "
14369
14433
  f"coverage-matrix.json: {ids}"
14370
14434
  )
14371
14435
  except (_json.JSONDecodeError, UnicodeDecodeError) as e:
14372
14436
  issues.append(f"coverage-matrix.json is not valid JSON: {e}")
14373
- elif _requires_structured_artifacts or _requires_e2e_artifacts:
14437
+ elif (
14438
+ _requires_structured_artifacts
14439
+ or _requires_e2e_artifacts
14440
+ or _input.get("deferred_acceptance_verification") is not None
14441
+ ):
14374
14442
  issues.append(f"coverage-matrix.json not found in {doc_dir or 'workspace root'}")
14375
14443
 
14376
14444
  # --- test-evidence.json validation (M1 evidence bundle) ---
@@ -15795,6 +15863,8 @@ class RuntimeDaemon:
15795
15863
  preserved_paths=preserved_paths,
15796
15864
  )
15797
15865
  issues = self._validate_outputs(workspace_path, task, result)
15866
+ if result.failure_code == "implementation_plan_revision_required":
15867
+ return result
15798
15868
  if require_source_inventory:
15799
15869
  issues.extend(inventory_issues)
15800
15870
  if not issues:
@@ -15922,6 +15992,8 @@ class RuntimeDaemon:
15922
15992
  preserved_paths=preserved_paths,
15923
15993
  )
15924
15994
  remaining = self._validate_outputs(workspace_path, task, result)
15995
+ if result.failure_code == "implementation_plan_revision_required":
15996
+ return result
15925
15997
  if require_source_inventory:
15926
15998
  remaining.extend(inventory_issues)
15927
15999
  if remaining:
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
  from typing import Any
16
16
 
17
17
  RUNTIME_EVIDENCE_PLAN_VERSION = 1
18
+ DEFERRED_ACCEPTANCE_VERIFICATION_PROTOCOL_VERSION = 2
18
19
  TEST_COVERAGE_CHECK_KIND = "test_coverage"
19
20
  STATIC_CHECK_KIND = "static_check"
20
21
  TYPE_CHECK_KIND = "type_check"
@@ -783,6 +784,160 @@ def parse_acceptance_criteria_coverage_artifacts(
783
784
  }
784
785
 
785
786
 
787
+ def validate_deferred_acceptance_criteria(
788
+ input_data: dict[str, Any],
789
+ coverage_matrix: dict[str, Any],
790
+ test_cases: dict[str, Any] | None,
791
+ ) -> tuple[set[str], list[str]]:
792
+ """Validate a server-owned final-QA handoff without claiming test success."""
793
+ contract = input_data.get("deferred_acceptance_verification")
794
+ if contract is None:
795
+ return set(), []
796
+ snapshot = input_data.get("qa_test_level_policy_snapshot") or {}
797
+ policy = snapshot.get("policy") if isinstance(snapshot, dict) else None
798
+ levels = policy.get("levels") if isinstance(policy, dict) else None
799
+ e2e_policy = levels.get("e2e") if isinstance(levels, dict) else None
800
+ if (
801
+ not isinstance(contract, dict)
802
+ or not isinstance(snapshot, dict)
803
+ or not isinstance(policy, dict)
804
+ or policy.get("enforcement") != "enforce"
805
+ or not isinstance(e2e_policy, dict)
806
+ or type(contract.get("version")) is not int
807
+ or contract["version"] not in (1, DEFERRED_ACCEPTANCE_VERIFICATION_PROTOCOL_VERSION)
808
+ or contract.get("execution_phase") != "verification_final"
809
+ or not isinstance(snapshot.get("fingerprint"), str)
810
+ or contract.get("qa_test_level_policy_fingerprint") != snapshot["fingerprint"]
811
+ or (
812
+ contract["version"] == 1
813
+ and (
814
+ e2e_policy.get("enabled") is not True
815
+ or e2e_policy.get("workflow_auto_run") is not True
816
+ )
817
+ )
818
+ ):
819
+ return set(), ["Final QA acceptance contract does not match the frozen testing policy"]
820
+
821
+ raw_expected = contract.get("acceptance_criteria_ids")
822
+ raw_deferred = contract.get("criteria")
823
+ if (
824
+ not isinstance(raw_expected, list)
825
+ or not raw_expected
826
+ or not isinstance(raw_deferred, list)
827
+ or not raw_deferred
828
+ ):
829
+ return set(), ["Final QA acceptance contract has no frozen acceptance criteria"]
830
+ expected = {_canonical_acceptance_criteria_reference(value) for value in raw_expected}
831
+ criteria = coverage_matrix["acceptance_criteria"]
832
+ identifiers = [_canonical_acceptance_criteria_reference(item["id"]) for item in criteria]
833
+ if None in expected or None in identifiers or len(set(identifiers)) != len(identifiers):
834
+ return set(), ["coverage-matrix.json has invalid or duplicate acceptance criterion IDs"]
835
+ if set(identifiers) != expected:
836
+ return set(), ["coverage-matrix.json does not match the frozen acceptance criteria"]
837
+
838
+ cases = (test_cases or {}).get("test_cases") or []
839
+ if not isinstance(cases, list):
840
+ return set(), ["Final QA acceptance requires a test_cases array"]
841
+ prepared_intents = {
842
+ case.get("intent_id") or case.get("test_intent_id")
843
+ for case in cases
844
+ if isinstance(case, dict)
845
+ and case.get("test_type") == "e2e"
846
+ and isinstance(case.get("intent_id") or case.get("test_intent_id"), str)
847
+ and case.get("id")
848
+ and case.get("script_path")
849
+ and case.get("script_content")
850
+ }
851
+ eligible = set()
852
+ for item in raw_deferred:
853
+ if not isinstance(item, dict):
854
+ return set(), ["Final QA acceptance contract has an invalid criterion mapping"]
855
+ identifier = _canonical_acceptance_criteria_reference(item.get("id"))
856
+ intent_ids = item.get("test_intent_ids")
857
+ if (
858
+ identifier not in expected
859
+ or not isinstance(intent_ids, list)
860
+ or not intent_ids
861
+ or any(not isinstance(value, str) or not value for value in intent_ids)
862
+ ):
863
+ return set(), ["Final QA acceptance contract has an invalid test intent mapping"]
864
+ if contract["version"] == 1:
865
+ if not set(intent_ids).issubset(prepared_intents):
866
+ return set(), [f"Final QA acceptance criterion {identifier} has no prepared E2E test case"]
867
+ else:
868
+ requirements = item.get("test_intents")
869
+ if (
870
+ not isinstance(requirements, list)
871
+ or len(requirements) != len(intent_ids)
872
+ or any(not isinstance(requirement, dict) for requirement in requirements)
873
+ or any(
874
+ not isinstance(requirement.get("id"), str)
875
+ for requirement in requirements
876
+ )
877
+ or len(set(intent_ids)) != len(intent_ids)
878
+ or {requirement.get("id") for requirement in requirements} != set(intent_ids)
879
+ ):
880
+ return set(), ["Final QA acceptance contract has invalid verification obligations"]
881
+ for requirement in requirements:
882
+ target = requirement.get("automation_target")
883
+ expected_refs = requirement.get("acceptance_criteria_refs")
884
+ if (
885
+ not isinstance(expected_refs, list)
886
+ or any(not isinstance(reference, str) for reference in expected_refs)
887
+ or identifier not in expected_refs
888
+ or not set(expected_refs).issubset(expected)
889
+ ):
890
+ return set(), ["Final QA acceptance contract has invalid verification references"]
891
+ if target == "e2e":
892
+ prepared = (
893
+ e2e_policy.get("enabled") is True
894
+ and e2e_policy.get("workflow_auto_run") is True
895
+ and requirement["id"] in prepared_intents
896
+ )
897
+ elif target == "manual":
898
+ reason = requirement.get("manual_reason_code")
899
+ if not isinstance(reason, str) or not reason.strip():
900
+ return set(), ["Final QA acceptance contract has invalid manual obligations"]
901
+ prepared = any(
902
+ isinstance(case, dict)
903
+ and isinstance(case.get("id"), str)
904
+ and bool(case["id"].strip())
905
+ and (case.get("intent_id") or case.get("test_intent_id")) == requirement["id"]
906
+ and case.get("test_type") == "manual"
907
+ and case.get("manual_reason_code") == reason
908
+ and isinstance(case.get("acceptance_criteria_refs"), list)
909
+ and {
910
+ _canonical_acceptance_criteria_reference(reference)
911
+ for reference in case["acceptance_criteria_refs"]
912
+ } == set(expected_refs)
913
+ and isinstance(case.get("steps"), list)
914
+ and bool(case["steps"])
915
+ and all(
916
+ isinstance(step, dict)
917
+ and isinstance(step.get("action"), str)
918
+ and bool(step["action"].strip())
919
+ for step in case["steps"]
920
+ )
921
+ and not case.get("script_path")
922
+ and not case.get("script_content")
923
+ for case in cases
924
+ )
925
+ else:
926
+ return set(), ["Final QA acceptance contract has an unsupported verification target"]
927
+ if not prepared:
928
+ return set(), [
929
+ f"Final QA acceptance criterion {identifier} has no prepared "
930
+ f"{target} test case for intent {requirement['id']}"
931
+ ]
932
+ eligible.add(identifier)
933
+ return {
934
+ item["id"] for item, identifier in zip(criteria, identifiers)
935
+ if identifier in eligible
936
+ and item["status"] == "unverifiable"
937
+ and item.get("covered_by", []) == []
938
+ }, []
939
+
940
+
786
941
  async def run_acceptance_criteria_coverage_evidence(
787
942
  workspace_path: str | Path,
788
943
  check: object,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.76.2
3
+ Version: 1.77.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.76.2"
3
+ version = "1.77.0"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes