forgexa-cli 1.18.4__tar.gz → 1.18.6__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.4
3
+ Version: 1.18.6
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.4"
2
+ __version__ = "1.18.6"
@@ -104,6 +104,34 @@ def _save_cli_tokens(access_token: str, refresh_token: str | None = None) -> Non
104
104
  token_path.chmod(0o600)
105
105
 
106
106
 
107
+ def _extract_access_token_subject(token: str | None) -> str | None:
108
+ raw = str(token or "").strip()
109
+ if not raw:
110
+ return None
111
+
112
+ parts = raw.split(".")
113
+ if len(parts) != 3:
114
+ return None
115
+
116
+ payload = parts[1]
117
+ payload += "=" * (-len(payload) % 4)
118
+ try:
119
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")))
120
+ except Exception:
121
+ return None
122
+
123
+ subject = str(data.get("sub") or "").strip()
124
+ if not subject:
125
+ return None
126
+
127
+ try:
128
+ UUID(subject)
129
+ except (TypeError, ValueError):
130
+ return None
131
+
132
+ return subject
133
+
134
+
107
135
  def _looks_like_pipx_environment(python_path: str | None = None) -> bool:
108
136
  candidate = (python_path or sys.executable or "").replace("\\", "/").lower()
109
137
  return "/pipx/venvs/" in candidate
@@ -527,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
527
555
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
528
556
  # Kept in sync with pyproject.toml version via bump-version.sh.
529
557
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
530
- DAEMON_VERSION = "1.18.4"
558
+ DAEMON_VERSION = "1.18.6"
531
559
 
532
560
 
533
561
  def _detect_client_type() -> str:
@@ -1313,6 +1341,17 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
1313
1341
  return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
1314
1342
 
1315
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
+
1316
1355
  # Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
1317
1356
  # Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
1318
1357
  _DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
@@ -3574,6 +3613,16 @@ class ProcessManager:
3574
3613
  "not found the model", # decoded Kimi 404 body
3575
3614
  "provider.api_error", # Kimi SDK error prefix
3576
3615
  "kimi authentication required", # pre-flight oauth check sentinel
3616
+ # Generic auth/login failures. These are evaluated only against failure
3617
+ # channels, so they are safe to treat as fallback-worthy.
3618
+ "not logged in",
3619
+ "please run /login",
3620
+ "please log in",
3621
+ "authentication failed",
3622
+ "authentication_failed",
3623
+ "authentication required",
3624
+ "authenticate with the github",
3625
+ "gh auth login",
3577
3626
  ]
3578
3627
 
3579
3628
  def __init__(self):
@@ -4015,6 +4064,17 @@ class ProcessManager:
4015
4064
  return normalized
4016
4065
 
4017
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
+
4018
4078
  # For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/<key>/analysis)
4019
4079
  # For delivery nodes, deliverables live in output_dir (docs/requirements/<key>/delivery)
4020
4080
  # For other nodes, use output_dir (docs/requirements/<key>/implement)
@@ -4354,6 +4414,14 @@ class ProcessManager:
4354
4414
  ]
4355
4415
 
4356
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)
4357
4425
  model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
4358
4426
  if model_override:
4359
4427
  env["ANTHROPIC_MODEL"] = model_override
@@ -4519,7 +4587,8 @@ class ProcessManager:
4519
4587
  """Run OpenCode CLI in non-interactive mode.
4520
4588
 
4521
4589
  Uses `opencode run --format json --dir <cwd>` for headless execution.
4522
- 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.
4523
4592
  NOTE: `--dir` is the correct flag (not `--cwd` which is invalid).
4524
4593
 
4525
4594
  Each invocation gets an isolated XDG_DATA_HOME so that concurrent
@@ -4566,16 +4635,18 @@ class ProcessManager:
4566
4635
  model_override = os.environ.get("FACTORY_OPENCODE_MODEL")
4567
4636
  if model_override:
4568
4637
  cmd += ["--model", model_override]
4569
- # -- ensures yargs treats everything after it as positional args, not flags.
4570
- # Without this, prompts containing --flag-like text cause yargs to print help and exit 1.
4571
- cmd += ["--", prompt]
4572
-
4573
4638
  # Isolate each opencode run in its own data directory to prevent
4574
4639
  # concurrent processes from racing on the shared SQLite WAL file.
4575
4640
  tmp_data_root = tempfile.mkdtemp(prefix=f"opencode-{task_id[:8]}-")
4576
4641
  try:
4577
4642
  isolated_data_dir = Path(tmp_data_root) / "opencode"
4578
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
+ ]
4579
4650
  # Copy auth.json so provider credentials are available.
4580
4651
  auth_src = Path.home() / ".local" / "share" / "opencode" / "auth.json"
4581
4652
  if auth_src.exists():
@@ -4760,49 +4831,45 @@ class ProcessManager:
4760
4831
  def _prepare_claude_environment(
4761
4832
  sandbox_root: "Path | None" = None,
4762
4833
  ) -> dict[str, str]:
4763
- """Prepare writable Claude config/data/cache dirs for isolated execution.
4834
+ """Prepare an isolated Claude home and mirror stable configuration files.
4764
4835
 
4765
4836
  When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
4766
4837
  directory. When None, falls back to the shared legacy path under ~/.forgexa.
4767
4838
 
4768
4839
  The daemon service runs with ProtectSystem=strict and only allows
4769
- writes under ~/.forgexa plus the workspace root. Claude Code writes
4770
- config, debug logs, cache, and session state under XDG paths and
4771
- ~/.claude by default, and newer builds may still touch $HOME/.claude.json
4772
- even when XDG paths are set. That can fail with EROFS inside the daemon.
4773
-
4774
- Point Claude at writable daemon-owned directories, redirect HOME to
4775
- the same writable sandbox, and mirror only the small set of user
4776
- config files it needs for headless execution.
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.
4777
4849
  """
4778
4850
  if sandbox_root is None:
4779
4851
  sandbox_root = Path.home() / ".forgexa" / "claude-home"
4780
- config_root = sandbox_root / "config"
4781
- data_root = sandbox_root / "data"
4782
- cache_root = sandbox_root / "cache"
4783
- state_root = sandbox_root / "state"
4784
4852
  home_claude_dir = sandbox_root / ".claude"
4785
- claude_config_dir = config_root / "claude"
4786
4853
 
4787
4854
  for path in (
4788
- config_root,
4789
- data_root,
4790
- cache_root,
4791
- state_root,
4792
4855
  home_claude_dir,
4793
- claude_config_dir,
4794
4856
  ):
4795
4857
  path.mkdir(parents=True, exist_ok=True)
4796
4858
 
4797
4859
  source_files = [
4798
4860
  (Path.home() / ".claude.json", sandbox_root / ".claude.json"),
4799
- (Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
4800
4861
  (Path.home() / ".claude" / "settings.json", home_claude_dir / "settings.json"),
4801
- (Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
4862
+ (Path.home() / ".claude" / "settings.local.json", home_claude_dir / "settings.local.json"),
4802
4863
  ]
4803
4864
  for source, dest in source_files:
4804
4865
  try:
4805
- 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():
4806
4873
  shutil.copy2(source, dest)
4807
4874
  except Exception as exc:
4808
4875
  logger.debug(
@@ -4812,19 +4879,11 @@ class ProcessManager:
4812
4879
 
4813
4880
  env = {
4814
4881
  "HOME": str(sandbox_root),
4815
- "XDG_CONFIG_HOME": str(config_root),
4816
- "XDG_DATA_HOME": str(data_root),
4817
- "XDG_CACHE_HOME": str(cache_root),
4818
- "XDG_STATE_HOME": str(state_root),
4819
4882
  "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
4820
4883
  os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
4821
4884
  ),
4822
4885
  }
4823
4886
 
4824
- if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
4825
- env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
4826
- Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
4827
-
4828
4887
  return env
4829
4888
 
4830
4889
  @staticmethod
@@ -6228,6 +6287,7 @@ class TaskPoller:
6228
6287
  self._on_success = lambda: None # Callbacks set by ServerConnection
6229
6288
  self._on_auth_failure = lambda: None
6230
6289
  self._empty_cycles = 0
6290
+ self.cancelled_ai_job_ids: list[str] = []
6231
6291
 
6232
6292
  def note_activity(self, *, had_work: bool) -> None:
6233
6293
  if had_work:
@@ -6288,12 +6348,20 @@ class TaskPoller:
6288
6348
  )
6289
6349
  if resp.status_code == 200:
6290
6350
  self._on_success()
6291
- 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", [])
6292
6358
  elif resp.status_code in (401, 403):
6293
6359
  self._on_auth_failure()
6360
+ self.cancelled_ai_job_ids = []
6294
6361
  return []
6295
6362
  except Exception as e:
6296
6363
  logger.debug("AIJob poll error: %s", e)
6364
+ self.cancelled_ai_job_ids = []
6297
6365
  return []
6298
6366
 
6299
6367
 
@@ -6720,17 +6788,36 @@ class RuntimeDaemon:
6720
6788
  except Exception:
6721
6789
  from jose import jwt as jwt_module
6722
6790
 
6791
+ token_path = Path.home() / ".forgexa" / "token"
6792
+ preferred_user_id = None
6793
+ try:
6794
+ if token_path.exists():
6795
+ preferred_user_id = _extract_access_token_subject(token_path.read_text().strip())
6796
+ except OSError:
6797
+ preferred_user_id = None
6798
+
6723
6799
  async with engine.connect() as conn:
6724
- row = await conn.execute(
6725
- select(User.id)
6726
- .order_by(
6727
- case((User.status == "active", 0), else_=1),
6728
- User.created_at,
6800
+ uid = None
6801
+ if preferred_user_id:
6802
+ preferred_result = await conn.execute(
6803
+ select(User.id)
6804
+ .where(User.id == UUID(preferred_user_id))
6805
+ .limit(1)
6729
6806
  )
6730
- .limit(1)
6731
- )
6732
- r = row.first()
6733
- uid = str(r[0]) if r else None
6807
+ preferred_row = preferred_result.first()
6808
+ uid = str(preferred_row[0]) if preferred_row else None
6809
+
6810
+ if not uid:
6811
+ row = await conn.execute(
6812
+ select(User.id)
6813
+ .order_by(
6814
+ case((User.status == "active", 0), else_=1),
6815
+ User.created_at,
6816
+ )
6817
+ .limit(1)
6818
+ )
6819
+ r = row.first()
6820
+ uid = str(r[0]) if r else None
6734
6821
 
6735
6822
  if uid:
6736
6823
  token = jwt_module.encode(
@@ -7051,6 +7138,19 @@ class RuntimeDaemon:
7051
7138
  async def _poll_and_execute(self) -> bool:
7052
7139
  """Poll for tasks from all connected servers and execute them."""
7053
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
+
7054
7154
  # Clean up completed tasks
7055
7155
  for task_id in list(self.active_tasks):
7056
7156
  if self.active_tasks[task_id].done():
@@ -7063,24 +7163,21 @@ class RuntimeDaemon:
7063
7163
  if conn.heartbeat:
7064
7164
  conn.heartbeat.update(total_active, self._agents_as_dicts())
7065
7165
 
7066
- # Check capacity
7067
- if total_active >= self.max_concurrent:
7068
- return True
7069
-
7070
7166
  # Poll from all connected servers
7071
7167
  for conn in self.connections:
7072
- if total_active >= self.max_concurrent:
7073
- break
7074
-
7075
- tasks = await conn.poller.poll()
7076
- ai_jobs: list[dict] = []
7077
- if len(self.active_tasks) < self.max_concurrent:
7078
- 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
7079
7176
 
7080
7177
  conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
7081
7178
  had_work = had_work or bool(tasks or ai_jobs)
7082
7179
 
7083
- for task in tasks:
7180
+ for task in tasks if has_capacity else []:
7084
7181
  if task.task_id in self.active_tasks:
7085
7182
  continue # Already running
7086
7183
 
@@ -7096,7 +7193,7 @@ class RuntimeDaemon:
7096
7193
  )
7097
7194
 
7098
7195
  # Poll for AIJobs (workspace-mode tasks)
7099
- for aj in ai_jobs:
7196
+ for aj in ai_jobs if has_capacity else []:
7100
7197
  job_id = aj.get("job_id", "")
7101
7198
  ai_task_key = f"aijob_{job_id}"
7102
7199
  if ai_task_key in self.active_tasks:
@@ -7110,6 +7207,9 @@ class RuntimeDaemon:
7110
7207
  self._execute_ai_job(aj, conn)
7111
7208
  )
7112
7209
 
7210
+ total_active = len(self.active_tasks)
7211
+ has_capacity = total_active < self.max_concurrent
7212
+
7113
7213
  return had_work
7114
7214
 
7115
7215
  async def _workspace_checkout_health_error(self, workspace_path: Path) -> str | None:
@@ -7744,7 +7844,11 @@ class RuntimeDaemon:
7744
7844
 
7745
7845
  # 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
7746
7846
  # Existing files from a prior iteration are not sufficient evidence.
7747
- 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:
7748
7852
  committed_git = await self.process_manager._collect_git_info_vs_parent(
7749
7853
  workspace_path,
7750
7854
  default_branch=default_branch,
@@ -7807,7 +7911,11 @@ class RuntimeDaemon:
7807
7911
  # This closes the mirror-sync gap: review agents update analysis.json
7808
7912
  # (open_risks, phase_handoffs) and this step ensures the content reaches
7809
7913
  # runtimes.py as an inline artifact regardless of push success.
7810
- 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
+ ):
7811
7919
  analysis_dir = (
7812
7920
  (task.input_data or {}).get("analysis_output_dir", "")
7813
7921
  or ""
@@ -8011,7 +8119,40 @@ class RuntimeDaemon:
8011
8119
 
8012
8120
  issues: list[str] = []
8013
8121
  node_type = task.node_type
8014
- 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}")
8015
8156
 
8016
8157
  if node_type == "analysis":
8017
8158
  # Use type profile to determine required analysis outputs
@@ -8182,7 +8323,7 @@ class RuntimeDaemon:
8182
8323
  pass # analysis.json corrupt — already flagged elsewhere
8183
8324
 
8184
8325
  # Testing-specific: validate structured test assets
8185
- if node_type == "testing":
8326
+ if node_type == "testing" and not verification_work_item:
8186
8327
  # Determine which checks to run for this requirement type.
8187
8328
  #
8188
8329
  # _skip_test_artifacts = True → skip ALL artifact checks
@@ -8777,21 +8918,26 @@ class RuntimeDaemon:
8777
8918
  always receives the file contents via the completion report and gate
8778
8919
  reviewers can see the analysis documents immediately.
8779
8920
  """
8780
- # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
8781
- doc_dir = (
8782
- (task.input_data or {}).get("analysis_output_dir", "")
8783
- or (task.input_data or {}).get("output_dir", "")
8784
- )
8785
- if not doc_dir:
8786
- 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)]
8787
8934
 
8788
- base = workspace_path / doc_dir.lstrip("./")
8789
- req_type = (task.input_data or {}).get("requirement_type", "feature")
8790
- _ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
8791
8935
  existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
8792
8936
 
8793
- for fname in _ANALYSIS_FILES:
8794
- 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
8795
8941
  if not fpath.exists() or fpath.stat().st_size == 0:
8796
8942
  continue
8797
8943
  try:
@@ -8799,7 +8945,7 @@ class RuntimeDaemon:
8799
8945
  if rel_path in existing_artifact_paths:
8800
8946
  continue # already attached
8801
8947
  content = fpath.read_text(encoding="utf-8", errors="replace")
8802
- mime = "text/markdown" if fname.endswith(".md") else "application/json"
8948
+ mime = "text/markdown" if rel_path.endswith(".md") else "application/json"
8803
8949
  result.artifacts.append({
8804
8950
  "path": rel_path,
8805
8951
  "content": content,
@@ -8809,7 +8955,7 @@ class RuntimeDaemon:
8809
8955
  "Attached analysis artifact inline: %s (%d bytes)", rel_path, len(content)
8810
8956
  )
8811
8957
  except Exception as e:
8812
- logger.warning("Failed to read analysis artifact %s: %s", fname, e)
8958
+ logger.warning("Failed to read analysis artifact %s: %s", candidate_path, e)
8813
8959
 
8814
8960
  async def _collect_design_artifacts(
8815
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.4
3
+ Version: 1.18.6
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.4"
3
+ version = "1.18.6"
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