@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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/runtime/index.json +5 -5
  3. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
  4. package/runtime/plugins/agent-pack/plugin.json +1 -1
  5. package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
  6. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
  7. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
  8. package/runtime/plugins/agents-init/content.json +15 -0
  9. package/runtime/plugins/agents-init/plugin.json +1 -1
  10. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +13 -0
  11. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
  12. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
  13. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
  14. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
  15. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
  16. package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
  17. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
  18. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  19. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
  20. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
  21. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
  22. package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakanano-moe/agenthelm",
3
- "version": "6.7.0",
3
+ "version": "6.8.0",
4
4
  "description": "agenthelm CLI: install, check and update the agenthelm plugins for Claude Code, Codex and Cursor; a user-level runtime; and a project menu that works without any AI agent.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -4,14 +4,14 @@
4
4
  "name": "agenthelm",
5
5
  "description": "Cross-agent project management plugins: project contract, lifecycle, extension capabilities and behavior rules. 跨 Agent 的项目管理插件集。",
6
6
  "version": "0.7.0",
7
- "updated_at": "2026-10-25",
7
+ "updated_at": "2026-10-27",
8
8
  "homepage": "./index.html",
9
9
  "domain": "agenthelm.sakanano.moe",
10
10
  "legacy_domain": "skill.sakanano.moe"
11
11
  },
12
12
  "plugin": {
13
13
  "manifest": "./plugins/agents-init/plugin.json",
14
- "version": "6.7.0",
14
+ "version": "6.8.0",
15
15
  "install_unit": "marketplace",
16
16
  "bundled_plugins": [
17
17
  "./plugins/project-orchestrator/plugin.json",
@@ -30,7 +30,7 @@
30
30
  "id": "agents-init",
31
31
  "name": "agents-init v6 / Project Profile initialization and migration",
32
32
  "description": "Create or migrate the project skeleton and root launcher; checks that project-orchestrator, agent-pack and opinion-manager from the same release are present before writing.",
33
- "version": "6.6.1",
33
+ "version": "6.7.0",
34
34
  "status": "stable",
35
35
  "entrypoints": {
36
36
  "browser": "./plugins/agents-init/skills/agents-init/index.html",
@@ -43,7 +43,7 @@
43
43
  "id": "project-orchestrator",
44
44
  "name": "project-orchestrator / project lifecycle orchestration",
45
45
  "description": "Organizes Case workspaces or keeps existing Task/Case layouts; search, lossless migration, verification, scoped Git delivery, and a project menu that works without any AI agent.",
46
- "version": "1.8.1",
46
+ "version": "1.9.0",
47
47
  "status": "stable",
48
48
  "entrypoints": {
49
49
  "agent": "./plugins/project-orchestrator/skills/project-orchestrator/SKILL.md",
@@ -55,7 +55,7 @@
55
55
  "id": "agent-pack",
56
56
  "name": "agent-pack / Agent extension capability management",
57
57
  "description": "Inventory, verify, install, sync, migrate and reconcile Plugins, Skills and MCP between projects and Codex / Cursor / Claude; the agenthelm command line and user-level runtime.",
58
- "version": "1.8.0",
58
+ "version": "1.9.0",
59
59
  "status": "stable",
60
60
  "entrypoints": {
61
61
  "agent": "./plugins/agent-pack/skills/agent-pack/SKILL.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-pack",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Deterministic Agent-extension capability management for configured workspaces.",
5
5
  "author": {
6
6
  "name": "lc4t"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "agent-pack",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "description": "Deterministic Agent-extension capability management: inventory, doctor, bootstrap, sync, transfer, and reconcile Agent Plugins / Skills / MCP servers across Codex, Cursor, Claude Code, and project sources. Ships a stdio MCP.",
6
6
  "author": {
7
7
  "name": "lc4t"
@@ -73,6 +73,10 @@ def write_json_atomic(path: Path, data: Any) -> None:
73
73
  os.replace(temporary, path)
74
74
 
75
75
 
76
+ def sha256_text(value: str) -> str:
77
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
78
+
79
+
76
80
  def sha256_file(path: Path) -> str:
77
81
  return hashlib.sha256(path.read_bytes()).hexdigest()
78
82
 
@@ -99,6 +103,11 @@ class Env:
99
103
  base = self.get("XDG_CACHE_HOME")
100
104
  return (Path(base) if base else self.home / ".cache") / "agenthelm"
101
105
 
106
+ @property
107
+ def state_dir(self) -> Path:
108
+ base = self.get("XDG_STATE_HOME")
109
+ return Path(base) if base else self.home / ".local" / "state"
110
+
102
111
  @property
103
112
  def config_file(self) -> Path:
104
113
  base = self.get("XDG_CONFIG_HOME")
@@ -356,10 +365,132 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
356
365
  return [{"status": "update-available" if result["updates"] else "current", "updates": result["updates"]}]
357
366
 
358
367
 
368
+ # ---------------------------------------------------------------- project scaffold
369
+
370
+ PROFILE_FILE = Path(".agents/moe.sakanano.agent-pack/project.json")
371
+ _SCAFFOLD_CACHE: dict[str, Any] = {}
372
+
373
+
374
+ def profile_root(project: Path | None) -> Path | None:
375
+ """The project root that holds the Project Profile, searching upwards from ``project``."""
376
+ if project is None:
377
+ return None
378
+ base = project.expanduser().resolve()
379
+ return next((path for path in [base, *base.parents] if (path / PROFILE_FILE).is_file()), None)
380
+
381
+
382
+ def scaffold_module(env: Env) -> Any | None:
383
+ """agents-init's scaffold module (the sibling copy first, then the newest installed one); None when too old."""
384
+ installed = installed_plugins(env, {"agents-init"})
385
+ copies = [PLUGIN_ROOT.parent / "agents-init", *(Path(item["path"]) for item in installed)]
386
+ usable = [path for path in copies if (path / "skills/agents-init/scripts/scaffold.py").is_file()]
387
+ if not usable:
388
+ return None
389
+ sibling = PLUGIN_ROOT.parent / "agents-init"
390
+ root = sibling if sibling in usable else max(usable, key=lambda p: version_key(_manifest_version(p) or "") or (0,))
391
+ scripts = root / "skills/agents-init/scripts"
392
+ key = str(scripts)
393
+ if key not in _SCAFFOLD_CACHE:
394
+ import importlib.util
395
+ spec = importlib.util.spec_from_file_location(f"agenthelm_scaffold_{len(_SCAFFOLD_CACHE)}", scripts / "scaffold.py")
396
+ module = importlib.util.module_from_spec(spec)
397
+ sys.modules[spec.name] = module
398
+ spec.loader.exec_module(module) # type: ignore[union-attr]
399
+ _SCAFFOLD_CACHE[key] = module
400
+ return _SCAFFOLD_CACHE[key]
401
+
402
+
403
+ def scaffold_report(env: Env, project: Path | None, *, reprompt: bool = False) -> dict | None:
404
+ """Read-only comparison of the project with the expected scaffold; None outside a project."""
405
+ root = profile_root(project)
406
+ if root is None:
407
+ return None
408
+ base = {"available": False, "root": str(root), "findings": [], "skipped": [], "error": ""}
409
+ module = scaffold_module(env)
410
+ if module is None:
411
+ return {**base, "error": "已安装的 agents-init 没有脚手架检查(更新 agents-init 后再检查)"}
412
+ try:
413
+ return {**module.check(root, state_dir=env.state_dir, reprompt=reprompt), "available": True,
414
+ "root": str(root), "error": ""}
415
+ except Exception as exc: # noqa: BLE001 - a broken Profile must not break plugin and content checks
416
+ return {**base, "error": str(exc)}
417
+
418
+
419
+ SYNC_SERVICES = (
420
+ ("iCloud Drive", re.compile(r"^(com~apple~CloudDocs|Mobile Documents|iCloud Drive|iCloudDrive)$")),
421
+ ("Dropbox", re.compile(r"^Dropbox( \(.*\))?$")),
422
+ ("OneDrive", re.compile(r"^OneDrive( - .*|-.*)?$")),
423
+ ("Google Drive", re.compile(r"^(GoogleDrive(-.*)?|Google Drive|My Drive)$")),
424
+ )
425
+ REBUILDABLE_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".cache", "dist", "build", "target",
426
+ ".tox", ".next", ".gradle"}
427
+
428
+
429
+ def sync_folder(root: Path) -> dict | None:
430
+ """The sync service whose folder holds the project (by path), and the rebuildable directories inside it."""
431
+ resolved = root.resolve()
432
+ service = next((name for name, pattern in SYNC_SERVICES if any(pattern.match(part) for part in resolved.parts)), None)
433
+ if service is None:
434
+ return None
435
+ found: list[str] = []
436
+ for current, directories, _files in os.walk(resolved):
437
+ depth = len(Path(current).relative_to(resolved).parts)
438
+ descend = []
439
+ for name in directories:
440
+ if name in REBUILDABLE_DIRS:
441
+ found.append((Path(current) / name).relative_to(resolved).as_posix())
442
+ elif name != ".git" and depth < 1: # 只看根目录和它的下一层
443
+ descend.append(name)
444
+ directories[:] = descend
445
+ return {"service": service, "path": str(resolved), "rebuildable": sorted(found)}
446
+
447
+
448
+ def machine_findings(env: Env, project: Path | None, *, reprompt: bool = False) -> dict:
449
+ """Findings about this machine only (no user-level runtime ...); their decisions live in the user state directory."""
450
+ root = profile_root(project)
451
+ result: dict = {"findings": [], "skipped": []}
452
+ if root is None:
453
+ return result
454
+ import agenthelm_runtime # noqa: PLC0415 - shipped next to this file
455
+
456
+ findings = []
457
+ data = agenthelm_runtime.report(env, None)
458
+ if data["current"] is None:
459
+ text = agenthelm_runtime.missing_text(data, agenthelm_runtime.command_status())
460
+ findings.append({"id": "local:runtime-missing", "kind": "runtime-missing", "level": "suggest", "scope": "machine",
461
+ "title": "这台机器还没有安装用户级运行时", "reason": text["reason"], "impact": text["impact"],
462
+ "fingerprint": "runtime-missing", "fix": "install-runtime", "next": text["label"]})
463
+ where = sync_folder(root)
464
+ if where is not None:
465
+ extra = f";其中有可重建目录:{'、'.join(where['rebuildable'][:6])}" if where["rebuildable"] else ""
466
+ findings.append({"id": "local:sync-folder", "kind": "sync-folder", "level": "suggest", "scope": "machine",
467
+ "title": f"项目位于同步盘({where['service']})",
468
+ "reason": f"{where['path']}{extra}",
469
+ "impact": "多台机器共用同一份项目时:项目文件随同步盘走,但运行时、缓存与 wf 的台账是每台机器各一份;"
470
+ "可重建目录(依赖、构建产物)同步很慢,也容易产生冲突副本",
471
+ "fingerprint": sha256_text(where["service"]), "fix": "sync-folder",
472
+ "next": "运行 agenthelm fix sync-folder 查看指引(只给方案,不改文件、不删内容);不需要就 fix --skip local:sync-folder",
473
+ "sync": where})
474
+ module = scaffold_module(env)
475
+ if module is None or not findings:
476
+ result["findings"] = findings
477
+ return result
478
+ decisions = module.load_decisions(root, env.state_dir)
479
+ result["findings"], result["skipped"] = module.partition(findings, decisions, reprompt=reprompt)
480
+ return result
481
+
482
+
359
483
  # ---------------------------------------------------------------- report
360
484
 
485
+ def scaffold_fingerprint(env: Env, scaffold: dict | None) -> dict | None:
486
+ module = scaffold_module(env)
487
+ if scaffold is None or module is None or not scaffold.get("available"):
488
+ return None
489
+ return module.fingerprint(scaffold)
490
+
491
+
361
492
  def run_check(env: Env, project: Path | None, *, latest_source: str | None = None, offline: bool = False,
362
- max_age: int = CACHE_TTL_SECONDS, hook_mode: bool = False) -> dict:
493
+ max_age: int = CACHE_TTL_SECONDS, hook_mode: bool = False, reprompt: bool = False) -> dict:
363
494
  latest, origin = load_latest(
364
495
  env, latest_source, offline=offline, max_age=max_age,
365
496
  timeout=HOOK_FETCH_TIMEOUT_SECONDS if hook_mode else FETCH_TIMEOUT_SECONDS, backoff=hook_mode,
@@ -369,6 +500,9 @@ def run_check(env: Env, project: Path | None, *, latest_source: str | None = Non
369
500
  plugins = plugin_findings(latest, installed)
370
501
  content = content_findings(project, latest) if project else []
371
502
  opinion = opinion_findings(project, installed) if project else []
503
+ scaffold = scaffold_report(env, project, reprompt=reprompt)
504
+ machine = machine_findings(env, project, reprompt=reprompt)
505
+ scaffold_active = len(scaffold["findings"]) if scaffold else 0
372
506
  behind = [item for item in plugins if item["status"] == "behind"]
373
507
  legacy = [item for item in plugins if item["client"] == "legacy"]
374
508
  content_updates = [item for item in content if item["status"] in {"update-available", "conflict"}]
@@ -380,14 +514,18 @@ def run_check(env: Env, project: Path | None, *, latest_source: str | None = Non
380
514
  "plugins": plugins,
381
515
  "content": content,
382
516
  "opinion": opinion,
517
+ "scaffold": scaffold,
518
+ "machine": machine,
383
519
  "summary": {
520
+ "scaffold_findings": scaffold_active,
521
+ "scaffold_skipped": len(scaffold["skipped"]) if scaffold else 0,
384
522
  "plugins_behind": len(behind),
385
523
  "breaking": any(item["breaking"] for item in behind),
386
524
  "content_updates": len(content_updates),
387
525
  "content_conflicts": sum(1 for item in content if item["status"] == "conflict"),
388
526
  "opinion_updates": len(opinion_updates),
389
527
  "legacy_installs": len(legacy),
390
- "up_to_date": not (behind or content_updates or opinion_updates or legacy),
528
+ "up_to_date": not (behind or content_updates or opinion_updates or legacy or scaffold_active),
391
529
  },
392
530
  }
393
531
 
@@ -414,10 +552,52 @@ def one_line(report: dict) -> str | None:
414
552
  parts.append("Opinion 有新版本模板或规则")
415
553
  if summary["legacy_installs"]:
416
554
  parts.append(f"{summary['legacy_installs']} 个旧方式安装待迁移")
555
+ if summary["scaffold_findings"]:
556
+ parts.append(f"项目脚手架有 {summary['scaffold_findings']} 项差异")
417
557
  return "agenthelm:" + ",".join(parts) + f"。运行 `{CLI} check` 查看详情;更新前会先预览,由你决定。"
418
558
 
419
559
 
420
- def render_text(report: dict) -> str:
560
+ def render_scaffold(report: dict, show_skipped: bool) -> list[str]:
561
+ scaffold = report.get("scaffold")
562
+ lines = []
563
+ if scaffold is None:
564
+ return lines
565
+ if scaffold.get("error"):
566
+ lines.append(f" [脚手架] 无法检查:{scaffold['error']}")
567
+ return lines
568
+ for item in scaffold["findings"]:
569
+ mark = "(稍后)" if item.get("deferred") else "(期望内容已变化,重新提示)" if item.get("changed_since_decision") else ""
570
+ fix = f" 处理:fix {item['fix']}" if item.get("fix") else " 需要你自己处理"
571
+ lines.append(f" [脚手架] {item['title']}{mark}:{item['reason']}\n ID {item['id']}{fix}")
572
+ for message in scaffold["decisions"]["errors"]:
573
+ lines.append(f" [脚手架] 决定文件有问题:{message}")
574
+ if scaffold["decisions"]["conflict_copies"]:
575
+ lines.append(" [脚手架] 同步服务留下了决定文件的冲突副本:"
576
+ + "、".join(scaffold["decisions"]["conflict_copies"]) + "(合并到 check-decisions.json 后删除)")
577
+ if not scaffold["findings"] and not scaffold["decisions"]["errors"]:
578
+ lines.append(" [脚手架] 与期望一致" + (f"({len(scaffold['skipped'])} 项已跳过)" if scaffold["skipped"] else ""))
579
+ skipped = [*scaffold["skipped"], *report.get("machine", {}).get("skipped", [])]
580
+ if show_skipped:
581
+ for item in skipped:
582
+ decision = item["decision"]
583
+ lines.append(f" [已跳过·{decision['decision']}] {item['id']}:{decision.get('reason') or '(未写理由)'}"
584
+ f" {decision.get('decided_at', '')}")
585
+ if not skipped:
586
+ lines.append(" 没有被跳过的项。")
587
+ elif skipped:
588
+ lines.append(f" (另有 {len(skipped)} 项已被你跳过:check --show-skipped 查看)")
589
+ return lines
590
+
591
+
592
+ def render_machine(report: dict) -> list[str]:
593
+ lines = []
594
+ for item in report.get("machine", {}).get("findings", []):
595
+ lines.append(f" [本机] {item['title']}:{item['reason']}\n 影响:{item['impact']}\n 处理:{item['next']}"
596
+ f"\n ID {item['id']}(只记在这台机器上;fix --skip {item['id']} --reason …)")
597
+ return lines
598
+
599
+
600
+ def render_text(report: dict, *, show_skipped: bool = False) -> str:
421
601
  lines = []
422
602
  origin = report["latest"]["source"]
423
603
  lines.append(f"云端版本源:{origin.get('source')}" + ("(缓存)" if origin.get("cached") else ""))
@@ -460,6 +640,8 @@ def render_text(report: dict) -> str:
460
640
  lines.append(f" [Opinion] 有新版本:{refs}(用 opinion-manager 预览后确认)")
461
641
  elif item["status"] in {"error", "unknown"}:
462
642
  lines.append(f" [Opinion] {item.get('error')}")
643
+ lines += render_scaffold(report, show_skipped)
644
+ lines += render_machine(report)
463
645
  if not report["plugins"]:
464
646
  lines.append(f"运行 `{CLI} install` 预览安装。")
465
647
  else:
@@ -974,7 +1156,14 @@ def parser() -> argparse.ArgumentParser:
974
1156
  check.add_argument("--refresh", action="store_true", help="忽略一天的缓存,立即联网")
975
1157
  check.add_argument("--hook", action="store_true", help="会话开始时静默调用:有更新才输出一行")
976
1158
  check.add_argument("--hook-format", choices=("text", "claude-hook"), default="text")
977
- check.add_argument("--strict", action="store_true", help="有可更新项时退出码为 1(CI 用)")
1159
+ check.add_argument("--strict", action="store_true", help="有可更新项或脚手架差异时退出码为 1(CI 用)")
1160
+ check.add_argument("--show-skipped", action="store_true", help="列出被你跳过的项及理由")
1161
+ check.add_argument("--reprompt", action="store_true", help="忽略已记录的决定,重新评估全部脚手架差异")
1162
+ check.add_argument("--fingerprint", action="store_true",
1163
+ help="输出脚手架指纹(骨架状态 + 受检 Profile 字段 + 内容锁 + 命名规则;不含 Opinion、自有字段、已跳过项)")
1164
+ check.add_argument("--explain", nargs="?", const="-", metavar="对方的 JSON",
1165
+ help="与 --fingerprint 合用:不带参数时逐项列出这台的明细;带另一台机器 check --fingerprint "
1166
+ "--output json 的输出文件时,指出两台差在哪里(有差异退出码 1)")
978
1167
 
979
1168
  install = sub.add_parser("install", help="经各客户端的原生市场安装 agenthelm 插件(默认预览)")
980
1169
  common(install, project=False)
@@ -1004,6 +1193,8 @@ def parser() -> argparse.ArgumentParser:
1004
1193
  doctor_parser = sub.add_parser("doctor", help="报告重复安装、版本不一致、旧方式残留与检查开关")
1005
1194
  common(doctor_parser)
1006
1195
 
1196
+ sub.add_parser("fix", add_help=False,
1197
+ help="修复项目脚手架差异与更新(等同 project fix;默认预览,--apply 写入;--skip/--unskip 记录决定)")
1007
1198
  sub.add_parser("runtime", add_help=False,
1008
1199
  help="用户级运行时:install / update / use / status(日常只需要 Python,不经过 npx)")
1009
1200
  sub.add_parser("project", add_help=False,
@@ -1070,10 +1261,48 @@ def run_runtime(argv: list[str]) -> int:
1070
1261
  return agenthelm_runtime.main(argv, sys.modules[__name__])
1071
1262
 
1072
1263
 
1264
+ def print_fingerprint(env: Env, report: dict, args: argparse.Namespace) -> int:
1265
+ """``check --fingerprint [--explain [file]]``: the scaffold fingerprint, and where two machines differ."""
1266
+ result = scaffold_fingerprint(env, report.get("scaffold"))
1267
+ if result is None:
1268
+ reason = (report.get("scaffold") or {}).get("error") or "当前目录不在 agenthelm 项目里"
1269
+ print(json.dumps({"ok": False, "error": reason}, ensure_ascii=False) if args.output == "json"
1270
+ else f"无法计算脚手架指纹:{reason}", file=sys.stderr if args.output != "json" else sys.stdout)
1271
+ return 4
1272
+ payload: dict = {"ok": True, "fingerprint": result["fingerprint"], "detail": result["detail"]}
1273
+ lines = [f"脚手架指纹 {result['fingerprint']}"]
1274
+ differences: list[str] = []
1275
+ if args.explain and args.explain != "-":
1276
+ try:
1277
+ other = read_json(Path(args.explain).expanduser())
1278
+ except (OSError, ValueError) as exc:
1279
+ print(f"读不了对方的输出:{exc}", file=sys.stderr)
1280
+ return 2
1281
+ theirs = other.get("detail") or (other.get("scaffold_fingerprint") or {}).get("detail") or other.get("fingerprint", {}).get("detail")
1282
+ if not isinstance(theirs, dict):
1283
+ print("对方的文件里没有指纹明细:请在对方机器运行 agenthelm check --fingerprint --output json 并保存输出", file=sys.stderr)
1284
+ return 2
1285
+ module = scaffold_module(env)
1286
+ differences = module.explain(result["detail"], theirs)
1287
+ payload["same"] = not differences
1288
+ payload["differences"] = differences
1289
+ lines.append("两台机器的脚手架一致。" if not differences else f"有 {len(differences)} 处不同:")
1290
+ lines += [f" {item}" for item in differences]
1291
+ elif args.explain:
1292
+ lines += [f" {key} = {value}" for key, value in result["detail"].items()]
1293
+ if args.output == "json":
1294
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1295
+ else:
1296
+ print("\n".join(lines))
1297
+ return 1 if differences else 0
1298
+
1299
+
1073
1300
  def main(argv: list[str] | None = None) -> int:
1074
1301
  argv = list(sys.argv[1:] if argv is None else argv)
1075
1302
  if argv[:1] == ["project"]:
1076
1303
  return run_project(argv[1:])
1304
+ if argv[:1] == ["fix"]:
1305
+ return run_project(["fix", *argv[1:]])
1077
1306
  if argv[:1] == ["runtime"]:
1078
1307
  return run_runtime(argv[1:])
1079
1308
  args = parser().parse_args(argv)
@@ -1094,12 +1323,14 @@ def main(argv: list[str] | None = None) -> int:
1094
1323
  try:
1095
1324
  if args.command == "check":
1096
1325
  report = run_check(env, project, latest_source=args.latest, offline=args.offline,
1097
- max_age=0 if args.refresh else CACHE_TTL_SECONDS)
1326
+ max_age=0 if args.refresh else CACHE_TTL_SECONDS, reprompt=args.reprompt)
1098
1327
  report.pop("_latest")
1328
+ if args.fingerprint or args.explain:
1329
+ return print_fingerprint(env, report, args)
1099
1330
  if args.output == "json":
1100
1331
  print(json.dumps(report, ensure_ascii=False, indent=2))
1101
1332
  else:
1102
- sys.stdout.write(render_text(report))
1333
+ sys.stdout.write(render_text(report, show_skipped=args.show_skipped))
1103
1334
  return 1 if args.strict and not report["summary"]["up_to_date"] else 0
1104
1335
  if args.command == "doctor":
1105
1336
  result = doctor(env, run_check(env, project, latest_source=args.latest))
@@ -157,6 +157,25 @@ def release_version(latest: dict | None) -> str | None:
157
157
  return tag[1:] if isinstance(tag, str) and tag.startswith("v") else tag
158
158
 
159
159
 
160
+ def command_status() -> dict:
161
+ """Which of the commands people type are on PATH right now (``wf`` is never installed by the runtime)."""
162
+ return {"agenthelm": shutil.which("agenthelm"), "wf": shutil.which("wf")}
163
+
164
+
165
+ def missing_text(data: dict, commands: dict) -> dict:
166
+ """Wording for “no user-level runtime installed”: says which commands work, instead of leaving “wf: not found” unexplained."""
167
+ def state(name: str) -> str:
168
+ return f"{name} 在 PATH 上({commands[name]})" if commands.get(name) else f"终端里的 {name} 命令目前不可用"
169
+ return {
170
+ "reason": f"本次由 {data['running_from']} 提供;{state('agenthelm')},{state('wf')}。",
171
+ "impact": "项目根目录的 ./menu 会通过客户端插件兜底,所以现在能用;"
172
+ "没有 AI 客户端或客户端插件被移除时打不开。wf 是单独的查找命令,运行时不会提供它:"
173
+ "用 ./menu find 词 或 agenthelm project find 词 代替",
174
+ "label": "安装运行时(agenthelm runtime install --apply,只写用户数据目录);"
175
+ "要在终端直接输入 agenthelm 时加 --link-bin ~/.local/bin(只链接 agenthelm,不含 wf)",
176
+ }
177
+
178
+
160
179
  def report(env: Any, latest: dict | None = None) -> dict:
161
180
  active = current(env)
162
181
  newest = release_version(latest)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-init",
3
- "version": "6.6.1",
3
+ "version": "6.7.0",
4
4
  "description": "Initialize or migrate an Agent project contract; ships with project-orchestrator, agent-pack and opinion-manager.",
5
5
  "author": {
6
6
  "name": "lc4t"
@@ -26,6 +26,21 @@
26
26
  "materializes_from_profile": [
27
27
  "entry.launcher"
28
28
  ]
29
+ },
30
+ {
31
+ "id": "agents-init/project-skeleton",
32
+ "version": "1.0.0",
33
+ "kind": "manifest",
34
+ "title": "期望骨架清单",
35
+ "description": "项目脚手架应有的文件、Profile 受检字段与不参与比较的字段(审计字段、Opinion 设置);agenthelm check 据此对比项目,不写入项目。",
36
+ "sources": [
37
+ "skills/agents-init/skeleton.json"
38
+ ],
39
+ "materializes": [],
40
+ "project_lock": "none",
41
+ "files": {
42
+ "skills/agents-init/skeleton.json": "70ea5e360f018c0d840d5d7aa022f811d665642a2d127f254f7a76335b4e77da"
43
+ }
29
44
  }
30
45
  ]
31
46
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "agents-init",
4
- "version": "6.6.1",
4
+ "version": "6.7.0",
5
5
  "description": "Initialize or migrate an Agent project contract (AGENTS.md, Project Profile, root launcher); ships with project-orchestrator, agent-pack and opinion-manager.",
6
6
  "author": {
7
7
  "name": "lc4t"
@@ -69,6 +69,19 @@ python3 scripts/init_project.py --mode migrate --project /path/to/project \
69
69
  --replace AGENTS.md --recovery-dir /safe/recovery/example-v6 --apply
70
70
  ```
71
71
 
72
+ 已初始化的项目对照最新脚手架用 `--mode check`(只读);其余模式默认只预览,`--apply` 才写入,已有文件不被覆盖,替换前备份到 `--recovery-dir`:
73
+
74
+ ```bash
75
+ python3 scripts/init_project.py --project /path/to/project --mode check
76
+ python3 scripts/init_project.py --project /path/to/project --mode add-skeleton [--target docs/refs/README.md]
77
+ python3 scripts/init_project.py --project /path/to/project --mode profile [--target runtime.commit_policy] --recovery-dir /safe/recovery/example
78
+ python3 scripts/init_project.py --project /path/to/project --mode repair-lock
79
+ python3 scripts/init_project.py --project /path/to/project --mode decide --target skel:docs/drafts/.gitkeep --decision skip --reason "…" --apply
80
+ python3 scripts/init_project.py --project /path/to/project --mode case-naming # 只给改名计划,不移动目录
81
+ ```
82
+
83
+ 期望骨架清单是 [skeleton.json](skeleton.json)(内容包 `agents-init/project-skeleton`);Opinion、Profile 里的项目自有字段与审计字段不参与比较。用户的跳过 / 保留决定写入 `.agents/moe.sakanano.agent-pack/check-decisions.json`,随项目同步。日常入口是 `agenthelm check` 与 `agenthelm fix`。
84
+
72
85
  ## 输出契约
73
86
 
74
87
  v6 基线结构如下:
@@ -34,7 +34,12 @@
34
34
  "distribution": {"enum": ["marketplace", "bundled", "external"]},
35
35
  "required": {"type": "boolean"},
36
36
  "commit_policy": {"enum": ["explicit", "project-policy"]},
37
- "push_policy": {"enum": ["explicit", "project-policy"]}
37
+ "push_policy": {"enum": ["explicit", "project-policy"]},
38
+ "source_repository": {"type": "string", "x-agenthelm-scope": "audit", "description": "审计记录:采用的来源仓库。运行时代码不读取,check 不比较,也不进脚手架指纹。"},
39
+ "source_ref": {"type": "string", "x-agenthelm-scope": "audit", "description": "审计记录:采用的发布 tag。同上,仅作记录。"},
40
+ "source_commit": {"type": "string", "x-agenthelm-scope": "audit", "description": "审计记录:采用的提交。同上,仅作记录。"},
41
+ "plugin_version": {"type": "string", "x-agenthelm-scope": "audit", "description": "审计记录:采用时的 project-orchestrator 版本。同上,仅作记录。"},
42
+ "capability_manager_version": {"type": "string", "x-agenthelm-scope": "audit", "description": "审计记录:采用时的 agent-pack 版本。同上,仅作记录。"}
38
43
  }
39
44
  },
40
45
  "opinion": {
@@ -45,7 +50,8 @@
45
50
  "provider": {"type": ["string", "null"]},
46
51
  "project_overlay": {"type": ["string", "null"]},
47
52
  "strict_mode": {"enum": ["smart", "strict", "disabled"]},
48
- "loading_mode": {"enum": ["tiered", "full"]}
53
+ "loading_mode": {"enum": ["tiered", "full"]},
54
+ "tool_version": {"type": "string", "x-agenthelm-scope": "audit", "description": "审计记录:生成该 Opinion 时的工具版本;不参与比较。"}
49
55
  }
50
56
  },
51
57
  "memory": {
@@ -99,7 +105,16 @@
99
105
  "type": ["object", "null"], "additionalProperties": true,
100
106
  "properties": {
101
107
  "root": {"$ref": "#/$defs/relativePath"},
102
- "artifact_mode": {"enum": ["case-local", "external-references"]}
108
+ "artifact_mode": {"enum": ["case-local", "external-references"]},
109
+ "command": {"type": ["array", "string"], "description": "项目自己的 Case 工具命令;声明后项目入口不绕过它写 Case。"},
110
+ "directory_naming": {
111
+ "type": "object", "additionalProperties": true,
112
+ "description": "Case 目录命名规则,check 据此检查目录名;缺省按 CASE-日期-序号-主题。fix case-naming 只给改名计划,不移动目录。",
113
+ "properties": {
114
+ "style": {"enum": ["case-id", "date-topic", "custom"]},
115
+ "pattern": {"type": "string", "description": "匹配整个目录名的正则;style 为 custom 时必填。"}
116
+ }
117
+ }
103
118
  }
104
119
  },
105
120
  "assets": {
@@ -715,6 +715,17 @@ def _profile_launcher(profile: dict) -> str | None:
715
715
  return validate_entry_name(launcher) if isinstance(launcher, str) else None
716
716
 
717
717
 
718
+ def _scaffold():
719
+ """scaffold.py 与本文件同目录;绑定到正在运行的本模块,保证异常类一致。"""
720
+ here = str(Path(__file__).resolve().parent)
721
+ if here not in sys.path:
722
+ sys.path.insert(0, here)
723
+ import scaffold # noqa: PLC0415
724
+
725
+ scaffold.bind(sys.modules[__name__])
726
+ return scaffold
727
+
728
+
718
729
  def _unified_diff(relative: str, current: str, new: str, limit: int = 200) -> str:
719
730
  lines = list(difflib.unified_diff(
720
731
  current.splitlines(keepends=True), new.splitlines(keepends=True),
@@ -756,6 +767,7 @@ def refresh_project(args: argparse.Namespace, root: Path) -> tuple[int, dict]:
756
767
  if accept & keep:
757
768
  raise InitError("同一文件不能同时 --accept 与 --keep:" + ", ".join(sorted(map(str, accept & keep))))
758
769
  plan: list[dict] = []
770
+ kept_before = _scaffold().keep_fingerprints(root)
759
771
  for relative in tracked:
760
772
  new = rendered[relative]
761
773
  new_digest = hashlib.sha256(new.encode("utf-8")).hexdigest()
@@ -776,7 +788,10 @@ def refresh_project(args: argparse.Namespace, root: Path) -> tuple[int, dict]:
776
788
  state, action = "clean", "keep" if relative in keep else "replace"
777
789
  else:
778
790
  state = "modified"
779
- action = "replace" if relative in accept else "keep" if relative in keep else "blocked"
791
+ decided = kept_before.get(relative.as_posix()) == new_digest # 之前选过保留,且期望内容没变
792
+ action = "replace" if relative in accept else "keep" if relative in keep or decided else "blocked"
793
+ if decided and relative not in accept:
794
+ state = "kept-by-decision"
780
795
  item = {"path": relative.as_posix(), "state": state, "action": action, "new_sha256": new_digest}
781
796
  if action in {"replace", "blocked", "create"} or (state == "clean" and action == "keep"):
782
797
  item["diff"] = _unified_diff(relative.as_posix(), current or "", new)
@@ -831,6 +846,12 @@ def refresh_project(args: argparse.Namespace, root: Path) -> tuple[int, dict]:
831
846
  temporary = lock_path.with_name(f".{lock_path.name}.{os.getpid()}.tmp")
832
847
  temporary.write_text(json_text(lock), encoding="utf-8")
833
848
  os.replace(temporary, lock_path)
849
+ # 显式 --keep 的选择记入决定文件:同样的模板内容以后不再追问,模板实质变化时才再问。
850
+ for item in plan:
851
+ if Path(item["path"]) in keep and item["state"] in {"modified", "local-only", "kept-by-decision"}:
852
+ _scaffold().decide(root, f"content:{item['path']}", "keep", reason="refresh --keep",
853
+ fingerprint=item["new_sha256"], scope="project", apply=True)
854
+ result.setdefault("decisions", []).append(f"content:{item['path']}")
834
855
  result["applied"] = True
835
856
  return 0, result
836
857
 
@@ -943,8 +964,10 @@ def add_entry(args: argparse.Namespace, root: Path) -> tuple[int, dict]:
943
964
  lock_path = root / CONTENT_LOCK_PATH
944
965
  lock = _read_project_json(root, CONTENT_LOCK_PATH, "content lock") if lock_path.exists() \
945
966
  else {"schema_version": 1, "packs": {}}
967
+ # 新条目只记录刚写入的启动器,它与当前模板一致,所以版本就是当前包版本;
968
+ # 不再写占位的 0.0.0(那会让 check 永远提示“可更新”)。
946
969
  pack_entry = lock.setdefault("packs", {}).get(pack["id"]) or \
947
- {"plugin": "agents-init", "version": UNKNOWN_PACK_VERSION, "files": {}}
970
+ {"plugin": "agents-init", "version": pack["version"], "files": {}}
948
971
  pack_entry["files"] = dict(sorted({**pack_entry.get("files", {}),
949
972
  name: hashlib.sha256(template.encode("utf-8")).hexdigest()}.items()))
950
973
  lock["packs"][pack["id"]] = pack_entry
@@ -964,9 +987,20 @@ def parser() -> argparse.ArgumentParser:
964
987
  result.add_argument("--stack", type=split_csv)
965
988
  result.add_argument("--runtime")
966
989
  result.add_argument("--agent-cli", type=split_csv)
967
- result.add_argument("--mode", choices=("init", "migrate", "refresh", "entry"), default="init",
990
+ result.add_argument("--mode", choices=("init", "migrate", "refresh", "entry", "check", "add-skeleton", "profile",
991
+ "repair-lock", "decide", "case-naming"), default="init",
968
992
  help="refresh:内容包更新后按 Profile 重新渲染已跟踪的模板文件;"
969
- "entry:为已初始化的项目补上根目录启动器与 Profile 的 entry 段")
993
+ "entry:为已初始化的项目补上根目录启动器与 Profile 的 entry 段;"
994
+ "check:只读,对比期望骨架清单;add-skeleton:按模板补上缺失的骨架文件;"
995
+ "profile:把缺失或不符的受检字段改为模板值;repair-lock:修复内容锁的 0.0.0 版本;"
996
+ "decide:记录或撤销对某个发现的决定;case-naming:只输出 Case 目录改名计划与受影响的引用,不移动任何目录")
997
+ result.add_argument("--target", action="append", default=[],
998
+ help="add-skeleton:骨架路径;profile:字段;decide:发现 ID(可重复;省略则处理全部可处理项)")
999
+ result.add_argument("--decision", choices=("skip", "keep", "later", "unskip"), help="decide:决定;unskip 撤销")
1000
+ result.add_argument("--reason", help="decide:理由(建议写,随项目同步)")
1001
+ result.add_argument("--fingerprint", help="decide:期望指纹(缺省取该发现当前的指纹)")
1002
+ result.add_argument("--reprompt", action="store_true", help="check:忽略已记录的决定,重新评估全部发现")
1003
+ result.add_argument("--state-dir", type=Path, help="机器本地决定的状态目录(缺省 $XDG_STATE_HOME 或 ~/.local/state)")
970
1004
  result.add_argument("--entry-name", help=f"根目录启动器文件名(init 缺省 {DEFAULT_ENTRY};entry 模式必填)")
971
1005
  result.add_argument("--no-entry", action="store_true", help="init:不生成根目录启动器")
972
1006
  result.add_argument("--readme-entry", action="store_true",
@@ -996,6 +1030,11 @@ def main(argv: list[str] | None = None) -> int:
996
1030
  args = parser().parse_args(argv)
997
1031
  try:
998
1032
  root = validate_root(args.project)
1033
+ if args.mode in ("check", "add-skeleton", "profile", "repair-lock", "decide", "case-naming"):
1034
+ scaffold = _scaffold()
1035
+ code, result = scaffold.run_mode(args, root)
1036
+ print(json_text(result), end="") if args.output == "json" else print(scaffold.render_text(result), end="")
1037
+ return code
999
1038
  if args.mode == "entry":
1000
1039
  code, result = add_entry(args, root)
1001
1040
  if args.output == "json":