forgexa-cli 1.18.5__tar.gz → 1.18.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.5
3
+ Version: 1.18.7
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.18.5"
2
+ __version__ = "1.18.7"
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
555
555
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
556
556
  # Kept in sync with pyproject.toml version via bump-version.sh.
557
557
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
558
- DAEMON_VERSION = "1.18.5"
558
+ DAEMON_VERSION = "1.18.7"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -1341,6 +1341,17 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
1341
1341
  return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
1342
1342
 
1343
1343
 
1344
+ def _is_requirement_analysis_path(path: str) -> bool:
1345
+ """Recognize v1 and v2 requirement analysis paths without backend imports."""
1346
+ normalized = str(path or "").replace("\\", "/").lstrip("./")
1347
+ return (
1348
+ normalized.startswith("docs/requirements/")
1349
+ and "/implement/" not in normalized
1350
+ and "/bugfix/" not in normalized
1351
+ and "/delivery/" not in normalized
1352
+ )
1353
+
1354
+
1344
1355
  # Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
1345
1356
  # Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
1346
1357
  _DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
@@ -4053,6 +4064,17 @@ class ProcessManager:
4053
4064
  return normalized
4054
4065
 
4055
4066
  def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
4067
+ input_data = task.input_data or {}
4068
+ if input_data.get("verification_work_item"):
4069
+ path_by_node_type = {
4070
+ "analysis": input_data.get("verification_analysis_doc_path", ""),
4071
+ "fix": input_data.get("bugfix_doc_path", ""),
4072
+ "testing": input_data.get("verification_test_report_path", ""),
4073
+ }
4074
+ required_path = str(path_by_node_type.get(task.node_type, "") or "")
4075
+ required_path = required_path.replace("\\", "/").lstrip("./")
4076
+ return {required_path} if required_path else set()
4077
+
4056
4078
  # For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/<key>/analysis)
4057
4079
  # For delivery nodes, deliverables live in output_dir (docs/requirements/<key>/delivery)
4058
4080
  # For other nodes, use output_dir (docs/requirements/<key>/implement)
@@ -4392,6 +4414,14 @@ class ProcessManager:
4392
4414
  ]
4393
4415
 
4394
4416
  env = os.environ.copy()
4417
+ for key in (
4418
+ "XDG_CONFIG_HOME",
4419
+ "XDG_DATA_HOME",
4420
+ "XDG_CACHE_HOME",
4421
+ "XDG_STATE_HOME",
4422
+ "CLAUDE_CODE_DEBUG_LOGS_DIR",
4423
+ ):
4424
+ env.pop(key, None)
4395
4425
  model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
4396
4426
  if model_override:
4397
4427
  env["ANTHROPIC_MODEL"] = model_override
@@ -4557,7 +4587,8 @@ class ProcessManager:
4557
4587
  """Run OpenCode CLI in non-interactive mode.
4558
4588
 
4559
4589
  Uses `opencode run --format json --dir <cwd>` for headless execution.
4560
- The message is passed as a positional argument.
4590
+ The full prompt is attached through a temporary file so it never exceeds
4591
+ the operating system command-line length limit.
4561
4592
  NOTE: `--dir` is the correct flag (not `--cwd` which is invalid).
4562
4593
 
4563
4594
  Each invocation gets an isolated XDG_DATA_HOME so that concurrent
@@ -4604,16 +4635,18 @@ class ProcessManager:
4604
4635
  model_override = os.environ.get("FACTORY_OPENCODE_MODEL")
4605
4636
  if model_override:
4606
4637
  cmd += ["--model", model_override]
4607
- # -- ensures yargs treats everything after it as positional args, not flags.
4608
- # Without this, prompts containing --flag-like text cause yargs to print help and exit 1.
4609
- cmd += ["--", prompt]
4610
-
4611
4638
  # Isolate each opencode run in its own data directory to prevent
4612
4639
  # concurrent processes from racing on the shared SQLite WAL file.
4613
4640
  tmp_data_root = tempfile.mkdtemp(prefix=f"opencode-{task_id[:8]}-")
4614
4641
  try:
4615
4642
  isolated_data_dir = Path(tmp_data_root) / "opencode"
4616
4643
  isolated_data_dir.mkdir()
4644
+ prompt_file = Path(tmp_data_root) / "instructions.md"
4645
+ prompt_file.write_text(prompt, encoding="utf-8")
4646
+ cmd += [
4647
+ "--file", str(prompt_file),
4648
+ "--", "Read and follow the attached instructions.",
4649
+ ]
4617
4650
  # Copy auth.json so provider credentials are available.
4618
4651
  auth_src = Path.home() / ".local" / "share" / "opencode" / "auth.json"
4619
4652
  if auth_src.exists():
@@ -4798,59 +4831,45 @@ class ProcessManager:
4798
4831
  def _prepare_claude_environment(
4799
4832
  sandbox_root: "Path | None" = None,
4800
4833
  ) -> dict[str, str]:
4801
- """Prepare writable Claude config/data/cache dirs for isolated execution.
4834
+ """Prepare an isolated Claude home and mirror stable configuration files.
4802
4835
 
4803
4836
  When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
4804
4837
  directory. When None, falls back to the shared legacy path under ~/.forgexa.
4805
4838
 
4806
4839
  The daemon service runs with ProtectSystem=strict and only allows
4807
- writes under ~/.forgexa plus the workspace root. Claude Code writes
4808
- config, debug logs, cache, and session state under XDG paths and
4809
- ~/.claude by default, and newer builds may still touch $HOME/.claude.json
4810
- even when XDG paths are set. That can fail with EROFS inside the daemon.
4811
-
4812
- Point Claude at writable daemon-owned directories, redirect HOME to
4813
- the same writable sandbox, and mirror the user's Claude home state so
4814
- the isolated subprocess keeps the interactive login/session context.
4840
+ writes under ~/.forgexa plus the workspace root. Redirecting HOME lets
4841
+ Claude use its default config, cache, and session locations under a
4842
+ writable task sandbox.
4843
+
4844
+ Mirror only stable authentication and settings files. Runtime state such
4845
+ as session-env, projects, task locks, and history must never cross into
4846
+ an isolated daemon invocation. Do not set CLAUDE_CODE_DEBUG_LOGS_DIR:
4847
+ Claude Code 2.1.63 treats a directory supplied through that variable as
4848
+ a file after TodoWrite and exits with EISDIR.
4815
4849
  """
4816
4850
  if sandbox_root is None:
4817
4851
  sandbox_root = Path.home() / ".forgexa" / "claude-home"
4818
- config_root = sandbox_root / "config"
4819
- data_root = sandbox_root / "data"
4820
- cache_root = sandbox_root / "cache"
4821
- state_root = sandbox_root / "state"
4822
4852
  home_claude_dir = sandbox_root / ".claude"
4823
- claude_config_dir = config_root / "claude"
4824
4853
 
4825
4854
  for path in (
4826
- config_root,
4827
- data_root,
4828
- cache_root,
4829
- state_root,
4830
4855
  home_claude_dir,
4831
- claude_config_dir,
4832
4856
  ):
4833
4857
  path.mkdir(parents=True, exist_ok=True)
4834
4858
 
4835
- source_claude_dir = Path.home() / ".claude"
4836
- try:
4837
- if source_claude_dir.is_dir():
4838
- shutil.copytree(source_claude_dir, home_claude_dir, dirs_exist_ok=True)
4839
- except Exception as exc:
4840
- logger.debug(
4841
- "Claude home mirror skipped %s -> %s: %s",
4842
- source_claude_dir, home_claude_dir, exc,
4843
- )
4844
-
4845
4859
  source_files = [
4846
4860
  (Path.home() / ".claude.json", sandbox_root / ".claude.json"),
4847
- (Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
4848
4861
  (Path.home() / ".claude" / "settings.json", home_claude_dir / "settings.json"),
4849
- (Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
4862
+ (Path.home() / ".claude" / "settings.local.json", home_claude_dir / "settings.local.json"),
4850
4863
  ]
4851
4864
  for source, dest in source_files:
4852
4865
  try:
4853
- if source.is_file() and not dest.exists():
4866
+ if not source.is_file():
4867
+ continue
4868
+ if dest.is_dir():
4869
+ shutil.rmtree(dest)
4870
+ elif dest.is_file() or dest.is_symlink():
4871
+ dest.unlink()
4872
+ if not dest.exists():
4854
4873
  shutil.copy2(source, dest)
4855
4874
  except Exception as exc:
4856
4875
  logger.debug(
@@ -4860,19 +4879,11 @@ class ProcessManager:
4860
4879
 
4861
4880
  env = {
4862
4881
  "HOME": str(sandbox_root),
4863
- "XDG_CONFIG_HOME": str(config_root),
4864
- "XDG_DATA_HOME": str(data_root),
4865
- "XDG_CACHE_HOME": str(cache_root),
4866
- "XDG_STATE_HOME": str(state_root),
4867
4882
  "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
4868
4883
  os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
4869
4884
  ),
4870
4885
  }
4871
4886
 
4872
- if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
4873
- env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
4874
- Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
4875
-
4876
4887
  return env
4877
4888
 
4878
4889
  @staticmethod
@@ -6276,6 +6287,7 @@ class TaskPoller:
6276
6287
  self._on_success = lambda: None # Callbacks set by ServerConnection
6277
6288
  self._on_auth_failure = lambda: None
6278
6289
  self._empty_cycles = 0
6290
+ self.cancelled_ai_job_ids: list[str] = []
6279
6291
 
6280
6292
  def note_activity(self, *, had_work: bool) -> None:
6281
6293
  if had_work:
@@ -6336,12 +6348,20 @@ class TaskPoller:
6336
6348
  )
6337
6349
  if resp.status_code == 200:
6338
6350
  self._on_success()
6339
- return resp.json().get("ai_jobs", [])
6351
+ data = resp.json()
6352
+ self.cancelled_ai_job_ids = [
6353
+ str(job_id)
6354
+ for job_id in data.get("cancelled_job_ids", [])
6355
+ if job_id
6356
+ ]
6357
+ return data.get("ai_jobs", [])
6340
6358
  elif resp.status_code in (401, 403):
6341
6359
  self._on_auth_failure()
6360
+ self.cancelled_ai_job_ids = []
6342
6361
  return []
6343
6362
  except Exception as e:
6344
6363
  logger.debug("AIJob poll error: %s", e)
6364
+ self.cancelled_ai_job_ids = []
6345
6365
  return []
6346
6366
 
6347
6367
 
@@ -7118,6 +7138,19 @@ class RuntimeDaemon:
7118
7138
  async def _poll_and_execute(self) -> bool:
7119
7139
  """Poll for tasks from all connected servers and execute them."""
7120
7140
  had_work = False
7141
+
7142
+ def _cancel_server_requested_ai_jobs(conn: ServerConnection) -> None:
7143
+ for job_id in conn.poller.cancelled_ai_job_ids:
7144
+ ai_task_key = f"aijob_{job_id}"
7145
+ active_task = self.active_tasks.get(ai_task_key)
7146
+ if not active_task or active_task.done():
7147
+ continue
7148
+ active_process = self.process_manager.active_processes.get(job_id)
7149
+ if active_process:
7150
+ _kill_proc(active_process)
7151
+ active_task.cancel()
7152
+ logger.info("[%s] Cancelled AIJob %s at server request", conn.label, job_id)
7153
+
7121
7154
  # Clean up completed tasks
7122
7155
  for task_id in list(self.active_tasks):
7123
7156
  if self.active_tasks[task_id].done():
@@ -7130,24 +7163,21 @@ class RuntimeDaemon:
7130
7163
  if conn.heartbeat:
7131
7164
  conn.heartbeat.update(total_active, self._agents_as_dicts())
7132
7165
 
7133
- # Check capacity
7134
- if total_active >= self.max_concurrent:
7135
- return True
7136
-
7137
7166
  # Poll from all connected servers
7138
7167
  for conn in self.connections:
7139
- if total_active >= self.max_concurrent:
7140
- break
7141
-
7142
- tasks = await conn.poller.poll()
7143
- ai_jobs: list[dict] = []
7144
- if len(self.active_tasks) < self.max_concurrent:
7145
- ai_jobs = await conn.poller.poll_ai_jobs()
7168
+ has_capacity = total_active < self.max_concurrent
7169
+ tasks = await conn.poller.poll() if has_capacity else []
7170
+ # Always poll AI jobs so server-requested cancellations can stop a
7171
+ # process that is itself consuming the last available slot.
7172
+ ai_jobs = await conn.poller.poll_ai_jobs()
7173
+ _cancel_server_requested_ai_jobs(conn)
7174
+ total_active = len(self.active_tasks)
7175
+ has_capacity = total_active < self.max_concurrent
7146
7176
 
7147
7177
  conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
7148
7178
  had_work = had_work or bool(tasks or ai_jobs)
7149
7179
 
7150
- for task in tasks:
7180
+ for task in tasks if has_capacity else []:
7151
7181
  if task.task_id in self.active_tasks:
7152
7182
  continue # Already running
7153
7183
 
@@ -7163,7 +7193,7 @@ class RuntimeDaemon:
7163
7193
  )
7164
7194
 
7165
7195
  # Poll for AIJobs (workspace-mode tasks)
7166
- for aj in ai_jobs:
7196
+ for aj in ai_jobs if has_capacity else []:
7167
7197
  job_id = aj.get("job_id", "")
7168
7198
  ai_task_key = f"aijob_{job_id}"
7169
7199
  if ai_task_key in self.active_tasks:
@@ -7177,6 +7207,9 @@ class RuntimeDaemon:
7177
7207
  self._execute_ai_job(aj, conn)
7178
7208
  )
7179
7209
 
7210
+ total_active = len(self.active_tasks)
7211
+ has_capacity = total_active < self.max_concurrent
7212
+
7180
7213
  return had_work
7181
7214
 
7182
7215
  async def _workspace_checkout_health_error(self, workspace_path: Path) -> str | None:
@@ -7811,7 +7844,11 @@ class RuntimeDaemon:
7811
7844
 
7812
7845
  # 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
7813
7846
  # Existing files from a prior iteration are not sufficient evidence.
7814
- if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
7847
+ requires_current_run_deliverable = task.node_type in ("analysis", "design", "fix") or (
7848
+ task.node_type == "testing"
7849
+ and bool((task.input_data or {}).get("verification_work_item"))
7850
+ )
7851
+ if result.status == "success" and requires_current_run_deliverable:
7815
7852
  committed_git = await self.process_manager._collect_git_info_vs_parent(
7816
7853
  workspace_path,
7817
7854
  default_branch=default_branch,
@@ -7874,7 +7911,11 @@ class RuntimeDaemon:
7874
7911
  # This closes the mirror-sync gap: review agents update analysis.json
7875
7912
  # (open_risks, phase_handoffs) and this step ensures the content reaches
7876
7913
  # runtimes.py as an inline artifact regardless of push success.
7877
- if result.status == "success" and task.node_type in ("review", "coding", "testing"):
7914
+ if (
7915
+ result.status == "success"
7916
+ and task.node_type in ("review", "coding", "testing")
7917
+ and not bool((task.input_data or {}).get("verification_work_item"))
7918
+ ):
7878
7919
  analysis_dir = (
7879
7920
  (task.input_data or {}).get("analysis_output_dir", "")
7880
7921
  or ""
@@ -8078,7 +8119,40 @@ class RuntimeDaemon:
8078
8119
 
8079
8120
  issues: list[str] = []
8080
8121
  node_type = task.node_type
8081
- req_type = (task.input_data or {}).get("requirement_type", "feature")
8122
+ input_data = task.input_data or {}
8123
+ req_type = input_data.get("requirement_type", "feature")
8124
+ verification_work_item = bool(input_data.get("verification_work_item"))
8125
+
8126
+ if verification_work_item:
8127
+ forbidden_paths = sorted(
8128
+ path
8129
+ for path in ProcessManager._normalize_repo_paths(result.files_changed)
8130
+ if _is_requirement_analysis_path(path)
8131
+ )
8132
+ if forbidden_paths:
8133
+ issues.append(
8134
+ "Verification work item modified requirement analysis assets: "
8135
+ + ", ".join(forbidden_paths[:5])
8136
+ )
8137
+
8138
+ if verification_work_item and node_type == "analysis":
8139
+ analysis_doc_path = str(input_data.get("verification_analysis_doc_path", "") or "")
8140
+ analysis_doc_path = analysis_doc_path.replace("\\", "/").lstrip("./")
8141
+ analysis_doc = workspace_path / analysis_doc_path
8142
+ if not analysis_doc_path or not analysis_doc.exists():
8143
+ issues.append(f"Verification analysis record missing: {analysis_doc_path}")
8144
+ elif analysis_doc.stat().st_size == 0:
8145
+ issues.append(f"Verification analysis record is empty: {analysis_doc_path}")
8146
+ return issues
8147
+
8148
+ if verification_work_item and node_type == "testing":
8149
+ test_report_path = str(input_data.get("verification_test_report_path", "") or "")
8150
+ test_report_path = test_report_path.replace("\\", "/").lstrip("./")
8151
+ test_report = workspace_path / test_report_path
8152
+ if not test_report_path or not test_report.exists():
8153
+ issues.append(f"Verification test report missing: {test_report_path}")
8154
+ elif test_report.stat().st_size == 0:
8155
+ issues.append(f"Verification test report is empty: {test_report_path}")
8082
8156
 
8083
8157
  if node_type == "analysis":
8084
8158
  # Use type profile to determine required analysis outputs
@@ -8249,7 +8323,7 @@ class RuntimeDaemon:
8249
8323
  pass # analysis.json corrupt — already flagged elsewhere
8250
8324
 
8251
8325
  # Testing-specific: validate structured test assets
8252
- if node_type == "testing":
8326
+ if node_type == "testing" and not verification_work_item:
8253
8327
  # Determine which checks to run for this requirement type.
8254
8328
  #
8255
8329
  # _skip_test_artifacts = True → skip ALL artifact checks
@@ -8844,21 +8918,26 @@ class RuntimeDaemon:
8844
8918
  always receives the file contents via the completion report and gate
8845
8919
  reviewers can see the analysis documents immediately.
8846
8920
  """
8847
- # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
8848
- doc_dir = (
8849
- (task.input_data or {}).get("analysis_output_dir", "")
8850
- or (task.input_data or {}).get("output_dir", "")
8851
- )
8852
- if not doc_dir:
8853
- return
8921
+ input_data = task.input_data or {}
8922
+ verification_work_item = bool(input_data.get("verification_work_item"))
8923
+ if verification_work_item:
8924
+ analysis_doc_path = str(input_data.get("verification_analysis_doc_path", "") or "")
8925
+ candidate_paths = [analysis_doc_path] if analysis_doc_path else []
8926
+ else:
8927
+ # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
8928
+ doc_dir = input_data.get("analysis_output_dir", "") or input_data.get("output_dir", "")
8929
+ if not doc_dir:
8930
+ return
8931
+ base = workspace_path / doc_dir.lstrip("./")
8932
+ req_type = input_data.get("requirement_type", "feature")
8933
+ candidate_paths = [str(base / fname) for fname in _get_analysis_outputs_for_type(req_type)]
8854
8934
 
8855
- base = workspace_path / doc_dir.lstrip("./")
8856
- req_type = (task.input_data or {}).get("requirement_type", "feature")
8857
- _ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
8858
8935
  existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
8859
8936
 
8860
- for fname in _ANALYSIS_FILES:
8861
- fpath = base / fname
8937
+ for candidate_path in candidate_paths:
8938
+ fpath = Path(candidate_path)
8939
+ if not fpath.is_absolute():
8940
+ fpath = workspace_path / fpath
8862
8941
  if not fpath.exists() or fpath.stat().st_size == 0:
8863
8942
  continue
8864
8943
  try:
@@ -8866,7 +8945,7 @@ class RuntimeDaemon:
8866
8945
  if rel_path in existing_artifact_paths:
8867
8946
  continue # already attached
8868
8947
  content = fpath.read_text(encoding="utf-8", errors="replace")
8869
- mime = "text/markdown" if fname.endswith(".md") else "application/json"
8948
+ mime = "text/markdown" if rel_path.endswith(".md") else "application/json"
8870
8949
  result.artifacts.append({
8871
8950
  "path": rel_path,
8872
8951
  "content": content,
@@ -8876,7 +8955,7 @@ class RuntimeDaemon:
8876
8955
  "Attached analysis artifact inline: %s (%d bytes)", rel_path, len(content)
8877
8956
  )
8878
8957
  except Exception as e:
8879
- logger.warning("Failed to read analysis artifact %s: %s", fname, e)
8958
+ logger.warning("Failed to read analysis artifact %s: %s", candidate_path, e)
8880
8959
 
8881
8960
  async def _collect_design_artifacts(
8882
8961
  self, workspace_path: Path, task: TaskInfo, result: TaskResult
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.5
3
+ Version: 1.18.7
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.18.5"
3
+ version = "1.18.7"
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