forgexa-cli 1.85.3__tar.gz → 1.86.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 (34) hide show
  1. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/daemon.py +125 -16
  4. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/pyproject.toml +1 -1
  6. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/README.md +0 -0
  7. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/aws_logs_evidence.py +0 -0
  13. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/observability_contract.py +0 -0
  15. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/observability_evidence.py +0 -0
  16. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/runtime_evidence.py +0 -0
  18. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/zcode_protocol.py +0 -0
  19. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  20. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  21. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  22. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/requires.txt +0 -0
  23. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  24. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/setup.cfg +0 -0
  25. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_auth_and_runtime_commands.py +0 -0
  26. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_autoupgrade.py +0 -0
  27. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_check_command.py +0 -0
  28. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
  29. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_local_bind_commands.py +0 -0
  30. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_publish_script.py +0 -0
  31. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_runtime_credentials.py +0 -0
  32. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_session_credentials.py +0 -0
  33. {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_silent_install.py +0 -0
  34. {forgexa_cli-1.85.3 → forgexa_cli-1.86.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.85.3
3
+ Version: 1.86.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.85.3"
2
+ __version__ = "1.86.0"
@@ -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.86.0"
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_SOURCE_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,
@@ -14021,6 +14080,11 @@ class RuntimeDaemon:
14021
14080
  paths = agent_core.missing_planned_task_files({
14022
14081
  "files": planned.get("files") or [], "items": planned.get("items") or [],
14023
14082
  }, [])
14083
+ source_file_limit = (
14084
+ _RUNTIME_CRITIC_SOURCE_MAX_FILES if planned.get("type") == "design"
14085
+ else min(_RUNTIME_CRITIC_SOURCE_MAX_FILES, max(20, len(set(paths + changed_files))))
14086
+ )
14087
+ source_deadline = time.monotonic() + _RUNTIME_CRITIC_SOURCE_TIMEOUT_SECONDS if source_file_limit > 20 else None
14024
14088
  assigned_paths = list(paths)
14025
14089
  primary_paths = list(paths)
14026
14090
  if planned.get("type") in ("testing", "review") and getattr(task, "requirement_key", ""):
@@ -14109,17 +14173,52 @@ class RuntimeDaemon:
14109
14173
  "items": related.get("items") or [],
14110
14174
  }, []))
14111
14175
  reference_baseline = {**baseline, "related_tasks": reference_tasks}
14176
+ document_sources = {}
14177
+ requirement_key = str(getattr(task, "requirement_key", "") or "")
14178
+ if source_deadline is not None and requirement_key:
14179
+ document_paths = [path for path in dict.fromkeys([*assigned_paths, *changed_files])
14180
+ if path.startswith(f"docs/requirements/{requirement_key}/implement/")
14181
+ and path.endswith(".md") and ".." not in Path(path).parts
14182
+ and re.fullmatch(r"[A-Za-z0-9_./-]+", path) and not _is_sensitive_local_path(path)][:8]
14183
+ document_bytes = 0
14184
+ for path in document_paths:
14185
+ seconds_left = source_deadline - time.monotonic()
14186
+ if seconds_left <= 0:
14187
+ coverage["source_collection_timed_out"] = True
14188
+ coverage["design_reference_documents_unavailable"] = True
14189
+ break
14190
+ try:
14191
+ source = await self.workspace_manager._git(
14192
+ "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=min(10, max(1, int(seconds_left))),
14193
+ )
14194
+ encoded_size = len(source.encode("utf-8"))
14195
+ if document_bytes + encoded_size > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
14196
+ coverage["design_reference_documents_unavailable"] = True
14197
+ continue
14198
+ document_sources[path] = source
14199
+ document_bytes += encoded_size
14200
+ except Exception:
14201
+ coverage["design_reference_documents_unavailable"] = True
14202
+ reference_baseline = {**reference_baseline, "task": {
14203
+ **planned, "description": str(planned.get("description") or "") + "\n" + "\n".join(document_sources.values()),
14204
+ }}
14112
14205
  reference_candidates = _runtime_critic_reference_candidates(reference_baseline, primary_paths)
14113
14206
  if reference_candidates:
14114
14207
  try:
14115
14208
  reference_modules = sorted({
14116
14209
  Path(path).parent.as_posix() for path in reference_candidates
14117
14210
  if len(Path(path).parts) == 5 and Path(path).parts[:2] == ("app", "code")
14118
- and Path(path).suffix.lower() == ".php"
14211
+ and Path(path).suffix.lower() in {".php", ".xml"}
14119
14212
  })[:5]
14213
+ inventory_timeout = 10
14214
+ if source_deadline is not None:
14215
+ seconds_left = source_deadline - time.monotonic()
14216
+ if seconds_left <= 0:
14217
+ raise TimeoutError("Source collection deadline reached")
14218
+ inventory_timeout = min(10, max(1, int(seconds_left)))
14120
14219
  inventory = await self.workspace_manager._git(
14121
14220
  "ls-tree", "-r", "--name-only", after_sha, "--", *reference_candidates, *reference_modules,
14122
- cwd=workspace_path, timeout=10,
14221
+ cwd=workspace_path, timeout=inventory_timeout,
14123
14222
  )
14124
14223
  if len(inventory.encode("utf-8")) > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
14125
14224
  raise ValueError("Committed reference inventory exceeds its bounded evidence budget")
@@ -14134,15 +14233,24 @@ class RuntimeDaemon:
14134
14233
  coverage["candidate_files"] = len(paths)
14135
14234
  remaining = _RUNTIME_CRITIC_DIFF_MAX_CHARS - len(diff or "")
14136
14235
  source_context = []
14137
- for path in paths[:20]:
14236
+ for path in paths[:source_file_limit]:
14237
+ source_timeout = 10
14238
+ if source_deadline is not None:
14239
+ seconds_left = source_deadline - time.monotonic()
14240
+ if seconds_left <= 0:
14241
+ coverage["source_collection_timed_out"] = True
14242
+ break
14243
+ source_timeout = min(10, max(1, int(seconds_left)))
14138
14244
  normalized = Path(path)
14139
14245
  if normalized.is_absolute() or ".." in normalized.parts or path.endswith("/"):
14140
14246
  coverage["skipped_paths"] += 1
14141
14247
  continue
14142
14248
  try:
14143
- source = await self.workspace_manager._git(
14144
- "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=10,
14145
- )
14249
+ source = document_sources.get(path)
14250
+ if source is None:
14251
+ source = await self.workspace_manager._git(
14252
+ "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=source_timeout,
14253
+ )
14146
14254
  except Exception as error:
14147
14255
  coverage["unreadable_files"] += 1
14148
14256
  detail = str(error).lower()
@@ -14176,6 +14284,7 @@ class RuntimeDaemon:
14176
14284
  coverage["capsule_incomplete"] = (
14177
14285
  coverage["omitted_files"] > 0 or coverage.get("reference_inventory_unavailable", False)
14178
14286
  or coverage.get("verification_inventory_unavailable", False)
14287
+ or coverage.get("design_reference_documents_unavailable", False)
14179
14288
  )
14180
14289
  result.metrics["runtime_critic_coverage"] = coverage
14181
14290
  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.86.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.85.3"
3
+ version = "1.86.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