@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1

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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/runtime/index.json +6 -6
  3. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
  4. package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
  5. package/runtime/plugins/agent-pack/plugin.json +1 -1
  6. package/runtime/plugins/agent-pack/references/local-only.md +126 -0
  7. package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
  8. package/runtime/plugins/agent-pack/references/nudge.md +58 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
  12. package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
  13. package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
  14. package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
  15. package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
  16. package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
  17. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
  18. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
  19. package/runtime/plugins/agents-init/content.json +17 -2
  20. package/runtime/plugins/agents-init/plugin.json +1 -1
  21. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
  23. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
  24. package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
  25. package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
  26. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
  27. package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
  28. package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
  29. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
  30. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
  31. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
  32. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
  33. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
  34. package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
  35. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
  36. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  37. package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
  38. package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
  39. package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
  40. package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
  41. package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
  42. package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
  43. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
  44. package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
  45. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
  46. package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
  47. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
  48. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
  49. package/runtime/plugins/project-orchestrator/content.json +30 -3
  50. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  51. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
  52. package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
  53. package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
  54. package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
  55. package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
  56. package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
  57. package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
  58. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
  59. package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
  60. package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
  61. package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
  62. package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
  63. package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
  64. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
  65. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
  66. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
  67. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
  68. package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
  69. package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
  70. package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
  71. package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
  72. package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
  73. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
  74. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
  75. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
  76. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
  77. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
  78. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
  79. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
  80. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
  81. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
  82. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
  83. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
  84. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
  85. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
  86. package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
@@ -6,7 +6,9 @@
6
6
  - ``update-plugins``:各客户端里的插件代码(调用客户端自己的市场命令,需联网);
7
7
  - ``update-content``:项目里由模板生成的文件,改过的文件逐个保留或采用,先备份;
8
8
  - ``install-runtime`` / ``update-runtime``:用户级运行时,新版本装在旁边,旧版本保留;
9
- - ``add-entry``:为已初始化的项目补上根目录启动器与 Profile 的 entry 段。
9
+ - ``add-entry``:为已初始化的项目补上根目录启动器与 Profile 的 entry 段;
10
+ - ``add-skeleton`` / ``profile`` / ``repair-lock``:脚手架差异(缺少的骨架文件、Profile 字段、内容锁版本);
11
+ 逐项可选 采用 / 永久跳过 / 稍后,跳过与稍后记入决定文件(见 agents-init 的 scaffold.py)。
10
12
 
11
13
  个人规则(Opinion)的新版本只报告,从不在这里采纳:那由 opinion-manager 预览后经用户确认。
12
14
  插件、模板与运行时的检查经 agent-pack(``HOST``)完成;没有它时这些项标为不可用。
@@ -23,8 +25,10 @@ from typing import Any
23
25
 
24
26
  import assets
25
27
  from project_base import DEFAULT_LAUNCHER, LAUNCHER_RE, EntryError
28
+ from i18n_orchestrator import _, N_ # noqa: E402
29
+ from i18n_orchestrator import _ # noqa: E402
26
30
 
27
- CLIENT_LABEL = {"claude": "Claude Code", "codex": "Codex", "cursor": "Cursor", "legacy": "旧方式安装"}
31
+ CLIENT_LABEL = {"claude": "Claude Code", "codex": "Codex", "cursor": "Cursor", "legacy": N_("旧方式安装")}
28
32
 
29
33
 
30
34
  def _iso(seconds: float | None) -> str:
@@ -41,19 +45,82 @@ def runtime_module(host: Any) -> Any:
41
45
 
42
46
  # ---------------------------------------------------------------- check
43
47
 
48
+ SCAFFOLD_NEXT = {"add-skeleton": N_("按模板补上(只新建,不改已有文件)"),
49
+ "profile": N_("把字段改为模板值(先备份,预览里有 diff)"),
50
+ "repair-lock": N_("按实际版本修复内容锁(不涉及你的文件)"),
51
+ "update-content": N_("逐文件选择保留或采用新模板"),
52
+ "case-naming": N_("查看改名计划与受影响的引用(只给计划,不移动目录)"),
53
+ "update-region": N_("更新受管区块:运行 init_project.py --mode region(改过的区块逐个 --keep / --accept)")}
54
+
55
+
56
+ def _scaffold_findings(scaffold: dict | None, findings: list[dict], ok: list[str]) -> None:
57
+ if scaffold is None:
58
+ return
59
+ if scaffold.get("error"):
60
+ findings.append({"id": "scaffold-check", "level": "unavailable", "area": _("脚手架"), "title": _("无法检查项目脚手架"),
61
+ "reason": scaffold["error"], "impact": _("只影响脚手架一致性提示"), "next": []})
62
+ return
63
+ for item in scaffold["findings"]:
64
+ mark = _("(稍后)") if item.get("deferred") else _("(期望内容已变化)") if item.get("changed_since_decision") else ""
65
+ # 菜单只执行 FIXES 里的处理项;其余(如 update-region)只给出怎么处理的说明。
66
+ step = {"label": _(SCAFFOLD_NEXT.get(item["fix"], item["fix"])), "action": f"fix:{item['fix']}" if item["fix"] in FIXES else ""} \
67
+ if item["fix"] else {"label": _("这一项需要你自己处理"), "action": ""}
68
+ findings.append({"id": item["id"], "level": "suggest", "area": _("脚手架"), "title": item["title"] + mark,
69
+ "reason": item["reason"], "impact": _("脚手架和最新版不一致;不影响日常使用,"
70
+ "不想要的可以永久跳过(一次决定,所有机器生效)"), "next": [step],
71
+ "decision": {"id": item["id"], "fingerprint": item["fingerprint"], "scope": "project"}})
72
+ for message in scaffold["decisions"]["errors"]:
73
+ findings.append({"id": "scaffold-decisions", "level": "unavailable", "area": _("脚手架"),
74
+ "title": _("决定文件有问题"), "reason": message, "impact": _("已记录的决定暂时不生效"), "next": []})
75
+ if scaffold["decisions"]["conflict_copies"]:
76
+ findings.append({"id": "scaffold-decisions-conflict", "level": "suggest", "area": _("脚手架"),
77
+ "title": _("同步服务留下了决定文件的冲突副本"),
78
+ "reason": _("、").join(scaffold["decisions"]["conflict_copies"]),
79
+ "impact": _("另一台机器写入的决定可能没有生效"),
80
+ "next": [{"label": _("把其中的条目合并进 check-decisions.json 后删除副本"), "action": ""}]})
81
+ if not scaffold["findings"]:
82
+ ok.append(_("项目脚手架(与期望一致") + (_(",{0} 项已跳过").format(len(scaffold['skipped'])) if scaffold["skipped"] else "") + _(")"))
83
+
84
+
85
+ def _local_only_finding(item: dict) -> dict:
86
+ """agent-pack 的 local-only 发现 -> 健康检查里的一项;选项里第一个带命令的对应 fix local-only。"""
87
+ steps, linked = [], False
88
+ for option in item.get("options") or []:
89
+ action = "fix:local-only" if option.get("command") and not linked else ""
90
+ linked = linked or bool(action)
91
+ steps.append({"label": option["label"], "action": action})
92
+ return {"id": item["id"], "level": "unavailable" if item.get("kind") == "error" else "suggest", "area": _("同步盘"),
93
+ "title": item["title"], "reason": item["reason"], "impact": item["impact"], "next": steps,
94
+ "decision": {"id": item["id"], "fingerprint": item["fingerprint"], "scope": "machine"}}
95
+
96
+
44
97
  def check_updates(provider: Any, findings: list[dict], ok: list[str]) -> dict:
45
98
  host = provider.host
46
99
  if host is None:
47
100
  return {"status": "unavailable", "cached_at": ""}
48
101
  env = host.current_env()
102
+ # 脚手架与本机检查不依赖联网,先做;决定文件让已决定的项不再出现。
103
+ reprompt = getattr(provider, "reprompt", False)
104
+ scaffold = host.scaffold_report(env, provider.root, reprompt=reprompt) if hasattr(host, "scaffold_report") else None
105
+ machine = host.machine_findings(env, provider.root, reprompt=reprompt) if hasattr(host, "machine_findings") else None
106
+ provider._skipped = [*(scaffold["skipped"] if scaffold else []), *(machine["skipped"] if machine else [])]
107
+ _scaffold_findings(scaffold, findings, ok)
108
+ for item in (machine["findings"] if machine else []):
109
+ if item["id"] == "local:sync-folder":
110
+ findings.append({"id": "sync-folder", "level": "suggest", "area": _("环境"), "title": item["title"],
111
+ "reason": item["reason"], "impact": item["impact"],
112
+ "next": [{"label": _("查看同步盘使用指引(只给方案,不改文件、不删内容)"), "action": "fix:sync-folder"}],
113
+ "decision": {"id": item["id"], "fingerprint": item["fingerprint"], "scope": "machine"}})
114
+ elif item.get("fix") == "local-only" or item.get("kind") == "error":
115
+ findings.append(_local_only_finding(item))
49
116
  try:
50
117
  report = host.run_check(env, provider.root, offline=provider.offline)
51
118
  except (host.CheckError, OSError, ValueError) as exc:
52
- findings.append({"id": "update-check", "level": "unavailable", "area": "更新", "title": "无法检查更新",
53
- "reason": str(exc), "impact": "不影响找资料、看进展等日常功能",
54
- "next": [{"label": "联网后重新检查", "action": ""}]})
119
+ findings.append({"id": "update-check", "level": "unavailable", "area": _("更新"), "title": _("无法检查更新"),
120
+ "reason": str(exc), "impact": _("不影响找资料、看进展等日常功能"),
121
+ "next": [{"label": _("联网后重新检查"), "action": ""}]})
55
122
  provider._report = None
56
- _check_runtime(provider, env, None, findings, ok)
123
+ _check_runtime(provider, env, None, findings, ok, machine)
57
124
  return {"status": "offline", "cached_at": ""}
58
125
  provider._report = report
59
126
  origin = report["latest"]["source"]
@@ -64,7 +131,7 @@ def check_updates(provider: Any, findings: list[dict], ok: list[str]) -> dict:
64
131
  _plugin_findings(provider, env, report, findings, ok)
65
132
  _content_findings(report, findings, ok)
66
133
  _opinion_findings(report, findings, ok)
67
- _check_runtime(provider, env, report["_latest"], findings, ok)
134
+ _check_runtime(provider, env, report["_latest"], findings, ok, machine)
68
135
  return source
69
136
 
70
137
 
@@ -74,95 +141,114 @@ def _plugin_findings(provider: Any, env: Any, report: dict, findings: list[dict]
74
141
  behind = [item for item in plugins if item["status"] == "behind"]
75
142
  if behind:
76
143
  breaking = [item for item in behind if item["breaking"]]
77
- details = ";".join(f"{item['plugin']} {item['version']} → {item['latest']}({CLIENT_LABEL[item['client']]})"
144
+ details = _(";").join(_("{0} {1} → {2}({3})").format(item['plugin'], item['version'], item['latest'], _(CLIENT_LABEL[item['client']]))
78
145
  for item in behind)
79
- findings.append({"id": "update-plugins", "level": "update", "area": "插件",
80
- "title": f"{len(behind)} 个插件有新版本" + ("(含不兼容变更)" if breaking else ""),
81
- "reason": details, "impact": "只影响 AI 客户端里的插件;不更新也能继续使用本菜单",
82
- "next": [{"label": "查看更新内容与迁移说明,再决定是否更新", "action": "fix:update-plugins"}]})
146
+ findings.append({"id": "update-plugins", "level": "update", "area": _("插件"),
147
+ "title": _("{0} 个插件有新版本").format(len(behind)) + (_("(含不兼容变更)") if breaking else ""),
148
+ "reason": details, "impact": _("只影响 AI 客户端里的插件;不更新也能继续使用本菜单"),
149
+ "next": [{"label": _("查看更新内容与迁移说明,再决定是否更新"), "action": "fix:update-plugins"}]})
83
150
  elif plugins:
84
- ok.append("客户端插件(最新)")
151
+ ok.append(_("客户端插件(最新)"))
85
152
  if not plugins:
86
153
  clients = host.detect_clients(env)
87
154
  if clients:
88
- findings.append({"id": "no-plugins", "level": "unavailable", "area": "插件",
89
- "title": "AI 客户端里还没有安装 agenthelm 插件",
90
- "reason": "检测到 " + "、".join(CLIENT_LABEL.get(c, c) for c in clients) + ",但没有市场安装",
91
- "impact": "只影响 AI Agent 使用;本菜单不受影响",
92
- "next": [{"label": "需要时运行 agenthelm install 预览安装", "action": ""}]})
155
+ findings.append({"id": "no-plugins", "level": "unavailable", "area": _("插件"),
156
+ "title": _("AI 客户端里还没有安装 agenthelm 插件"),
157
+ "reason": _("检测到 ") + _("、").join(_(CLIENT_LABEL.get(c, c)) for c in clients) + _(",但没有市场安装"),
158
+ "impact": _("只影响 AI Agent 使用;本菜单不受影响"),
159
+ "next": [{"label": _("需要时运行 agenthelm install 预览安装"), "action": ""}]})
93
160
  else:
94
- findings.append({"id": "no-ai-client", "level": "unavailable", "area": "环境", "title": "本机没有 AI 客户端",
95
- "reason": "没有找到 Claude Code、Codex 或 Cursor",
96
- "impact": "本菜单的日常功能全部不受影响;只有“安装插件到客户端”不可用",
97
- "next": [{"label": "需要时安装任一客户端,再运行 agenthelm install", "action": ""}]})
161
+ findings.append({"id": "no-ai-client", "level": "unavailable", "area": _("环境"), "title": _("本机没有 AI 客户端"),
162
+ "reason": _("没有找到 Claude Code、Codex 或 Cursor"),
163
+ "impact": _("本菜单的日常功能全部不受影响;只有“安装插件到客户端”不可用"),
164
+ "next": [{"label": _("需要时安装任一客户端,再运行 agenthelm install"), "action": ""}]})
98
165
  if report["summary"]["legacy_installs"]:
99
- findings.append({"id": "legacy", "level": "suggest", "area": "插件", "title": "有旧方式安装待迁移",
100
- "reason": "旧的复制 + 软链安装与市场安装并存会重复", "impact": "Agent 里可能看到重复的 Skill",
101
- "next": [{"label": "运行 agenthelm migrate --install 预览迁移", "action": ""}]})
166
+ findings.append({"id": "legacy", "level": "suggest", "area": _("插件"), "title": _("有旧方式安装待迁移"),
167
+ "reason": _("旧的复制 + 软链安装与市场安装并存会重复"), "impact": _("Agent 里可能看到重复的 Skill"),
168
+ "next": [{"label": _("运行 agenthelm migrate --install 预览迁移"), "action": ""}]})
102
169
  for issue in host.doctor(env, report)["issues"]:
103
170
  if issue["issue"] in ("version-skew", "duplicate", "incomplete"):
104
- findings.append({"id": f"doctor-{issue['issue']}", "level": "suggest", "area": "插件",
105
- "title": {"version-skew": "各客户端插件版本不一致", "duplicate": "插件重复安装",
106
- "incomplete": "插件没有装全"}[issue["issue"]],
107
- "reason": issue["detail"], "impact": "只影响 AI Agent 使用",
108
- "next": [{"label": "运行 agenthelm doctor 查看", "action": ""}]})
171
+ findings.append({"id": f"doctor-{issue['issue']}", "level": "suggest", "area": _("插件"),
172
+ "title": {"version-skew": _("各客户端插件版本不一致"), "duplicate": _("插件重复安装"),
173
+ "incomplete": _("插件没有装全")}[issue["issue"]],
174
+ "reason": issue["detail"], "impact": _("只影响 AI Agent 使用"),
175
+ "next": [{"label": _("运行 agenthelm doctor 查看"), "action": ""}]})
109
176
 
110
177
 
111
178
  def _content_findings(report: dict, findings: list[dict], ok: list[str]) -> None:
112
179
  updates = [item for item in report["content"] if item.get("status") in ("update-available", "conflict")]
113
180
  for item in report["content"]:
114
181
  if item.get("pack") is None:
115
- findings.append({"id": "content-lock", "level": "suggest", "area": "项目模板", "title": "内容锁无法读取",
116
- "reason": item.get("error", ""), "impact": "模板更新无法判断哪些文件改过",
117
- "next": [{"label": "检查 .agents/moe.sakanano.agent-pack/content.lock.json", "action": ""}]})
182
+ findings.append({"id": "content-lock", "level": "suggest", "area": _("项目模板"), "title": _("内容锁无法读取"),
183
+ "reason": item.get("error", ""), "impact": _("模板更新无法判断哪些文件改过"),
184
+ "next": [{"label": _("检查 .agents/moe.sakanano.agent-pack/content.lock.json"), "action": ""}]})
118
185
  if updates:
119
186
  changed = sorted({path for item in updates for path in item["locally_changed"]})
120
- findings.append({"id": "update-content", "level": "update", "area": "项目模板",
121
- "title": "项目模板/样例有新版本",
122
- "reason": ";".join(f"{item['pack']} {item['version']} → {item['latest']}" for item in updates)
123
- + (f";你改过的:{'、'.join(changed)}" if changed else ""),
124
- "impact": "不更新不影响使用;更新时你改过的文件逐个选择保留或采用",
125
- "next": [{"label": "查看影响并选择", "action": "fix:update-content"}]})
187
+ findings.append({"id": "update-content", "level": "update", "area": _("项目模板"),
188
+ "title": _("项目模板/样例有新版本"),
189
+ "reason": _(";").join(f"{item['pack']} {item['version']} → {item['latest']}" for item in updates)
190
+ + (_(";你改过的:{0}").format(_('、').join(changed)) if changed else ""),
191
+ "impact": _("不更新不影响使用;更新时你改过的文件逐个选择保留或采用"),
192
+ "next": [{"label": _("查看影响并选择"), "action": "fix:update-content"}]})
126
193
  elif report["content"]:
127
- ok.append("项目模板(最新)")
194
+ ok.append(_("项目模板(最新)"))
128
195
 
129
196
 
130
197
  def _opinion_findings(report: dict, findings: list[dict], ok: list[str]) -> None:
131
198
  for item in report["opinion"]:
132
199
  if item["status"] == "update-available":
133
- refs = ";".join(f"{update['current']} → {update['available'][-1]}" for update in item["updates"])
134
- findings.append({"id": "opinion-updates", "level": "update", "area": "个人规则",
135
- "title": "行为规则模板有新版本", "reason": refs,
136
- "impact": "工具升级不会自动采纳个人规则;当前规则继续有效",
137
- "next": [{"label": "需要时在 Agent 会话中用 opinion-manager 预览后确认", "action": ""}]})
200
+ refs = _(";").join(f"{update['current']} → {update['available'][-1]}" for update in item["updates"])
201
+ findings.append({"id": "opinion-updates", "level": "update", "area": _("个人规则"),
202
+ "title": _("行为规则模板有新版本"), "reason": refs,
203
+ "impact": _("工具升级不会自动采纳个人规则;当前规则继续有效"),
204
+ "next": [{"label": _("需要时在 Agent 会话中用 opinion-manager 预览后确认"), "action": ""}]})
138
205
  elif item["status"] in ("error", "unknown"):
139
- findings.append({"id": "opinion-check", "level": "unavailable", "area": "个人规则",
140
- "title": "行为规则版本无法检查", "reason": item.get("error", ""),
141
- "impact": "只影响规则更新提示", "next": []})
206
+ findings.append({"id": "opinion-check", "level": "unavailable", "area": _("个人规则"),
207
+ "title": _("行为规则版本无法检查"), "reason": item.get("error", ""),
208
+ "impact": _("只影响规则更新提示"), "next": []})
142
209
  else:
143
- ok.append("行为规则(已是锁定的最新版本)")
210
+ ok.append(_("行为规则(已是锁定的最新版本)"))
144
211
 
145
212
 
146
- def _check_runtime(provider: Any, env: Any, latest: dict | None, findings: list[dict], ok: list[str]) -> None:
213
+ def _check_runtime(provider: Any, env: Any, latest: dict | None, findings: list[dict], ok: list[str],
214
+ machine: dict | None = None) -> None:
147
215
  runtime = runtime_module(provider.host)
148
216
  data = runtime.report(env, latest)
149
217
  active = data["current"]
150
218
  if active is None:
151
- findings.append({"id": "install-runtime", "level": "suggest", "area": "运行时", "title": "还没有安装用户级运行时",
152
- "reason": f"本次由 {data['running_from']} 提供;装好运行时后,不依赖 AI 客户端与 npx 也能打开菜单",
153
- "impact": "现在能用;换一台没有 AI 客户端的机器或客户端插件被移除时会打不开",
154
- "next": [{"label": "安装运行时(只写用户数据目录)", "action": "fix:install-runtime"}]})
219
+ if machine is not None and not machine["findings"]:
220
+ return # 用户已决定不提示这台机器的运行时(决定存在用户状态目录,不进项目)
221
+ text = runtime.missing_text(data, runtime.command_status()) if hasattr(runtime, "missing_text") else {
222
+ "reason": _("本次由 {0} 提供;装好运行时后,不依赖 AI 客户端与 npx 也能打开菜单").format(data['running_from']),
223
+ "impact": _("现在能用;换一台没有 AI 客户端的机器或客户端插件被移除时会打不开"),
224
+ "label": _("安装运行时(只写用户数据目录)")}
225
+ decision = ({"id": "local:runtime-missing", "fingerprint": "runtime-missing", "scope": "machine"}
226
+ if machine is not None else None)
227
+ findings.append({"id": "install-runtime", "level": "suggest", "area": _("运行时"), "title": _("这台机器还没有安装用户级运行时"),
228
+ "reason": text["reason"], "impact": text["impact"],
229
+ "next": [{"label": text["label"], "action": "fix:install-runtime"}],
230
+ **({"decision": decision} if decision else {})})
155
231
  elif data["update_available"]:
156
- findings.append({"id": "update-runtime", "level": "update", "area": "运行时", "title": "运行时有新版本",
157
- "reason": f"当前 {active['version']},最新 {data['latest_release']}",
158
- "impact": "不更新也能继续使用", "next": [{"label": "下载并切换(旧版本保留)", "action": "fix:update-runtime"}]})
232
+ findings.append({"id": "update-runtime", "level": "update", "area": _("运行时"), "title": _("运行时有新版本"),
233
+ "reason": _("当前 {0},最新 {1}").format(active['version'], data['latest_release']),
234
+ "impact": _("不更新也能继续使用"), "next": [{"label": _("下载并切换(旧版本保留)"), "action": "fix:update-runtime"}]})
159
235
  else:
160
- ok.append(f"运行时 {active['version']}")
236
+ ok.append(_("运行时 {0}").format(active['version']))
161
237
 
162
238
 
163
239
  # ---------------------------------------------------------------- plan
164
240
 
165
- FIXES = ("rebuild-index", "update-plugins", "update-content", "install-runtime", "update-runtime", "add-entry")
241
+ FIXES = ("rebuild-index", "update-plugins", "update-content", "install-runtime", "update-runtime", "add-entry",
242
+ "add-skeleton", "profile", "repair-lock", "case-naming", "sync-folder", "local-only")
243
+ # 可以带目标的处理项:fix add-skeleton <路径…>、fix profile <字段…>
244
+ TARGETED = ("add-skeleton", "profile")
245
+
246
+
247
+ def split_targets(ids: list[str]) -> tuple[list[str], list[str]]:
248
+ """``["add-skeleton", "a.md"]`` -> (["add-skeleton"], ["a.md"]); other ids are returned unchanged."""
249
+ if ids and ids[0] in TARGETED:
250
+ return ids[:1], ids[1:]
251
+ return ids, []
166
252
 
167
253
 
168
254
  def available_ids(provider: Any) -> list[str]:
@@ -170,7 +256,7 @@ def available_ids(provider: Any) -> list[str]:
170
256
  found = []
171
257
  for finding in health["findings"]:
172
258
  for step in finding["next"]:
173
- verb, _, target = step["action"].partition(":")
259
+ verb, _sep, target = step["action"].partition(":")
174
260
  if verb == "fix" and target in FIXES and target not in found:
175
261
  found.append(target)
176
262
  return found
@@ -181,7 +267,7 @@ def plan(provider: Any, ids: list[str], choices: dict[str, str]) -> dict:
181
267
  chosen = ids or offered
182
268
  unknown = [fix_id for fix_id in chosen if fix_id not in FIXES]
183
269
  if unknown:
184
- raise EntryError("没有这些处理项:" + "、".join(unknown), "not-found", ["可处理:" + ("、".join(offered) or "(无)")])
270
+ raise EntryError(_("没有这些处理项:") + _("、").join(unknown), "not-found", [_("可处理:") + (_("、").join(offered) or _("(无)"))])
185
271
  steps = [_plan_step(provider, fix_id, choices) for fix_id in chosen]
186
272
  return {"steps": steps, "simulated": False, "update_source": (provider._health or {}).get("update_source")}
187
273
 
@@ -195,18 +281,22 @@ def _step(fix_id: str, title: str, **values: Any) -> dict:
195
281
 
196
282
  def _need_host(provider: Any, fix_id: str, title: str) -> dict | None:
197
283
  if provider.host is None:
198
- return _step(fix_id, title, blocked="当前运行方式没有携带 agent-pack:用 agenthelm 运行时打开后再处理")
284
+ return _step(fix_id, title, blocked=_("当前运行方式没有携带 agent-pack:用 agenthelm 运行时打开后再处理"))
199
285
  return None
200
286
 
201
287
 
202
288
  def _plan_step(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
203
289
  if fix_id == "rebuild-index":
204
290
  state_dir = provider.assets_project().state_dir()
205
- return _step(fix_id, "重建资料索引", changes=["按当前文件重新登记资料、版本与引用"],
206
- writes=[{"path": str(state_dir), "scope": "用户缓存(不改项目文件)"}],
207
- recovery="不涉及项目文件;索引可随时再次重建", verify="重新读取索引状态")
291
+ return _step(fix_id, _("重建资料索引"), changes=[_("按当前文件重新登记资料、版本与引用")],
292
+ writes=[{"path": str(state_dir), "scope": _("用户缓存(不改项目文件)")}],
293
+ recovery=_("不涉及项目文件;索引可随时再次重建"), verify=_("重新读取索引状态"))
208
294
  if fix_id == "add-entry":
209
295
  return _plan_entry(provider, choices)
296
+ if fix_id in ("add-skeleton", "profile", "repair-lock"):
297
+ return _plan_scaffold(provider, fix_id, choices)
298
+ if fix_id in ("case-naming", "sync-folder", "local-only"):
299
+ return _plan_guidance(provider, fix_id)
210
300
  blocked = _need_host(provider, fix_id, fix_id)
211
301
  if blocked:
212
302
  return blocked
@@ -215,44 +305,91 @@ def _plan_step(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
215
305
  if fix_id in ("install-runtime", "update-runtime"):
216
306
  return _plan_runtime(provider, env, fix_id)
217
307
  report = getattr(provider, "_report", None)
218
- if report is None:
219
- return _step(fix_id, fix_id, blocked="版本信息不可用(离线且没有缓存):联网后重新检查")
308
+ if report is None and fix_id != "update-content":
309
+ return _step(fix_id, fix_id, blocked=_("版本信息不可用(离线且没有缓存):联网后重新检查"))
220
310
  if fix_id == "update-plugins":
221
311
  steps = host.update_steps(env, report, None, None, env.home, [], [], None, "plugins")
222
312
  behind = [item for item in report["plugins"] if item["status"] == "behind"]
223
- changes = [f"{item['plugin']} → {step['to']}:{step['summary']}" for item in behind for step in item["steps"]]
313
+ changes = [_("{0} → {1}:{2}").format(item['plugin'], step['to'], step['summary']) for item in behind for step in item["steps"]]
224
314
  actions = [action for item in behind for step in item["steps"] for action in step["actions"]]
225
315
  blocked = next((step.blocked for step in steps if step.blocked), "") or \
226
- ("当前离线:需要联网,只能预览" if provider.offline else "")
227
- return _step(fix_id, "更新客户端里的插件", network=True, changes=changes + [f"需要的动作:{a}" for a in actions],
228
- writes=[{"path": step.description + (f"({' '.join(step.command)})" if step.command else ""),
229
- "scope": "客户端插件目录"} for step in steps],
316
+ (_("当前离线:需要联网,只能预览") if provider.offline else "")
317
+ return _step(fix_id, _("更新客户端里的插件"), network=True, changes=changes + [_("需要的动作:{0}").format(a) for a in actions],
318
+ writes=[{"path": step.description + (_("({0})").format(' '.join(step.command)) if step.command else ""),
319
+ "scope": _("客户端插件目录")} for step in steps],
230
320
  breaking=any(item["breaking"] for item in behind),
231
- recovery="客户端保留旧版本缓存;需要时用客户端命令回到旧版本",
232
- verify="重新检查各客户端已安装的版本", blocked=blocked)
321
+ recovery=_("客户端保留旧版本缓存;需要时用客户端命令回到旧版本"),
322
+ verify=_("重新检查各客户端已安装的版本"), blocked=blocked)
233
323
  return _plan_content(provider, env, report, choices)
234
324
 
235
325
 
236
- def _plan_content(provider: Any, env: Any, report: dict, choices: dict[str, str]) -> dict:
326
+ def _run_refresh(provider: Any, accept: list[str], keep: list[str], recovery: Path, *, apply: bool) -> dict:
327
+ """agents-init 的 refresh:模板包版本没有更新、但有文件与当前模板不同(例如没有基线)时直接用它。"""
328
+ script = _init_script(provider)
329
+ if script is None:
330
+ raise EntryError(_("找不到支持刷新的 agents-init"), "unavailable", [_("更新插件或运行时后再试")])
331
+ command = [sys.executable, str(script), "--project", str(provider.root), "--mode", "refresh", "--output", "json"]
332
+ for path in accept:
333
+ command += ["--accept", path]
334
+ for path in keep:
335
+ command += ["--keep", path]
336
+ if apply or accept:
337
+ command += ["--recovery-dir", str(recovery)]
338
+ if apply:
339
+ command.append("--apply")
340
+ completed = subprocess.run(command, capture_output=True, text=True, check=False, timeout=120)
341
+ try:
342
+ result = json.loads(completed.stdout)
343
+ except ValueError as exc:
344
+ raise EntryError(_("agents-init 没有返回结果:") + (completed.stderr or "").strip()[:200], "failed") from exc
345
+ if "files" not in result:
346
+ raise EntryError(str(result.get("error") or _("agents-init 拒绝")), "failed")
347
+ return result
348
+
349
+
350
+ def _content_preview(provider: Any, env: Any, report: dict | None, accept: list[str], keep: list[str],
351
+ recovery: Path) -> tuple[str, list[dict], str, bool]:
352
+ """(说明, 文件计划, 阻塞原因, 是否走 refresh 直通路径)。"""
353
+ host = provider.host
354
+ steps = host.update_steps(env, report, provider.root, None, env.home, accept, keep, recovery, "content") \
355
+ if report is not None else []
356
+ if steps:
357
+ step = steps[0]
358
+ if step.blocked:
359
+ return step.description, [], step.blocked, False
360
+ preview = host.execute_steps([step], apply=False, recovery_root=None, child_env=env.child_environment())[0]
361
+ if preview.get("status") == "failed":
362
+ return step.description, [], preview.get("error", _("预览失败")), False
363
+ return step.description, (preview.get("result") or {}).get("files") or [], "", False
364
+ try:
365
+ result = _run_refresh(provider, accept, keep, recovery, apply=False)
366
+ except EntryError as exc:
367
+ return _("项目模板/样例"), [], str(exc), True
368
+ files = result.get("files") or []
369
+ if all(item["state"] in ("unchanged", "kept-by-decision") for item in files):
370
+ return _("项目模板/样例"), [], _("没有需要更新的模板"), True
371
+ return _("按当前模板对比项目契约文件"), files, "", True
372
+
373
+
374
+ def _plan_content(provider: Any, env: Any, report: dict | None, choices: dict[str, str]) -> dict:
237
375
  host = provider.host
238
376
  accept = sorted(path for path, choice in choices.items() if choice == "accept")
239
377
  keep = sorted(path for path, choice in choices.items() if choice == "keep")
240
378
  recovery = Path(choices.get("_recovery") or host.recovery_dir_for(env, "content"))
241
- steps = host.update_steps(env, report, provider.root, None, env.home, accept, keep, recovery, "content")
242
- if not steps:
243
- return _step("update-content", "项目模板/样例", blocked="没有需要更新的模板")
244
- step = steps[0]
245
- if step.blocked:
246
- return _step("update-content", step.description, blocked=step.blocked)
247
- preview = host.execute_steps([step], apply=False, recovery_root=None, child_env=env.child_environment())[0]
248
- files = (preview.get("result") or {}).get("files") or []
249
- if preview.get("status") == "failed":
250
- return _step("update-content", step.description, blocked=preview.get("error", "预览失败"))
251
- custom = [{"path": item["path"], "state": "你改过", "default": "keep"} for item in files
379
+ description, files, error, direct = _content_preview(provider, env, report, accept, keep, recovery)
380
+ if error:
381
+ return _step("update-content", description, blocked=error)
382
+
383
+ class _Step: # 保持下面沿用的 step.description 写法
384
+ pass
385
+
386
+ step = _Step()
387
+ step.description = description
388
+ custom = [{"path": item["path"], "state": _("你改过"), "default": "keep"} for item in files
252
389
  if item["state"] == "modified" or (item["path"] in choices and item["state"] in ("modified", "missing"))]
253
390
  for item in files:
254
391
  if item["state"] == "missing" and not any(entry["path"] == item["path"] for entry in custom):
255
- custom.append({"path": item["path"], "state": "新文件(目前不存在)", "default": "keep"})
392
+ custom.append({"path": item["path"], "state": _("新文件(目前不存在)"), "default": "keep"})
256
393
  effective = {item["path"]: choices.get(item["path"], item["default"]) for item in custom}
257
394
  writes = []
258
395
  for item in files:
@@ -262,18 +399,19 @@ def _plan_content(provider: Any, env: Any, report: dict, choices: dict[str, str]
262
399
  if action == "skip":
263
400
  action = "create" if effective.get(item["path"]) == "accept" else "keep"
264
401
  if action in ("replace", "create"):
265
- writes.append({"path": item["path"], "scope": "项目(先备份)" if action == "replace" else "项目(新建)"})
402
+ writes.append({"path": item["path"], "scope": _("项目(先备份)") if action == "replace" else _("项目(新建)")})
266
403
  # 刷新总会把内容锁更新到新版本(保留的文件沿用旧基线),所以它总在写入范围里。
267
- writes.append({"path": ".agents/moe.sakanano.agent-pack/content.lock.json", "scope": "项目(内容锁)"})
404
+ writes.append({"path": ".agents/moe.sakanano.agent-pack/content.lock.json", "scope": _("项目(内容锁)")})
268
405
  return _step("update-content", step.description, changes=[_content_change(item, effective) for item in files],
269
406
  writes=writes, custom=custom, recovery=str(recovery),
270
- verify="重新检查模板版本;保留的文件内容不变", payload={"recovery": str(recovery), "files": files})
407
+ verify=_("重新检查模板版本;保留的文件内容不变"),
408
+ payload={"recovery": str(recovery), "files": files, "direct": direct})
271
409
 
272
410
 
273
- CONTENT_STATE = {"unchanged": "与新模板相同", "clean": "没改过", "modified": "你改过", "missing": "目前不存在",
274
- "local-only": "只有你的改动,新模板没变"}
275
- CONTENT_ACTION = {"keep": "保留", "replace": "换成新模板(先备份)", "create": "新建", "skip": "不新建",
276
- "blocked": "需要你选择"}
411
+ CONTENT_STATE = {"unchanged": N_("与新模板相同"), "clean": N_("没改过"), "modified": N_("你改过"), "missing": N_("目前不存在"),
412
+ "local-only": N_("只有你的改动,新模板没变")}
413
+ CONTENT_ACTION = {"keep": N_("保留"), "replace": N_("换成新模板(先备份)"), "create": N_("新建"), "skip": N_("不新建"),
414
+ "blocked": N_("需要你选择")}
277
415
 
278
416
 
279
417
  def _content_change(item: dict, effective: dict[str, str]) -> str:
@@ -282,34 +420,34 @@ def _content_change(item: dict, effective: dict[str, str]) -> str:
282
420
  chosen = effective.get(item["path"], "keep")
283
421
  action = ("replace" if action == "blocked" else "create") if chosen == "accept" else \
284
422
  ("keep" if action == "blocked" else "skip")
285
- note = "(保留则不含新模板的改动)" if item["state"] == "modified" and action == "keep" else ""
286
- return f"{item['path']}:{CONTENT_STATE.get(item['state'], item['state'])} → {CONTENT_ACTION.get(action, action)}{note}"
423
+ note = _("(保留则不含新模板的改动)") if item["state"] == "modified" and action == "keep" else ""
424
+ return _("{0}:{1} → {2}{3}").format(item['path'], _(CONTENT_STATE.get(item['state'], item['state'])), _(CONTENT_ACTION.get(action, action)), note)
287
425
 
288
426
 
289
427
  def _plan_runtime(provider: Any, env: Any, fix_id: str) -> dict:
290
428
  runtime = runtime_module(provider.host)
291
429
  host = provider.host
292
- title = "安装用户级运行时" if fix_id == "install-runtime" else "更新用户级运行时"
430
+ title = _("安装用户级运行时") if fix_id == "install-runtime" else _("更新用户级运行时")
293
431
  if fix_id == "install-runtime" and runtime.bundled_root() is not None:
294
- root, origin = runtime.bundled_root(), "随附内容"
432
+ root, origin = runtime.bundled_root(), _("随附内容")
295
433
  network = False
296
434
  else:
297
435
  if provider.offline:
298
- return _step(fix_id, title, network=True, blocked="当前离线:需要下载发布包,只能预览;也可用 agenthelm runtime install --source <发布包>")
436
+ return _step(fix_id, title, network=True, blocked=_("当前离线:需要下载发布包,只能预览;也可用 agenthelm runtime install --source <发布包>"))
299
437
  try:
300
- latest, _ = host.load_latest(env, None)
438
+ latest, _origin = host.load_latest(env, None)
301
439
  except (host.CheckError, OSError, ValueError) as exc:
302
- return _step(fix_id, title, network=True, blocked=f"拿不到版本信息:{exc}")
440
+ return _step(fix_id, title, network=True, blocked=_("拿不到版本信息:{0}").format(exc))
303
441
  version = runtime.release_version(latest)
304
- return _step(fix_id, title, network=True, changes=[f"下载发布包 {version or '(最新)'} 并核对摘要"],
305
- writes=[{"path": str(runtime.runtimes_dir(env) / (version or "<最新版本>")), "scope": "用户数据目录(新版本)"},
306
- {"path": str(runtime.current_link(env)), "scope": "切换当前版本"}],
307
- recovery="旧版本保留;agenthelm runtime use <旧版本> --apply 切回", verify="重新读取当前运行时版本",
442
+ return _step(fix_id, title, network=True, changes=[_("下载发布包 {0} 并核对摘要").format(version or _('(最新)'))],
443
+ writes=[{"path": str(runtime.runtimes_dir(env) / (version or _("<最新版本>"))), "scope": _("用户数据目录(新版本)")},
444
+ {"path": str(runtime.current_link(env)), "scope": _("切换当前版本")}],
445
+ recovery=_("旧版本保留;agenthelm runtime use <旧版本> --apply 切回"), verify=_("重新读取当前运行时版本"),
308
446
  payload={"from_release": True})
309
447
  planned = runtime.plan(env, root, origin)
310
- return _step(fix_id, title, network=network, changes=[f"运行时 {planned['version']}({origin})"],
311
- writes=planned["writes"], recovery="旧版本保留;agenthelm runtime use <旧版本> --apply 切回",
312
- verify="重新读取当前运行时版本", payload={"from_release": False, "plan": planned})
448
+ return _step(fix_id, title, network=network, changes=[_("运行时 {0}({1})").format(planned['version'], origin)],
449
+ writes=planned["writes"], recovery=_("旧版本保留;agenthelm runtime use <旧版本> --apply 切回"),
450
+ verify=_("重新读取当前运行时版本"), payload={"from_release": False, "plan": planned})
313
451
 
314
452
 
315
453
  def _init_script(provider: Any) -> Path | None:
@@ -327,7 +465,7 @@ def _init_script(provider: Any) -> Path | None:
327
465
  def _run_init(provider: Any, name: str, choices: dict[str, str], recovery: str, *, apply: bool) -> dict:
328
466
  script = _init_script(provider)
329
467
  if script is None:
330
- raise EntryError("找不到支持生成入口的 agents-init", "unavailable", ["更新插件或运行时后再试"])
468
+ raise EntryError(_("找不到支持生成入口的 agents-init"), "unavailable", [_("更新插件或运行时后再试")])
331
469
  command = [sys.executable, str(script), "--project", str(provider.root), "--mode", "entry",
332
470
  "--entry-name", name, "--output", "json"]
333
471
  if choices.get(name) in ("keep", "accept"):
@@ -342,7 +480,7 @@ def _run_init(provider: Any, name: str, choices: dict[str, str], recovery: str,
342
480
  try:
343
481
  return json.loads(completed.stdout)
344
482
  except ValueError as exc:
345
- raise EntryError("agents-init 没有返回结果:" + (completed.stderr or "").strip()[:200], "failed") from exc
483
+ raise EntryError(_("agents-init 没有返回结果:") + (completed.stderr or "").strip()[:200], "failed") from exc
346
484
 
347
485
 
348
486
  def _recovery_dir(provider: Any, label: str) -> str:
@@ -355,8 +493,8 @@ def _recovery_dir(provider: Any, label: str) -> str:
355
493
  def _plan_entry(provider: Any, choices: dict[str, str]) -> dict:
356
494
  name = choices.get("entry-name") or provider.launcher() or DEFAULT_LAUNCHER
357
495
  if not LAUNCHER_RE.fullmatch(name):
358
- raise EntryError(f"启动器名称不合法:{name}", "usage", ["只能用字母、数字、. _ -,例如 menu、go"])
359
- title = f"生成根目录启动器 ./{name}" if provider.launcher() != name else f"完善入口 ./{name}"
496
+ raise EntryError(_("启动器名称不合法:{0}").format(name), "usage", [_("只能用字母、数字、. _ -,例如 menu、go")])
497
+ title = _("生成根目录启动器 ./{0}").format(name) if provider.launcher() != name else _("完善入口 ./{0}").format(name)
360
498
  recovery = choices.get("_recovery") or _recovery_dir(provider, "entry")
361
499
  try:
362
500
  result = _run_init(provider, name, choices, recovery, apply=False)
@@ -366,18 +504,236 @@ def _plan_entry(provider: Any, choices: dict[str, str]) -> dict:
366
504
  except EntryError as exc:
367
505
  return _step("add-entry", title, blocked=str(exc))
368
506
  if result.get("code"):
369
- return _step("add-entry", title, blocked=result.get("error", "agents-init 拒绝"))
507
+ return _step("add-entry", title, blocked=result.get("error", _("agents-init 拒绝")))
370
508
  entry = result.get("entry") or {}
371
- custom = [{"path": name, "state": "已有同名文件(你的)", "default": "keep"}] \
509
+ custom = [{"path": name, "state": _("已有同名文件(你的)"), "default": "keep"}] \
372
510
  if entry.get("action") in ("keep", "replace", "blocked") else []
373
511
  readme = provider.root / "README.md"
374
512
  has_section = readme.is_file() and "<!-- agenthelm:entry -->" in readme.read_text(encoding="utf-8", errors="replace")
375
513
  if not has_section:
376
- custom.append({"path": "README.md", "state": "可追加「怎样使用」一节(带标记)", "default": "keep"})
377
- writes = [{"path": path, "scope": "项目"} for path in entry.get("writes", [])]
514
+ custom.append({"path": "README.md", "state": _("可追加「怎样使用」一节(带标记)"), "default": "keep"})
515
+ writes = [{"path": path, "scope": _("项目")} for path in entry.get("writes", [])]
378
516
  return _step("add-entry", title, changes=entry.get("changes", []), writes=writes, custom=custom,
379
- recovery=recovery if choices.get(name) == "accept" else "新增文件可直接删除;Profile 改动前后见预览",
380
- verify=f"重新读取 Profile 的 entry 段与 ./{name}", payload={"name": name, "recovery": recovery})
517
+ recovery=recovery if choices.get(name) == "accept" else _("新增文件可直接删除;Profile 改动前后见预览"),
518
+ verify=_("重新读取 Profile 的 entry 段与 ./{0}").format(name), payload={"name": name, "recovery": recovery})
519
+
520
+
521
+ # ---------------------------------------------------------------- scaffold fixes
522
+
523
+ SCAFFOLD_TITLE = {"add-skeleton": N_("补上缺少的骨架文件"), "profile": N_("修正 Profile 字段"), "repair-lock": N_("修复内容锁版本")}
524
+ DECISIONS_FILE = ".agents/moe.sakanano.agent-pack/check-decisions.json"
525
+
526
+
527
+ def scaffold_module(provider: Any) -> Any | None:
528
+ host = provider.host
529
+ return host.scaffold_module(host.current_env()) if host is not None and hasattr(host, "scaffold_module") else None
530
+
531
+
532
+ def _preview_lines(text: str, limit: int = 30) -> str:
533
+ lines = text.splitlines()
534
+ shown = [f"+{line}" for line in lines[:limit]]
535
+ return "\n".join(shown + ([_("... 还有 {0} 行").format(len(lines) - limit)] if len(lines) > limit else [])) or _("(空文件)")
536
+
537
+
538
+ def _plan_scaffold(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
539
+ title = _(SCAFFOLD_TITLE[fix_id])
540
+ module = scaffold_module(provider)
541
+ if module is None:
542
+ return _step(fix_id, title, blocked=_("需要带脚手架检查的 agents-init(更新插件或运行时后再试)"))
543
+ root = provider.root
544
+ state_dir = provider.host.current_env().state_dir
545
+ wanted = [item for item in (choices.get("_targets") or "").split("\n") if item]
546
+ try:
547
+ if fix_id == "repair-lock":
548
+ result = module.repair_lock(root)
549
+ if result["blocked"]:
550
+ return _step(fix_id, title, blocked=result["blocked"])
551
+ return _step(fix_id, title, changes=result["changes"] + [result["diff"]] if result["diff"] else result["changes"],
552
+ writes=[{"path": ".agents/moe.sakanano.agent-pack/content.lock.json", "scope": _("项目(内容锁)")}],
553
+ recovery=_("内容锁由工具生成;写入前再次核对内容未变,可用 git 或同步盘历史还原"),
554
+ verify=_("重新检查:内容锁不再显示 0.0.0"))
555
+ report = module.check(root, state_dir=state_dir, reprompt=True)
556
+ recovery = Path(choices.get("_recovery") or _recovery_dir(provider, fix_id))
557
+ if fix_id == "add-skeleton":
558
+ rows = [row["path"] for row in report["skeleton"] if row["state"] == "missing" and row["required"]]
559
+ rows = [p for p in rows if not wanted or p in wanted]
560
+ unknown = [p for p in wanted if p not in {r["path"] for r in report["skeleton"]}]
561
+ if unknown:
562
+ raise EntryError(_("不在期望骨架清单里:") + _("、").join(unknown), "usage")
563
+ if not rows:
564
+ return _step(fix_id, title, blocked=_("没有缺少的骨架文件"))
565
+ ctx = module.Context(root)
566
+ custom = [{"path": p, "state": _("缺少"), "default": "accept", "options": True} for p in rows]
567
+ decided = {p: choices.get(p, "accept") for p in rows}
568
+ accepted = [p for p, choice in decided.items() if choice == "accept"]
569
+ preview = module.add_skeleton(root, accepted, apply=False, state_dir=state_dir) if accepted else \
570
+ {"create": [], "unchanged": []}
571
+ writes = [{"path": item["path"], "scope": _("项目(新建)")} for item in preview["create"]]
572
+ other = {p: c for p, c in decided.items() if c != "accept"}
573
+ if other:
574
+ writes.append({"path": DECISIONS_FILE, "scope": _("项目(决定文件,随项目同步)")})
575
+ return _step(fix_id, title, changes=[_("{0}:{1}").format(p, {'accept': _('新建'), 'skip': _('永久跳过'), 'later': _('稍后')}[c])
576
+ for p, c in decided.items()],
577
+ writes=writes, custom=custom,
578
+ recovery=_("只新建文件,已有文件不会被改动;新建的可直接删除"),
579
+ verify=_("重新检查:这些文件不再显示为缺少"),
580
+ payload={"accepted": accepted, "decisions": other, "recovery": str(recovery),
581
+ "diffs": {p: _preview_lines(ctx.expected_text(p) or "") for p in rows}})
582
+ # profile
583
+ rows = {row["field"]: row for row in report["profile"] if row["state"] != "ok" and row["mode"] in ("default", "equal")}
584
+ unknown = [f for f in wanted if f not in rows]
585
+ if unknown:
586
+ raise EntryError(_("这些字段不需要或不能自动修复:") + _("、").join(unknown), "usage")
587
+ fields = [f for f in rows if not wanted or f in wanted]
588
+ if not fields:
589
+ return _step(fix_id, title, blocked=_("没有需要修正的 Profile 字段"))
590
+ decided = {f: choices.get(f, "accept") for f in fields}
591
+ accepted = [f for f, choice in decided.items() if choice == "accept"]
592
+ diffs = {f: module.fix_profile(root, [f], apply=False, state_dir=state_dir)["diff"] for f in fields}
593
+ writes = []
594
+ if accepted:
595
+ writes.append({"path": ".agents/moe.sakanano.agent-pack/project.json", "scope": _("项目(先备份)")})
596
+ other = {f: c for f, c in decided.items() if c != "accept"}
597
+ if other:
598
+ writes.append({"path": DECISIONS_FILE, "scope": _("项目(决定文件,随项目同步)")})
599
+ return _step(fix_id, title,
600
+ changes=[f"{f} → {json.dumps(rows[f]['expected'], ensure_ascii=False)}"
601
+ if decided[f] == "accept" else _("{0}:{1}").format(f, {'skip': _('永久跳过'), 'later': _('稍后')}[decided[f]])
602
+ for f in fields],
603
+ writes=writes, custom=[{"path": f, "state": _("缺少") if rows[f]["state"] == "missing" else _("不符合"),
604
+ "default": "accept", "options": True} for f in fields],
605
+ recovery=str(recovery), verify=_("重新检查:这些字段不再显示为缺少或不符合"),
606
+ payload={"accepted": accepted, "decisions": other, "recovery": str(recovery), "diffs": diffs})
607
+ except Exception as exc: # noqa: BLE001 - init errors come from a separately loaded module copy
608
+ if isinstance(exc, EntryError):
609
+ raise
610
+ return _step(fix_id, title, blocked=str(exc))
611
+
612
+
613
+ PLAN_ONLY = N_("只给方案,不会自动执行;按方案自己处理后重新检查")
614
+
615
+
616
+ def _plan_guidance(provider: Any, fix_id: str) -> dict:
617
+ """Plan-only items: they explain what to do and change nothing (no directory moves, no deletions)."""
618
+ if fix_id == "case-naming":
619
+ title = _("Case 目录命名:改名计划")
620
+ module = scaffold_module(provider)
621
+ if module is None:
622
+ return _step(fix_id, title, blocked=_("需要带脚手架检查的 agents-init(更新插件或运行时后再试)"))
623
+ try:
624
+ plan = module.case_naming_plan(provider.root)
625
+ except Exception as exc: # noqa: BLE001
626
+ return _step(fix_id, title, blocked=str(_entry_error(exc)))
627
+ if plan["error"]:
628
+ return _step(fix_id, title, blocked=_("Profile 的 work.case.directory_naming 无法使用:") + plan["error"])
629
+ if not plan["renames"]:
630
+ return _step(fix_id, title, blocked=_("Case 目录命名与规则一致,没有需要改的"))
631
+ changes = []
632
+ for item in plan["renames"]:
633
+ line = f"{item['from']} → {item['to'] or _('(需要你决定新名字)')}"
634
+ if item["references"]:
635
+ line += _(";被这些文件提到,改名后要同步:{0}").format(_('、').join(item['references'][:5])) \
636
+ + (_(" 等 {0} 个").format(len(item['references'])) if len(item["references"]) > 5 else "")
637
+ if item["note"]:
638
+ line += _("({0})").format(item['note'])
639
+ changes.append(line)
640
+ if plan["declaration_hint"]:
641
+ hint = json.dumps(plan["declaration_hint"]["if_this_is_your_convention"], ensure_ascii=False)
642
+ changes.append(_("如果这些名字正是你的约定,不用改名:在 Profile 里声明它,之后按声明检查 → ") + hint)
643
+ changes.append(_("每个 Case 的 CASE.md 里的 id 字段也要和新目录名保持一致"))
644
+ return _step(fix_id, title, changes=changes, recovery=_("本处理项不移动、不改写任何文件"),
645
+ verify=_("按计划改名并更新引用后,重新运行检查"), blocked=_(PLAN_ONLY) + _("(不会移动目录)"))
646
+ if fix_id == "local-only":
647
+ return _plan_local_only(provider)
648
+ title = _("同步盘使用指引")
649
+ host = provider.host
650
+ where = host.sync_folder(provider.root) if host is not None and hasattr(host, "sync_folder") else None
651
+ if where is None:
652
+ return _step(fix_id, title, blocked=_("项目不在同步盘里"))
653
+ changes = [
654
+ _("随项目同步的:Profile、内容锁、决定文件(check-decisions.json)、所有项目文件"),
655
+ _("每台机器各一份的:用户级运行时(~/.local/share/agenthelm)、版本缓存、wf 的资料台账缓存(按项目绝对路径命名)、本机决定"),
656
+ _("所以:两台机器的 wf 结果可能不同——台账各自重建,且取决于各自装的 project-orchestrator 版本;"
657
+ "用 agenthelm check --fingerprint 对比两台机器的脚手架是否一致"),
658
+ ]
659
+ if where["rebuildable"]:
660
+ listed = _("、").join(where["rebuildable"][:8])
661
+ changes.append(_("项目里有可重建目录:{0}。它们不需要同步,可以:").format(listed))
662
+ changes.append(_(" · 推荐:agenthelm local-only check 逐项查看,agenthelm local-only fix 把 .venv、node_modules 等"
663
+ "外置到本机(~/.local/share/agenthelm/local-only/…)并在原位置建软链接;yarn 经典版、bun、uv 实测可用,"
664
+ "npm 会把链接换回真实目录、pnpm 会安装失败,同步目录里请尽量避免它们"))
665
+ changes.append(_(" · 写进 .gitignore(版本管理忽略它们,不影响同步盘)"))
666
+ changes.append(_(" · 或在 {0} 的设置里把这些目录设为不同步(选择性同步)").format(where['service']))
667
+ changes.append(_("本工具不会替你移动、改名或删除任何内容"))
668
+ return _step(fix_id, title, changes=changes, recovery=_("本处理项不改任何文件"), verify=_("不涉及"),
669
+ blocked=_(PLAN_ONLY))
670
+
671
+
672
+ def _plan_local_only(provider: Any) -> dict:
673
+ """只展示 agenthelm local-only fix 的预览:移动目录属于用户的同步盘内容,由它自带预览与回滚的命令执行。"""
674
+ title = _("本机专属目录:外置到本机")
675
+ host = provider.host
676
+ if host is None or not hasattr(host, "local_only_plan"):
677
+ return _step("local-only", title, blocked=_("需要带 local-only 的 agent-pack(更新插件或运行时后再试)"))
678
+ try:
679
+ plan = host.local_only_plan(host.current_env(), provider.root)
680
+ except Exception as exc: # noqa: BLE001 - LocalOnlyError 等:把原因原样给用户
681
+ return _step("local-only", title, blocked=str(exc))
682
+ changes = [step["detail"] for step in plan["steps"]] + [_("不处理:{0}").format(message) for message in plan["blocked"]]
683
+ if not plan["steps"]:
684
+ return _step("local-only", title, changes=changes,
685
+ blocked=_("没有可以自动处理的项;体积阈值等需要你选择,见 health 里各项给出的选项"))
686
+ changes.append(_("执行:agenthelm local-only fix --apply(先预览;移动失败整体撤销)"))
687
+ return _step("local-only", title, changes=changes, recovery=_("移动失败整体撤销;旧配置备份在 ~/.agent-pack/recovery/"),
688
+ verify=_("重新运行 health,或 agenthelm local-only check"), blocked=PLAN_ONLY + _("(这里只展示预览)"))
689
+
690
+
691
+ def record_decisions(provider: Any, items: dict[str, str], reason: str, *, prefix: str, apply: bool) -> list[str]:
692
+ """Persist 永久跳过 / 稍后 choices (``{path_or_field: skip|later}``) made while fixing."""
693
+ module = scaffold_module(provider)
694
+ env = provider.host.current_env()
695
+ written = []
696
+ for key, choice in items.items():
697
+ module.decide(provider.root, f"{prefix}:{key}", choice, reason=reason if choice == "skip" else "",
698
+ apply=apply, state_dir=env.state_dir)
699
+ written.append(f"{prefix}:{key}")
700
+ return written
701
+
702
+
703
+ def _entry_error(exc: Exception) -> EntryError:
704
+ """agents-init raises its own InitError (a separately loaded class): map its code to the entry's."""
705
+ if isinstance(exc, EntryError):
706
+ return exc
707
+ code = {"conflict": "conflict", "profile-invalid": "profile-invalid"}.get(getattr(exc, "code", ""), "usage")
708
+ return EntryError(str(exc), code)
709
+
710
+
711
+ def decide_ids(provider: Any, skip: list[str], unskip: list[str], reason: str, apply: bool) -> dict:
712
+ """``fix --skip <id> --reason …`` / ``--unskip <id>``: only decisions are written, never project files."""
713
+ module = scaffold_module(provider)
714
+ if module is None:
715
+ raise EntryError(_("需要带脚手架检查的 agents-init(更新插件或运行时后再试)"), "unavailable")
716
+ health = provider._health or provider.health()
717
+ known = {f["decision"]["id"]: f["decision"] for f in health["findings"] if f.get("decision")}
718
+ env = provider.host.current_env()
719
+ results = []
720
+ for identifier in skip:
721
+ if identifier not in known:
722
+ raise EntryError(_("现在没有这个发现:{0}").format(identifier), "usage", [_("可跳过:") + (_("、").join(sorted(known)) or _("(无)"))])
723
+ item = known[identifier]
724
+ try:
725
+ results.append(module.decide(provider.root, identifier, "skip", reason=reason,
726
+ fingerprint=item["fingerprint"], scope=item["scope"], apply=apply,
727
+ state_dir=env.state_dir))
728
+ except Exception as exc: # noqa: BLE001
729
+ raise _entry_error(exc) from exc
730
+ for identifier in unskip:
731
+ try:
732
+ results.append(module.undecide(provider.root, identifier, apply=apply, state_dir=env.state_dir))
733
+ except Exception as exc: # noqa: BLE001
734
+ raise _entry_error(exc) from exc
735
+ provider._health = None
736
+ return {"decisions": results, "applied": apply}
381
737
 
382
738
 
383
739
  # ---------------------------------------------------------------- apply
@@ -395,7 +751,7 @@ def apply(provider: Any, planned: dict, choices: dict[str, str]) -> dict:
395
751
  fresh = _plan_step(provider, step["id"], effective)
396
752
  if [w["path"] for w in fresh["writes"]] != [w["path"] for w in step["writes"]]:
397
753
  results.append({"id": step["id"], "status": "skipped",
398
- "detail": "预览之后情况有变化(写入范围不同),没有执行:请重新预览"})
754
+ "detail": _("预览之后情况有变化(写入范围不同),没有执行:请重新预览")})
399
755
  continue
400
756
  try:
401
757
  results.append(_apply_step(provider, fresh, effective))
@@ -406,6 +762,31 @@ def apply(provider: Any, planned: dict, choices: dict[str, str]) -> dict:
406
762
  return {"results": results, "simulated": False}
407
763
 
408
764
 
765
+ def _apply_scaffold(provider: Any, step: dict, choices: dict[str, str], base: dict) -> dict:
766
+ fix_id = step["id"]
767
+ module = scaffold_module(provider)
768
+ env = provider.host.current_env()
769
+ payload = step["payload"]
770
+ if fix_id == "repair-lock":
771
+ result = module.repair_lock(provider.root, apply=True)
772
+ return {**base, "verify": _("内容锁版本已修复") if result["applied"] else _("没有需要修复的内容")}
773
+ accepted = payload["accepted"]
774
+ written = []
775
+ if fix_id == "add-skeleton" and accepted:
776
+ result = module.add_skeleton(provider.root, accepted, apply=True, state_dir=env.state_dir)
777
+ written = [item["path"] for item in result["create"]]
778
+ if fix_id == "profile" and accepted:
779
+ result = module.fix_profile(provider.root, accepted, apply=True, recovery_dir=Path(payload["recovery"]),
780
+ state_dir=env.state_dir)
781
+ written = [".agents/moe.sakanano.agent-pack/project.json"]
782
+ prefix = "skel" if fix_id == "add-skeleton" else "profile"
783
+ recorded = record_decisions(provider, payload["decisions"], choices.get("_reason", ""), prefix=prefix, apply=True)
784
+ return {**base, "written": written + ([DECISIONS_FILE] if recorded else []),
785
+ "verify": _("重新检查:") + (_("、").join(accepted) or _("没有写入文件"))
786
+ + (_(";已记录决定:{0}").format(_('、').join(recorded)) if recorded else ""),
787
+ "recovery": payload["recovery"] if fix_id == "profile" and accepted else _("只新建文件,无需备份")}
788
+
789
+
409
790
  def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
410
791
  fix_id = step["id"]
411
792
  base = {"id": fix_id, "status": "applied", "written": [w["path"] for w in step["writes"]], "kept": [],
@@ -413,16 +794,20 @@ def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
413
794
  if fix_id == "rebuild-index":
414
795
  result = assets.rebuild(provider.assets_project(), force=True)
415
796
  state = assets.status(provider.assets_project())
416
- return {**base, "verify": f"索引{'最新' if state['status'] == 'fresh' else '状态:' + state['status']},"
417
- f"{result['file_count']} 个文件"}
797
+ return {**base, "verify": _("索引{0},{1} 个文件").format(_('最新') if state['status'] == 'fresh' else _('状态:') + state['status'], result['file_count'])}
798
+ if fix_id in ("add-skeleton", "profile", "repair-lock"):
799
+ try:
800
+ return _apply_scaffold(provider, step, choices, base)
801
+ except Exception as exc: # noqa: BLE001
802
+ raise _entry_error(exc) from exc
418
803
  if fix_id == "add-entry":
419
804
  name = step["payload"]["name"]
420
805
  result = _run_init(provider, name, choices, step["payload"]["recovery"], apply=True)
421
806
  if not result.get("applied"):
422
- raise EntryError("没有写入:" + str(result.get("hint") or result.get("error") or result.get("blocked")), "failed")
807
+ raise EntryError(_("没有写入:") + str(result.get("hint") or result.get("error") or result.get("blocked")), "failed")
423
808
  launcher = provider.root / name
424
809
  ready = launcher.is_file() and launcher.stat().st_mode & 0o111
425
- return {**base, "verify": f"./{name} {'已就绪' if ready else '未就绪'};Profile 已声明 entry"}
810
+ return {**base, "verify": _("./{0} {1};Profile 已声明 entry").format(name, _('已就绪') if ready else _('未就绪'))}
426
811
  host = provider.host
427
812
  env = host.current_env()
428
813
  if fix_id in ("install-runtime", "update-runtime"):
@@ -430,9 +815,9 @@ def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
430
815
  if step["payload"].get("from_release"):
431
816
  import tempfile
432
817
  with tempfile.TemporaryDirectory(prefix="agenthelm-runtime-") as temporary:
433
- latest, _ = host.load_latest(env, None, max_age=0)
818
+ latest, _origin = host.load_latest(env, None, max_age=0)
434
819
  root = host.release_source(latest, Path(temporary))
435
- planned = runtime.plan(env, root, "GitHub Release(已核对摘要)")
820
+ planned = runtime.plan(env, root, _("GitHub Release(已核对摘要)"))
436
821
  result = runtime.apply(env, planned, root)
437
822
  else:
438
823
  planned = step["payload"]["plan"]
@@ -447,22 +832,27 @@ def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
447
832
  behind = [item for item in after["plugins"] if item["status"] == "behind"]
448
833
  return {**base, "status": "failed" if failed else "applied",
449
834
  "detail": failed[0].get("output", "") if failed else "",
450
- "verify": "重新检查:" + ("全部为最新" if not behind else f"仍有 {len(behind)} 个落后")}
835
+ "verify": _("重新检查:") + (_("全部为最新") if not behind else _("仍有 {0} 个落后").format(len(behind)))}
451
836
  # update-content
452
837
  custom = {item["path"] for item in step["custom"]}
453
838
  keep = [path for path in custom if choices.get(path, "keep") == "keep"]
454
839
  accept = [path for path in custom if choices.get(path) == "accept"]
455
840
  before = {path: _digest(provider.root / path) for path in keep}
456
841
  recovery = Path(step["payload"]["recovery"])
457
- steps = host.update_steps(env, provider._report, provider.root, None, env.home, accept, keep, recovery, "content")
458
- outcome = host.execute_steps(steps, apply=True, recovery_root=None, child_env=env.child_environment())[0]
459
- result = outcome.get("result") or {}
460
- if outcome["status"] == "failed" or not result.get("applied"):
461
- raise EntryError("模板更新没有执行:" + str(outcome.get("error") or result.get("hint") or result), "failed")
842
+ if step["payload"].get("direct"):
843
+ result = _run_refresh(provider, accept, keep, recovery, apply=True)
844
+ if not result.get("applied"):
845
+ raise EntryError(_("模板更新没有执行:") + str(result.get("hint") or result), "failed")
846
+ else:
847
+ steps = host.update_steps(env, provider._report, provider.root, None, env.home, accept, keep, recovery, "content")
848
+ outcome = host.execute_steps(steps, apply=True, recovery_root=None, child_env=env.child_environment())[0]
849
+ result = outcome.get("result") or {}
850
+ if outcome["status"] == "failed" or not result.get("applied"):
851
+ raise EntryError(_("模板更新没有执行:") + str(outcome.get("error") or result.get("hint") or result), "failed")
462
852
  untouched = all(_digest(provider.root / path) == digest for path, digest in before.items())
463
- return {**base, "kept": keep, "accepted": accept, "recovery": result.get("recovery") or "没有替换文件,无需备份",
464
- "verify": "重新读取:保留的文件" + ("内容未变" if untouched else "被改动了,请检查")
465
- + ";内容锁已更新到新版本"}
853
+ return {**base, "kept": keep, "accepted": accept, "recovery": result.get("recovery") or _("没有替换文件,无需备份"),
854
+ "verify": _("重新读取:保留的文件") + (_("内容未变") if untouched else _("被改动了,请检查"))
855
+ + _(";内容锁已更新到新版本")}
466
856
 
467
857
 
468
858
  def _digest(path: Path) -> str | None: