@sakanano-moe/agenthelm 6.6.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.
Files changed (35) hide show
  1. package/README.md +68 -0
  2. package/README.zh-CN.md +28 -0
  3. package/package.json +2 -2
  4. package/runtime/index.json +15 -15
  5. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +11 -4
  6. package/runtime/plugins/agent-pack/plugin.json +1 -1
  7. package/runtime/plugins/agent-pack/references/capability-config.md +29 -5
  8. package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_config.py +178 -22
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
  12. package/runtime/plugins/agent-pack/runtime/mcp_server.py +30 -8
  13. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +2 -2
  14. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +5 -5
  15. package/runtime/plugins/agents-init/content.json +15 -0
  16. package/runtime/plugins/agents-init/plugin.json +2 -2
  17. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +14 -1
  18. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
  19. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
  20. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
  21. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
  23. package/runtime/plugins/agents-init/skills/agents-init/skill.json +3 -3
  24. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +5 -5
  25. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  26. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +1 -1
  27. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +2 -2
  28. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +10 -4
  29. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  30. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
  31. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
  32. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
  33. package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
  34. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +1 -1
  35. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +2 -2
@@ -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]) -> None:
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
- 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"}]})
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 _plan_content(provider: Any, env: Any, report: dict, choices: dict[str, str]) -> dict:
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
- 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", "预览失败"))
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="重新检查模板版本;保留的文件内容不变", payload={"recovery": str(recovery), "files": files})
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
- 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")
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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: project-orchestrator
3
- description: 按项目契约统一执行会话发现、Task/Case 选择、能力路由、文件落位、验证、进度更新和限定范围的 Git 交付。当项目在 AGENTS.md 中声明 project-orchestrator,且当前工作可能创建或修改项目文件时使用。Agent 扩展(Plugin/Skill/MCP)的盘点、迁移与同步不在本 Skill 内——那是 agent-pack。
3
+ description: Run session discovery, Task/Case selection, capability routing, file placement, verification, progress updates and scoped Git delivery according to the project contract. Use when a project declares project-orchestrator in AGENTS.md and the current work may create or modify project files. Inventory, migration and sync of Agent extensions (Plugin/Skill/MCP) are not part of this Skill; that is agent-pack. 中文:按项目契约统一执行会话发现、Task/Case 选择、能力路由、文件落位、验证、进度更新和限定范围的 Git 交付。当项目在 AGENTS.md 中声明 project-orchestrator,且当前工作可能创建或修改项目文件时使用。Agent 扩展(Plugin/Skill/MCP)的盘点、迁移与同步不在本 Skill 内——那是 agent-pack。
4
4
  ---
5
5
 
6
6
  # Project Orchestrator(项目编排器)
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Project Orchestrator"
3
- short_description: "按项目契约编排 Task/Case、路由工具与叶子 Skill、验证与受控 Git 交付"
4
- default_prompt: "使用 $project-orchestrator 读取当前项目契约,并以满足请求所需的最小生命周期推进工作。"
3
+ short_description: "Run Task/Case work by the project contract: route tools and leaf Skills, verify, and deliver scoped Git changes"
4
+ default_prompt: "Use $project-orchestrator to discover this project's contract and run the current request through the smallest valid lifecycle."