forgexa-cli 1.10.3__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.3
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.3"
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.3"
407
+ DAEMON_VERSION = "1.10.4"
400
408
 
401
409
 
402
410
  def _detect_client_type() -> str:
@@ -649,28 +657,46 @@ class TaskResult:
649
657
 
650
658
 
651
659
  def _resolve_git_author(project: dict) -> tuple[str, str]:
652
- """Resolve git commit author (name, email) using a fallback chain.
653
-
654
- Priority:
655
- 1. Task triggering user — populated by the API server from ``graph.triggered_by``
656
- (the human who submitted/re-triggered the workflow).
657
- 2. Runtime owner user — populated by the API server from ``runtime.owner``
658
- (the user who registered this daemon).
659
- 3. Hardcoded fallback ``"Forgexa Agent" / "agent@forgexa.net"``
660
- (backwards-compatible default when neither is available).
661
-
662
- Using real user identities makes commits auditable: customers can trace
663
- each automated commit back to the person who initiated the workflow.
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.
664
677
  """
665
- name = (project.get("triggered_by_git_name") or "").strip()
666
- email = (project.get("triggered_by_git_email") or "").strip()
667
- if name and email:
668
- return name, email
669
- name = (project.get("runtime_owner_git_name") or "").strip()
670
- email = (project.get("runtime_owner_git_email") or "").strip()
671
- if name and email:
672
- return name, email
673
- return "Forgexa Agent", "agent@forgexa.net"
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
674
700
 
675
701
 
676
702
  # ── Type-aware analysis outputs (inline fallback for standalone daemons) ──
@@ -5931,6 +5957,12 @@ class RuntimeDaemon:
5931
5957
  "deletions": deletions,
5932
5958
  })
5933
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
+
5934
5966
  proc = await asyncio.create_subprocess_exec(
5935
5967
  "git", "diff", "--cached", "--stat",
5936
5968
  cwd=str(cwd),
@@ -5943,6 +5975,55 @@ class RuntimeDaemon:
5943
5975
 
5944
5976
  return {"files": files, "total_stat": total_stat}
5945
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
+
5946
6027
  async def _build_auto_commit_message(
5947
6028
  self,
5948
6029
  title: str,
@@ -5952,11 +6033,34 @@ class RuntimeDaemon:
5952
6033
  change_summary: dict,
5953
6034
  workspace_path: Path | None = None,
5954
6035
  ) -> str:
5955
- """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
+
5956
6061
  files = change_summary.get("files", [])
5957
- total_stat = change_summary.get("total_stat", "")
5958
6062
 
5959
- # Determine commit type
6063
+ # ── Determine conventional-commit type ────────────────────────────
5960
6064
  if node_type in ("analysis", "planning", "review"):
5961
6065
  commit_type = "docs"
5962
6066
  elif files and all("test" in f["path"].lower() for f in files):
@@ -5967,51 +6071,481 @@ class RuntimeDaemon:
5967
6071
  for f in files
5968
6072
  ):
5969
6073
  commit_type = "docs"
5970
- elif "fix" in title.lower():
6074
+ elif "fix" in node_type.lower():
5971
6075
  commit_type = "fix"
5972
6076
  else:
5973
6077
  commit_type = "feat"
5974
6078
 
5975
- 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}"
5976
6130
  if len(header) > 72:
5977
6131
  header = header[:69] + "..."
5978
6132
 
5979
6133
  parts = [header, ""]
5980
6134
 
5981
- # ── Analysis/planning phase: extract structured insights from analysis.json ──
6135
+ # ── Body: bullet-point list of specific changes (Claude Code style) ──
5982
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.
5983
6139
  insights = await self._extract_analysis_insights(files, workspace_path)
5984
6140
  if insights:
5985
6141
  parts.extend(insights)
5986
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}")
6162
+
6163
+ return "\n".join(parts).rstrip()
5987
6164
 
5988
- # ── Group files into readable sections ──
5989
- sections = self._group_commit_files(files)
5990
- VERBS = {"A": "Add", "M": "Update", "D": "Remove", "R": "Rename"}
5991
- for section_name, section_files in sections.items():
5992
- parts.append(f"{section_name}:")
5993
- for f in section_files[:15]:
5994
- verb = VERBS.get(f["status"], "Change")
5995
- stats = ""
5996
- if f["additions"] and f["deletions"]:
5997
- stats = f" (+{f['additions']} \u2212{f['deletions']})"
5998
- elif f["additions"]:
5999
- stats = f" (+{f['additions']})"
6000
- elif f["deletions"]:
6001
- stats = f" (\u2212{f['deletions']})"
6002
- parts.append(f"- {verb} {f['path']}{stats}")
6003
- if len(section_files) > 15:
6004
- parts.append(f" ... and {len(section_files) - 15} more files")
6005
- parts.append("")
6006
-
6007
- if total_stat:
6008
- parts.append(total_stat)
6009
- parts.append("")
6010
- parts.append(f"Task: {task_id}")
6011
- parts.append(f"Phase: {node_type}")
6012
- parts.append(f"Agent: {agent_type}")
6013
-
6014
- return "\n".join(parts)
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)
6015
6549
 
6016
6550
  async def _extract_analysis_insights(
6017
6551
  self, files: list[dict], workspace_path: Path
@@ -6038,10 +6572,10 @@ class RuntimeDaemon:
6038
6572
 
6039
6573
  lines: list[str] = []
6040
6574
 
6041
- # 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
6042
6577
  raw_summary = data.get("summary")
6043
6578
  if isinstance(raw_summary, dict):
6044
- # Some agents produce summary as a structured object; extract description
6045
6579
  summary = (
6046
6580
  raw_summary.get("description")
6047
6581
  or raw_summary.get("title")
@@ -6054,7 +6588,8 @@ class RuntimeDaemon:
6054
6588
  summary = raw_summary
6055
6589
  else:
6056
6590
  summary = str(raw_summary) if raw_summary else ""
6057
- summary = summary.strip()
6591
+ summary = _re2.sub(r"[^\x00-\x7F]+", " ", summary.strip())
6592
+ summary = _re2.sub(r"\s+", " ", summary).strip()
6058
6593
  if summary:
6059
6594
  words = summary.split()
6060
6595
  current = ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.10.3
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.3"
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