forgexa-cli 1.85.2__tar.gz → 1.85.4__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 (34) hide show
  1. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/PKG-INFO +1 -1
  2. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/agent_core.py +118 -2
  4. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/daemon.py +141 -19
  5. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/pyproject.toml +1 -1
  7. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/README.md +0 -0
  8. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/aws_logs_evidence.py +0 -0
  13. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/observability_contract.py +0 -0
  15. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/observability_evidence.py +0 -0
  16. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/runtime_evidence.py +0 -0
  18. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli/zcode_protocol.py +0 -0
  19. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  20. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  21. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  22. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/requires.txt +0 -0
  23. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  24. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/setup.cfg +0 -0
  25. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_auth_and_runtime_commands.py +0 -0
  26. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_autoupgrade.py +0 -0
  27. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_check_command.py +0 -0
  28. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  29. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_local_bind_commands.py +0 -0
  30. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_publish_script.py +0 -0
  31. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_runtime_credentials.py +0 -0
  32. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_session_credentials.py +0 -0
  33. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/tests/test_silent_install.py +0 -0
  34. {forgexa_cli-1.85.2 → forgexa_cli-1.85.4}/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.85.2
3
+ Version: 1.85.4
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.85.2"
2
+ __version__ = "1.85.4"
@@ -79,10 +79,114 @@ LOCAL_BIND_PRIVATE_VALIDATION_METRIC_KEYS = frozenset({
79
79
  })
80
80
 
81
81
 
82
- def assess_approved_plan_conformance(baseline: dict, report: object) -> dict:
82
+ def verification_obligation_fingerprint(task: dict) -> str:
83
+ task_input = task.get("input") if isinstance(task.get("input"), dict) else {}
84
+ responsibility = {
85
+ "id": task.get("id"), "type": task.get("type"), "description": task.get("description") or "",
86
+ "items": task.get("items") or [], "depends_on": task.get("depends_on") or [],
87
+ "files": task_input.get("files") or task.get("files") or [],
88
+ }
89
+ return hashlib.sha256(json.dumps(responsibility, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()).hexdigest()
90
+
91
+
92
+ def approved_contribution_review_instructions(baseline: dict, audit_contract: dict | None) -> str:
93
+ task = baseline.get("task") or {}
94
+ obligations = baseline.get("verification_obligations") or {}
95
+ if (
96
+ not isinstance(audit_contract, dict) or audit_contract.get("version") != 2
97
+ or audit_contract.get("plan_id") != baseline.get("plan_id") or audit_contract.get("subject_task_id") != task.get("id")
98
+ or audit_contract.get("final_verification") is not False or task.get("type") not in ("coding", "fix")
99
+ or not isinstance(obligations, dict) or type(obligations.get("version")) is not int or obligations["version"] != 1
100
+ ):
101
+ return ""
102
+ verifiers = [check for check in obligations.get("checks") or [] if isinstance(check, dict)
103
+ and check.get("type") in ("testing", "verify") and check.get("task_id") != task["id"]]
104
+ if not verifiers:
105
+ return ""
106
+ example = {"contribution_check": {"check_id": str(task["id"]) + ":assigned", "status": "unverified",
107
+ "evidence": ["<independent source and every assigned check evidence>"]},
108
+ "pending_checks": [{"check_id": verifiers[0]["check_id"], "task_id": verifiers[0]["task_id"],
109
+ "status": "unverified", "evidence": ["<observed outstanding approved verification>"]}]}
110
+ return (
111
+ "\n## Assigned Contribution and Pending Verification\n"
112
+ "Within the bound plan_conformance object, contribution_check covers all requirements and mandatory checks of this task, "
113
+ "not only static source. When independently evidenced as conformant, other approved verification owners may remain pending. "
114
+ "Use the exact frozen check_id/task_id pairs; this does not authorize moving an own check to another owner, "
115
+ "inventing an owner, waiving a check or changing its phase. Keep the overall status unverified while pending_checks remain. "
116
+ "The score and verdict assess the assigned contribution, not unfinished work assigned elsewhere. "
117
+ "A real own gap stays deviation or unverified. The default example is not completion evidence.\n"
118
+ "Scoped contribution fields example:\n" + json.dumps(example, ensure_ascii=True, separators=(",", ":"))
119
+ )
120
+
121
+
122
+ def _assess_contribution_conformance(baseline: dict, report: dict, audit_contract: dict | None, result: dict) -> dict:
123
+ obligations = baseline.get("verification_obligations")
124
+ subject = result["task_id"]
125
+ if (
126
+ not isinstance(audit_contract, dict) or audit_contract.get("version") != 2
127
+ or audit_contract.get("plan_id") != baseline.get("plan_id")
128
+ or audit_contract.get("subject_task_id") != subject or audit_contract.get("final_verification") is not False
129
+ or (baseline.get("task") or {}).get("type") not in ("coding", "fix")
130
+ or not isinstance(obligations, dict) or type(obligations.get("version")) is not int or obligations["version"] != 1
131
+ ):
132
+ return result
133
+ checks = obligations.get("checks")
134
+ if not isinstance(checks, list) or any(not isinstance(check, dict) for check in checks):
135
+ return result
136
+ by_id = {check.get("check_id"): check for check in checks if isinstance(check.get("check_id"), str)}
137
+ own = by_id.get(str(subject) + ":assigned")
138
+ tasks = audit_contract.get("tasks")
139
+ if not isinstance(tasks, list) or any(not isinstance(task, dict) or not isinstance(task.get("id"), str) for task in tasks):
140
+ return result
141
+ approved = {task["id"]: task for task in tasks}
142
+ contribution = report.get("contribution_check")
143
+ pending = report.get("pending_checks")
144
+ if (
145
+ len(by_id) != len(checks) or not isinstance(own, dict) or own.get("task_id") != subject
146
+ or subject not in approved or own.get("task_fingerprint") != verification_obligation_fingerprint(approved[subject])
147
+ or own.get("task_fingerprint") != verification_obligation_fingerprint(baseline["task"])
148
+ or own.get("required") is not True or not isinstance(contribution, dict)
149
+ or contribution.get("check_id") != own["check_id"] or contribution.get("status") != "conformant"
150
+ or contribution.get("plan_revision") is not None
151
+ or not isinstance(pending, list) or not pending
152
+ ):
153
+ return result
154
+ evidence = contribution.get("evidence")
155
+ if not isinstance(evidence, list) or not evidence or any(not isinstance(item, str) or not item.strip() for item in evidence):
156
+ return result
157
+ normalized = []
158
+ seen = set()
159
+ for check in pending:
160
+ if not isinstance(check, dict) or not isinstance(check.get("check_id"), str):
161
+ return result
162
+ binding = by_id.get(check["check_id"])
163
+ references = check.get("evidence")
164
+ reason = check.get("blocking_reason")
165
+ if (
166
+ not isinstance(binding, dict) or check["check_id"] in seen or binding.get("required") is not True
167
+ or check.get("task_id") == subject or binding.get("task_id") != check.get("task_id")
168
+ or binding.get("type") not in ("testing", "verify") or approved.get(check.get("task_id"), {}).get("type") != binding.get("type")
169
+ or binding.get("task_fingerprint") != verification_obligation_fingerprint(approved.get(check.get("task_id"), {}))
170
+ or check.get("status") not in ("blocked", "unverified") or check.get("plan_revision") is not None
171
+ or not isinstance(references, list) or not references
172
+ or any(not isinstance(item, str) or not item.strip() for item in references)
173
+ or reason is not None and (not isinstance(reason, str) or reason not in IMPLEMENTATION_ENVIRONMENT_BLOCKERS)
174
+ ):
175
+ return result
176
+ seen.add(check["check_id"])
177
+ normalized.append({"check_id": check["check_id"], "task_id": check["task_id"], "status": check["status"],
178
+ "evidence_refs": [hashlib.sha256(item.encode()).hexdigest() for item in references],
179
+ **({"blocking_reason": reason} if reason is not None else {})})
180
+ result.update(node_complete=True, contribution_check={"check_id": own["check_id"], "status": "conformant",
181
+ "evidence_refs": [hashlib.sha256(item.encode()).hexdigest() for item in evidence]}, pending_checks=normalized,
182
+ verification_pending=sorted({check["task_id"] for check in normalized}))
183
+ return result
184
+
185
+
186
+ def assess_approved_plan_conformance(baseline: dict, report: object, *, audit_contract: dict | None = None) -> dict:
83
187
  result = {
84
188
  "status": "unverified", "baseline_fingerprint": baseline.get("fingerprint"),
85
- "task_id": (baseline.get("task") or {}).get("id"), "evidence_refs": [],
189
+ "task_id": (baseline.get("task") or {}).get("id"), "evidence_refs": [], "node_complete": False,
86
190
  }
87
191
  expected = {key: value for key, value in baseline.items() if key != "fingerprint"}
88
192
  fingerprint = hashlib.sha256(json.dumps(
@@ -114,8 +218,20 @@ def assess_approved_plan_conformance(baseline: dict, report: object) -> dict:
114
218
  result["reason"] = "approved_plan_review_evidence_missing"
115
219
  result["diagnostic_code"] = "approved_plan_review_evidence_missing"
116
220
  return result
221
+ contribution = report.get("contribution_check")
222
+ if report["status"] == "conformant" and (
223
+ bool(report.get("pending_checks"))
224
+ or "pending_checks" in report and not isinstance(report["pending_checks"], list)
225
+ or contribution is not None and (not isinstance(contribution, dict) or contribution.get("status") != "conformant")
226
+ ):
227
+ result["reason"] = "approved_plan_review_missing_or_mismatched"
228
+ result["diagnostic_code"] = "approved_plan_review_contribution_conflict"
229
+ return result
117
230
  result["status"] = report["status"]
118
231
  result["evidence_refs"] = [hashlib.sha256(item.encode()).hexdigest() for item in evidence]
232
+ result["node_complete"] = report["status"] == "conformant"
233
+ if report["status"] == "unverified" and report.get("contribution_check") is not None:
234
+ return _assess_contribution_conformance(baseline, report, audit_contract, result)
119
235
  return result
120
236
 
121
237
 
@@ -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.85.2"
1237
+ DAEMON_VERSION = "1.85.4"
1238
1238
 
1239
1239
 
1240
1240
  def _detect_client_type() -> str:
@@ -1663,6 +1663,8 @@ _RUNTIME_CRITIC_TIMEOUT_SECONDS = 300
1663
1663
  _RUNTIME_CRITIC_DIFF_MAX_CHARS = 30_000
1664
1664
  _RUNTIME_CRITIC_OUTPUT_MAX_CHARS = 20_000
1665
1665
  _RUNTIME_CRITIC_SOURCE_MAX_BYTES = 1_000_000
1666
+ _RUNTIME_CRITIC_DESIGN_MAX_FILES = 128
1667
+ _RUNTIME_CRITIC_SOURCE_TIMEOUT_SECONDS = 200
1666
1668
  _RUNTIME_CRITIC_SYSTEM_PROMPT = """You are the Critic Agent for a software delivery gate.
1667
1669
  Review only the supplied task context, code diff and committed source snapshots. You are running in an
1668
1670
  isolated review capsule, not the project workspace. Do not write or modify files,
@@ -1809,6 +1811,7 @@ def _build_runtime_critic_prompt(
1809
1811
  "failed checks remain blocking. State the concrete missing evidence; never invent a run, "
1810
1812
  "treat asset existence as proof of correctness, or claim final QA or release acceptance."
1811
1813
  )
1814
+ parts.append(agent_core.approved_contribution_review_instructions(baseline, audit_contract))
1812
1815
  else:
1813
1816
  planned_context = {
1814
1817
  key: (task.input_data or {}).get(key)
@@ -3074,9 +3077,25 @@ def _runtime_critic_reference_candidates(
3074
3077
  php_context and all(re.fullmatch(r"[A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*", part) for part in Path(candidate).parts)
3075
3078
  ):
3076
3079
  directories.add(candidate)
3080
+ alias_roots = {}
3081
+ for path in [*source_paths, *explicit]:
3082
+ parts = Path(path).parts
3083
+ if safe_path(path) and len(parts) > 4 and parts[:2] == ("app", "code"):
3084
+ alias_roots.setdefault(parts[2], set()).add("app/code")
3085
+ elif safe_path(path) and len(parts) > 2 and parts[0] == "vendor":
3086
+ alias_roots.setdefault(parts[1], set()).add("vendor")
3087
+ for reference in tuple(explicit):
3088
+ roots = alias_roots.get(Path(reference).parts[0], set())
3089
+ if len(roots) == 1:
3090
+ rooted = safe_path(next(iter(roots)) + "/" + reference)
3091
+ if rooted is not None and (inventory is None or rooted in inventory):
3092
+ explicit.add(rooted)
3093
+ directories.add(Path(rooted).parent.as_posix())
3094
+ php_context = php_context or any(Path(path).suffix.lower() == ".php" for path in explicit)
3077
3095
  names = set(re.findall(
3078
3096
  r"(?<![\w./\\:-])([A-Za-z0-9_][A-Za-z0-9_.-]*\.(?:php|py|tsx?|jsx?|xml|json|md|ya?ml|toml|html|phtml))\b", text,
3079
3097
  ))
3098
+ names.update(re.findall(r"(?<![\w./\\:-])(composer\.lock)\b", text))
3080
3099
  if php_context:
3081
3100
  names.update(name + ".php" for name in re.findall(
3082
3101
  r"(?<![\w./\\:-])([A-Z][A-Za-z0-9_]*)::[A-Za-z_][A-Za-z0-9_]*\b", text,
@@ -3086,14 +3105,30 @@ def _runtime_critic_reference_candidates(
3086
3105
  r"(?<![\w.\\:-])(?=[A-Za-z0-9]*[a-z])([A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*)(?![\w.\\:-])", bare_text,
3087
3106
  ))
3088
3107
  module_roots = sorted({
3089
- "/".join(Path(path).parts[:4]) for path in source_paths
3108
+ "/".join(Path(path).parts[:4]) for path in [*source_paths, *explicit]
3090
3109
  if safe_path(path) and len(Path(path).parts) > 4 and Path(path).parts[:2] == ("app", "code")
3091
3110
  and Path(path).suffix.lower() == ".php"
3092
3111
  })[:5]
3093
3112
  known_names = {Path(path).name for path in [*source_paths, *explicit]}
3094
- candidates = set(explicit)
3095
- for name in sorted(names - known_names)[:20]:
3096
- if inventory is not None and module_roots and name.endswith(".php"):
3113
+ root_files = names & {"composer.lock", "AGENTS.md"}
3114
+ candidates = set(explicit) | root_files
3115
+ if any(name.endswith("Factory.php") for name in names | known_names):
3116
+ configurations = {f"{root}/etc/di.xml" for root in module_roots}
3117
+ if inventory is not None:
3118
+ configurations.intersection_update(inventory)
3119
+ if inventory is None or len(configurations) == 1:
3120
+ candidates.update(configurations)
3121
+ for reference in explicit:
3122
+ if Path(reference).suffix.lower() != ".xml" or reference.startswith(("app/code/", "vendor/")):
3123
+ continue
3124
+ module_references = {f"{root}/{reference}" for root in module_roots}
3125
+ if inventory is not None:
3126
+ module_references.intersection_update(inventory)
3127
+ if len(module_references) != 1:
3128
+ continue
3129
+ candidates.update(module_references)
3130
+ for name in sorted(names - known_names - root_files)[:20]:
3131
+ if inventory is not None and module_roots and Path(name).suffix.lower() in {".php", ".xml"}:
3097
3132
  matches = [path for path in inventory if Path(path).name == name
3098
3133
  and any(path.startswith(root + "/") for root in module_roots)]
3099
3134
  if len(matches) == 1 and (path := safe_path(matches[0])) is not None:
@@ -3101,9 +3136,14 @@ def _runtime_critic_reference_candidates(
3101
3136
  continue
3102
3137
  candidates.update(path for directory in sorted(directories)[:5]
3103
3138
  if (path := safe_path(f"{directory}/{name}")) is not None)
3104
- if name.endswith(".php"):
3139
+ if Path(name).suffix.lower() in {".php", ".xml"}:
3105
3140
  candidates.update(f"{root}/{name}" for root in module_roots)
3106
- return sorted(candidates - set(source_paths))[:128]
3141
+ primary = (explicit | root_files) - set(source_paths)
3142
+ ordered = sorted(primary, key=lambda path: (
3143
+ path not in root_files, inventory is None or path not in inventory,
3144
+ not path.startswith(("app/code/", "vendor/", "setup/", "build/", "config/")), path,
3145
+ ))
3146
+ return (ordered + sorted(candidates - primary - set(source_paths)))[:128]
3107
3147
 
3108
3148
 
3109
3149
  def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path | None = None):
@@ -4007,8 +4047,15 @@ class WorkspaceManager:
4007
4047
 
4008
4048
  if remote_branch.strip():
4009
4049
  try:
4050
+ # The tracking ref is a cache of remote state and the remote is
4051
+ # authoritative (its existence and tip were just confirmed via
4052
+ # ls-remote). Update it even when the remote was rewritten: a
4053
+ # fresh-analysis reset legitimately force-pushes this branch,
4054
+ # and a non-forced refspec is rejected non-fast-forward against
4055
+ # the stale tracking ref forever — the same cache-alignment
4056
+ # rule every other fetch in this file already follows.
4010
4057
  await self._git(
4011
- "fetch", "origin", f"{target_branch}:refs/remotes/origin/{target_branch}",
4058
+ "fetch", "origin", f"+{target_branch}:refs/remotes/origin/{target_branch}",
4012
4059
  cwd=local_path,
4013
4060
  )
4014
4061
  except RuntimeError as exc:
@@ -4020,6 +4067,19 @@ class WorkspaceManager:
4020
4067
  "rev-list", f"origin/{target_branch}..HEAD", "--count", cwd=local_path,
4021
4068
  )
4022
4069
  if ahead_output.strip() not in ("", "0"):
4070
+ behind_output = await self._git(
4071
+ "rev-list", f"HEAD..origin/{target_branch}", "--count", cwd=local_path,
4072
+ )
4073
+ if behind_output.strip() not in ("", "0"):
4074
+ raise RuntimeError(
4075
+ f"local_binding_unavailable: local branch '{target_branch}' diverged "
4076
+ f"from origin/{target_branch} (ahead {ahead_output.strip()}, behind "
4077
+ f"{behind_output.strip()}); the remote branch was rewritten — for "
4078
+ "example by a fresh-analysis reset, whose retired tips are preserved "
4079
+ "under 'forgexa/fresh-analysis-backup/*' tags. Align your local branch "
4080
+ f"(git reset --hard origin/{target_branch}) after keeping anything you "
4081
+ "need, then retry."
4082
+ )
4023
4083
  raise RuntimeError(
4024
4084
  f"local_binding_unavailable: local branch '{target_branch}' has "
4025
4085
  f"{ahead_output.strip()} unpushed commit(s) ahead of origin — push them "
@@ -4057,7 +4117,7 @@ class WorkspaceManager:
4057
4117
 
4058
4118
  try:
4059
4119
  await self._git(
4060
- "fetch", "origin", f"{default_branch}:refs/remotes/origin/{default_branch}",
4120
+ "fetch", "origin", f"+{default_branch}:refs/remotes/origin/{default_branch}",
4061
4121
  cwd=local_path,
4062
4122
  )
4063
4123
  local_head = (await self._git("rev-parse", "HEAD", cwd=local_path)).strip()
@@ -5676,7 +5736,7 @@ class WorkspaceManager:
5676
5736
  try:
5677
5737
  await self._git(
5678
5738
  "fetch", "origin",
5679
- f"{default_branch}:refs/remotes/origin/{default_branch}",
5739
+ f"+{default_branch}:refs/remotes/origin/{default_branch}",
5680
5740
  cwd=main_repo,
5681
5741
  timeout=settings.GIT_FETCH_TIMEOUT,
5682
5742
  project_key=project_key,
@@ -14017,6 +14077,7 @@ class RuntimeDaemon:
14017
14077
  )
14018
14078
  if isinstance(baseline, dict) and re.fullmatch(r"[0-9a-fA-F]{40}", after_sha):
14019
14079
  planned = baseline.get("task") or {}
14080
+ source_deadline = time.monotonic() + _RUNTIME_CRITIC_SOURCE_TIMEOUT_SECONDS if planned.get("type") == "design" else None
14020
14081
  paths = agent_core.missing_planned_task_files({
14021
14082
  "files": planned.get("files") or [], "items": planned.get("items") or [],
14022
14083
  }, [])
@@ -14108,17 +14169,52 @@ class RuntimeDaemon:
14108
14169
  "items": related.get("items") or [],
14109
14170
  }, []))
14110
14171
  reference_baseline = {**baseline, "related_tasks": reference_tasks}
14172
+ document_sources = {}
14173
+ requirement_key = str(getattr(task, "requirement_key", "") or "")
14174
+ if source_deadline is not None and requirement_key:
14175
+ document_paths = [path for path in dict.fromkeys([*assigned_paths, *changed_files])
14176
+ if path.startswith(f"docs/requirements/{requirement_key}/implement/")
14177
+ and path.endswith(".md") and ".." not in Path(path).parts
14178
+ and re.fullmatch(r"[A-Za-z0-9_./-]+", path) and not _is_sensitive_local_path(path)][:8]
14179
+ document_bytes = 0
14180
+ for path in document_paths:
14181
+ seconds_left = source_deadline - time.monotonic()
14182
+ if seconds_left <= 0:
14183
+ coverage["source_collection_timed_out"] = True
14184
+ coverage["design_reference_documents_unavailable"] = True
14185
+ break
14186
+ try:
14187
+ source = await self.workspace_manager._git(
14188
+ "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=min(10, max(1, int(seconds_left))),
14189
+ )
14190
+ encoded_size = len(source.encode("utf-8"))
14191
+ if document_bytes + encoded_size > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
14192
+ coverage["design_reference_documents_unavailable"] = True
14193
+ continue
14194
+ document_sources[path] = source
14195
+ document_bytes += encoded_size
14196
+ except Exception:
14197
+ coverage["design_reference_documents_unavailable"] = True
14198
+ reference_baseline = {**reference_baseline, "task": {
14199
+ **planned, "description": str(planned.get("description") or "") + "\n" + "\n".join(document_sources.values()),
14200
+ }}
14111
14201
  reference_candidates = _runtime_critic_reference_candidates(reference_baseline, primary_paths)
14112
14202
  if reference_candidates:
14113
14203
  try:
14114
14204
  reference_modules = sorted({
14115
14205
  Path(path).parent.as_posix() for path in reference_candidates
14116
14206
  if len(Path(path).parts) == 5 and Path(path).parts[:2] == ("app", "code")
14117
- and Path(path).suffix.lower() == ".php"
14207
+ and Path(path).suffix.lower() in {".php", ".xml"}
14118
14208
  })[:5]
14209
+ inventory_timeout = 10
14210
+ if source_deadline is not None:
14211
+ seconds_left = source_deadline - time.monotonic()
14212
+ if seconds_left <= 0:
14213
+ raise TimeoutError("Source collection deadline reached")
14214
+ inventory_timeout = min(10, max(1, int(seconds_left)))
14119
14215
  inventory = await self.workspace_manager._git(
14120
14216
  "ls-tree", "-r", "--name-only", after_sha, "--", *reference_candidates, *reference_modules,
14121
- cwd=workspace_path, timeout=10,
14217
+ cwd=workspace_path, timeout=inventory_timeout,
14122
14218
  )
14123
14219
  if len(inventory.encode("utf-8")) > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
14124
14220
  raise ValueError("Committed reference inventory exceeds its bounded evidence budget")
@@ -14133,15 +14229,25 @@ class RuntimeDaemon:
14133
14229
  coverage["candidate_files"] = len(paths)
14134
14230
  remaining = _RUNTIME_CRITIC_DIFF_MAX_CHARS - len(diff or "")
14135
14231
  source_context = []
14136
- for path in paths[:20]:
14232
+ source_file_limit = _RUNTIME_CRITIC_DESIGN_MAX_FILES if planned.get("type") == "design" else 20
14233
+ for path in paths[:source_file_limit]:
14234
+ source_timeout = 10
14235
+ if source_deadline is not None:
14236
+ seconds_left = source_deadline - time.monotonic()
14237
+ if seconds_left <= 0:
14238
+ coverage["source_collection_timed_out"] = True
14239
+ break
14240
+ source_timeout = min(10, max(1, int(seconds_left)))
14137
14241
  normalized = Path(path)
14138
14242
  if normalized.is_absolute() or ".." in normalized.parts or path.endswith("/"):
14139
14243
  coverage["skipped_paths"] += 1
14140
14244
  continue
14141
14245
  try:
14142
- source = await self.workspace_manager._git(
14143
- "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=10,
14144
- )
14246
+ source = document_sources.get(path)
14247
+ if source is None:
14248
+ source = await self.workspace_manager._git(
14249
+ "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=source_timeout,
14250
+ )
14145
14251
  except Exception as error:
14146
14252
  coverage["unreadable_files"] += 1
14147
14253
  detail = str(error).lower()
@@ -14175,6 +14281,7 @@ class RuntimeDaemon:
14175
14281
  coverage["capsule_incomplete"] = (
14176
14282
  coverage["omitted_files"] > 0 or coverage.get("reference_inventory_unavailable", False)
14177
14283
  or coverage.get("verification_inventory_unavailable", False)
14284
+ or coverage.get("design_reference_documents_unavailable", False)
14178
14285
  )
14179
14286
  result.metrics["runtime_critic_coverage"] = coverage
14180
14287
  if not diff:
@@ -14292,7 +14399,8 @@ class RuntimeDaemon:
14292
14399
  **metadata,
14293
14400
  }
14294
14401
  conformance = (
14295
- agent_core.assess_approved_plan_conformance(baseline, structured_result.get("plan_conformance"))
14402
+ agent_core.assess_approved_plan_conformance(baseline, structured_result.get("plan_conformance"),
14403
+ audit_contract=(task.input_data or {}).get("implementation_audit_contract"))
14296
14404
  if isinstance(baseline, dict) else None
14297
14405
  )
14298
14406
  if conformance is not None and conformance["status"] != "conformant":
@@ -14320,6 +14428,15 @@ class RuntimeDaemon:
14320
14428
  "evidence": conformance["evidence_refs"],
14321
14429
  },
14322
14430
  }
14431
+ if conformance.get("node_complete") and conformance["status"] == "unverified":
14432
+ contribution = conformance["contribution_check"]
14433
+ structured_result["plan_conformance"]["contribution_check"] = {
14434
+ "check_id": contribution["check_id"], "status": contribution["status"], "evidence": contribution["evidence_refs"],
14435
+ }
14436
+ structured_result["plan_conformance"]["pending_checks"] = [{
14437
+ "check_id": check["check_id"], "task_id": check["task_id"], "status": check["status"], "evidence": check["evidence_refs"],
14438
+ **({"blocking_reason": check["blocking_reason"]} if "blocking_reason" in check else {}),
14439
+ } for check in conformance["pending_checks"]]
14323
14440
  encoded_result = json.dumps(
14324
14441
  structured_result,
14325
14442
  ensure_ascii=True,
@@ -14512,6 +14629,7 @@ class RuntimeDaemon:
14512
14629
  delivered_files = list(result.files_changed)
14513
14630
  delivered_files.extend(input_data.get("planned_task_predecessor_files") or [])
14514
14631
  for missing_path in agent_core.missing_planned_task_files(input_data, delivered_files):
14632
+ result.metrics["failure_domain"] = "delivery"
14515
14633
  issues.append(_OutputValidationIssue(
14516
14634
  "planned_task_incomplete",
14517
14635
  f"Planned task file has no implementation evidence: {missing_path}. "
@@ -16516,7 +16634,10 @@ class RuntimeDaemon:
16516
16634
  ])
16517
16635
 
16518
16636
  if (
16519
- agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16637
+ result.metrics.get("failure_domain") == "environment" and all(
16638
+ issue.code in {"implementation_audit_incomplete", "acceptance_criteria_uncovered"} for issue in issues
16639
+ )
16640
+ or agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16520
16641
  and result.metrics.get("implementation_audit_subject_blocked") is True
16521
16642
  ):
16522
16643
  result.status = "failed"
@@ -16719,7 +16840,8 @@ class RuntimeDaemon:
16719
16840
  result.failure_code = "implementation_audit_incomplete"
16720
16841
  result.error = "; ".join(remaining[:3])
16721
16842
  if (
16722
- agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16843
+ result.metrics.get("failure_domain") == "environment"
16844
+ or agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16723
16845
  and result.metrics.get("implementation_audit_subject_blocked") is True
16724
16846
  ):
16725
16847
  result.error += ". Resolve the reported verification prerequisites before retrying."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.85.2
3
+ Version: 1.85.4
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.85.2"
3
+ version = "1.85.4"
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