@sakanano-moe/agenthelm 6.7.0 → 6.8.0
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.
- package/package.json +1 -1
- package/runtime/index.json +5 -5
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agents-init/content.json +15 -0
- package/runtime/plugins/agents-init/plugin.json +1 -1
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +13 -0
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
|
@@ -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``)完成;没有它时这些项标为不可用。
|
|
@@ -41,11 +43,58 @@ def runtime_module(host: Any) -> Any:
|
|
|
41
43
|
|
|
42
44
|
# ---------------------------------------------------------------- check
|
|
43
45
|
|
|
46
|
+
SCAFFOLD_NEXT = {"add-skeleton": "按模板补上(只新建,不改已有文件)",
|
|
47
|
+
"profile": "把字段改为模板值(先备份,预览里有 diff)",
|
|
48
|
+
"repair-lock": "按实际版本修复内容锁(不涉及你的文件)",
|
|
49
|
+
"update-content": "逐文件选择保留或采用新模板",
|
|
50
|
+
"case-naming": "查看改名计划与受影响的引用(只给计划,不移动目录)"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _scaffold_findings(scaffold: dict | None, findings: list[dict], ok: list[str]) -> None:
|
|
54
|
+
if scaffold is None:
|
|
55
|
+
return
|
|
56
|
+
if scaffold.get("error"):
|
|
57
|
+
findings.append({"id": "scaffold-check", "level": "unavailable", "area": "脚手架", "title": "无法检查项目脚手架",
|
|
58
|
+
"reason": scaffold["error"], "impact": "只影响脚手架一致性提示", "next": []})
|
|
59
|
+
return
|
|
60
|
+
for item in scaffold["findings"]:
|
|
61
|
+
mark = "(稍后)" if item.get("deferred") else "(期望内容已变化)" if item.get("changed_since_decision") else ""
|
|
62
|
+
step = {"label": SCAFFOLD_NEXT[item["fix"]], "action": f"fix:{item['fix']}"} if item["fix"] \
|
|
63
|
+
else {"label": "这一项需要你自己处理", "action": ""}
|
|
64
|
+
findings.append({"id": item["id"], "level": "suggest", "area": "脚手架", "title": item["title"] + mark,
|
|
65
|
+
"reason": item["reason"], "impact": "脚手架和最新版不一致;不影响日常使用,"
|
|
66
|
+
"不想要的可以永久跳过(一次决定,所有机器生效)", "next": [step],
|
|
67
|
+
"decision": {"id": item["id"], "fingerprint": item["fingerprint"], "scope": "project"}})
|
|
68
|
+
for message in scaffold["decisions"]["errors"]:
|
|
69
|
+
findings.append({"id": "scaffold-decisions", "level": "unavailable", "area": "脚手架",
|
|
70
|
+
"title": "决定文件有问题", "reason": message, "impact": "已记录的决定暂时不生效", "next": []})
|
|
71
|
+
if scaffold["decisions"]["conflict_copies"]:
|
|
72
|
+
findings.append({"id": "scaffold-decisions-conflict", "level": "suggest", "area": "脚手架",
|
|
73
|
+
"title": "同步服务留下了决定文件的冲突副本",
|
|
74
|
+
"reason": "、".join(scaffold["decisions"]["conflict_copies"]),
|
|
75
|
+
"impact": "另一台机器写入的决定可能没有生效",
|
|
76
|
+
"next": [{"label": "把其中的条目合并进 check-decisions.json 后删除副本", "action": ""}]})
|
|
77
|
+
if not scaffold["findings"]:
|
|
78
|
+
ok.append("项目脚手架(与期望一致" + (f",{len(scaffold['skipped'])} 项已跳过" if scaffold["skipped"] else "") + ")")
|
|
79
|
+
|
|
80
|
+
|
|
44
81
|
def check_updates(provider: Any, findings: list[dict], ok: list[str]) -> dict:
|
|
45
82
|
host = provider.host
|
|
46
83
|
if host is None:
|
|
47
84
|
return {"status": "unavailable", "cached_at": ""}
|
|
48
85
|
env = host.current_env()
|
|
86
|
+
# 脚手架与本机检查不依赖联网,先做;决定文件让已决定的项不再出现。
|
|
87
|
+
reprompt = getattr(provider, "reprompt", False)
|
|
88
|
+
scaffold = host.scaffold_report(env, provider.root, reprompt=reprompt) if hasattr(host, "scaffold_report") else None
|
|
89
|
+
machine = host.machine_findings(env, provider.root, reprompt=reprompt) if hasattr(host, "machine_findings") else None
|
|
90
|
+
provider._skipped = [*(scaffold["skipped"] if scaffold else []), *(machine["skipped"] if machine else [])]
|
|
91
|
+
_scaffold_findings(scaffold, findings, ok)
|
|
92
|
+
for item in (machine["findings"] if machine else []):
|
|
93
|
+
if item["id"] == "local:sync-folder":
|
|
94
|
+
findings.append({"id": "sync-folder", "level": "suggest", "area": "环境", "title": item["title"],
|
|
95
|
+
"reason": item["reason"], "impact": item["impact"],
|
|
96
|
+
"next": [{"label": "查看同步盘使用指引(只给方案,不改文件、不删内容)", "action": "fix:sync-folder"}],
|
|
97
|
+
"decision": {"id": item["id"], "fingerprint": item["fingerprint"], "scope": "machine"}})
|
|
49
98
|
try:
|
|
50
99
|
report = host.run_check(env, provider.root, offline=provider.offline)
|
|
51
100
|
except (host.CheckError, OSError, ValueError) as exc:
|
|
@@ -53,7 +102,7 @@ def check_updates(provider: Any, findings: list[dict], ok: list[str]) -> dict:
|
|
|
53
102
|
"reason": str(exc), "impact": "不影响找资料、看进展等日常功能",
|
|
54
103
|
"next": [{"label": "联网后重新检查", "action": ""}]})
|
|
55
104
|
provider._report = None
|
|
56
|
-
_check_runtime(provider, env, None, findings, ok)
|
|
105
|
+
_check_runtime(provider, env, None, findings, ok, machine)
|
|
57
106
|
return {"status": "offline", "cached_at": ""}
|
|
58
107
|
provider._report = report
|
|
59
108
|
origin = report["latest"]["source"]
|
|
@@ -64,7 +113,7 @@ def check_updates(provider: Any, findings: list[dict], ok: list[str]) -> dict:
|
|
|
64
113
|
_plugin_findings(provider, env, report, findings, ok)
|
|
65
114
|
_content_findings(report, findings, ok)
|
|
66
115
|
_opinion_findings(report, findings, ok)
|
|
67
|
-
_check_runtime(provider, env, report["_latest"], findings, ok)
|
|
116
|
+
_check_runtime(provider, env, report["_latest"], findings, ok, machine)
|
|
68
117
|
return source
|
|
69
118
|
|
|
70
119
|
|
|
@@ -143,15 +192,24 @@ def _opinion_findings(report: dict, findings: list[dict], ok: list[str]) -> None
|
|
|
143
192
|
ok.append("行为规则(已是锁定的最新版本)")
|
|
144
193
|
|
|
145
194
|
|
|
146
|
-
def _check_runtime(provider: Any, env: Any, latest: dict | None, findings: list[dict], ok: list[str]
|
|
195
|
+
def _check_runtime(provider: Any, env: Any, latest: dict | None, findings: list[dict], ok: list[str],
|
|
196
|
+
machine: dict | None = None) -> None:
|
|
147
197
|
runtime = runtime_module(provider.host)
|
|
148
198
|
data = runtime.report(env, latest)
|
|
149
199
|
active = data["current"]
|
|
150
200
|
if active is None:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
201
|
+
if machine is not None and not machine["findings"]:
|
|
202
|
+
return # 用户已决定不提示这台机器的运行时(决定存在用户状态目录,不进项目)
|
|
203
|
+
text = runtime.missing_text(data, runtime.command_status()) if hasattr(runtime, "missing_text") else {
|
|
204
|
+
"reason": f"本次由 {data['running_from']} 提供;装好运行时后,不依赖 AI 客户端与 npx 也能打开菜单",
|
|
205
|
+
"impact": "现在能用;换一台没有 AI 客户端的机器或客户端插件被移除时会打不开",
|
|
206
|
+
"label": "安装运行时(只写用户数据目录)"}
|
|
207
|
+
decision = ({"id": "local:runtime-missing", "fingerprint": "runtime-missing", "scope": "machine"}
|
|
208
|
+
if machine is not None else None)
|
|
209
|
+
findings.append({"id": "install-runtime", "level": "suggest", "area": "运行时", "title": "这台机器还没有安装用户级运行时",
|
|
210
|
+
"reason": text["reason"], "impact": text["impact"],
|
|
211
|
+
"next": [{"label": text["label"], "action": "fix:install-runtime"}],
|
|
212
|
+
**({"decision": decision} if decision else {})})
|
|
155
213
|
elif data["update_available"]:
|
|
156
214
|
findings.append({"id": "update-runtime", "level": "update", "area": "运行时", "title": "运行时有新版本",
|
|
157
215
|
"reason": f"当前 {active['version']},最新 {data['latest_release']}",
|
|
@@ -162,7 +220,17 @@ def _check_runtime(provider: Any, env: Any, latest: dict | None, findings: list[
|
|
|
162
220
|
|
|
163
221
|
# ---------------------------------------------------------------- plan
|
|
164
222
|
|
|
165
|
-
FIXES = ("rebuild-index", "update-plugins", "update-content", "install-runtime", "update-runtime", "add-entry"
|
|
223
|
+
FIXES = ("rebuild-index", "update-plugins", "update-content", "install-runtime", "update-runtime", "add-entry",
|
|
224
|
+
"add-skeleton", "profile", "repair-lock", "case-naming", "sync-folder")
|
|
225
|
+
# 可以带目标的处理项:fix add-skeleton <路径…>、fix profile <字段…>
|
|
226
|
+
TARGETED = ("add-skeleton", "profile")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def split_targets(ids: list[str]) -> tuple[list[str], list[str]]:
|
|
230
|
+
"""``["add-skeleton", "a.md"]`` -> (["add-skeleton"], ["a.md"]); other ids are returned unchanged."""
|
|
231
|
+
if ids and ids[0] in TARGETED:
|
|
232
|
+
return ids[:1], ids[1:]
|
|
233
|
+
return ids, []
|
|
166
234
|
|
|
167
235
|
|
|
168
236
|
def available_ids(provider: Any) -> list[str]:
|
|
@@ -207,6 +275,10 @@ def _plan_step(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
|
|
|
207
275
|
recovery="不涉及项目文件;索引可随时再次重建", verify="重新读取索引状态")
|
|
208
276
|
if fix_id == "add-entry":
|
|
209
277
|
return _plan_entry(provider, choices)
|
|
278
|
+
if fix_id in ("add-skeleton", "profile", "repair-lock"):
|
|
279
|
+
return _plan_scaffold(provider, fix_id, choices)
|
|
280
|
+
if fix_id in ("case-naming", "sync-folder"):
|
|
281
|
+
return _plan_guidance(provider, fix_id)
|
|
210
282
|
blocked = _need_host(provider, fix_id, fix_id)
|
|
211
283
|
if blocked:
|
|
212
284
|
return blocked
|
|
@@ -215,7 +287,7 @@ def _plan_step(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
|
|
|
215
287
|
if fix_id in ("install-runtime", "update-runtime"):
|
|
216
288
|
return _plan_runtime(provider, env, fix_id)
|
|
217
289
|
report = getattr(provider, "_report", None)
|
|
218
|
-
if report is None:
|
|
290
|
+
if report is None and fix_id != "update-content":
|
|
219
291
|
return _step(fix_id, fix_id, blocked="版本信息不可用(离线且没有缓存):联网后重新检查")
|
|
220
292
|
if fix_id == "update-plugins":
|
|
221
293
|
steps = host.update_steps(env, report, None, None, env.home, [], [], None, "plugins")
|
|
@@ -233,21 +305,68 @@ def _plan_step(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
|
|
|
233
305
|
return _plan_content(provider, env, report, choices)
|
|
234
306
|
|
|
235
307
|
|
|
236
|
-
def
|
|
308
|
+
def _run_refresh(provider: Any, accept: list[str], keep: list[str], recovery: Path, *, apply: bool) -> dict:
|
|
309
|
+
"""agents-init 的 refresh:模板包版本没有更新、但有文件与当前模板不同(例如没有基线)时直接用它。"""
|
|
310
|
+
script = _init_script(provider)
|
|
311
|
+
if script is None:
|
|
312
|
+
raise EntryError("找不到支持刷新的 agents-init", "unavailable", ["更新插件或运行时后再试"])
|
|
313
|
+
command = [sys.executable, str(script), "--project", str(provider.root), "--mode", "refresh", "--output", "json"]
|
|
314
|
+
for path in accept:
|
|
315
|
+
command += ["--accept", path]
|
|
316
|
+
for path in keep:
|
|
317
|
+
command += ["--keep", path]
|
|
318
|
+
if apply or accept:
|
|
319
|
+
command += ["--recovery-dir", str(recovery)]
|
|
320
|
+
if apply:
|
|
321
|
+
command.append("--apply")
|
|
322
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False, timeout=120)
|
|
323
|
+
try:
|
|
324
|
+
result = json.loads(completed.stdout)
|
|
325
|
+
except ValueError as exc:
|
|
326
|
+
raise EntryError("agents-init 没有返回结果:" + (completed.stderr or "").strip()[:200], "failed") from exc
|
|
327
|
+
if "files" not in result:
|
|
328
|
+
raise EntryError(str(result.get("error") or "agents-init 拒绝"), "failed")
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _content_preview(provider: Any, env: Any, report: dict | None, accept: list[str], keep: list[str],
|
|
333
|
+
recovery: Path) -> tuple[str, list[dict], str, bool]:
|
|
334
|
+
"""(说明, 文件计划, 阻塞原因, 是否走 refresh 直通路径)。"""
|
|
335
|
+
host = provider.host
|
|
336
|
+
steps = host.update_steps(env, report, provider.root, None, env.home, accept, keep, recovery, "content") \
|
|
337
|
+
if report is not None else []
|
|
338
|
+
if steps:
|
|
339
|
+
step = steps[0]
|
|
340
|
+
if step.blocked:
|
|
341
|
+
return step.description, [], step.blocked, False
|
|
342
|
+
preview = host.execute_steps([step], apply=False, recovery_root=None, child_env=env.child_environment())[0]
|
|
343
|
+
if preview.get("status") == "failed":
|
|
344
|
+
return step.description, [], preview.get("error", "预览失败"), False
|
|
345
|
+
return step.description, (preview.get("result") or {}).get("files") or [], "", False
|
|
346
|
+
try:
|
|
347
|
+
result = _run_refresh(provider, accept, keep, recovery, apply=False)
|
|
348
|
+
except EntryError as exc:
|
|
349
|
+
return "项目模板/样例", [], str(exc), True
|
|
350
|
+
files = result.get("files") or []
|
|
351
|
+
if all(item["state"] in ("unchanged", "kept-by-decision") for item in files):
|
|
352
|
+
return "项目模板/样例", [], "没有需要更新的模板", True
|
|
353
|
+
return "按当前模板对比项目契约文件", files, "", True
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _plan_content(provider: Any, env: Any, report: dict | None, choices: dict[str, str]) -> dict:
|
|
237
357
|
host = provider.host
|
|
238
358
|
accept = sorted(path for path, choice in choices.items() if choice == "accept")
|
|
239
359
|
keep = sorted(path for path, choice in choices.items() if choice == "keep")
|
|
240
360
|
recovery = Path(choices.get("_recovery") or host.recovery_dir_for(env, "content"))
|
|
241
|
-
|
|
242
|
-
if
|
|
243
|
-
return _step("update-content",
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return _step("update-content", step.description, blocked=preview.get("error", "预览失败"))
|
|
361
|
+
description, files, error, direct = _content_preview(provider, env, report, accept, keep, recovery)
|
|
362
|
+
if error:
|
|
363
|
+
return _step("update-content", description, blocked=error)
|
|
364
|
+
|
|
365
|
+
class _Step: # 保持下面沿用的 step.description 写法
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
step = _Step()
|
|
369
|
+
step.description = description
|
|
251
370
|
custom = [{"path": item["path"], "state": "你改过", "default": "keep"} for item in files
|
|
252
371
|
if item["state"] == "modified" or (item["path"] in choices and item["state"] in ("modified", "missing"))]
|
|
253
372
|
for item in files:
|
|
@@ -267,7 +386,8 @@ def _plan_content(provider: Any, env: Any, report: dict, choices: dict[str, str]
|
|
|
267
386
|
writes.append({"path": ".agents/moe.sakanano.agent-pack/content.lock.json", "scope": "项目(内容锁)"})
|
|
268
387
|
return _step("update-content", step.description, changes=[_content_change(item, effective) for item in files],
|
|
269
388
|
writes=writes, custom=custom, recovery=str(recovery),
|
|
270
|
-
verify="重新检查模板版本;保留的文件内容不变",
|
|
389
|
+
verify="重新检查模板版本;保留的文件内容不变",
|
|
390
|
+
payload={"recovery": str(recovery), "files": files, "direct": direct})
|
|
271
391
|
|
|
272
392
|
|
|
273
393
|
CONTENT_STATE = {"unchanged": "与新模板相同", "clean": "没改过", "modified": "你改过", "missing": "目前不存在",
|
|
@@ -380,6 +500,204 @@ def _plan_entry(provider: Any, choices: dict[str, str]) -> dict:
|
|
|
380
500
|
verify=f"重新读取 Profile 的 entry 段与 ./{name}", payload={"name": name, "recovery": recovery})
|
|
381
501
|
|
|
382
502
|
|
|
503
|
+
# ---------------------------------------------------------------- scaffold fixes
|
|
504
|
+
|
|
505
|
+
SCAFFOLD_TITLE = {"add-skeleton": "补上缺少的骨架文件", "profile": "修正 Profile 字段", "repair-lock": "修复内容锁版本"}
|
|
506
|
+
DECISIONS_FILE = ".agents/moe.sakanano.agent-pack/check-decisions.json"
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def scaffold_module(provider: Any) -> Any | None:
|
|
510
|
+
host = provider.host
|
|
511
|
+
return host.scaffold_module(host.current_env()) if host is not None and hasattr(host, "scaffold_module") else None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _preview_lines(text: str, limit: int = 30) -> str:
|
|
515
|
+
lines = text.splitlines()
|
|
516
|
+
shown = [f"+{line}" for line in lines[:limit]]
|
|
517
|
+
return "\n".join(shown + ([f"... 还有 {len(lines) - limit} 行"] if len(lines) > limit else [])) or "(空文件)"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _plan_scaffold(provider: Any, fix_id: str, choices: dict[str, str]) -> dict:
|
|
521
|
+
title = SCAFFOLD_TITLE[fix_id]
|
|
522
|
+
module = scaffold_module(provider)
|
|
523
|
+
if module is None:
|
|
524
|
+
return _step(fix_id, title, blocked="需要带脚手架检查的 agents-init(更新插件或运行时后再试)")
|
|
525
|
+
root = provider.root
|
|
526
|
+
state_dir = provider.host.current_env().state_dir
|
|
527
|
+
wanted = [item for item in (choices.get("_targets") or "").split("\n") if item]
|
|
528
|
+
try:
|
|
529
|
+
if fix_id == "repair-lock":
|
|
530
|
+
result = module.repair_lock(root)
|
|
531
|
+
if result["blocked"]:
|
|
532
|
+
return _step(fix_id, title, blocked=result["blocked"])
|
|
533
|
+
return _step(fix_id, title, changes=result["changes"] + [result["diff"]] if result["diff"] else result["changes"],
|
|
534
|
+
writes=[{"path": ".agents/moe.sakanano.agent-pack/content.lock.json", "scope": "项目(内容锁)"}],
|
|
535
|
+
recovery="内容锁由工具生成;写入前再次核对内容未变,可用 git 或同步盘历史还原",
|
|
536
|
+
verify="重新检查:内容锁不再显示 0.0.0")
|
|
537
|
+
report = module.check(root, state_dir=state_dir, reprompt=True)
|
|
538
|
+
recovery = Path(choices.get("_recovery") or _recovery_dir(provider, fix_id))
|
|
539
|
+
if fix_id == "add-skeleton":
|
|
540
|
+
rows = [row["path"] for row in report["skeleton"] if row["state"] == "missing" and row["required"]]
|
|
541
|
+
rows = [p for p in rows if not wanted or p in wanted]
|
|
542
|
+
unknown = [p for p in wanted if p not in {r["path"] for r in report["skeleton"]}]
|
|
543
|
+
if unknown:
|
|
544
|
+
raise EntryError("不在期望骨架清单里:" + "、".join(unknown), "usage")
|
|
545
|
+
if not rows:
|
|
546
|
+
return _step(fix_id, title, blocked="没有缺少的骨架文件")
|
|
547
|
+
ctx = module.Context(root)
|
|
548
|
+
custom = [{"path": p, "state": "缺少", "default": "accept", "options": True} for p in rows]
|
|
549
|
+
decided = {p: choices.get(p, "accept") for p in rows}
|
|
550
|
+
accepted = [p for p, choice in decided.items() if choice == "accept"]
|
|
551
|
+
preview = module.add_skeleton(root, accepted, apply=False, state_dir=state_dir) if accepted else \
|
|
552
|
+
{"create": [], "unchanged": []}
|
|
553
|
+
writes = [{"path": item["path"], "scope": "项目(新建)"} for item in preview["create"]]
|
|
554
|
+
other = {p: c for p, c in decided.items() if c != "accept"}
|
|
555
|
+
if other:
|
|
556
|
+
writes.append({"path": DECISIONS_FILE, "scope": "项目(决定文件,随项目同步)"})
|
|
557
|
+
return _step(fix_id, title, changes=[f"{p}:{ {'accept': '新建', 'skip': '永久跳过', 'later': '稍后'}[c] }"
|
|
558
|
+
for p, c in decided.items()],
|
|
559
|
+
writes=writes, custom=custom,
|
|
560
|
+
recovery="只新建文件,已有文件不会被改动;新建的可直接删除",
|
|
561
|
+
verify="重新检查:这些文件不再显示为缺少",
|
|
562
|
+
payload={"accepted": accepted, "decisions": other, "recovery": str(recovery),
|
|
563
|
+
"diffs": {p: _preview_lines(ctx.expected_text(p) or "") for p in rows}})
|
|
564
|
+
# profile
|
|
565
|
+
rows = {row["field"]: row for row in report["profile"] if row["state"] != "ok" and row["mode"] in ("default", "equal")}
|
|
566
|
+
unknown = [f for f in wanted if f not in rows]
|
|
567
|
+
if unknown:
|
|
568
|
+
raise EntryError("这些字段不需要或不能自动修复:" + "、".join(unknown), "usage")
|
|
569
|
+
fields = [f for f in rows if not wanted or f in wanted]
|
|
570
|
+
if not fields:
|
|
571
|
+
return _step(fix_id, title, blocked="没有需要修正的 Profile 字段")
|
|
572
|
+
decided = {f: choices.get(f, "accept") for f in fields}
|
|
573
|
+
accepted = [f for f, choice in decided.items() if choice == "accept"]
|
|
574
|
+
diffs = {f: module.fix_profile(root, [f], apply=False, state_dir=state_dir)["diff"] for f in fields}
|
|
575
|
+
writes = []
|
|
576
|
+
if accepted:
|
|
577
|
+
writes.append({"path": ".agents/moe.sakanano.agent-pack/project.json", "scope": "项目(先备份)"})
|
|
578
|
+
other = {f: c for f, c in decided.items() if c != "accept"}
|
|
579
|
+
if other:
|
|
580
|
+
writes.append({"path": DECISIONS_FILE, "scope": "项目(决定文件,随项目同步)"})
|
|
581
|
+
return _step(fix_id, title,
|
|
582
|
+
changes=[f"{f} → {json.dumps(rows[f]['expected'], ensure_ascii=False)}"
|
|
583
|
+
if decided[f] == "accept" else f"{f}:{ {'skip': '永久跳过', 'later': '稍后'}[decided[f]] }"
|
|
584
|
+
for f in fields],
|
|
585
|
+
writes=writes, custom=[{"path": f, "state": "缺少" if rows[f]["state"] == "missing" else "不符合",
|
|
586
|
+
"default": "accept", "options": True} for f in fields],
|
|
587
|
+
recovery=str(recovery), verify="重新检查:这些字段不再显示为缺少或不符合",
|
|
588
|
+
payload={"accepted": accepted, "decisions": other, "recovery": str(recovery), "diffs": diffs})
|
|
589
|
+
except Exception as exc: # noqa: BLE001 - init errors come from a separately loaded module copy
|
|
590
|
+
if isinstance(exc, EntryError):
|
|
591
|
+
raise
|
|
592
|
+
return _step(fix_id, title, blocked=str(exc))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
PLAN_ONLY = "只给方案,不会自动执行;按方案自己处理后重新检查"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _plan_guidance(provider: Any, fix_id: str) -> dict:
|
|
599
|
+
"""Plan-only items: they explain what to do and change nothing (no directory moves, no deletions)."""
|
|
600
|
+
if fix_id == "case-naming":
|
|
601
|
+
title = "Case 目录命名:改名计划"
|
|
602
|
+
module = scaffold_module(provider)
|
|
603
|
+
if module is None:
|
|
604
|
+
return _step(fix_id, title, blocked="需要带脚手架检查的 agents-init(更新插件或运行时后再试)")
|
|
605
|
+
try:
|
|
606
|
+
plan = module.case_naming_plan(provider.root)
|
|
607
|
+
except Exception as exc: # noqa: BLE001
|
|
608
|
+
return _step(fix_id, title, blocked=str(_entry_error(exc)))
|
|
609
|
+
if plan["error"]:
|
|
610
|
+
return _step(fix_id, title, blocked="Profile 的 work.case.directory_naming 无法使用:" + plan["error"])
|
|
611
|
+
if not plan["renames"]:
|
|
612
|
+
return _step(fix_id, title, blocked="Case 目录命名与规则一致,没有需要改的")
|
|
613
|
+
changes = []
|
|
614
|
+
for item in plan["renames"]:
|
|
615
|
+
line = f"{item['from']} → {item['to'] or '(需要你决定新名字)'}"
|
|
616
|
+
if item["references"]:
|
|
617
|
+
line += f";被这些文件提到,改名后要同步:{'、'.join(item['references'][:5])}" \
|
|
618
|
+
+ (f" 等 {len(item['references'])} 个" if len(item["references"]) > 5 else "")
|
|
619
|
+
if item["note"]:
|
|
620
|
+
line += f"({item['note']})"
|
|
621
|
+
changes.append(line)
|
|
622
|
+
if plan["declaration_hint"]:
|
|
623
|
+
hint = json.dumps(plan["declaration_hint"]["if_this_is_your_convention"], ensure_ascii=False)
|
|
624
|
+
changes.append("如果这些名字正是你的约定,不用改名:在 Profile 里声明它,之后按声明检查 → " + hint)
|
|
625
|
+
changes.append("每个 Case 的 CASE.md 里的 id 字段也要和新目录名保持一致")
|
|
626
|
+
return _step(fix_id, title, changes=changes, recovery="本处理项不移动、不改写任何文件",
|
|
627
|
+
verify="按计划改名并更新引用后,重新运行检查", blocked=PLAN_ONLY + "(不会移动目录)")
|
|
628
|
+
title = "同步盘使用指引"
|
|
629
|
+
host = provider.host
|
|
630
|
+
where = host.sync_folder(provider.root) if host is not None and hasattr(host, "sync_folder") else None
|
|
631
|
+
if where is None:
|
|
632
|
+
return _step(fix_id, title, blocked="项目不在同步盘里")
|
|
633
|
+
changes = [
|
|
634
|
+
"随项目同步的:Profile、内容锁、决定文件(check-decisions.json)、所有项目文件",
|
|
635
|
+
"每台机器各一份的:用户级运行时(~/.local/share/agenthelm)、版本缓存、wf 的资料台账缓存(按项目绝对路径命名)、本机决定",
|
|
636
|
+
"所以:两台机器的 wf 结果可能不同——台账各自重建,且取决于各自装的 project-orchestrator 版本;"
|
|
637
|
+
"用 agenthelm check --fingerprint 对比两台机器的脚手架是否一致",
|
|
638
|
+
]
|
|
639
|
+
if where["rebuildable"]:
|
|
640
|
+
listed = "、".join(where["rebuildable"][:8])
|
|
641
|
+
changes.append(f"项目里有可重建目录:{listed}。它们不需要同步,可以:")
|
|
642
|
+
changes.append(" · 写进 .gitignore(版本管理忽略它们,不影响同步盘)")
|
|
643
|
+
if where["service"] == "iCloud Drive":
|
|
644
|
+
changes.append(" · iCloud:把目录改名为 <名字>.nosync,再建同名符号链接——例如 "
|
|
645
|
+
"mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules(需要你自己执行)")
|
|
646
|
+
else:
|
|
647
|
+
changes.append(f" · {where['service']}:在它的设置里把这些目录设为不同步(选择性同步)")
|
|
648
|
+
changes.append("本工具不会替你移动、改名或删除任何内容")
|
|
649
|
+
return _step(fix_id, title, changes=changes, recovery="本处理项不改任何文件", verify="不涉及",
|
|
650
|
+
blocked=PLAN_ONLY)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def record_decisions(provider: Any, items: dict[str, str], reason: str, *, prefix: str, apply: bool) -> list[str]:
|
|
654
|
+
"""Persist 永久跳过 / 稍后 choices (``{path_or_field: skip|later}``) made while fixing."""
|
|
655
|
+
module = scaffold_module(provider)
|
|
656
|
+
env = provider.host.current_env()
|
|
657
|
+
written = []
|
|
658
|
+
for key, choice in items.items():
|
|
659
|
+
module.decide(provider.root, f"{prefix}:{key}", choice, reason=reason if choice == "skip" else "",
|
|
660
|
+
apply=apply, state_dir=env.state_dir)
|
|
661
|
+
written.append(f"{prefix}:{key}")
|
|
662
|
+
return written
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _entry_error(exc: Exception) -> EntryError:
|
|
666
|
+
"""agents-init raises its own InitError (a separately loaded class): map its code to the entry's."""
|
|
667
|
+
if isinstance(exc, EntryError):
|
|
668
|
+
return exc
|
|
669
|
+
code = {"conflict": "conflict", "profile-invalid": "profile-invalid"}.get(getattr(exc, "code", ""), "usage")
|
|
670
|
+
return EntryError(str(exc), code)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def decide_ids(provider: Any, skip: list[str], unskip: list[str], reason: str, apply: bool) -> dict:
|
|
674
|
+
"""``fix --skip <id> --reason …`` / ``--unskip <id>``: only decisions are written, never project files."""
|
|
675
|
+
module = scaffold_module(provider)
|
|
676
|
+
if module is None:
|
|
677
|
+
raise EntryError("需要带脚手架检查的 agents-init(更新插件或运行时后再试)", "unavailable")
|
|
678
|
+
health = provider._health or provider.health()
|
|
679
|
+
known = {f["decision"]["id"]: f["decision"] for f in health["findings"] if f.get("decision")}
|
|
680
|
+
env = provider.host.current_env()
|
|
681
|
+
results = []
|
|
682
|
+
for identifier in skip:
|
|
683
|
+
if identifier not in known:
|
|
684
|
+
raise EntryError(f"现在没有这个发现:{identifier}", "usage", ["可跳过:" + ("、".join(sorted(known)) or "(无)")])
|
|
685
|
+
item = known[identifier]
|
|
686
|
+
try:
|
|
687
|
+
results.append(module.decide(provider.root, identifier, "skip", reason=reason,
|
|
688
|
+
fingerprint=item["fingerprint"], scope=item["scope"], apply=apply,
|
|
689
|
+
state_dir=env.state_dir))
|
|
690
|
+
except Exception as exc: # noqa: BLE001
|
|
691
|
+
raise _entry_error(exc) from exc
|
|
692
|
+
for identifier in unskip:
|
|
693
|
+
try:
|
|
694
|
+
results.append(module.undecide(provider.root, identifier, apply=apply, state_dir=env.state_dir))
|
|
695
|
+
except Exception as exc: # noqa: BLE001
|
|
696
|
+
raise _entry_error(exc) from exc
|
|
697
|
+
provider._health = None
|
|
698
|
+
return {"decisions": results, "applied": apply}
|
|
699
|
+
|
|
700
|
+
|
|
383
701
|
# ---------------------------------------------------------------- apply
|
|
384
702
|
|
|
385
703
|
def apply(provider: Any, planned: dict, choices: dict[str, str]) -> dict:
|
|
@@ -406,6 +724,31 @@ def apply(provider: Any, planned: dict, choices: dict[str, str]) -> dict:
|
|
|
406
724
|
return {"results": results, "simulated": False}
|
|
407
725
|
|
|
408
726
|
|
|
727
|
+
def _apply_scaffold(provider: Any, step: dict, choices: dict[str, str], base: dict) -> dict:
|
|
728
|
+
fix_id = step["id"]
|
|
729
|
+
module = scaffold_module(provider)
|
|
730
|
+
env = provider.host.current_env()
|
|
731
|
+
payload = step["payload"]
|
|
732
|
+
if fix_id == "repair-lock":
|
|
733
|
+
result = module.repair_lock(provider.root, apply=True)
|
|
734
|
+
return {**base, "verify": "内容锁版本已修复" if result["applied"] else "没有需要修复的内容"}
|
|
735
|
+
accepted = payload["accepted"]
|
|
736
|
+
written = []
|
|
737
|
+
if fix_id == "add-skeleton" and accepted:
|
|
738
|
+
result = module.add_skeleton(provider.root, accepted, apply=True, state_dir=env.state_dir)
|
|
739
|
+
written = [item["path"] for item in result["create"]]
|
|
740
|
+
if fix_id == "profile" and accepted:
|
|
741
|
+
result = module.fix_profile(provider.root, accepted, apply=True, recovery_dir=Path(payload["recovery"]),
|
|
742
|
+
state_dir=env.state_dir)
|
|
743
|
+
written = [".agents/moe.sakanano.agent-pack/project.json"]
|
|
744
|
+
prefix = "skel" if fix_id == "add-skeleton" else "profile"
|
|
745
|
+
recorded = record_decisions(provider, payload["decisions"], choices.get("_reason", ""), prefix=prefix, apply=True)
|
|
746
|
+
return {**base, "written": written + ([DECISIONS_FILE] if recorded else []),
|
|
747
|
+
"verify": "重新检查:" + ("、".join(accepted) or "没有写入文件")
|
|
748
|
+
+ (f";已记录决定:{'、'.join(recorded)}" if recorded else ""),
|
|
749
|
+
"recovery": payload["recovery"] if fix_id == "profile" and accepted else "只新建文件,无需备份"}
|
|
750
|
+
|
|
751
|
+
|
|
409
752
|
def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
|
|
410
753
|
fix_id = step["id"]
|
|
411
754
|
base = {"id": fix_id, "status": "applied", "written": [w["path"] for w in step["writes"]], "kept": [],
|
|
@@ -415,6 +758,11 @@ def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
|
|
|
415
758
|
state = assets.status(provider.assets_project())
|
|
416
759
|
return {**base, "verify": f"索引{'最新' if state['status'] == 'fresh' else '状态:' + state['status']},"
|
|
417
760
|
f"{result['file_count']} 个文件"}
|
|
761
|
+
if fix_id in ("add-skeleton", "profile", "repair-lock"):
|
|
762
|
+
try:
|
|
763
|
+
return _apply_scaffold(provider, step, choices, base)
|
|
764
|
+
except Exception as exc: # noqa: BLE001
|
|
765
|
+
raise _entry_error(exc) from exc
|
|
418
766
|
if fix_id == "add-entry":
|
|
419
767
|
name = step["payload"]["name"]
|
|
420
768
|
result = _run_init(provider, name, choices, step["payload"]["recovery"], apply=True)
|
|
@@ -454,11 +802,16 @@ def _apply_step(provider: Any, step: dict, choices: dict[str, str]) -> dict:
|
|
|
454
802
|
accept = [path for path in custom if choices.get(path) == "accept"]
|
|
455
803
|
before = {path: _digest(provider.root / path) for path in keep}
|
|
456
804
|
recovery = Path(step["payload"]["recovery"])
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
805
|
+
if step["payload"].get("direct"):
|
|
806
|
+
result = _run_refresh(provider, accept, keep, recovery, apply=True)
|
|
807
|
+
if not result.get("applied"):
|
|
808
|
+
raise EntryError("模板更新没有执行:" + str(result.get("hint") or result), "failed")
|
|
809
|
+
else:
|
|
810
|
+
steps = host.update_steps(env, provider._report, provider.root, None, env.home, accept, keep, recovery, "content")
|
|
811
|
+
outcome = host.execute_steps(steps, apply=True, recovery_root=None, child_env=env.child_environment())[0]
|
|
812
|
+
result = outcome.get("result") or {}
|
|
813
|
+
if outcome["status"] == "failed" or not result.get("applied"):
|
|
814
|
+
raise EntryError("模板更新没有执行:" + str(outcome.get("error") or result.get("hint") or result), "failed")
|
|
462
815
|
untouched = all(_digest(provider.root / path) == digest for path, digest in before.items())
|
|
463
816
|
return {**base, "kept": keep, "accepted": accept, "recovery": result.get("recovery") or "没有替换文件,无需备份",
|
|
464
817
|
"verify": "重新读取:保留的文件" + ("内容未变" if untouched else "被改动了,请检查")
|
|
@@ -256,6 +256,12 @@ def fix_screen(provider, preset: list[str] | None = None) -> None:
|
|
|
256
256
|
for item in step["custom"]:
|
|
257
257
|
if step["blocked"]:
|
|
258
258
|
continue
|
|
259
|
+
if item.get("options"):
|
|
260
|
+
choices[item["path"]] = _decide_item(item, step["payload"].get("diffs", {}).get(item["path"], ""))
|
|
261
|
+
if choices[item["path"]] == "skip" and "_reason" not in choices:
|
|
262
|
+
choices["_reason"] = choose("永久跳过的理由(回车略过;会随项目同步,换一台机器也看得到)", "")
|
|
263
|
+
asked = True
|
|
264
|
+
continue
|
|
259
265
|
answer = choose(f"{item['path']} {item['state']}:k 不改(默认)/ a 采用", "k") \
|
|
260
266
|
if item["path"] == "README.md" else \
|
|
261
267
|
choose(f"{item['path']} {item['state']}:k 保留你的版本(默认)/ a 采用新模板", "k")
|
|
@@ -275,6 +281,18 @@ def fix_screen(provider, preset: list[str] | None = None) -> None:
|
|
|
275
281
|
show(cli.render_apply(provider.apply_fix(plan, choices)))
|
|
276
282
|
|
|
277
283
|
|
|
284
|
+
def _decide_item(item: dict, diff: str) -> str:
|
|
285
|
+
"""每项四选一:采用 / 永久跳过 / 稍后 / 查看差异(看完再选)。"""
|
|
286
|
+
while True:
|
|
287
|
+
answer = choose(f"{item['path']}({item['state']}):a 采用(默认)/ s 永久跳过 / l 稍后 / d 查看差异", "a")
|
|
288
|
+
if answer == "d":
|
|
289
|
+
print(S.dim(diff or "(没有差异内容可显示)"))
|
|
290
|
+
continue
|
|
291
|
+
if answer in ("a", "s", "l"):
|
|
292
|
+
return {"a": "accept", "s": "skip", "l": "later"}[answer]
|
|
293
|
+
print(S.yellow("请输入 a、s、l 或 d。"))
|
|
294
|
+
|
|
295
|
+
|
|
278
296
|
def record_screen(provider, target: str | None = None) -> None:
|
|
279
297
|
show(cli.header(provider, "记录事项"))
|
|
280
298
|
capability = next(item for item in provider.capabilities() if item["id"] == "record")
|
|
@@ -69,6 +69,8 @@ class RealProvider:
|
|
|
69
69
|
self.via_launcher = via_launcher
|
|
70
70
|
self._health: dict | None = None
|
|
71
71
|
self._items: dict | None = None
|
|
72
|
+
self.reprompt = False # health/check:忽略已记录的决定,重新评估
|
|
73
|
+
self._skipped: list[dict] = []
|
|
72
74
|
|
|
73
75
|
# -------------------------------------------------------------- shared pieces
|
|
74
76
|
|
|
@@ -330,7 +332,8 @@ class RealProvider:
|
|
|
330
332
|
self._check_memory(findings, ok)
|
|
331
333
|
project_write.check_tools(self, findings, ok)
|
|
332
334
|
source = project_maintain.check_updates(self, findings, ok)
|
|
333
|
-
self._health = {"findings": findings, "ok": ok, "read_at": now_iso(), "update_source": source
|
|
335
|
+
self._health = {"findings": findings, "ok": ok, "read_at": now_iso(), "update_source": source,
|
|
336
|
+
"skipped": self._skipped}
|
|
334
337
|
return self._health
|
|
335
338
|
|
|
336
339
|
def _check_environment(self, findings: list[dict], ok: list[str]) -> None:
|
|
@@ -452,6 +455,9 @@ class RealProvider:
|
|
|
452
455
|
def apply_fix(self, plan: dict, choices: dict[str, str]) -> dict:
|
|
453
456
|
return project_maintain.apply(self, plan, choices)
|
|
454
457
|
|
|
458
|
+
def decide(self, skip: list[str], unskip: list[str], reason: str, apply: bool) -> dict:
|
|
459
|
+
return project_maintain.decide_ids(self, skip, unskip, reason, apply)
|
|
460
|
+
|
|
455
461
|
def record_preview(self, target: str | None, change: dict) -> dict:
|
|
456
462
|
return project_write.preview(self, target, change)
|
|
457
463
|
|