Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/xskill/agents/skill_edit_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ def maybe_run(self) -> bool:
return False
# 守门 2: 阈值
data = C.load_candidates(self.skill_dir)
ready = C.ready_for_promotion_v2(data, threshold=self.threshold)
ready = C.ready_for_promotion_v2(
data, threshold=self.threshold, skill_dir=self.skill_dir,
)
if not ready:
return False
ready = self._detect_and_resolve_conflicts(ready)
if not ready:
return False
# 守门 3: 若场景是 "create staging"(即在 main 上)→ 额外要求 main 真有人用过
Expand Down Expand Up @@ -207,6 +212,29 @@ def maybe_run(self) -> bool:
self.skill_dir.name)
return True

def _detect_and_resolve_conflicts(self, ready: list[dict]) -> list[dict]:
"""Persist conflicts, auto-resolve obvious ones, and return safe candidates."""
def _load(atom_id: str):
if self.store is None:
raise FileNotFoundError(atom_id)
return self.store.load(atom_id)

groups = C.detect_conflicts(
self.skill_dir,
ready,
atom_loader=_load if self.store is not None else None,
)
hard = [g for g in groups if g.type == "hard" and not g.resolution]
if hard:
C.resolve_conflicts(self.skill_dir, hard)
if C.has_unresolved_hard_conflicts(self.skill_dir):
logger.warning(
"Skill %s 存在未解决的硬冲突,暂停自动更新",
self.skill_dir.name,
)
return []
return C.filter_candidates_by_resolved_conflicts(self.skill_dir, ready)

def _main_has_ux_score(self) -> bool:
"""检查该 skill 的 .ux_scores.jsonl 是否有至少 1 条 side=main 记录。

Expand Down Expand Up @@ -265,6 +293,21 @@ def _run(self, ready: list[dict], current_branch_name: str) -> None:
f"- atom_id={c['atom_id']} weightscore={c['weightscore']}{ext}"
)
scenario_lines.append("")
soft_hints = C.soft_conflict_merge_hints(
self.skill_dir,
candidate_ids={str(c.get("atom_id", "")) for c in ready},
)
if soft_hints:
import json as _json
scenario_lines.append("# 冲突合并提示")
scenario_lines.append(
"以下 soft conflict 已由系统判定为可共存。写 SKILL.md 时不要二选一,"
"应合并为条件分支、优先级规则或同一 section 下的不同步骤:"
)
scenario_lines.append(
_json.dumps(soft_hints, ensure_ascii=False, indent=2)
)
scenario_lines.append("")
scenario_lines.append(f"目标 skill 目录: {self.skill_dir}")
scenario_lines.append(f"目标 SKILL.md 路径: {skill_md}")

Expand Down
8 changes: 6 additions & 2 deletions src/xskill/agents/user_edit_absorb_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ def has_pending_dest_edit(
判据:
- dest 存在
- 能读到 install-meta 里的 installed_at
- dest 某文件 mtime > installed_at + 1 秒(用户改过)
- dest 某文件 mtime > installed_at(用户改过)
- now - max_mtime >= quiet_seconds(停手 ≥3 分钟)
"""
if not dest_dir.is_dir():
Expand All @@ -314,7 +314,11 @@ def has_pending_dest_edit(
if installed_at is None:
return False
max_mtime = _dest_user_edit_mtime(dest_dir, exclude)
if max_mtime - installed_at < 1.0:
# install-meta 写在 dest 外部,且 installed_at 是 copy 完成后的浮点秒。
# dest 内部内容只要晚于 installed_at,就说明安装后被用户或外部 agent 改过。
# 这里不沿用 source 仓的 1 秒阈值;那个阈值是为 git commit 整数秒截断
# 设计的,放在 copy-mode dest 上会让 Windows CI 的边界时间戳漏判。
if max_mtime <= installed_at:
return False
if (time.time() - max_mtime) < quiet_seconds:
return False
Expand Down
127 changes: 127 additions & 0 deletions src/xskill/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,116 @@ def cmd_search(args, xskill) -> int:
return 1


def _find_skill_dir_for_conflict(xskill, skill_name: str):
sd = xskill.skill_repo.root / skill_name
if not sd.is_dir():
print(f"error: skill not found: {skill_name}", file=sys.stderr)
return None
return sd


def _all_skill_dirs(xskill):
root = xskill.skill_repo.root
if not root.is_dir():
return []
return [p for p in sorted(root.iterdir()) if p.is_dir() and not p.name.startswith(".")]


def cmd_conflict(args, xskill) -> int:
from xskill.skill import candidates as C

action = args.conflict_action
if action == "list":
sd = _find_skill_dir_for_conflict(xskill, args.skill_name)
if sd is None:
return 1
data = C.load_conflicts(sd)
conflicts = data.get("conflicts", []) or []
if not conflicts:
print(f"(no conflicts for {args.skill_name})")
return 0
print("ID\tTYPE\tSTATUS\tSUMMARY")
for item in conflicts:
status = "resolved" if item.get("resolution") else "unresolved"
print(
f"{item.get('id')}\t{item.get('type')}\t{status}\t"
f"{item.get('conflict_summary', '')}"
)
return 0

if action == "show":
import yaml
for sd in _all_skill_dirs(xskill):
data = C.load_conflicts(sd)
for item in data.get("conflicts", []) or []:
if item.get("id") == args.conflict_id:
print(f"skill: {sd.name}")
print(yaml.safe_dump(item, allow_unicode=True, sort_keys=False))
return 0
print(f"error: conflict not found: {args.conflict_id}", file=sys.stderr)
return 1

if action == "resolve":
sd = _find_skill_dir_for_conflict(xskill, args.skill_name)
if sd is None:
return 1
data = C.load_conflicts(sd)
conflicts = data.get("conflicts", []) or []
unresolved = [
item for item in conflicts
if item.get("type") == "hard" and not item.get("resolution")
]
if not unresolved:
print(f"(no unresolved hard conflicts for {args.skill_name})")
return 0
changed = False
for item in unresolved:
atoms = item.get("atoms", []) or []
print("")
print(f"Skill {args.skill_name} conflict {item.get('id')}:")
print(f" {item.get('conflict_summary', '')}")
for idx, atom in enumerate(atoms):
label = chr(ord("A") + idx)
print(
f" [{label}] {atom.get('position', '')} "
f"({atom.get('weightscore', 0)}分, "
f"{atom.get('supporting_trajs', 1)}条轨迹支持)"
)
choice = input("选择: [A/B...] winner / [M] 合并为条件分支 / [S] 跳过: ")
choice = choice.strip().upper()
if choice == "S" or not choice:
continue
from datetime import datetime
resolved_at = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
if choice == "M":
merged = input("合并内容: ").strip()
item["resolution"] = {
"strategy": "conditional_merge",
"merged_content": merged,
"resolved_by": "user",
"resolved_at": resolved_at,
}
changed = True
continue
index = ord(choice[0]) - ord("A")
if index < 0 or index >= len(atoms):
print(" invalid choice; skipped")
continue
item["resolution"] = {
"strategy": "manual",
"winner": atoms[index].get("atom_id"),
"resolved_by": "user",
"resolved_at": resolved_at,
}
changed = True
if changed:
C.save_conflicts(sd, {"conflicts": conflicts})
print("resolved")
return 0

return 1


# ═══════════════════════════════════════════════════════════════
# argparse
# ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -376,6 +486,15 @@ def build_parser() -> argparse.ArgumentParser:
p_stats.add_argument("--watch", action="store_true",
help="htop 式整屏刷新(每 2s)")

p_conflict = sub.add_parser(
"conflict", help="Inspect and resolve skill candidate conflicts",
)
p_conflict.add_argument("conflict_action", choices=["list", "show", "resolve"])
p_conflict.add_argument("skill_name", nargs="?",
help="skill name for list/resolve")
p_conflict.add_argument("conflict_id", nargs="?",
help="conflict id for show")

return p


Expand Down Expand Up @@ -424,6 +543,13 @@ def main() -> int:
if args.command == "registry" and args.registry_action in ("add", "remove"):
if not args.path:
parser.error(f"path is required for 'registry {args.registry_action}'")
if args.command == "conflict":
if args.conflict_action in ("list", "resolve") and not args.skill_name:
parser.error(f"skill_name is required for 'conflict {args.conflict_action}'")
if args.conflict_action == "show" and not (args.conflict_id or args.skill_name):
parser.error("conflict_id is required for 'conflict show'")
if args.conflict_action == "show" and not args.conflict_id:
args.conflict_id = args.skill_name

# connect 是瘦客户端:不读 config.yaml / 不需要 llm.api_key / 不构造 XSkill 门面
if args.command == "connect":
Expand Down Expand Up @@ -462,6 +588,7 @@ def main() -> int:
"serve": cmd_serve,
"registry": cmd_registry,
"search": cmd_search,
"conflict": cmd_conflict,
}.get(args.command)
return handler(args, xskill) if handler else (parser.print_help() or 1)

Expand Down
Loading
Loading