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