forgexa-cli 1.85.3__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.3 → forgexa_cli-1.85.4}/PKG-INFO +1 -1
  2. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/daemon.py +122 -16
  4. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/pyproject.toml +1 -1
  6. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/README.md +0 -0
  7. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/aws_logs_evidence.py +0 -0
  13. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/observability_contract.py +0 -0
  15. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/observability_evidence.py +0 -0
  16. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/runtime_evidence.py +0 -0
  18. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli/zcode_protocol.py +0 -0
  19. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  20. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  21. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  22. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/requires.txt +0 -0
  23. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  24. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/setup.cfg +0 -0
  25. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_auth_and_runtime_commands.py +0 -0
  26. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_autoupgrade.py +0 -0
  27. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_check_command.py +0 -0
  28. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  29. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_local_bind_commands.py +0 -0
  30. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_publish_script.py +0 -0
  31. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_runtime_credentials.py +0 -0
  32. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_session_credentials.py +0 -0
  33. {forgexa_cli-1.85.3 → forgexa_cli-1.85.4}/tests/test_silent_install.py +0 -0
  34. {forgexa_cli-1.85.3 → 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.3
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.3"
2
+ __version__ = "1.85.4"
@@ -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.3"
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,
@@ -3075,9 +3077,25 @@ def _runtime_critic_reference_candidates(
3075
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)
3076
3078
  ):
3077
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)
3078
3095
  names = set(re.findall(
3079
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,
3080
3097
  ))
3098
+ names.update(re.findall(r"(?<![\w./\\:-])(composer\.lock)\b", text))
3081
3099
  if php_context:
3082
3100
  names.update(name + ".php" for name in re.findall(
3083
3101
  r"(?<![\w./\\:-])([A-Z][A-Za-z0-9_]*)::[A-Za-z_][A-Za-z0-9_]*\b", text,
@@ -3087,14 +3105,30 @@ def _runtime_critic_reference_candidates(
3087
3105
  r"(?<![\w.\\:-])(?=[A-Za-z0-9]*[a-z])([A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*)(?![\w.\\:-])", bare_text,
3088
3106
  ))
3089
3107
  module_roots = sorted({
3090
- "/".join(Path(path).parts[:4]) for path in source_paths
3108
+ "/".join(Path(path).parts[:4]) for path in [*source_paths, *explicit]
3091
3109
  if safe_path(path) and len(Path(path).parts) > 4 and Path(path).parts[:2] == ("app", "code")
3092
3110
  and Path(path).suffix.lower() == ".php"
3093
3111
  })[:5]
3094
3112
  known_names = {Path(path).name for path in [*source_paths, *explicit]}
3095
- candidates = set(explicit)
3096
- for name in sorted(names - known_names)[:20]:
3097
- 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"}:
3098
3132
  matches = [path for path in inventory if Path(path).name == name
3099
3133
  and any(path.startswith(root + "/") for root in module_roots)]
3100
3134
  if len(matches) == 1 and (path := safe_path(matches[0])) is not None:
@@ -3102,9 +3136,14 @@ def _runtime_critic_reference_candidates(
3102
3136
  continue
3103
3137
  candidates.update(path for directory in sorted(directories)[:5]
3104
3138
  if (path := safe_path(f"{directory}/{name}")) is not None)
3105
- if name.endswith(".php"):
3139
+ if Path(name).suffix.lower() in {".php", ".xml"}:
3106
3140
  candidates.update(f"{root}/{name}" for root in module_roots)
3107
- 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]
3108
3147
 
3109
3148
 
3110
3149
  def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path | None = None):
@@ -4008,8 +4047,15 @@ class WorkspaceManager:
4008
4047
 
4009
4048
  if remote_branch.strip():
4010
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.
4011
4057
  await self._git(
4012
- "fetch", "origin", f"{target_branch}:refs/remotes/origin/{target_branch}",
4058
+ "fetch", "origin", f"+{target_branch}:refs/remotes/origin/{target_branch}",
4013
4059
  cwd=local_path,
4014
4060
  )
4015
4061
  except RuntimeError as exc:
@@ -4021,6 +4067,19 @@ class WorkspaceManager:
4021
4067
  "rev-list", f"origin/{target_branch}..HEAD", "--count", cwd=local_path,
4022
4068
  )
4023
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
+ )
4024
4083
  raise RuntimeError(
4025
4084
  f"local_binding_unavailable: local branch '{target_branch}' has "
4026
4085
  f"{ahead_output.strip()} unpushed commit(s) ahead of origin — push them "
@@ -4058,7 +4117,7 @@ class WorkspaceManager:
4058
4117
 
4059
4118
  try:
4060
4119
  await self._git(
4061
- "fetch", "origin", f"{default_branch}:refs/remotes/origin/{default_branch}",
4120
+ "fetch", "origin", f"+{default_branch}:refs/remotes/origin/{default_branch}",
4062
4121
  cwd=local_path,
4063
4122
  )
4064
4123
  local_head = (await self._git("rev-parse", "HEAD", cwd=local_path)).strip()
@@ -5677,7 +5736,7 @@ class WorkspaceManager:
5677
5736
  try:
5678
5737
  await self._git(
5679
5738
  "fetch", "origin",
5680
- f"{default_branch}:refs/remotes/origin/{default_branch}",
5739
+ f"+{default_branch}:refs/remotes/origin/{default_branch}",
5681
5740
  cwd=main_repo,
5682
5741
  timeout=settings.GIT_FETCH_TIMEOUT,
5683
5742
  project_key=project_key,
@@ -14018,6 +14077,7 @@ class RuntimeDaemon:
14018
14077
  )
14019
14078
  if isinstance(baseline, dict) and re.fullmatch(r"[0-9a-fA-F]{40}", after_sha):
14020
14079
  planned = baseline.get("task") or {}
14080
+ source_deadline = time.monotonic() + _RUNTIME_CRITIC_SOURCE_TIMEOUT_SECONDS if planned.get("type") == "design" else None
14021
14081
  paths = agent_core.missing_planned_task_files({
14022
14082
  "files": planned.get("files") or [], "items": planned.get("items") or [],
14023
14083
  }, [])
@@ -14109,17 +14169,52 @@ class RuntimeDaemon:
14109
14169
  "items": related.get("items") or [],
14110
14170
  }, []))
14111
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
+ }}
14112
14201
  reference_candidates = _runtime_critic_reference_candidates(reference_baseline, primary_paths)
14113
14202
  if reference_candidates:
14114
14203
  try:
14115
14204
  reference_modules = sorted({
14116
14205
  Path(path).parent.as_posix() for path in reference_candidates
14117
14206
  if len(Path(path).parts) == 5 and Path(path).parts[:2] == ("app", "code")
14118
- and Path(path).suffix.lower() == ".php"
14207
+ and Path(path).suffix.lower() in {".php", ".xml"}
14119
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)))
14120
14215
  inventory = await self.workspace_manager._git(
14121
14216
  "ls-tree", "-r", "--name-only", after_sha, "--", *reference_candidates, *reference_modules,
14122
- cwd=workspace_path, timeout=10,
14217
+ cwd=workspace_path, timeout=inventory_timeout,
14123
14218
  )
14124
14219
  if len(inventory.encode("utf-8")) > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
14125
14220
  raise ValueError("Committed reference inventory exceeds its bounded evidence budget")
@@ -14134,15 +14229,25 @@ class RuntimeDaemon:
14134
14229
  coverage["candidate_files"] = len(paths)
14135
14230
  remaining = _RUNTIME_CRITIC_DIFF_MAX_CHARS - len(diff or "")
14136
14231
  source_context = []
14137
- 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)))
14138
14241
  normalized = Path(path)
14139
14242
  if normalized.is_absolute() or ".." in normalized.parts or path.endswith("/"):
14140
14243
  coverage["skipped_paths"] += 1
14141
14244
  continue
14142
14245
  try:
14143
- source = await self.workspace_manager._git(
14144
- "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=10,
14145
- )
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
+ )
14146
14251
  except Exception as error:
14147
14252
  coverage["unreadable_files"] += 1
14148
14253
  detail = str(error).lower()
@@ -14176,6 +14281,7 @@ class RuntimeDaemon:
14176
14281
  coverage["capsule_incomplete"] = (
14177
14282
  coverage["omitted_files"] > 0 or coverage.get("reference_inventory_unavailable", False)
14178
14283
  or coverage.get("verification_inventory_unavailable", False)
14284
+ or coverage.get("design_reference_documents_unavailable", False)
14179
14285
  )
14180
14286
  result.metrics["runtime_critic_coverage"] = coverage
14181
14287
  if not diff:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.85.3
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.3"
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