forgexa-cli 1.10.3__tar.gz → 1.10.5__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.5
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.5"
@@ -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.5"
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) ──
@@ -4520,7 +4546,7 @@ class RuntimeDaemon:
4520
4546
  # If this is not caught the agent will run `git add -A` and commit a
4521
4547
  # catastrophic mass-deletion (e.g. SI-434: 47,566 files deleted).
4522
4548
  try:
4523
- _index_count_out = await self._git(
4549
+ _index_count_out = await self.workspace_manager._git(
4524
4550
  "ls-files", "--cached", "--", ".", cwd=workspace_path,
4525
4551
  timeout=30,
4526
4552
  )
@@ -5466,10 +5492,12 @@ class RuntimeDaemon:
5466
5492
  )
5467
5493
  await _flush_output_to_server()
5468
5494
 
5469
- # 4. Auto-commit if successful
5495
+ # 4. Auto-commit if successful (always call _auto_commit on success:
5496
+ # _auto_commit handles both uncommitted changes AND internally-committed
5497
+ # changes that just need to be pushed — same as _execute_task).
5470
5498
  input_ctx = aj.get("input_context", {})
5471
5499
  git_info = {}
5472
- if result.status == "success" and result.files_changed:
5500
+ if result.status == "success":
5473
5501
  git_info = await self._auto_commit(workspace_path, fake_task)
5474
5502
 
5475
5503
  # 5. Report completion
@@ -5556,7 +5584,7 @@ class RuntimeDaemon:
5556
5584
  task: TaskInfo,
5557
5585
  workspace_path: Path,
5558
5586
  result: TaskResult,
5559
- reporter: "TaskReporter",
5587
+ reporter: "ProgressReporter",
5560
5588
  on_chunk: Any,
5561
5589
  max_retries: int = 2,
5562
5590
  ) -> TaskResult:
@@ -5931,6 +5959,12 @@ class RuntimeDaemon:
5931
5959
  "deletions": deletions,
5932
5960
  })
5933
5961
 
5962
+ # Enrich top changed files with symbol hints from staged additions.
5963
+ # This lets commit summaries mention the code concept that changed
5964
+ # instead of only repeating file names.
5965
+ for file_info in files[:20]:
5966
+ file_info["symbols"] = await self._extract_staged_file_symbols(cwd, file_info["path"])
5967
+
5934
5968
  proc = await asyncio.create_subprocess_exec(
5935
5969
  "git", "diff", "--cached", "--stat",
5936
5970
  cwd=str(cwd),
@@ -5943,6 +5977,55 @@ class RuntimeDaemon:
5943
5977
 
5944
5978
  return {"files": files, "total_stat": total_stat}
5945
5979
 
5980
+ async def _extract_staged_file_symbols(self, cwd: Path, filepath: str) -> list[str]:
5981
+ """Extract symbol names from staged diff additions for one file.
5982
+
5983
+ The goal is not to fully parse each language. We only need a few
5984
+ high-signal names from common declaration patterns so the commit
5985
+ summary can say what concept changed instead of only which file changed.
5986
+ """
5987
+ patterns = (
5988
+ re.compile(r"^(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)"),
5989
+ re.compile(r"^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)"),
5990
+ re.compile(r"^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*="),
5991
+ re.compile(r"^(?:export\s+)?interface\s+([A-Za-z_][A-Za-z0-9_]*)"),
5992
+ re.compile(r"^(?:export\s+)?type\s+([A-Za-z_][A-Za-z0-9_]*)\b"),
5993
+ )
5994
+
5995
+ try:
5996
+ proc = await asyncio.create_subprocess_exec(
5997
+ "git", "diff", "--cached", "--unified=0", "--", filepath,
5998
+ cwd=str(cwd),
5999
+ stdout=asyncio.subprocess.PIPE,
6000
+ stderr=asyncio.subprocess.PIPE,
6001
+ )
6002
+ out, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
6003
+ except Exception:
6004
+ return []
6005
+
6006
+ seen: set[str] = set()
6007
+ symbols: list[str] = []
6008
+ for raw_line in out.decode(errors="replace").splitlines():
6009
+ if not raw_line.startswith("+") or raw_line.startswith("+++"):
6010
+ continue
6011
+ line = raw_line[1:].strip()
6012
+ if not line:
6013
+ continue
6014
+ for pattern in patterns:
6015
+ match = pattern.match(line)
6016
+ if not match:
6017
+ continue
6018
+ symbol = match.group(1).strip()
6019
+ if len(symbol) < 2 or symbol in seen:
6020
+ break
6021
+ seen.add(symbol)
6022
+ symbols.append(symbol)
6023
+ break
6024
+ if len(symbols) >= 4:
6025
+ break
6026
+
6027
+ return symbols
6028
+
5946
6029
  async def _build_auto_commit_message(
5947
6030
  self,
5948
6031
  title: str,
@@ -5952,11 +6035,34 @@ class RuntimeDaemon:
5952
6035
  change_summary: dict,
5953
6036
  workspace_path: Path | None = None,
5954
6037
  ) -> str:
5955
- """Build a rich, natural-language commit message for daemon auto-commits."""
6038
+ """Build a clean, English-only commit message for daemon auto-commits.
6039
+
6040
+ Format (Claude Code style):
6041
+ <type>(<phase>): <concise english subject — max 72 chars>
6042
+ <blank line>
6043
+ - <specific change bullet 1>
6044
+ - <specific change bullet 2>
6045
+ ...
6046
+ [blank line + Task/Phase/Agent footer only when DAEMON_COMMIT_VERBOSE=true]
6047
+
6048
+ Subject: conventional-commit prefix + title derived from the work-item
6049
+ title (non-ASCII stripped). A single generic word remnant (e.g. just
6050
+ "implement" left after stripping Chinese) triggers file-path inference.
6051
+
6052
+ Body: bullet-point list describing WHAT changed in each file/group —
6053
+ specific and human-readable like Claude Code commits, never counts or
6054
+ vague summaries. For analysis/planning, structured insights from
6055
+ analysis.json are used when available.
6056
+
6057
+ Footer (Task / Phase / Agent): only appended when DAEMON_COMMIT_VERBOSE
6058
+ is true (default false). Useful for internal audit trails but hidden
6059
+ by default so commits look clean to external clients.
6060
+ """
6061
+ import re as _re
6062
+
5956
6063
  files = change_summary.get("files", [])
5957
- total_stat = change_summary.get("total_stat", "")
5958
6064
 
5959
- # Determine commit type
6065
+ # ── Determine conventional-commit type ────────────────────────────
5960
6066
  if node_type in ("analysis", "planning", "review"):
5961
6067
  commit_type = "docs"
5962
6068
  elif files and all("test" in f["path"].lower() for f in files):
@@ -5967,51 +6073,481 @@ class RuntimeDaemon:
5967
6073
  for f in files
5968
6074
  ):
5969
6075
  commit_type = "docs"
5970
- elif "fix" in title.lower():
6076
+ elif "fix" in node_type.lower():
5971
6077
  commit_type = "fix"
5972
6078
  else:
5973
6079
  commit_type = "feat"
5974
6080
 
5975
- header = f"{commit_type}({node_type}): {title}"
6081
+ # ── Sanitize subject: strip non-ASCII so the subject is always English ──
6082
+ # Work-item titles may be in Chinese or other scripts (user-supplied).
6083
+ # We keep ASCII segments; if nothing meaningful remains we infer a
6084
+ # specific English subject from the changed file paths.
6085
+ sanitized_title = _re.sub(r"[^\x00-\x7F]+", " ", title)
6086
+ sanitized_title = _re.sub(r"\s+", " ", sanitized_title).strip(" -:.")
6087
+
6088
+ # Strip leading requirement key (e.g. "LP-1:" / "AILP-20:") to evaluate
6089
+ # whether the remaining goal phrase has enough content. The req key
6090
+ # itself will be preserved in the final subject line; we only strip it
6091
+ # here to measure the goal phrase quality.
6092
+ _req_key_match = _re.match(r"(?i)([A-Z]+-\d+)([\s:.\-]+)", sanitized_title)
6093
+ _req_key_prefix = _req_key_match.group(0) if _req_key_match else ""
6094
+ goal_phrase = _re.sub(r"(?i)^[A-Z]+-\d+[\s:.\-]+", "", sanitized_title).strip(" -:.")
6095
+ # Strip common leading phase verbs so we don't repeat "implement implement..."
6096
+ bare_goal = _re.sub(
6097
+ r"^(implement|update|fix|add|refactor|improve|create|build|setup|init|generate|plan|analyze)\s+",
6098
+ "", goal_phrase, flags=_re.IGNORECASE,
6099
+ ).strip()
6100
+ bare_words = set(bare_goal.lower().split()) - {"and", "or", "the", "a", "an", "to", "for"}
6101
+ # Also check if sanitized_title is ONLY a req key (e.g. "LP-1" or "AILP-20:")
6102
+ _only_req_key = bool(_re.fullmatch(r"(?i)[A-Z]+-\d+[\s:.\-]*", sanitized_title.strip()))
6103
+ goal_is_trivial = (
6104
+ len(sanitized_title) < 4
6105
+ or _only_req_key
6106
+ or len(bare_words) == 0
6107
+ # A single remaining word (any word) after stripping phase verbs and
6108
+ # req key is almost always an isolated remnant from a mostly-non-ASCII
6109
+ # title (e.g. "AILP-20: integration" after Chinese stripped)
6110
+ or len(bare_words) <= 1
6111
+ )
6112
+
6113
+ if goal_is_trivial:
6114
+ # Derive a specific subject from changed files; prepend req key if present.
6115
+ # Re-scan the original sanitized_title for a req key with separator.
6116
+ # Note: when title is fully Chinese, sanitized becomes just the req key
6117
+ # like "LP-1" without separator; we still want to prepend it.
6118
+ _req_scan = _re.match(r"(?i)([A-Z]+-\d+)", sanitized_title)
6119
+ req_key_clean = _req_scan.group(1).upper() if _req_scan else ""
6120
+ derived = self._derive_subject_from_files(node_type, files)
6121
+ sanitized_title = f"{req_key_clean}: {derived}" if req_key_clean else derived
6122
+ else:
6123
+ # Preserve case of the req-key prefix (uppercase), lowercase only the goal
6124
+ if _req_key_prefix:
6125
+ req_key_upper = _req_key_match.group(1).upper() + _req_key_match.group(2)
6126
+ goal_lower = goal_phrase[0].lower() + goal_phrase[1:] if goal_phrase else goal_phrase
6127
+ sanitized_title = req_key_upper + goal_lower
6128
+ else:
6129
+ sanitized_title = sanitized_title[0].lower() + sanitized_title[1:]
6130
+
6131
+ header = f"{commit_type}({node_type}): {sanitized_title}"
5976
6132
  if len(header) > 72:
5977
6133
  header = header[:69] + "..."
5978
6134
 
5979
6135
  parts = [header, ""]
5980
6136
 
5981
- # ── Analysis/planning phase: extract structured insights from analysis.json ──
6137
+ # ── Body: bullet-point list of specific changes (Claude Code style) ──
5982
6138
  if node_type in ("analysis", "planning") and workspace_path:
6139
+ # For analysis/planning: structured insights from analysis.json
6140
+ # give the richest description; fall back to bullet body if missing.
5983
6141
  insights = await self._extract_analysis_insights(files, workspace_path)
5984
6142
  if insights:
5985
6143
  parts.extend(insights)
5986
6144
  parts.append("")
6145
+ else:
6146
+ bullets = self._build_bullet_body(node_type, files, workspace_path)
6147
+ if bullets:
6148
+ parts.extend(bullets)
6149
+ parts.append("")
6150
+ else:
6151
+ # All other phases: specific bullet points per changed file/group
6152
+ bullets = self._build_bullet_body(node_type, files, workspace_path)
6153
+ if bullets:
6154
+ parts.extend(bullets)
6155
+ parts.append("")
6156
+
6157
+ # ── Footer: traceability metadata (opt-in via DAEMON_COMMIT_VERBOSE) ──
6158
+ # Hidden by default — keeps commits clean for external/client projects.
6159
+ # Set DAEMON_COMMIT_VERBOSE=true to include Task/Phase/Agent in footer.
6160
+ if settings.DAEMON_COMMIT_VERBOSE:
6161
+ parts.append(f"Task: {task_id}")
6162
+ parts.append(f"Phase: {node_type}")
6163
+ parts.append(f"Agent: {agent_type}")
6164
+
6165
+ return "\n".join(parts).rstrip()
6166
+
6167
+ @staticmethod
6168
+ def _is_test_path(path: str) -> bool:
6169
+ p = path.lower()
6170
+ return (
6171
+ "test_" in p or "_test" in p or "/test" in p
6172
+ or p.endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx", ".test.js"))
6173
+ )
6174
+
6175
+ @staticmethod
6176
+ def _infer_change_topic(files: list[dict], *, include_support: bool = False) -> str:
6177
+ """Infer the main business/topic phrase from changed files.
6178
+
6179
+ This prefers stable domain nouns like "search", "project API key",
6180
+ or "workspace settings" over file-structure words like "service",
6181
+ "routes", or raw test symbols.
6182
+ """
6183
+ noise = {
6184
+ "add", "update", "fix", "implement", "create", "build", "main",
6185
+ "index", "base", "default", "config", "settings", "helper",
6186
+ "helpers", "utility", "utils", "service", "services", "route",
6187
+ "routes", "handler", "handlers", "controller", "controllers",
6188
+ "model", "models", "schema", "schemas", "migration", "migrations",
6189
+ "database", "table", "frontend", "backend", "page", "pages",
6190
+ "component", "components", "view", "views", "panel", "modal",
6191
+ "button", "input", "form", "bar", "card", "docs", "doc",
6192
+ "guide", "readme", "changelog", "tests", "test", "spec",
6193
+ "integration", "regression", "unit", "request", "response",
6194
+ "issue", "require", "rotate", "repair", "stream", "flow",
6195
+ }
6196
+
6197
+ def _humanize_text(text: str) -> str:
6198
+ words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
6199
+ words = re.sub(r"[_\-/]+", " ", words)
6200
+ return re.sub(r"\s+", " ", words).strip().lower()
6201
+
6202
+ counts: dict[str, int] = {}
6203
+ order: dict[str, int] = {}
6204
+ ranked_files = sorted(
6205
+ files,
6206
+ key=lambda f: f.get("additions", 0) + f.get("deletions", 0),
6207
+ reverse=True,
6208
+ )
6209
+ for file_info in ranked_files[:12]:
6210
+ path_lower = file_info["path"].lower()
6211
+ if not include_support and (
6212
+ RuntimeDaemon._is_test_path(path_lower) or path_lower.startswith("docs/")
6213
+ ):
6214
+ continue
6215
+ weight = max(1, min(5, (file_info.get("additions", 0) + file_info.get("deletions", 0)) // 30 + 1))
6216
+ stem = file_info["path"].rsplit("/", 1)[-1].rsplit(".", 1)[0]
6217
+ sources = [stem] + list(file_info.get("symbols", []))[:3]
6218
+ for source in sources:
6219
+ for token in _humanize_text(str(source)).split():
6220
+ if len(token) < 3 or token.startswith("test"):
6221
+ continue
6222
+ if token in noise or re.fullmatch(r"[a-z]+\d+", token):
6223
+ continue
6224
+ counts[token] = counts.get(token, 0) + weight
6225
+ order.setdefault(token, len(order))
6226
+
6227
+ if not counts:
6228
+ return ""
6229
+
6230
+ ranked = sorted(counts.items(), key=lambda item: (-item[1], order[item[0]]))
6231
+ top_tokens = [token for token, _ in ranked[:8]]
6232
+ top_set = set(top_tokens)
6233
+
6234
+ if "key" in top_set and "api" in top_set:
6235
+ return "project API key" if "project" in top_set else "API key"
6236
+ if "workspace" in top_set and "settings" in top_set:
6237
+ return "workspace settings"
6238
+ if "user" in top_set and "profile" in top_set:
6239
+ return "user profile"
6240
+ if "access" in top_set and "control" in top_set:
6241
+ return "access control"
6242
+ if "search" in top_set:
6243
+ return "search"
6244
+ if "auth" in top_set or "authentication" in top_set:
6245
+ return "authentication"
6246
+ if "log" in top_set or "logs" in top_set:
6247
+ return "API log" if "api" in top_set else "log"
6248
+
6249
+ first = top_tokens[0]
6250
+ if len(ranked) > 1 and ranked[1][1] >= max(2, ranked[0][1] - 1):
6251
+ second = top_tokens[1]
6252
+ display = {"api": "API", "ui": "UI", "qa": "QA", "jwt": "JWT", "oauth": "OAuth"}
6253
+ return f"{display.get(first, first)} {display.get(second, second)}"
6254
+ display = {"api": "API", "ui": "UI", "qa": "QA", "jwt": "JWT", "oauth": "OAuth"}
6255
+ return display.get(first, first)
6256
+
6257
+ @staticmethod
6258
+ def _derive_subject_from_files(node_type: str, files: list[dict]) -> str:
6259
+ """Infer a specific English subject from changed files.
6260
+
6261
+ The subject should summarize the feature area and the primary change
6262
+ surface, instead of echoing file names or test symbols.
6263
+ """
6264
+ _PHASE_VERBS = {
6265
+ "analysis": "analyze",
6266
+ "planning": "plan",
6267
+ "coding": "implement",
6268
+ "fix": "fix",
6269
+ "bugfix": "fix",
6270
+ "testing": "add tests for",
6271
+ "review": "review",
6272
+ "refactor": "refactor",
6273
+ "documentation": "document",
6274
+ "improvement": "improve",
6275
+ }
6276
+
6277
+ verb = _PHASE_VERBS.get(node_type, "update")
6278
+
6279
+ topic = RuntimeDaemon._infer_change_topic(files)
6280
+ paths = [f["path"].lower() for f in files]
6281
+ has_api = any(
6282
+ ("route" in path or "endpoint" in path or "/api/" in path or "/v1/" in path or "/v2/" in path)
6283
+ and not RuntimeDaemon._is_test_path(path)
6284
+ for path in paths
6285
+ )
6286
+ has_frontend = any(
6287
+ (path.endswith((".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss")) or path.startswith("frontend/"))
6288
+ and not RuntimeDaemon._is_test_path(path)
6289
+ for path in paths
6290
+ )
6291
+ has_service = any(
6292
+ any(token in path for token in ("service", "worker", "manager", "queue")) and not RuntimeDaemon._is_test_path(path)
6293
+ for path in paths
6294
+ )
6295
+ has_model = any(
6296
+ any(token in path for token in ("model", "schema", "migration", "alembic")) and not RuntimeDaemon._is_test_path(path)
6297
+ for path in paths
6298
+ )
6299
+ has_utility = any(
6300
+ any(token in path for token in ("util", "helper", "middleware", "auth")) and not RuntimeDaemon._is_test_path(path)
6301
+ for path in paths
6302
+ )
6303
+ has_tests = any(RuntimeDaemon._is_test_path(path) for path in paths)
6304
+
6305
+ areas: list[str] = []
6306
+ if has_api:
6307
+ areas.append("routes" if topic and "api" in topic.lower() else "API")
6308
+ if has_frontend:
6309
+ areas.append("UI")
6310
+ if has_service:
6311
+ areas.append("service flow")
6312
+ if has_model:
6313
+ areas.append("data model")
6314
+ if has_utility and not has_api:
6315
+ areas.append("helpers")
6316
+ if node_type in ("fix", "bugfix") and has_tests and not has_frontend:
6317
+ areas.append("tests")
6318
+
6319
+ unique_areas: list[str] = []
6320
+ for area in areas:
6321
+ if area not in unique_areas:
6322
+ unique_areas.append(area)
6323
+ areas = unique_areas[:2]
6324
+
6325
+ if topic:
6326
+ if len(areas) >= 2:
6327
+ return f"{verb} {topic} {areas[0]} and {areas[1]}"
6328
+ if len(areas) == 1:
6329
+ return f"{verb} {topic} {areas[0]}"
6330
+ return f"{verb} {topic}"
6331
+
6332
+ if len(areas) >= 2:
6333
+ return f"{verb} {areas[0]} and {areas[1]}"
6334
+ if len(areas) == 1:
6335
+ return f"{verb} {areas[0]}"
6336
+
6337
+ # Final fallback — generic but still English
6338
+ _FALLBACK = {
6339
+ "analysis": "analyze requirements and generate specifications",
6340
+ "planning": "generate implementation plan and task breakdown",
6341
+ "coding": "implement feature changes",
6342
+ "fix": "apply bug fix",
6343
+ "testing": "add automated test coverage",
6344
+ "review": "generate code review documentation",
6345
+ "refactor": "refactor code structure",
6346
+ "documentation": "update project documentation",
6347
+ "improvement": "apply code improvements",
6348
+ }
6349
+ return _FALLBACK.get(node_type, "implement changes")
6350
+
6351
+ @staticmethod
6352
+ def _build_bullet_body(
6353
+ node_type: str,
6354
+ files: list[dict],
6355
+ workspace_path: Path | None = None,
6356
+ ) -> list[str]:
6357
+ """Build a Claude Code-style bullet-point body describing specific changes.
6358
+
6359
+ Each bullet describes what a file or group of related files does, using
6360
+ the actual filename and directory context to produce specific, useful
6361
+ descriptions rather than generic counts or labels.
6362
+
6363
+ Example output:
6364
+ - Add `UserAuthService` with JWT token generation and refresh logic
6365
+ - Update `/api/v1/users` endpoints to require Bearer token authentication
6366
+ - Add unit tests for token validation and expiry edge cases
6367
+ - Update `alembic/versions/` migration to add `users.token_hash` column
6368
+
6369
+ Rules:
6370
+ - Max 8 bullets (most important first, by additions+deletions)
6371
+ - Related files are grouped into a single bullet (e.g. test files)
6372
+ - File paths are shown in backticks for readability
6373
+ - Status prefix: "Add" for new files (A), "Remove" for deleted (D),
6374
+ "Update" for modified (M), "Refactor" for pure-deletion rewrites
6375
+ - Never shows raw +/- counts or file sizes
6376
+ """
6377
+ if not files:
6378
+ return []
6379
+
6380
+ # ── File classification helpers ────────────────────────────────────
6381
+ def _is_test(path: str) -> bool:
6382
+ return RuntimeDaemon._is_test_path(path)
6383
+
6384
+ def _is_doc(path: str) -> bool:
6385
+ p = path.lower()
6386
+ return p.endswith((".md", ".rst")) or p.startswith("docs/")
6387
+
6388
+ def _is_migration(path: str) -> bool:
6389
+ p = path.lower()
6390
+ return "alembic" in p or "migration" in p or p.endswith(".sql")
6391
+
6392
+ def _is_config(path: str) -> bool:
6393
+ p = path.lower()
6394
+ fname = p.rsplit("/", 1)[-1]
6395
+ return fname.endswith((".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env")) and not _is_doc(p)
6396
+
6397
+ def _is_frontend(path: str) -> bool:
6398
+ p = path.lower()
6399
+ return p.endswith((".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss")) or p.startswith("frontend/")
6400
+
6401
+ def _status_verb(f: dict) -> str:
6402
+ s = f.get("status", "M")
6403
+ adds = f.get("additions", 0)
6404
+ dels = f.get("deletions", 0)
6405
+ if s == "A":
6406
+ return "Add"
6407
+ if s == "D":
6408
+ return "Remove"
6409
+ if adds > 0 and dels == 0:
6410
+ return "Add"
6411
+ if adds == 0 and dels > 0:
6412
+ return "Remove"
6413
+ if dels > adds * 2:
6414
+ return "Refactor"
6415
+ return "Update"
6416
+
6417
+ def _has_api(group: list[dict]) -> bool:
6418
+ return any(
6419
+ ("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())
6420
+ and not _is_test(f["path"])
6421
+ for f in group
6422
+ )
5987
6423
 
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)
6424
+ def _has_frontend(group: list[dict]) -> bool:
6425
+ return any(
6426
+ (f["path"].lower().endswith((".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss")) or f["path"].lower().startswith("frontend/"))
6427
+ and not _is_test(f["path"])
6428
+ for f in group
6429
+ )
6430
+
6431
+ def _has_service(group: list[dict]) -> bool:
6432
+ return any(
6433
+ any(token in f["path"].lower() for token in ("service", "worker", "manager", "queue")) and not _is_test(f["path"])
6434
+ for f in group
6435
+ )
6436
+
6437
+ def _has_model(group: list[dict]) -> bool:
6438
+ return any(
6439
+ any(token in f["path"].lower() for token in ("model", "schema", "migration", "alembic")) and not _is_test(f["path"])
6440
+ for f in group
6441
+ )
6442
+
6443
+ def _has_utility(group: list[dict]) -> bool:
6444
+ return any(
6445
+ any(token in f["path"].lower() for token in ("util", "helper", "middleware", "auth")) and not _is_test(f["path"])
6446
+ for f in group
6447
+ )
6448
+
6449
+ # ── Sort files by change volume (most significant first) ───────────
6450
+ sorted_files = sorted(
6451
+ files,
6452
+ key=lambda f: f.get("additions", 0) + f.get("deletions", 0),
6453
+ reverse=True,
6454
+ )
6455
+
6456
+ # ── Group special categories for combined bullets ──────────────────
6457
+ tests = [f for f in sorted_files if _is_test(f["path"])]
6458
+ migrations = [f for f in sorted_files if _is_migration(f["path"])]
6459
+ docs = [f for f in sorted_files if _is_doc(f["path"]) and not _is_migration(f["path"])]
6460
+ configs = [f for f in sorted_files if _is_config(f["path"])]
6461
+ main_files = [
6462
+ f for f in sorted_files
6463
+ if not _is_test(f["path"]) and not _is_migration(f["path"])
6464
+ and not _is_doc(f["path"]) and not _is_config(f["path"])
6465
+ ]
6466
+
6467
+ bullets: list[str] = []
6468
+
6469
+ topic = RuntimeDaemon._infer_change_topic(files)
6470
+ primary_surfaces: list[str] = []
6471
+ if _has_api(main_files):
6472
+ primary_surfaces.append("API routes")
6473
+ if _has_frontend(main_files):
6474
+ primary_surfaces.append("UI")
6475
+ if _has_service(main_files):
6476
+ primary_surfaces.append("service flow")
6477
+ if _has_model(main_files):
6478
+ primary_surfaces.append("data model")
6479
+ if _has_utility(main_files) and "service flow" not in primary_surfaces:
6480
+ primary_surfaces.append("request/auth helpers")
6481
+
6482
+ primary_surfaces = primary_surfaces[:2]
6483
+ primary_verb = "Fix" if node_type in ("fix", "bugfix") else "Implement"
6484
+ if main_files:
6485
+ if topic and len(primary_surfaces) >= 2:
6486
+ bullets.append(f"- {primary_verb} {topic} {primary_surfaces[0]} and {primary_surfaces[1]}")
6487
+ elif topic and len(primary_surfaces) == 1:
6488
+ bullets.append(f"- {primary_verb} {topic} {primary_surfaces[0]}")
6489
+ elif len(primary_surfaces) >= 2:
6490
+ bullets.append(f"- {primary_verb} {primary_surfaces[0]} and {primary_surfaces[1]}")
6491
+ elif len(primary_surfaces) == 1:
6492
+ bullets.append(f"- {primary_verb} {primary_surfaces[0]}")
6493
+
6494
+ if migrations:
6495
+ if len(migrations) == 1:
6496
+ verb = _status_verb(migrations[0])
6497
+ mpath = migrations[0]["path"]
6498
+ mname = mpath.rsplit("/", 1)[-1].rsplit(".", 1)[0]
6499
+ mdesc = re.sub(r"^\d+[_\-]", "", mname).replace("_", " ").strip()
6500
+ bullets.append(f"- {verb} `{mdesc}` database migration")
6501
+ else:
6502
+ bullets.append(f"- Add {len(migrations)} database migrations")
6503
+
6504
+ if tests:
6505
+ test_verbs = {_status_verb(t) for t in tests}
6506
+ tv = "Add" if "Add" in test_verbs else "Update"
6507
+ support_topic = topic or RuntimeDaemon._infer_change_topic(tests, include_support=True)
6508
+ if len(tests) == 1:
6509
+ stem = tests[0]["path"].rsplit("/", 1)[-1].rsplit(".", 1)[0]
6510
+ stem = re.sub(r"^test_|_test$", "", stem).replace("_", " ")
6511
+ if support_topic:
6512
+ bullets.append(f"- {tv} test coverage for {support_topic}")
6513
+ else:
6514
+ bullets.append(f"- {tv} `{stem}` test coverage" if stem else f"- {tv} unit tests")
6515
+ else:
6516
+ if support_topic:
6517
+ bullets.append(f"- {tv} regression and integration coverage for {support_topic}")
6518
+ else:
6519
+ bullets.append(f"- {tv} {len(tests)} test files")
6520
+
6521
+ if docs:
6522
+ if len(docs) == 1:
6523
+ verb = _status_verb(docs[0])
6524
+ fname = docs[0]["path"].rsplit("/", 1)[-1]
6525
+ bullets.append(f"- {verb} `{fname}` documentation")
6526
+ else:
6527
+ bullets.append(f"- Update {len(docs)} documentation files")
6528
+
6529
+ if configs:
6530
+ if len(configs) == 1:
6531
+ verb = _status_verb(configs[0])
6532
+ fname = configs[0]["path"].rsplit("/", 1)[-1]
6533
+ bullets.append(f"- {verb} `{fname}` configuration")
6534
+ else:
6535
+ bullets.append(f"- Update {len(configs)} configuration files")
6536
+
6537
+ return bullets[:8]
6538
+
6539
+ @staticmethod
6540
+ def _build_narrative_body(
6541
+ node_type: str,
6542
+ sections: dict,
6543
+ sanitized_title: str = "",
6544
+ files: list[dict] | None = None,
6545
+ ) -> list[str]:
6546
+ """[Legacy] Build narrative sentences — kept for callers that still use it.
6547
+
6548
+ Prefer _build_bullet_body for new call sites.
6549
+ """
6550
+ return RuntimeDaemon._build_bullet_body(node_type, files or [], None)
6015
6551
 
6016
6552
  async def _extract_analysis_insights(
6017
6553
  self, files: list[dict], workspace_path: Path
@@ -6038,10 +6574,10 @@ class RuntimeDaemon:
6038
6574
 
6039
6575
  lines: list[str] = []
6040
6576
 
6041
- # Summary — word-wrap at 78 chars
6577
+ # Summary — word-wrap at 78 chars; strip non-ASCII to keep English-only
6578
+ import re as _re2
6042
6579
  raw_summary = data.get("summary")
6043
6580
  if isinstance(raw_summary, dict):
6044
- # Some agents produce summary as a structured object; extract description
6045
6581
  summary = (
6046
6582
  raw_summary.get("description")
6047
6583
  or raw_summary.get("title")
@@ -6054,7 +6590,8 @@ class RuntimeDaemon:
6054
6590
  summary = raw_summary
6055
6591
  else:
6056
6592
  summary = str(raw_summary) if raw_summary else ""
6057
- summary = summary.strip()
6593
+ summary = _re2.sub(r"[^\x00-\x7F]+", " ", summary.strip())
6594
+ summary = _re2.sub(r"\s+", " ", summary).strip()
6058
6595
  if summary:
6059
6596
  words = summary.split()
6060
6597
  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.5
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.5"
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