forgexa-cli 1.10.2__tar.gz → 1.10.4__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.10.2
3
+ Version: 1.10.4
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: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.10.2"
2
+ __version__ = "1.10.4"
@@ -376,6 +376,14 @@ except (ImportError, ModuleNotFoundError):
376
376
  """
377
377
  return os.environ.get("FACTORY_CODEX_SANDBOX", "bypass").strip().lower()
378
378
 
379
+ @property
380
+ def DAEMON_COMMIT_VERBOSE(self) -> bool:
381
+ """When true, append Task / Phase / Agent traceability metadata to
382
+ the commit message footer. Default false: commits show subject +
383
+ readable narrative body only — suitable for client-facing repos.
384
+ """
385
+ return os.environ.get("DAEMON_COMMIT_VERBOSE", "false").strip().lower() in ("1", "true", "yes")
386
+
379
387
  def get_daemon_workspaces_root(self) -> str:
380
388
  root = self.DAEMON_WORKSPACES_ROOT
381
389
  if not root:
@@ -396,7 +404,7 @@ except (ImportError, ModuleNotFoundError):
396
404
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
397
405
  # Kept in sync with pyproject.toml version via bump-version.sh.
398
406
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
399
- DAEMON_VERSION = "1.10.2"
407
+ DAEMON_VERSION = "1.10.4"
400
408
 
401
409
 
402
410
  def _detect_client_type() -> str:
@@ -648,6 +656,49 @@ class TaskResult:
648
656
  git: dict = field(default_factory=dict)
649
657
 
650
658
 
659
+ def _resolve_git_author(project: dict) -> tuple[str, str]:
660
+ """Resolve git commit author (name, email) using a 5-tier fallback chain.
661
+
662
+ Priority (highest → lowest):
663
+ 0a. Project-member override (member_git_commit_name/email)
664
+ Set per-user in project settings; ideal for client-assigned emails
665
+ (e.g. HP project: keep name, override email to jason.sun@hp.com).
666
+ 0b. Project default (project_git_commit_name/email)
667
+ Project-wide fallback for all members who have no individual override.
668
+ 1. Task triggering user (triggered_by_git_name/email)
669
+ The human who submitted or re-triggered the workflow.
670
+ 2. Runtime owner user (runtime_owner_git_name/email)
671
+ The user who registered this daemon (fallback for automated triggers).
672
+ 3. Hardcoded fallback ("Forgexa Agent" / "agent@forgexa.net")
673
+
674
+ Name and email are resolved **independently**. This means you can configure
675
+ only the email override (leave name blank) and the name will still come from
676
+ the triggered-by user, satisfying "same name, different email" requirements.
677
+ """
678
+ def _first_non_empty(*candidates) -> str:
679
+ for v in candidates:
680
+ v = (v or "").strip()
681
+ if v:
682
+ return v
683
+ return ""
684
+
685
+ name = _first_non_empty(
686
+ project.get("member_git_commit_name"),
687
+ project.get("project_git_commit_name"),
688
+ project.get("triggered_by_git_name"),
689
+ project.get("runtime_owner_git_name"),
690
+ "Forgexa Agent",
691
+ )
692
+ email = _first_non_empty(
693
+ project.get("member_git_commit_email"),
694
+ project.get("project_git_commit_email"),
695
+ project.get("triggered_by_git_email"),
696
+ project.get("runtime_owner_git_email"),
697
+ "agent@forgexa.net",
698
+ )
699
+ return name, email
700
+
701
+
651
702
  # ── Type-aware analysis outputs (inline fallback for standalone daemons) ──
652
703
  # Mirrors type_workflow_profiles.py — used when import is unavailable (CLI/Desktop).
653
704
  _ANALYSIS_OUTPUTS_BY_TYPE: dict[str, list[str]] = {
@@ -1206,9 +1257,10 @@ class WorkspaceManager:
1206
1257
  readme = ws_path / "README.md"
1207
1258
  readme.write_text(f"# {project_key}\n\nInitialized by Forgexa.\n")
1208
1259
  await self._git("add", ".", cwd=ws_path)
1260
+ _git_name, _git_email = _resolve_git_author(project)
1209
1261
  await self._git(
1210
- "-c", "user.name=Forgexa Agent",
1211
- "-c", "user.email=agent@forgexa.net",
1262
+ "-c", f"user.name={_git_name}",
1263
+ "-c", f"user.email={_git_email}",
1212
1264
  "commit", "-m", "Initial commit",
1213
1265
  cwd=ws_path,
1214
1266
  )
@@ -4569,9 +4621,10 @@ class RuntimeDaemon:
4569
4621
  # Remove physical files
4570
4622
  shutil.rmtree(str(dir_to_wipe), ignore_errors=True)
4571
4623
  # Commit the wipe so the branch diff is clean
4624
+ _git_name, _git_email = _resolve_git_author(task.project)
4572
4625
  await self._git(
4573
- "-c", "user.name=Forgexa Agent",
4574
- "-c", "user.email=agent@forgexa.net",
4626
+ "-c", f"user.name={_git_name}",
4627
+ "-c", f"user.email={_git_email}",
4575
4628
  "commit", "-m",
4576
4629
  f"cleanup: wipe analysis docs in {output_dir_norm} before fresh re-analysis",
4577
4630
  cwd=workspace_path,
@@ -5808,13 +5861,15 @@ class RuntimeDaemon:
5808
5861
  except Exception as msg_err:
5809
5862
  logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
5810
5863
  commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
5864
+ _git_name, _git_email = _resolve_git_author(task.project)
5811
5865
  proc = await asyncio.create_subprocess_exec(
5812
5866
  "git", "commit", "-m", commit_msg,
5813
5867
  cwd=str(workspace_path),
5814
5868
  stdout=asyncio.subprocess.PIPE,
5815
5869
  stderr=asyncio.subprocess.PIPE,
5816
- env={**os.environ, "GIT_AUTHOR_NAME": "Forgexa Agent", "GIT_AUTHOR_EMAIL": "agent@forgexa.net",
5817
- "GIT_COMMITTER_NAME": "Forgexa Agent", "GIT_COMMITTER_EMAIL": "agent@forgexa.net"},
5870
+ env={**os.environ,
5871
+ "GIT_AUTHOR_NAME": _git_name, "GIT_AUTHOR_EMAIL": _git_email,
5872
+ "GIT_COMMITTER_NAME": _git_name, "GIT_COMMITTER_EMAIL": _git_email},
5818
5873
  )
5819
5874
  await proc.communicate()
5820
5875
  has_new_commit = True
@@ -5854,7 +5909,7 @@ class RuntimeDaemon:
5854
5909
  return {"push_error": f"Would push to default branch {current_branch}"}
5855
5910
 
5856
5911
  # ── Push ──
5857
- push_error = await self._push_branch(workspace_path, project_key)
5912
+ push_error = await self._push_branch(workspace_path, project_key, task=task)
5858
5913
  if push_error:
5859
5914
  return {"push_error": push_error}
5860
5915
  return {}
@@ -5902,6 +5957,12 @@ class RuntimeDaemon:
5902
5957
  "deletions": deletions,
5903
5958
  })
5904
5959
 
5960
+ # Enrich top changed files with symbol hints from staged additions.
5961
+ # This lets commit summaries mention the code concept that changed
5962
+ # instead of only repeating file names.
5963
+ for file_info in files[:20]:
5964
+ file_info["symbols"] = await self._extract_staged_file_symbols(cwd, file_info["path"])
5965
+
5905
5966
  proc = await asyncio.create_subprocess_exec(
5906
5967
  "git", "diff", "--cached", "--stat",
5907
5968
  cwd=str(cwd),
@@ -5914,6 +5975,55 @@ class RuntimeDaemon:
5914
5975
 
5915
5976
  return {"files": files, "total_stat": total_stat}
5916
5977
 
5978
+ async def _extract_staged_file_symbols(self, cwd: Path, filepath: str) -> list[str]:
5979
+ """Extract symbol names from staged diff additions for one file.
5980
+
5981
+ The goal is not to fully parse each language. We only need a few
5982
+ high-signal names from common declaration patterns so the commit
5983
+ summary can say what concept changed instead of only which file changed.
5984
+ """
5985
+ patterns = (
5986
+ re.compile(r"^(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)"),
5987
+ re.compile(r"^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)"),
5988
+ re.compile(r"^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*="),
5989
+ re.compile(r"^(?:export\s+)?interface\s+([A-Za-z_][A-Za-z0-9_]*)"),
5990
+ re.compile(r"^(?:export\s+)?type\s+([A-Za-z_][A-Za-z0-9_]*)\b"),
5991
+ )
5992
+
5993
+ try:
5994
+ proc = await asyncio.create_subprocess_exec(
5995
+ "git", "diff", "--cached", "--unified=0", "--", filepath,
5996
+ cwd=str(cwd),
5997
+ stdout=asyncio.subprocess.PIPE,
5998
+ stderr=asyncio.subprocess.PIPE,
5999
+ )
6000
+ out, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
6001
+ except Exception:
6002
+ return []
6003
+
6004
+ seen: set[str] = set()
6005
+ symbols: list[str] = []
6006
+ for raw_line in out.decode(errors="replace").splitlines():
6007
+ if not raw_line.startswith("+") or raw_line.startswith("+++"):
6008
+ continue
6009
+ line = raw_line[1:].strip()
6010
+ if not line:
6011
+ continue
6012
+ for pattern in patterns:
6013
+ match = pattern.match(line)
6014
+ if not match:
6015
+ continue
6016
+ symbol = match.group(1).strip()
6017
+ if len(symbol) < 2 or symbol in seen:
6018
+ break
6019
+ seen.add(symbol)
6020
+ symbols.append(symbol)
6021
+ break
6022
+ if len(symbols) >= 4:
6023
+ break
6024
+
6025
+ return symbols
6026
+
5917
6027
  async def _build_auto_commit_message(
5918
6028
  self,
5919
6029
  title: str,
@@ -5923,11 +6033,34 @@ class RuntimeDaemon:
5923
6033
  change_summary: dict,
5924
6034
  workspace_path: Path | None = None,
5925
6035
  ) -> str:
5926
- """Build a rich, natural-language commit message for daemon auto-commits."""
6036
+ """Build a clean, English-only commit message for daemon auto-commits.
6037
+
6038
+ Format (Claude Code style):
6039
+ <type>(<phase>): <concise english subject — max 72 chars>
6040
+ <blank line>
6041
+ - <specific change bullet 1>
6042
+ - <specific change bullet 2>
6043
+ ...
6044
+ [blank line + Task/Phase/Agent footer only when DAEMON_COMMIT_VERBOSE=true]
6045
+
6046
+ Subject: conventional-commit prefix + title derived from the work-item
6047
+ title (non-ASCII stripped). A single generic word remnant (e.g. just
6048
+ "implement" left after stripping Chinese) triggers file-path inference.
6049
+
6050
+ Body: bullet-point list describing WHAT changed in each file/group —
6051
+ specific and human-readable like Claude Code commits, never counts or
6052
+ vague summaries. For analysis/planning, structured insights from
6053
+ analysis.json are used when available.
6054
+
6055
+ Footer (Task / Phase / Agent): only appended when DAEMON_COMMIT_VERBOSE
6056
+ is true (default false). Useful for internal audit trails but hidden
6057
+ by default so commits look clean to external clients.
6058
+ """
6059
+ import re as _re
6060
+
5927
6061
  files = change_summary.get("files", [])
5928
- total_stat = change_summary.get("total_stat", "")
5929
6062
 
5930
- # Determine commit type
6063
+ # ── Determine conventional-commit type ────────────────────────────
5931
6064
  if node_type in ("analysis", "planning", "review"):
5932
6065
  commit_type = "docs"
5933
6066
  elif files and all("test" in f["path"].lower() for f in files):
@@ -5938,51 +6071,481 @@ class RuntimeDaemon:
5938
6071
  for f in files
5939
6072
  ):
5940
6073
  commit_type = "docs"
5941
- elif "fix" in title.lower():
6074
+ elif "fix" in node_type.lower():
5942
6075
  commit_type = "fix"
5943
6076
  else:
5944
6077
  commit_type = "feat"
5945
6078
 
5946
- header = f"{commit_type}({node_type}): {title}"
6079
+ # ── Sanitize subject: strip non-ASCII so the subject is always English ──
6080
+ # Work-item titles may be in Chinese or other scripts (user-supplied).
6081
+ # We keep ASCII segments; if nothing meaningful remains we infer a
6082
+ # specific English subject from the changed file paths.
6083
+ sanitized_title = _re.sub(r"[^\x00-\x7F]+", " ", title)
6084
+ sanitized_title = _re.sub(r"\s+", " ", sanitized_title).strip(" -:.")
6085
+
6086
+ # Strip leading requirement key (e.g. "LP-1:" / "AILP-20:") to evaluate
6087
+ # whether the remaining goal phrase has enough content. The req key
6088
+ # itself will be preserved in the final subject line; we only strip it
6089
+ # here to measure the goal phrase quality.
6090
+ _req_key_match = _re.match(r"(?i)([A-Z]+-\d+)([\s:.\-]+)", sanitized_title)
6091
+ _req_key_prefix = _req_key_match.group(0) if _req_key_match else ""
6092
+ goal_phrase = _re.sub(r"(?i)^[A-Z]+-\d+[\s:.\-]+", "", sanitized_title).strip(" -:.")
6093
+ # Strip common leading phase verbs so we don't repeat "implement implement..."
6094
+ bare_goal = _re.sub(
6095
+ r"^(implement|update|fix|add|refactor|improve|create|build|setup|init|generate|plan|analyze)\s+",
6096
+ "", goal_phrase, flags=_re.IGNORECASE,
6097
+ ).strip()
6098
+ bare_words = set(bare_goal.lower().split()) - {"and", "or", "the", "a", "an", "to", "for"}
6099
+ # Also check if sanitized_title is ONLY a req key (e.g. "LP-1" or "AILP-20:")
6100
+ _only_req_key = bool(_re.fullmatch(r"(?i)[A-Z]+-\d+[\s:.\-]*", sanitized_title.strip()))
6101
+ goal_is_trivial = (
6102
+ len(sanitized_title) < 4
6103
+ or _only_req_key
6104
+ or len(bare_words) == 0
6105
+ # A single remaining word (any word) after stripping phase verbs and
6106
+ # req key is almost always an isolated remnant from a mostly-non-ASCII
6107
+ # title (e.g. "AILP-20: integration" after Chinese stripped)
6108
+ or len(bare_words) <= 1
6109
+ )
6110
+
6111
+ if goal_is_trivial:
6112
+ # Derive a specific subject from changed files; prepend req key if present.
6113
+ # Re-scan the original sanitized_title for a req key with separator.
6114
+ # Note: when title is fully Chinese, sanitized becomes just the req key
6115
+ # like "LP-1" without separator; we still want to prepend it.
6116
+ _req_scan = _re.match(r"(?i)([A-Z]+-\d+)", sanitized_title)
6117
+ req_key_clean = _req_scan.group(1).upper() if _req_scan else ""
6118
+ derived = self._derive_subject_from_files(node_type, files)
6119
+ sanitized_title = f"{req_key_clean}: {derived}" if req_key_clean else derived
6120
+ else:
6121
+ # Preserve case of the req-key prefix (uppercase), lowercase only the goal
6122
+ if _req_key_prefix:
6123
+ req_key_upper = _req_key_match.group(1).upper() + _req_key_match.group(2)
6124
+ goal_lower = goal_phrase[0].lower() + goal_phrase[1:] if goal_phrase else goal_phrase
6125
+ sanitized_title = req_key_upper + goal_lower
6126
+ else:
6127
+ sanitized_title = sanitized_title[0].lower() + sanitized_title[1:]
6128
+
6129
+ header = f"{commit_type}({node_type}): {sanitized_title}"
5947
6130
  if len(header) > 72:
5948
6131
  header = header[:69] + "..."
5949
6132
 
5950
6133
  parts = [header, ""]
5951
6134
 
5952
- # ── Analysis/planning phase: extract structured insights from analysis.json ──
6135
+ # ── Body: bullet-point list of specific changes (Claude Code style) ──
5953
6136
  if node_type in ("analysis", "planning") and workspace_path:
6137
+ # For analysis/planning: structured insights from analysis.json
6138
+ # give the richest description; fall back to bullet body if missing.
5954
6139
  insights = await self._extract_analysis_insights(files, workspace_path)
5955
6140
  if insights:
5956
6141
  parts.extend(insights)
5957
6142
  parts.append("")
6143
+ else:
6144
+ bullets = self._build_bullet_body(node_type, files, workspace_path)
6145
+ if bullets:
6146
+ parts.extend(bullets)
6147
+ parts.append("")
6148
+ else:
6149
+ # All other phases: specific bullet points per changed file/group
6150
+ bullets = self._build_bullet_body(node_type, files, workspace_path)
6151
+ if bullets:
6152
+ parts.extend(bullets)
6153
+ parts.append("")
6154
+
6155
+ # ── Footer: traceability metadata (opt-in via DAEMON_COMMIT_VERBOSE) ──
6156
+ # Hidden by default — keeps commits clean for external/client projects.
6157
+ # Set DAEMON_COMMIT_VERBOSE=true to include Task/Phase/Agent in footer.
6158
+ if settings.DAEMON_COMMIT_VERBOSE:
6159
+ parts.append(f"Task: {task_id}")
6160
+ parts.append(f"Phase: {node_type}")
6161
+ parts.append(f"Agent: {agent_type}")
5958
6162
 
5959
- # ── Group files into readable sections ──
5960
- sections = self._group_commit_files(files)
5961
- VERBS = {"A": "Add", "M": "Update", "D": "Remove", "R": "Rename"}
5962
- for section_name, section_files in sections.items():
5963
- parts.append(f"{section_name}:")
5964
- for f in section_files[:15]:
5965
- verb = VERBS.get(f["status"], "Change")
5966
- stats = ""
5967
- if f["additions"] and f["deletions"]:
5968
- stats = f" (+{f['additions']} \u2212{f['deletions']})"
5969
- elif f["additions"]:
5970
- stats = f" (+{f['additions']})"
5971
- elif f["deletions"]:
5972
- stats = f" (\u2212{f['deletions']})"
5973
- parts.append(f"- {verb} {f['path']}{stats}")
5974
- if len(section_files) > 15:
5975
- parts.append(f" ... and {len(section_files) - 15} more files")
5976
- parts.append("")
5977
-
5978
- if total_stat:
5979
- parts.append(total_stat)
5980
- parts.append("")
5981
- parts.append(f"Task: {task_id}")
5982
- parts.append(f"Phase: {node_type}")
5983
- parts.append(f"Agent: {agent_type}")
5984
-
5985
- return "\n".join(parts)
6163
+ return "\n".join(parts).rstrip()
6164
+
6165
+ @staticmethod
6166
+ def _is_test_path(path: str) -> bool:
6167
+ p = path.lower()
6168
+ return (
6169
+ "test_" in p or "_test" in p or "/test" in p
6170
+ or p.endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx", ".test.js"))
6171
+ )
6172
+
6173
+ @staticmethod
6174
+ def _infer_change_topic(files: list[dict], *, include_support: bool = False) -> str:
6175
+ """Infer the main business/topic phrase from changed files.
6176
+
6177
+ This prefers stable domain nouns like "search", "project API key",
6178
+ or "workspace settings" over file-structure words like "service",
6179
+ "routes", or raw test symbols.
6180
+ """
6181
+ noise = {
6182
+ "add", "update", "fix", "implement", "create", "build", "main",
6183
+ "index", "base", "default", "config", "settings", "helper",
6184
+ "helpers", "utility", "utils", "service", "services", "route",
6185
+ "routes", "handler", "handlers", "controller", "controllers",
6186
+ "model", "models", "schema", "schemas", "migration", "migrations",
6187
+ "database", "table", "frontend", "backend", "page", "pages",
6188
+ "component", "components", "view", "views", "panel", "modal",
6189
+ "button", "input", "form", "bar", "card", "docs", "doc",
6190
+ "guide", "readme", "changelog", "tests", "test", "spec",
6191
+ "integration", "regression", "unit", "request", "response",
6192
+ "issue", "require", "rotate", "repair", "stream", "flow",
6193
+ }
6194
+
6195
+ def _humanize_text(text: str) -> str:
6196
+ words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
6197
+ words = re.sub(r"[_\-/]+", " ", words)
6198
+ return re.sub(r"\s+", " ", words).strip().lower()
6199
+
6200
+ counts: dict[str, int] = {}
6201
+ order: dict[str, int] = {}
6202
+ ranked_files = sorted(
6203
+ files,
6204
+ key=lambda f: f.get("additions", 0) + f.get("deletions", 0),
6205
+ reverse=True,
6206
+ )
6207
+ for file_info in ranked_files[:12]:
6208
+ path_lower = file_info["path"].lower()
6209
+ if not include_support and (
6210
+ RuntimeDaemon._is_test_path(path_lower) or path_lower.startswith("docs/")
6211
+ ):
6212
+ continue
6213
+ weight = max(1, min(5, (file_info.get("additions", 0) + file_info.get("deletions", 0)) // 30 + 1))
6214
+ stem = file_info["path"].rsplit("/", 1)[-1].rsplit(".", 1)[0]
6215
+ sources = [stem] + list(file_info.get("symbols", []))[:3]
6216
+ for source in sources:
6217
+ for token in _humanize_text(str(source)).split():
6218
+ if len(token) < 3 or token.startswith("test"):
6219
+ continue
6220
+ if token in noise or re.fullmatch(r"[a-z]+\d+", token):
6221
+ continue
6222
+ counts[token] = counts.get(token, 0) + weight
6223
+ order.setdefault(token, len(order))
6224
+
6225
+ if not counts:
6226
+ return ""
6227
+
6228
+ ranked = sorted(counts.items(), key=lambda item: (-item[1], order[item[0]]))
6229
+ top_tokens = [token for token, _ in ranked[:8]]
6230
+ top_set = set(top_tokens)
6231
+
6232
+ if "key" in top_set and "api" in top_set:
6233
+ return "project API key" if "project" in top_set else "API key"
6234
+ if "workspace" in top_set and "settings" in top_set:
6235
+ return "workspace settings"
6236
+ if "user" in top_set and "profile" in top_set:
6237
+ return "user profile"
6238
+ if "access" in top_set and "control" in top_set:
6239
+ return "access control"
6240
+ if "search" in top_set:
6241
+ return "search"
6242
+ if "auth" in top_set or "authentication" in top_set:
6243
+ return "authentication"
6244
+ if "log" in top_set or "logs" in top_set:
6245
+ return "API log" if "api" in top_set else "log"
6246
+
6247
+ first = top_tokens[0]
6248
+ if len(ranked) > 1 and ranked[1][1] >= max(2, ranked[0][1] - 1):
6249
+ second = top_tokens[1]
6250
+ display = {"api": "API", "ui": "UI", "qa": "QA", "jwt": "JWT", "oauth": "OAuth"}
6251
+ return f"{display.get(first, first)} {display.get(second, second)}"
6252
+ display = {"api": "API", "ui": "UI", "qa": "QA", "jwt": "JWT", "oauth": "OAuth"}
6253
+ return display.get(first, first)
6254
+
6255
+ @staticmethod
6256
+ def _derive_subject_from_files(node_type: str, files: list[dict]) -> str:
6257
+ """Infer a specific English subject from changed files.
6258
+
6259
+ The subject should summarize the feature area and the primary change
6260
+ surface, instead of echoing file names or test symbols.
6261
+ """
6262
+ _PHASE_VERBS = {
6263
+ "analysis": "analyze",
6264
+ "planning": "plan",
6265
+ "coding": "implement",
6266
+ "fix": "fix",
6267
+ "bugfix": "fix",
6268
+ "testing": "add tests for",
6269
+ "review": "review",
6270
+ "refactor": "refactor",
6271
+ "documentation": "document",
6272
+ "improvement": "improve",
6273
+ }
6274
+
6275
+ verb = _PHASE_VERBS.get(node_type, "update")
6276
+
6277
+ topic = RuntimeDaemon._infer_change_topic(files)
6278
+ paths = [f["path"].lower() for f in files]
6279
+ has_api = any(
6280
+ ("route" in path or "endpoint" in path or "/api/" in path or "/v1/" in path or "/v2/" in path)
6281
+ and not RuntimeDaemon._is_test_path(path)
6282
+ for path in paths
6283
+ )
6284
+ has_frontend = any(
6285
+ (path.endswith((".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss")) or path.startswith("frontend/"))
6286
+ and not RuntimeDaemon._is_test_path(path)
6287
+ for path in paths
6288
+ )
6289
+ has_service = any(
6290
+ any(token in path for token in ("service", "worker", "manager", "queue")) and not RuntimeDaemon._is_test_path(path)
6291
+ for path in paths
6292
+ )
6293
+ has_model = any(
6294
+ any(token in path for token in ("model", "schema", "migration", "alembic")) and not RuntimeDaemon._is_test_path(path)
6295
+ for path in paths
6296
+ )
6297
+ has_utility = any(
6298
+ any(token in path for token in ("util", "helper", "middleware", "auth")) and not RuntimeDaemon._is_test_path(path)
6299
+ for path in paths
6300
+ )
6301
+ has_tests = any(RuntimeDaemon._is_test_path(path) for path in paths)
6302
+
6303
+ areas: list[str] = []
6304
+ if has_api:
6305
+ areas.append("routes" if topic and "api" in topic.lower() else "API")
6306
+ if has_frontend:
6307
+ areas.append("UI")
6308
+ if has_service:
6309
+ areas.append("service flow")
6310
+ if has_model:
6311
+ areas.append("data model")
6312
+ if has_utility and not has_api:
6313
+ areas.append("helpers")
6314
+ if node_type in ("fix", "bugfix") and has_tests and not has_frontend:
6315
+ areas.append("tests")
6316
+
6317
+ unique_areas: list[str] = []
6318
+ for area in areas:
6319
+ if area not in unique_areas:
6320
+ unique_areas.append(area)
6321
+ areas = unique_areas[:2]
6322
+
6323
+ if topic:
6324
+ if len(areas) >= 2:
6325
+ return f"{verb} {topic} {areas[0]} and {areas[1]}"
6326
+ if len(areas) == 1:
6327
+ return f"{verb} {topic} {areas[0]}"
6328
+ return f"{verb} {topic}"
6329
+
6330
+ if len(areas) >= 2:
6331
+ return f"{verb} {areas[0]} and {areas[1]}"
6332
+ if len(areas) == 1:
6333
+ return f"{verb} {areas[0]}"
6334
+
6335
+ # Final fallback — generic but still English
6336
+ _FALLBACK = {
6337
+ "analysis": "analyze requirements and generate specifications",
6338
+ "planning": "generate implementation plan and task breakdown",
6339
+ "coding": "implement feature changes",
6340
+ "fix": "apply bug fix",
6341
+ "testing": "add automated test coverage",
6342
+ "review": "generate code review documentation",
6343
+ "refactor": "refactor code structure",
6344
+ "documentation": "update project documentation",
6345
+ "improvement": "apply code improvements",
6346
+ }
6347
+ return _FALLBACK.get(node_type, "implement changes")
6348
+
6349
+ @staticmethod
6350
+ def _build_bullet_body(
6351
+ node_type: str,
6352
+ files: list[dict],
6353
+ workspace_path: Path | None = None,
6354
+ ) -> list[str]:
6355
+ """Build a Claude Code-style bullet-point body describing specific changes.
6356
+
6357
+ Each bullet describes what a file or group of related files does, using
6358
+ the actual filename and directory context to produce specific, useful
6359
+ descriptions rather than generic counts or labels.
6360
+
6361
+ Example output:
6362
+ - Add `UserAuthService` with JWT token generation and refresh logic
6363
+ - Update `/api/v1/users` endpoints to require Bearer token authentication
6364
+ - Add unit tests for token validation and expiry edge cases
6365
+ - Update `alembic/versions/` migration to add `users.token_hash` column
6366
+
6367
+ Rules:
6368
+ - Max 8 bullets (most important first, by additions+deletions)
6369
+ - Related files are grouped into a single bullet (e.g. test files)
6370
+ - File paths are shown in backticks for readability
6371
+ - Status prefix: "Add" for new files (A), "Remove" for deleted (D),
6372
+ "Update" for modified (M), "Refactor" for pure-deletion rewrites
6373
+ - Never shows raw +/- counts or file sizes
6374
+ """
6375
+ if not files:
6376
+ return []
6377
+
6378
+ # ── File classification helpers ────────────────────────────────────
6379
+ def _is_test(path: str) -> bool:
6380
+ return RuntimeDaemon._is_test_path(path)
6381
+
6382
+ def _is_doc(path: str) -> bool:
6383
+ p = path.lower()
6384
+ return p.endswith((".md", ".rst")) or p.startswith("docs/")
6385
+
6386
+ def _is_migration(path: str) -> bool:
6387
+ p = path.lower()
6388
+ return "alembic" in p or "migration" in p or p.endswith(".sql")
6389
+
6390
+ def _is_config(path: str) -> bool:
6391
+ p = path.lower()
6392
+ fname = p.rsplit("/", 1)[-1]
6393
+ return fname.endswith((".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env")) and not _is_doc(p)
6394
+
6395
+ def _is_frontend(path: str) -> bool:
6396
+ p = path.lower()
6397
+ return p.endswith((".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss")) or p.startswith("frontend/")
6398
+
6399
+ def _status_verb(f: dict) -> str:
6400
+ s = f.get("status", "M")
6401
+ adds = f.get("additions", 0)
6402
+ dels = f.get("deletions", 0)
6403
+ if s == "A":
6404
+ return "Add"
6405
+ if s == "D":
6406
+ return "Remove"
6407
+ if adds > 0 and dels == 0:
6408
+ return "Add"
6409
+ if adds == 0 and dels > 0:
6410
+ return "Remove"
6411
+ if dels > adds * 2:
6412
+ return "Refactor"
6413
+ return "Update"
6414
+
6415
+ def _has_api(group: list[dict]) -> bool:
6416
+ return any(
6417
+ ("route" in f["path"].lower() or "endpoint" in f["path"].lower() or "/api/" in f["path"].lower() or "/v1/" in f["path"].lower() or "/v2/" in f["path"].lower())
6418
+ and not _is_test(f["path"])
6419
+ for f in group
6420
+ )
6421
+
6422
+ def _has_frontend(group: list[dict]) -> bool:
6423
+ return any(
6424
+ (f["path"].lower().endswith((".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss")) or f["path"].lower().startswith("frontend/"))
6425
+ and not _is_test(f["path"])
6426
+ for f in group
6427
+ )
6428
+
6429
+ def _has_service(group: list[dict]) -> bool:
6430
+ return any(
6431
+ any(token in f["path"].lower() for token in ("service", "worker", "manager", "queue")) and not _is_test(f["path"])
6432
+ for f in group
6433
+ )
6434
+
6435
+ def _has_model(group: list[dict]) -> bool:
6436
+ return any(
6437
+ any(token in f["path"].lower() for token in ("model", "schema", "migration", "alembic")) and not _is_test(f["path"])
6438
+ for f in group
6439
+ )
6440
+
6441
+ def _has_utility(group: list[dict]) -> bool:
6442
+ return any(
6443
+ any(token in f["path"].lower() for token in ("util", "helper", "middleware", "auth")) and not _is_test(f["path"])
6444
+ for f in group
6445
+ )
6446
+
6447
+ # ── Sort files by change volume (most significant first) ───────────
6448
+ sorted_files = sorted(
6449
+ files,
6450
+ key=lambda f: f.get("additions", 0) + f.get("deletions", 0),
6451
+ reverse=True,
6452
+ )
6453
+
6454
+ # ── Group special categories for combined bullets ──────────────────
6455
+ tests = [f for f in sorted_files if _is_test(f["path"])]
6456
+ migrations = [f for f in sorted_files if _is_migration(f["path"])]
6457
+ docs = [f for f in sorted_files if _is_doc(f["path"]) and not _is_migration(f["path"])]
6458
+ configs = [f for f in sorted_files if _is_config(f["path"])]
6459
+ main_files = [
6460
+ f for f in sorted_files
6461
+ if not _is_test(f["path"]) and not _is_migration(f["path"])
6462
+ and not _is_doc(f["path"]) and not _is_config(f["path"])
6463
+ ]
6464
+
6465
+ bullets: list[str] = []
6466
+
6467
+ topic = RuntimeDaemon._infer_change_topic(files)
6468
+ primary_surfaces: list[str] = []
6469
+ if _has_api(main_files):
6470
+ primary_surfaces.append("API routes")
6471
+ if _has_frontend(main_files):
6472
+ primary_surfaces.append("UI")
6473
+ if _has_service(main_files):
6474
+ primary_surfaces.append("service flow")
6475
+ if _has_model(main_files):
6476
+ primary_surfaces.append("data model")
6477
+ if _has_utility(main_files) and "service flow" not in primary_surfaces:
6478
+ primary_surfaces.append("request/auth helpers")
6479
+
6480
+ primary_surfaces = primary_surfaces[:2]
6481
+ primary_verb = "Fix" if node_type in ("fix", "bugfix") else "Implement"
6482
+ if main_files:
6483
+ if topic and len(primary_surfaces) >= 2:
6484
+ bullets.append(f"- {primary_verb} {topic} {primary_surfaces[0]} and {primary_surfaces[1]}")
6485
+ elif topic and len(primary_surfaces) == 1:
6486
+ bullets.append(f"- {primary_verb} {topic} {primary_surfaces[0]}")
6487
+ elif len(primary_surfaces) >= 2:
6488
+ bullets.append(f"- {primary_verb} {primary_surfaces[0]} and {primary_surfaces[1]}")
6489
+ elif len(primary_surfaces) == 1:
6490
+ bullets.append(f"- {primary_verb} {primary_surfaces[0]}")
6491
+
6492
+ if migrations:
6493
+ if len(migrations) == 1:
6494
+ verb = _status_verb(migrations[0])
6495
+ mpath = migrations[0]["path"]
6496
+ mname = mpath.rsplit("/", 1)[-1].rsplit(".", 1)[0]
6497
+ mdesc = re.sub(r"^\d+[_\-]", "", mname).replace("_", " ").strip()
6498
+ bullets.append(f"- {verb} `{mdesc}` database migration")
6499
+ else:
6500
+ bullets.append(f"- Add {len(migrations)} database migrations")
6501
+
6502
+ if tests:
6503
+ test_verbs = {_status_verb(t) for t in tests}
6504
+ tv = "Add" if "Add" in test_verbs else "Update"
6505
+ support_topic = topic or RuntimeDaemon._infer_change_topic(tests, include_support=True)
6506
+ if len(tests) == 1:
6507
+ stem = tests[0]["path"].rsplit("/", 1)[-1].rsplit(".", 1)[0]
6508
+ stem = re.sub(r"^test_|_test$", "", stem).replace("_", " ")
6509
+ if support_topic:
6510
+ bullets.append(f"- {tv} test coverage for {support_topic}")
6511
+ else:
6512
+ bullets.append(f"- {tv} `{stem}` test coverage" if stem else f"- {tv} unit tests")
6513
+ else:
6514
+ if support_topic:
6515
+ bullets.append(f"- {tv} regression and integration coverage for {support_topic}")
6516
+ else:
6517
+ bullets.append(f"- {tv} {len(tests)} test files")
6518
+
6519
+ if docs:
6520
+ if len(docs) == 1:
6521
+ verb = _status_verb(docs[0])
6522
+ fname = docs[0]["path"].rsplit("/", 1)[-1]
6523
+ bullets.append(f"- {verb} `{fname}` documentation")
6524
+ else:
6525
+ bullets.append(f"- Update {len(docs)} documentation files")
6526
+
6527
+ if configs:
6528
+ if len(configs) == 1:
6529
+ verb = _status_verb(configs[0])
6530
+ fname = configs[0]["path"].rsplit("/", 1)[-1]
6531
+ bullets.append(f"- {verb} `{fname}` configuration")
6532
+ else:
6533
+ bullets.append(f"- Update {len(configs)} configuration files")
6534
+
6535
+ return bullets[:8]
6536
+
6537
+ @staticmethod
6538
+ def _build_narrative_body(
6539
+ node_type: str,
6540
+ sections: dict,
6541
+ sanitized_title: str = "",
6542
+ files: list[dict] | None = None,
6543
+ ) -> list[str]:
6544
+ """[Legacy] Build narrative sentences — kept for callers that still use it.
6545
+
6546
+ Prefer _build_bullet_body for new call sites.
6547
+ """
6548
+ return RuntimeDaemon._build_bullet_body(node_type, files or [], None)
5986
6549
 
5987
6550
  async def _extract_analysis_insights(
5988
6551
  self, files: list[dict], workspace_path: Path
@@ -6009,10 +6572,10 @@ class RuntimeDaemon:
6009
6572
 
6010
6573
  lines: list[str] = []
6011
6574
 
6012
- # Summary — word-wrap at 78 chars
6575
+ # Summary — word-wrap at 78 chars; strip non-ASCII to keep English-only
6576
+ import re as _re2
6013
6577
  raw_summary = data.get("summary")
6014
6578
  if isinstance(raw_summary, dict):
6015
- # Some agents produce summary as a structured object; extract description
6016
6579
  summary = (
6017
6580
  raw_summary.get("description")
6018
6581
  or raw_summary.get("title")
@@ -6025,7 +6588,8 @@ class RuntimeDaemon:
6025
6588
  summary = raw_summary
6026
6589
  else:
6027
6590
  summary = str(raw_summary) if raw_summary else ""
6028
- summary = summary.strip()
6591
+ summary = _re2.sub(r"[^\x00-\x7F]+", " ", summary.strip())
6592
+ summary = _re2.sub(r"\s+", " ", summary).strip()
6029
6593
  if summary:
6030
6594
  words = summary.split()
6031
6595
  current = ""
@@ -6148,6 +6712,7 @@ class RuntimeDaemon:
6148
6712
  """
6149
6713
  git = self.workspace_manager._git
6150
6714
  target = f"origin/{default_branch}"
6715
+ _git_name, _git_email = _resolve_git_author(task.project)
6151
6716
 
6152
6717
  # Fetch latest remote state
6153
6718
  try:
@@ -6195,8 +6760,8 @@ class RuntimeDaemon:
6195
6760
  # ── Tier 1: rebase (safe — branch not yet on remote) ──
6196
6761
  try:
6197
6762
  await git(
6198
- "-c", "user.name=Forgexa Agent",
6199
- "-c", "user.email=agent@forgexa.net",
6763
+ "-c", f"user.name={_git_name}",
6764
+ "-c", f"user.email={_git_email}",
6200
6765
  "rebase", target,
6201
6766
  cwd=workspace_path, timeout=120,
6202
6767
  )
@@ -6218,8 +6783,8 @@ class RuntimeDaemon:
6218
6783
  # ── Tier 2: merge ──
6219
6784
  try:
6220
6785
  await git(
6221
- "-c", "user.name=Forgexa Agent",
6222
- "-c", "user.email=agent@forgexa.net",
6786
+ "-c", f"user.name={_git_name}",
6787
+ "-c", f"user.email={_git_email}",
6223
6788
  "merge", target, "--no-edit",
6224
6789
  cwd=workspace_path, timeout=120,
6225
6790
  )
@@ -6242,8 +6807,7 @@ class RuntimeDaemon:
6242
6807
  in a clean state and push whatever we had before.
6243
6808
  """
6244
6809
  git = self.workspace_manager._git
6245
-
6246
- # 1. List conflicted files
6810
+ _git_name, _git_email = _resolve_git_author(task.project)
6247
6811
  try:
6248
6812
  diff_out = await git(
6249
6813
  "diff", "--name-only", "--diff-filter=U", cwd=workspace_path,
@@ -6256,8 +6820,8 @@ class RuntimeDaemon:
6256
6820
  # No actual conflicts (merge completed or something unusual)
6257
6821
  try:
6258
6822
  await git(
6259
- "-c", "user.name=Forgexa Agent",
6260
- "-c", "user.email=agent@forgexa.net",
6823
+ "-c", f"user.name={_git_name}",
6824
+ "-c", f"user.email={_git_email}",
6261
6825
  "merge", "--continue", cwd=workspace_path,
6262
6826
  )
6263
6827
  except RuntimeError:
@@ -6382,8 +6946,8 @@ class RuntimeDaemon:
6382
6946
  await git("add", "-A", cwd=workspace_path)
6383
6947
  try:
6384
6948
  await git(
6385
- "-c", "user.name=Forgexa Agent",
6386
- "-c", "user.email=agent@forgexa.net",
6949
+ "-c", f"user.name={_git_name}",
6950
+ "-c", f"user.email={_git_email}",
6387
6951
  "commit", "--no-edit",
6388
6952
  cwd=workspace_path,
6389
6953
  )
@@ -6399,9 +6963,13 @@ class RuntimeDaemon:
6399
6963
  except RuntimeError:
6400
6964
  await git("reset", "--hard", "HEAD", cwd=workspace_path)
6401
6965
 
6402
- async def _push_branch(self, workspace_path: Path, project_key: str = "default") -> str | None:
6966
+ async def _push_branch(
6967
+ self, workspace_path: Path, project_key: str = "default",
6968
+ task: "TaskInfo | None" = None,
6969
+ ) -> str | None:
6403
6970
  """Push the current branch to origin. Returns error message on failure, None on success."""
6404
6971
  git = self.workspace_manager._git
6972
+ _git_name, _git_email = _resolve_git_author(task.project if task else {})
6405
6973
  try:
6406
6974
  # Get current branch name
6407
6975
  branch = (await git("rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path)).strip()
@@ -6538,8 +7106,8 @@ class RuntimeDaemon:
6538
7106
  cwd=workspace_path,
6539
7107
  )
6540
7108
  await git(
6541
- "-c", "user.name=Forgexa Agent",
6542
- "-c", "user.email=agent@forgexa.net",
7109
+ "-c", f"user.name={_git_name}",
7110
+ "-c", f"user.email={_git_email}",
6543
7111
  "cherry-pick", *new_shas,
6544
7112
  cwd=workspace_path,
6545
7113
  )
@@ -6587,8 +7155,8 @@ class RuntimeDaemon:
6587
7155
  # version. This is correct: on an agent-managed feature
6588
7156
  # branch the latest agent output is always authoritative.
6589
7157
  await git(
6590
- "-c", "user.name=Forgexa Agent",
6591
- "-c", "user.email=agent@forgexa.net",
7158
+ "-c", f"user.name={_git_name}",
7159
+ "-c", f"user.email={_git_email}",
6592
7160
  "rebase", "-X", "theirs", f"origin/{branch}",
6593
7161
  cwd=workspace_path,
6594
7162
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.10.2
3
+ Version: 1.10.4
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: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.10.2"
3
+ version = "1.10.4"
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 = { text = "MIT" }
File without changes
File without changes