forgexa-cli 1.85.0__tar.gz → 1.85.2__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.0 → forgexa_cli-1.85.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/agent_core.py +178 -12
  4. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/daemon.py +59 -12
  5. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/pyproject.toml +1 -1
  7. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/README.md +0 -0
  8. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/aws_logs_evidence.py +0 -0
  13. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/observability_contract.py +0 -0
  15. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/observability_evidence.py +0 -0
  16. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/runtime_evidence.py +0 -0
  18. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/zcode_protocol.py +0 -0
  19. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  20. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  21. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  22. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/requires.txt +0 -0
  23. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  24. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/setup.cfg +0 -0
  25. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_auth_and_runtime_commands.py +0 -0
  26. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_autoupgrade.py +0 -0
  27. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_check_command.py +0 -0
  28. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
  29. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_local_bind_commands.py +0 -0
  30. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_publish_script.py +0 -0
  31. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_runtime_credentials.py +0 -0
  32. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_session_credentials.py +0 -0
  33. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_silent_install.py +0 -0
  34. {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/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.0
3
+ Version: 1.85.2
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.0"
2
+ __version__ = "1.85.2"
@@ -68,6 +68,11 @@ except ImportError:
68
68
  logger = logging.getLogger("agent_core")
69
69
 
70
70
  IMPLEMENTATION_AUDIT_PROTOCOL_VERSION = 3
71
+ IMPLEMENTATION_ENVIRONMENT_BLOCKERS = frozenset({
72
+ "environment_unavailable", "service_not_running", "dependency_unavailable",
73
+ "permission_required", "test_data_unavailable", "target_mismatch",
74
+ })
75
+ TASK_FAILURE_DOMAINS = frozenset({"code_quality", "execution", "environment", "evidence", "delivery", "plan", "verification"})
71
76
  LOCAL_BIND_PRIVATE_VALIDATION_METRIC_KEYS = frozenset({
72
77
  "validation_issues", "validation_issue_details", "timeout_recovery_validation_issues",
73
78
  "timeout_recovery_original_failure", "task_delivery_issue",
@@ -211,6 +216,17 @@ def extract_implementation_audit(text: str) -> dict | None:
211
216
  return report
212
217
 
213
218
 
219
+ def _implementation_acceptance_index(row: dict) -> int | None:
220
+ index = row.get("index", row.get("acceptance_index"))
221
+ if type(index) is not int:
222
+ return None
223
+ if "index" in row and "acceptance_index" in row and (
224
+ type(row["acceptance_index"]) is not int or row["acceptance_index"] != index
225
+ ):
226
+ return None
227
+ return index
228
+
229
+
214
230
  def redact_implementation_audit(contract: dict, report: object) -> dict | None:
215
231
  """Keep contract-owned status and hashed evidence without local free text."""
216
232
  from hashlib import sha256
@@ -227,6 +243,8 @@ def redact_implementation_audit(contract: dict, report: object) -> dict | None:
227
243
  check = {"status": row["status"], "evidence": evidence}
228
244
  if row["status"] != "covered":
229
245
  check.update(description="Local implementation issue remains unresolved; inspect local task evidence.", files=[])
246
+ if row.get("blocking_reason") in IMPLEMENTATION_ENVIRONMENT_BLOCKERS:
247
+ check["blocking_reason"] = row["blocking_reason"]
230
248
  if row.get("plan_revision"):
231
249
  check["plan_revision"] = {
232
250
  "reason": "Local evidence conflicts with the approved plan",
@@ -244,7 +262,7 @@ def redact_implementation_audit(contract: dict, report: object) -> dict | None:
244
262
  if contract.get("version") == 2 and row["task_id"] in coding_ids and isinstance(row.get("implementation_check"), dict):
245
263
  check["implementation_check"] = redact_check(row["implementation_check"])
246
264
  else:
247
- check.update(index=row["index"], task_ids=row["task_ids"])
265
+ check.update(index=_implementation_acceptance_index(row), task_ids=row["task_ids"])
248
266
  summary[key].append(check)
249
267
  return summary
250
268
 
@@ -270,7 +288,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
270
288
  checks.append(row)
271
289
  continue
272
290
  check = {"task_id": row.get("task_id"), **{
273
- key: implementation[key] for key in ("status", "evidence", "description", "files", "plan_revision")
291
+ key: implementation[key] for key in ("status", "evidence", "description", "files", "plan_revision", "blocking_reason")
274
292
  if key in implementation
275
293
  }}
276
294
  if row.get("plan_revision") is not None and implementation.get("status") != "covered":
@@ -285,6 +303,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
285
303
  "task_id": task_id, "status": row.get("status"),
286
304
  "description": row.get("description") or "Runtime acceptance remains pending",
287
305
  "files": [], "evidence": row.get("evidence") or [],
306
+ **({"blocking_reason": row["blocking_reason"]} if row.get("blocking_reason") else {}),
288
307
  } for task_id in pending_verification_ids)
289
308
  implementation_statuses = {row["task_id"]: row["status"] for row in checks}
290
309
  deferred_contract = contract.get("deferred_acceptance_verification")
@@ -328,8 +347,9 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
328
347
  and implementation_statuses.get(subject) == "covered"
329
348
  )
330
349
  for row in report["acceptance_checks"]:
350
+ index = _implementation_acceptance_index(row)
331
351
  if (
332
- row["index"] in reserved_indexes and row["status"] == "unverified" and row.get("plan_revision") is None
352
+ index in reserved_indexes and row["status"] == "unverified" and row.get("plan_revision") is None
333
353
  and (
334
354
  all(implementation_statuses.get(task_id) == "covered" for task_id in row["task_ids"])
335
355
  or prepared_verifier and subject in row["task_ids"] and all(
@@ -339,7 +359,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
339
359
  )
340
360
  )
341
361
  ):
342
- deferred_acceptance.append(row["index"])
362
+ deferred_acceptance.append(index)
343
363
  continue
344
364
  if (
345
365
  pending_verification_ids and row["status"] in ("blocked", "unverified")
@@ -351,6 +371,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
351
371
  pending.extend({
352
372
  "task_id": task_id, "status": row["status"], "description": row["description"],
353
373
  "files": row.get("files") or [], "evidence": row["evidence"],
374
+ **({"blocking_reason": row["blocking_reason"]} if row.get("blocking_reason") else {}),
354
375
  } for task_id in pending_verification_ids)
355
376
  acceptance_checks.append(row)
356
377
  result = assess_implementation_audit({
@@ -418,7 +439,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
418
439
  checked_criteria: set[int] = set()
419
440
  criterion_count = len(contract.get("acceptance_criteria") or [])
420
441
  for kind, rows in (("task", task_checks), ("acceptance", acceptance_checks)):
421
- for row in rows:
442
+ for position, row in enumerate(rows, 1):
422
443
  if not isinstance(row, dict):
423
444
  issues.append(f"Invalid implementation {kind} check")
424
445
  continue
@@ -431,6 +452,13 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
431
452
  ):
432
453
  issues.append(f"Implementation {kind} check requires an explicit status and concrete evidence")
433
454
  continue
455
+ blocking_reason = row.get("blocking_reason")
456
+ if blocking_reason is not None and (
457
+ not isinstance(blocking_reason, str) or blocking_reason not in IMPLEMENTATION_ENVIRONMENT_BLOCKERS
458
+ or status not in ("blocked", "unverified") or row.get("plan_revision") is not None
459
+ ):
460
+ issues.append("Implementation blocking_reason must identify an allowed unmet environment prerequisite on a blocked or unverified check")
461
+ continue
434
462
  if status == "covered" and row.get("plan_revision") is not None:
435
463
  issues.append(
436
464
  "A covered implementation check cannot request a plan revision; "
@@ -445,8 +473,19 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
445
473
  checked[task_id] = status
446
474
  related = [task_id]
447
475
  else:
448
- index = row.get("index")
476
+ index = _implementation_acceptance_index(row)
449
477
  related = row.get("task_ids")
478
+ if index is None:
479
+ detail = (
480
+ "index and acceptance_index must be identical integers"
481
+ if "index" in row and "acceptance_index" in row else
482
+ "index must be a one-based integer; acceptance_index is an exact alias, but id is not an index"
483
+ )
484
+ issues.append(
485
+ f"Implementation audit has invalid acceptance criterion or task references; "
486
+ f"acceptance row {position}: {detail}"
487
+ )
488
+ continue
450
489
  if type(index) is int and not 1 <= index <= criterion_count:
451
490
  if criterion_count == 0:
452
491
  issues.append(
@@ -462,11 +501,18 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
462
501
  )
463
502
  continue
464
503
  if (
465
- type(index) is not int or not 1 <= index <= criterion_count or index in checked_criteria
504
+ index in checked_criteria
466
505
  or not isinstance(related, list) or not related
467
506
  or any(not isinstance(task_id, str) or task_id not in expected for task_id in related)
468
507
  ):
469
- issues.append("Implementation audit has invalid acceptance criterion or task references")
508
+ detail = (
509
+ "duplicate index" if index in checked_criteria else
510
+ "task_ids must be a non-empty list of approved top-level task IDs"
511
+ )
512
+ issues.append(
513
+ f"Implementation audit has invalid acceptance criterion or task references; "
514
+ f"acceptance index {index}: {detail}"
515
+ )
470
516
  continue
471
517
  checked_criteria.add(index)
472
518
  if len(related) > 1:
@@ -477,7 +523,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
477
523
  description = row.get("description")
478
524
  paths = row.get("files") or []
479
525
  if not isinstance(description, str) or not description.strip() or not isinstance(paths, list):
480
- reference = f"task '{row['task_id']}'" if kind == "task" else f"acceptance index {row['index']}"
526
+ reference = f"task '{row['task_id']}'" if kind == "task" else f"acceptance index {index}"
481
527
  invalid_fields = []
482
528
  if not isinstance(description, str) or not description.strip():
483
529
  invalid_fields.append("description must be non-empty text")
@@ -511,6 +557,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
511
557
  "task_id": task_id, "status": status, "description": description,
512
558
  "files": paths, "evidence": evidence,
513
559
  **({"plan_revision": revision} if revision is not None else {}),
560
+ **({"blocking_reason": blocking_reason} if blocking_reason is not None else {}),
514
561
  } for task_id in related)
515
562
  required = expected if contract.get("final_verification") else {subject}
516
563
  if required - checked.keys():
@@ -533,6 +580,57 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
533
580
  return result
534
581
 
535
582
 
583
+ def implementation_audit_failure_domain(contract: dict, assessment: dict) -> str | None:
584
+ if not assessment.get("report_valid"):
585
+ return "evidence"
586
+ if assessment.get("node_complete"):
587
+ return None
588
+ if assessment.get("status") == "plan_revision_required":
589
+ return "plan"
590
+ subject = contract.get("subject_task_id")
591
+ findings = [finding for finding in assessment.get("findings") or [] if finding.get("task_id") == subject]
592
+ if findings and all(
593
+ finding.get("status") in ("blocked", "unverified")
594
+ and finding.get("blocking_reason") in IMPLEMENTATION_ENVIRONMENT_BLOCKERS for finding in findings
595
+ ):
596
+ return "environment"
597
+ role = next((task.get("type") for task in contract.get("tasks") or [] if task.get("id") == subject), None)
598
+ if role in ("coding", "fix") and any(finding.get("status") == "gap" for finding in findings):
599
+ return "code_quality"
600
+ return "verification"
601
+
602
+
603
+ def task_failure_domain(
604
+ failure_code: str | None, *, audit_contract: dict | None = None,
605
+ audit_assessment: dict | None = None, conformance: dict | None = None,
606
+ payload_failed: bool = False,
607
+ ) -> str:
608
+ if failure_code in {
609
+ "planned_task_incomplete", "planned_task_baseline_invalid", "approved_plan_baseline_modified",
610
+ "remediation_scope_violation", "verification_work_item_scope_violation", "uncommitted_workspace_changes",
611
+ "forbidden_branch_merge",
612
+ }:
613
+ return "delivery"
614
+ if failure_code == "implementation_plan_revision_required":
615
+ return "plan"
616
+ if payload_failed:
617
+ return "execution"
618
+ if failure_code == "implementation_audit_incomplete":
619
+ return implementation_audit_failure_domain(audit_contract or {}, audit_assessment or {}) or "verification"
620
+ if failure_code in {"approved_plan_deviation", "approved_plan_conformance_unverified"}:
621
+ assessment = conformance or {}
622
+ if assessment.get("reason") == "approved_plan_review_unavailable":
623
+ return "execution"
624
+ if assessment.get("reason") or assessment.get("diagnostic_code"):
625
+ return "evidence"
626
+ return "code_quality" if assessment.get("status") == "deviation" else "verification"
627
+ if failure_code == "acceptance_criteria_uncovered":
628
+ return "verification"
629
+ if failure_code in {"validation_unavailable", "validation_retry_exhausted"}:
630
+ return "evidence"
631
+ return "execution"
632
+
633
+
536
634
  _WORKSPACE_INSTRUCTION_PATHS = (
537
635
  "AGENTS.md",
538
636
  "CLAUDE.md",
@@ -1859,7 +1957,7 @@ def has_agent_token_usage(*sources: object) -> bool:
1859
1957
  return False
1860
1958
 
1861
1959
 
1862
- def _extract_output_signals(text: str) -> dict[str, Any]:
1960
+ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str, Any]:
1863
1961
  """Parse stdout/stderr-like streams for success and failure signals.
1864
1962
 
1865
1963
  Uses structural signal detection — NOT keyword matching on result text.
@@ -1903,6 +2001,8 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
1903
2001
 
1904
2002
  if ev_type == "turn.completed":
1905
2003
  has_turn_completed = True
2004
+ has_turn_failed = False
2005
+ error_messages.clear()
1906
2006
  elif ev_type == "turn.failed":
1907
2007
  has_turn_failed = True
1908
2008
  err = data.get("error") or {}
@@ -1932,10 +2032,18 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
1932
2032
  # Claude result format: {"type":"result","result":"...","is_error":false,...}
1933
2033
  if "exitCode" in data:
1934
2034
  # Copilot JSONL result
1935
- exit_code = int(data.get("exitCode") or 0)
2035
+ raw_exit_code = data["exitCode"]
2036
+ if type(raw_exit_code) is not int and not (
2037
+ isinstance(raw_exit_code, str) and re.fullmatch(r"[+-]?[0-9]+", raw_exit_code)
2038
+ ):
2039
+ error_messages.append("Agent result has an invalid exitCode")
2040
+ continue
2041
+ exit_code = int(raw_exit_code)
1936
2042
  if exit_code == 0:
1937
2043
  has_result = True
1938
2044
  has_meaningful_content = True
2045
+ has_turn_failed = False
2046
+ error_messages.clear()
1939
2047
  else:
1940
2048
  error_messages.append(f"Copilot exited with code {exit_code}")
1941
2049
  else:
@@ -1966,12 +2074,14 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
1966
2074
  # events appeared, the CLI never made an API call. The result
1967
2075
  # text is a CLI-level error (e.g. "API Error: Connection error.")
1968
2076
  # rather than the agent's actual work output.
1969
- no_api_call = not has_agent_token_usage(data) and not has_assistant_events
2077
+ no_api_call = not has_agent_token_usage(data, usage_source) and not has_assistant_events
1970
2078
  if no_api_call and result_text:
1971
2079
  error_messages.append(result_text)
1972
2080
  else:
1973
2081
  has_result = True
1974
2082
  has_meaningful_content = True
2083
+ has_turn_failed = False
2084
+ error_messages.clear()
1975
2085
  elif ev_type == "session.error":
1976
2086
  err = data.get("data") or {}
1977
2087
  if isinstance(err, dict):
@@ -4958,6 +5068,62 @@ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
4958
5068
  )
4959
5069
 
4960
5070
 
5071
+ # Transport failures a plain re-run can recover from. 421 Misdirected Request
5072
+ # is deliberately absent: it keeps its own recovery (retry without token env
5073
+ # vars) because it has historically indicated a credential-routing mismatch,
5074
+ # not a network blip.
5075
+ COPILOT_TRANSIENT_TRANSPORT_ERROR_PATTERNS = (
5076
+ "timed out",
5077
+ "timeout",
5078
+ "unexpected eof",
5079
+ "error sending request",
5080
+ "connection reset",
5081
+ "connection refused",
5082
+ "connection timed out",
5083
+ "connection error",
5084
+ "connection closed",
5085
+ "connection terminated",
5086
+ "network is unreachable",
5087
+ "no such host",
5088
+ "temporary failure in name resolution",
5089
+ )
5090
+
5091
+ COPILOT_STABLE_AUTH_ERROR_PATTERNS = (
5092
+ "unauthorized",
5093
+ "forbidden",
5094
+ "http 401",
5095
+ "http 403",
5096
+ "not logged in",
5097
+ "authentication required",
5098
+ "invalid api key",
5099
+ "your github token may be invalid",
5100
+ "rejected the cli github credential",
5101
+ )
5102
+
5103
+
5104
+ def is_copilot_model_discovery_transient_transport_error(*messages: str | None) -> bool:
5105
+ """Return whether Copilot failed model discovery on a transient transport error.
5106
+
5107
+ Matches connect timeouts, unexpected EOF, resets and DNS hiccups during
5108
+ model discovery — the class of failures observed as intermittent on
5109
+ unstable routes (a daemon-side rerun typically succeeds, and the same
5110
+ login works interactively), as opposed to stable auth/config breakage.
5111
+ Copilot appends its remediation boilerplate ("To resolve this, ... run
5112
+ '/login' ...") to every discovery failure, so auth patterns are only
5113
+ trusted when they appear before that boilerplate.
5114
+ """
5115
+ detail = " ".join(str(message or "").lower() for message in messages)
5116
+ detail = detail.partition("to resolve this")[0]
5117
+ if not (
5118
+ "retrieve the list of available models" in detail
5119
+ or "failed to load models" in detail
5120
+ ):
5121
+ return False
5122
+ if any(pattern in detail for pattern in COPILOT_STABLE_AUTH_ERROR_PATTERNS):
5123
+ return False
5124
+ return any(pattern in detail for pattern in COPILOT_TRANSIENT_TRANSPORT_ERROR_PATTERNS)
5125
+
5126
+
4961
5127
  def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
4962
5128
  """Return whether the OS refused an agent process, thread, or file watch."""
4963
5129
  detail = " ".join(str(message or "").lower() for message in messages)
@@ -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.0"
1237
+ DAEMON_VERSION = "1.85.2"
1238
1238
 
1239
1239
 
1240
1240
  def _detect_client_type() -> str:
@@ -3040,7 +3040,7 @@ def _runtime_critic_reference_candidates(
3040
3040
  if isinstance(related, dict):
3041
3041
  texts.append(str(related.get("description") or ""))
3042
3042
  texts.extend(str(item.get("description") or "") for item in related.get("items") or [] if isinstance(item, dict))
3043
- if planned.get("type") == "design":
3043
+ if planned.get("type") in ("design", "testing", "review"):
3044
3044
  texts.append(json.dumps({"analysis": baseline.get("approved_analysis"),
3045
3045
  "documents": baseline.get("analysis_documents")}, ensure_ascii=True)[:200_000])
3046
3046
  text = re.sub(r"https?://\S+", " ", "\n".join(texts))
@@ -3063,7 +3063,7 @@ def _runtime_critic_reference_candidates(
3063
3063
  php_context = any(Path(path).suffix.lower() == ".php" for path in source_paths)
3064
3064
  directories = {Path(path).parent.as_posix() for path in source_paths if safe_path(path)} - {"."}
3065
3065
  explicit = set()
3066
- for token in re.findall(r"(?<![\w./\\:-])[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+", text):
3066
+ for token in re.findall(r"(?<![A-Za-z0-9_./\\:-])[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+", text):
3067
3067
  candidate = safe_path(token)
3068
3068
  if candidate is None:
3069
3069
  continue
@@ -6439,8 +6439,8 @@ class ProcessManager:
6439
6439
  int(result.metrics.get("token_input", 0) or 0)
6440
6440
  + int(result.metrics.get("token_output", 0) or 0)
6441
6441
  ) > 0
6442
- signals = ProcessManager._extract_output_signals(
6443
- "\n".join(part for part in (stdout, stderr) if part)
6442
+ signals = agent_core._extract_output_signals(
6443
+ "\n".join(part for part in (stdout, stderr) if part), result.metrics,
6444
6444
  )
6445
6445
  if not has_token_usage and ProcessManager._should_scan_short_success_stdout(stdout, signals):
6446
6446
  error_channels = "\n".join(filter(None, [error_channels, stdout.strip()]))
@@ -8484,15 +8484,18 @@ class ProcessManager:
8484
8484
 
8485
8485
  model_discovery_detail = "\n".join([*structured_errors, stderr]).lower()
8486
8486
  if (
8487
- "could not retrieve the list of available models" in model_discovery_detail
8488
- and "timed out" in model_discovery_detail
8487
+ agent_core.is_copilot_model_discovery_transient_transport_error(
8488
+ *structured_errors, stderr,
8489
+ )
8490
+ and "misdirected request" not in model_discovery_detail
8489
8491
  and not _retry_model_discovery_timeout
8490
8492
  and not (copilot_exit == 0 and returncode == 0)
8491
8493
  and not signals["has_meaningful_content"]
8492
8494
  and not self._copilot_called_any_tools(stdout)
8493
8495
  ):
8494
8496
  logger.warning(
8495
- "Copilot model discovery timed out before work for task %s; "
8497
+ "Copilot model discovery hit a transient transport error "
8498
+ "(timeout / connect reset) before work for task %s; "
8496
8499
  "retrying once with the same credentials and configuration",
8497
8500
  task_id,
8498
8501
  )
@@ -13998,6 +14001,7 @@ class RuntimeDaemon:
13998
14001
 
13999
14002
  after_sha = str((result.git or {}).get("commit_sha") or before_sha or "").strip()
14000
14003
  committed_sources: dict[str, str] = {}
14004
+ unavailable_sources: list[dict[str, str]] = []
14001
14005
  source_bytes = 0
14002
14006
  coverage = {
14003
14007
  "candidate_files": 0, "full_source_files": 0, "unreadable_files": 0,
@@ -14026,6 +14030,27 @@ class RuntimeDaemon:
14026
14030
  "test-cases.json", "coverage-matrix.json", "test-evidence.json", "test-report.md",
14027
14031
  }
14028
14032
  ))
14033
+ if output_dir.startswith(f"docs/requirements/{task.requirement_key}/") and ".." not in Path(output_dir).parts:
14034
+ verification_dir = output_dir + "/verification"
14035
+ try:
14036
+ verification_paths = await self.workspace_manager._git(
14037
+ "ls-tree", "-r", "--name-only", "-z", after_sha, "--", *asset_paths, verification_dir,
14038
+ cwd=workspace_path, timeout=10,
14039
+ )
14040
+ if len(verification_paths.encode("utf-8")) > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
14041
+ raise ValueError("Committed verification inventory exceeds its bounded evidence budget")
14042
+ existing_artifacts = set(verification_paths.split("\0"))
14043
+ asset_paths = [path for path in asset_paths if path in existing_artifacts]
14044
+ paths.extend(path for path in verification_paths.split("\0") if (
14045
+ path.startswith(verification_dir + "/") and ".." not in Path(path).parts
14046
+ and re.fullmatch(r"[A-Za-z0-9_./-]+", path)
14047
+ and Path(path).suffix.lower() in {".php", ".py", ".ts", ".js", ".json", ".md", ".txt"}
14048
+ and not _is_sensitive_local_path(path)
14049
+ and Path(path).name.lower() not in {"env.php", "auth.json", "credentials.json", "secrets.json", "service-account.json"}
14050
+ and not any(part in {".git", ".forgexa", ".aws", ".ssh", ".env"} or part.startswith(".env.") for part in Path(path).parts)
14051
+ ))
14052
+ except Exception:
14053
+ coverage["verification_inventory_unavailable"] = True
14029
14054
  paths.extend(asset_paths)
14030
14055
  support_dirs = sorted({(Path(path).parent / "support").as_posix() for path in asset_paths if path.startswith("tests/")})
14031
14056
  for directory in support_dirs[:5]:
@@ -14117,8 +14142,19 @@ class RuntimeDaemon:
14117
14142
  source = await self.workspace_manager._git(
14118
14143
  "show", f"{after_sha}:{path}", cwd=workspace_path, timeout=10,
14119
14144
  )
14120
- except Exception:
14145
+ except Exception as error:
14121
14146
  coverage["unreadable_files"] += 1
14147
+ detail = str(error).lower()
14148
+ reason = "source_git_read_failed"
14149
+ if isinstance(error, UnicodeDecodeError):
14150
+ reason = "source_encoding_unavailable"
14151
+ elif isinstance(error, TimeoutError) or "timed out after" in detail:
14152
+ reason = "source_read_timeout"
14153
+ elif "cannot be mapped" in detail or "cannot allocate memory" in detail:
14154
+ reason = "source_memory_unavailable"
14155
+ elif "does not exist in" in detail or "exists on disk, but not in" in detail:
14156
+ reason = "source_path_unavailable"
14157
+ unavailable_sources.append({"path": path, "reason": reason})
14122
14158
  continue
14123
14159
  encoded_size = len(source.encode("utf-8"))
14124
14160
  if source_bytes + encoded_size <= _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
@@ -14136,7 +14172,10 @@ class RuntimeDaemon:
14136
14172
  diff = (diff or "No new changes; review committed delivery instead.") + "".join(source_context)
14137
14173
  coverage["full_source_files"] = len(committed_sources)
14138
14174
  coverage["omitted_files"] = coverage["candidate_files"] - coverage["full_source_files"]
14139
- coverage["capsule_incomplete"] = coverage["omitted_files"] > 0 or coverage.get("reference_inventory_unavailable", False)
14175
+ coverage["capsule_incomplete"] = (
14176
+ coverage["omitted_files"] > 0 or coverage.get("reference_inventory_unavailable", False)
14177
+ or coverage.get("verification_inventory_unavailable", False)
14178
+ )
14140
14179
  result.metrics["runtime_critic_coverage"] = coverage
14141
14180
  if not diff:
14142
14181
  return {
@@ -14164,6 +14203,8 @@ class RuntimeDaemon:
14164
14203
  work_item={},
14165
14204
  )
14166
14205
  review_task.input_prompt += "\n## Review Evidence Coverage\n" + json.dumps(coverage, sort_keys=True)
14206
+ if unavailable_sources:
14207
+ review_task.input_prompt += "\n## Unavailable Committed Sources\n" + json.dumps(unavailable_sources, sort_keys=True)
14167
14208
  if coverage["capsule_incomplete"]:
14168
14209
  review_task.input_prompt += (
14169
14210
  "\nSome candidate files were not available as complete source. Assess the evidence you can inspect; "
@@ -14183,6 +14224,7 @@ class RuntimeDaemon:
14183
14224
  "reviewed_commit_sha": after_sha,
14184
14225
  "baseline_fingerprint": baseline.get("fingerprint") if isinstance(baseline, dict) else None,
14185
14226
  "input_sha256": hashlib.sha256(review_task.input_prompt.encode("utf-8")).hexdigest(),
14227
+ "unavailable_sources": unavailable_sources,
14186
14228
  "snapshots": [
14187
14229
  {"path": path, "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
14188
14230
  "bytes": len(source.encode("utf-8"))}
@@ -14427,6 +14469,7 @@ class RuntimeDaemon:
14427
14469
  audit_contract = input_data.get("implementation_audit_contract")
14428
14470
  result.metrics.pop("implementation_audit_node_complete", None)
14429
14471
  result.metrics.pop("implementation_audit_subject_blocked", None)
14472
+ result.metrics.pop("failure_domain", None)
14430
14473
  if isinstance(audit_contract, dict):
14431
14474
  agent_id = str(result.metrics.get("actual_agent") or getattr(task, "agent_type", "") or "")
14432
14475
  audit_report = agent_core.extract_implementation_audit(_daemon_extract_agent_output(agent_id, result.stdout))
@@ -14437,13 +14480,17 @@ class RuntimeDaemon:
14437
14480
  if isinstance(audit_report, dict):
14438
14481
  result.observations.append({"type": "implementation_audit", "data": audit_report})
14439
14482
  audit_result = agent_core.assess_implementation_audit(audit_contract, audit_report)
14483
+ failure_domain = agent_core.implementation_audit_failure_domain(audit_contract, audit_result)
14484
+ if failure_domain is not None:
14485
+ result.metrics["failure_domain"] = failure_domain
14440
14486
  result.metrics["implementation_audit_node_complete"] = bool(
14441
14487
  audit_result["report_valid"] and audit_result["node_complete"]
14442
14488
  )
14443
14489
  result.metrics["implementation_audit_subject_blocked"] = bool(
14444
14490
  audit_result["report_valid"] and not audit_result["node_complete"]
14445
- and any(finding.get("task_id") == audit_contract.get("subject_task_id") and finding.get("status") == "blocked"
14446
- for finding in audit_result.get("findings") or [])
14491
+ and (failure_domain == "environment" or any(
14492
+ finding.get("task_id") == audit_contract.get("subject_task_id") and finding.get("status") == "blocked"
14493
+ for finding in audit_result.get("findings") or []))
14447
14494
  )
14448
14495
  if audit_result["report_valid"] and audit_result["status"] == "plan_revision_required":
14449
14496
  result.status = "failed"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.85.0
3
+ Version: 1.85.2
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.0"
3
+ version = "1.85.2"
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