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.
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/PKG-INFO +1 -1
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/daemon.py +125 -16
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/pyproject.toml +1 -1
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/README.md +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/aws_evidence.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/aws_logs_evidence.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/observability_contract.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/observability_evidence.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/setup.cfg +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_publish_script.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.85.3 → forgexa_cli-1.86.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
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.
|
|
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
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
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.
|
|
3139
|
+
if Path(name).suffix.lower() in {".php", ".xml"}:
|
|
3106
3140
|
candidates.update(f"{root}/{name}" for root in module_roots)
|
|
3107
|
-
|
|
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()
|
|
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=
|
|
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[:
|
|
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 =
|
|
14144
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|