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.
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/PKG-INFO +1 -1
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli/daemon.py +599 -62
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/pyproject.toml +1 -1
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/README.md +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.10.3 → forgexa_cli-1.10.5}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.10.
|
|
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.
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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"
|
|
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: "
|
|
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
|
|
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
|
|
6076
|
+
elif "fix" in node_type.lower():
|
|
5971
6077
|
commit_type = "fix"
|
|
5972
6078
|
else:
|
|
5973
6079
|
commit_type = "feat"
|
|
5974
6080
|
|
|
5975
|
-
|
|
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
|
-
# ──
|
|
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
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
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 = ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|