@sakanano-moe/agenthelm 6.5.3 → 6.6.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 (109) hide show
  1. package/bin/agenthelm.js +1 -1
  2. package/package.json +2 -2
  3. package/runtime/index.json +80 -0
  4. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
  5. package/runtime/plugins/agent-pack/.mcp.json +12 -0
  6. package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
  7. package/runtime/plugins/agent-pack/mcp.json +14 -0
  8. package/runtime/plugins/agent-pack/plugin.json +20 -0
  9. package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
  10. package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +42 -1
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
  12. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
  13. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
  14. package/runtime/plugins/agents-init/content.json +31 -0
  15. package/runtime/plugins/agents-init/plugin.json +19 -0
  16. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
  17. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
  18. package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
  19. package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
  20. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
  21. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
  23. package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
  24. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
  25. package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
  26. package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
  27. package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
  28. package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
  29. package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
  30. package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
  31. package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
  32. package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
  33. package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
  34. package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
  35. package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
  36. package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
  37. package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
  38. package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
  39. package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
  40. package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
  41. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
  42. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
  43. package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
  44. package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
  45. package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
  46. package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
  47. package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
  48. package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
  49. package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
  50. package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
  51. package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
  52. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
  53. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
  54. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
  55. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
  56. package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
  57. package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
  58. package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
  59. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
  60. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
  61. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
  62. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
  63. package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
  64. package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
  65. package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
  66. package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
  67. package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
  68. package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
  69. package/runtime/plugins/opinion-manager/content.json +63 -0
  70. package/runtime/plugins/opinion-manager/plugin.json +18 -0
  71. package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
  72. package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
  73. package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
  74. package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
  75. package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
  76. package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
  77. package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
  78. package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
  79. package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
  80. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
  81. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
  82. package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
  83. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
  84. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
  85. package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
  86. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
  87. package/runtime/plugins/project-orchestrator/content.json +37 -0
  88. package/runtime/plugins/project-orchestrator/plugin.json +19 -0
  89. package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
  90. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
  91. package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
  92. package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
  93. package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
  94. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
  95. package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
  96. package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
  97. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
  98. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
  99. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
  100. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
  101. package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
  102. package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
  103. package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
  104. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
  105. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
  106. package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
  107. /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
  108. /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
  109. /package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +0 -0
@@ -0,0 +1,522 @@
1
+ """项目入口的真实数据来源:读当前项目,不调用模型,不需要 AI 客户端。
2
+
3
+ - 找资料复用交付物台账(assets.py),台账在用户缓存里,项目文件是权威;
4
+ - 看进展读 Case(case.py 的格式),或项目在 ``entry.items`` 声明的事项工具的只读接口;
5
+ - 检查汇总运行环境、入口、资料索引、事项记录、记忆与(经 agent-pack 的)插件和模板状态;
6
+ - 维护与写入分别在 project_maintain.py、project_write.py。
7
+
8
+ 返回结构与 project_demo 一致,界面层不区分来源。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import assets
24
+ import case as case_tool
25
+ import project_maintain
26
+ import project_write
27
+ from project_base import (GROUP_KEYS, LAUNCHER_API, EntryError, entry_section, find_profile_root, inside,
28
+ read_profile)
29
+
30
+ RECORD_STAMP = re.compile(r"^(\d{4}-\d{2}-\d{2}T[0-9:]{5,8}(?:Z|[+-]\d{2}:\d{2})?)\s+(.*)$")
31
+ TOOL_TIMEOUT_SECONDS = 30
32
+
33
+
34
+ def now_iso() -> str:
35
+ return datetime.now().astimezone().replace(microsecond=0).isoformat()
36
+
37
+
38
+ def last_record(body: str) -> dict | None:
39
+ """The newest bullet under ``## 工作记录`` (records are appended, so the last one)."""
40
+ marker = "## 工作记录\n"
41
+ start = body.find(marker)
42
+ if start < 0:
43
+ return None
44
+ end = body.find("\n## ", start + len(marker))
45
+ lines = [line[2:].strip() for line in body[start + len(marker):None if end < 0 else end].splitlines()
46
+ if line.startswith("- ")]
47
+ if not lines:
48
+ return None
49
+ match = RECORD_STAMP.match(lines[-1])
50
+ return {"at": match.group(1), "text": match.group(2)} if match else {"at": "", "text": lines[-1]}
51
+
52
+
53
+ class RealProvider:
54
+ simulated = False
55
+
56
+ def __init__(self, start: Path | None, explicit: bool, *, offline: bool = False, host: Any = None,
57
+ via_launcher: int | None = None) -> None:
58
+ self.root = find_profile_root(start, explicit)
59
+ self.profile = read_profile(self.root)
60
+ self.entry = entry_section(self.profile)
61
+ self.entry_api = self.entry["api"]
62
+ self.name = str(self.profile.get("name") or self.root.name)
63
+ work = self.profile.get("work") if isinstance(self.profile.get("work"), dict) else {}
64
+ self.work_mode = work.get("mode") or "legacy"
65
+ case = work.get("case") if isinstance(work.get("case"), dict) else {}
66
+ self.case_command = case.get("command")
67
+ self.offline = offline
68
+ self.host = host
69
+ self.via_launcher = via_launcher
70
+ self._health: dict | None = None
71
+ self._items: dict | None = None
72
+
73
+ # -------------------------------------------------------------- shared pieces
74
+
75
+ def assets_project(self) -> assets.Project:
76
+ try:
77
+ return assets.Project(self.root)
78
+ except assets.AssetError as exc:
79
+ raise EntryError(f"资料扫描范围配置有误:{exc}", "profile-invalid",
80
+ ["检查 Profile 的 work.case.root 与 work.assets.roots"]) from exc
81
+
82
+ def index_state(self) -> dict:
83
+ try:
84
+ state = assets.status(self.assets_project())
85
+ except EntryError:
86
+ return {"status": "broken", "built_at": "", "file_count": 0}
87
+ status = {"fresh": "fresh", "stale": "stale", "missing": "missing"}.get(state["status"], "broken")
88
+ return {"status": status, "built_at": state.get("built_at", ""), "file_count": state.get("file_count", 0)}
89
+
90
+ def launcher(self) -> str | None:
91
+ return self.entry["launcher"]
92
+
93
+ def command_prefix(self) -> str:
94
+ return f"./{self.launcher()}" if self.launcher() else "agenthelm project"
95
+
96
+ # -------------------------------------------------------------- overview
97
+
98
+ def overview(self) -> dict:
99
+ types = (self.profile.get("profile") or {}).get("project_type") or []
100
+ counts: dict[str, int] = {}
101
+ try:
102
+ for item in self.items()["items"]:
103
+ counts[item["group"]] = counts.get(item["group"], 0) + 1
104
+ except EntryError:
105
+ pass
106
+ levels: dict[str, int] = {}
107
+ for finding in (self._health or {}).get("findings", []):
108
+ levels[finding["level"]] = levels.get(finding["level"], 0) + 1
109
+ tools = project_write.load_tools(self)
110
+ return {"name": self.name, "kind": "、".join(map(str, types)) or "通用",
111
+ "kind_label": ("、".join(map(str, types)) + " 项目") if types else "项目",
112
+ "launcher": self.launcher(), "index": self.index_state(), "items": counts,
113
+ "health": levels, "health_checked": self._health is not None, "read_at": now_iso(),
114
+ "tools": {"declared": len(tools["tools"]),
115
+ "available": sum(tool["state"] == "available" for tool in tools["tools"])}}
116
+
117
+ # -------------------------------------------------------------- find
118
+
119
+ def find(self, terms: list[str], *, ext: str | None = None, container: str | None = None,
120
+ limit: int = 10) -> dict:
121
+ project = self.assets_project()
122
+ try:
123
+ result = assets.find(project, terms, ext=ext, container=container, limit=limit)
124
+ except assets.AssetError as exc:
125
+ raise EntryError(f"查找失败:{exc}", "index-broken",
126
+ ["重建资料索引(只改用户缓存):fix rebuild-index --apply"]) from exc
127
+ return {**result, "index": self.index_state()}
128
+
129
+ def refs(self, path: str) -> dict:
130
+ try:
131
+ result = assets.refs(self.assets_project(), path)
132
+ except assets.AssetError as exc:
133
+ raise EntryError(f"查不到引用关系:{exc}", "index-broken") from exc
134
+ return {"path": result["path"], "inbound": [item["src"] for item in result["inbound"]],
135
+ "outbound": [item["dst"] for item in result["outbound"]],
136
+ "affected": result.get("affected_containers", [])}
137
+
138
+ def act(self, action: str, path: str) -> dict:
139
+ target = inside(self.root, path, "路径")
140
+ if not target.exists():
141
+ raise EntryError(f"文件不存在:{path}", "not-found", ["索引可能过期:重新查找一次"])
142
+ absolute = str(target)
143
+ verbs = {"open": "打开", "reveal": "显示所在目录", "copy": "复制路径"}
144
+ command = None if os.environ.get("AGENTHELM_OPEN") == "print" else _open_command(action, target)
145
+ if command and shutil.which(command[0]):
146
+ completed = subprocess.run(command, input=absolute.encode("utf-8") if action == "copy" else None,
147
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
148
+ timeout=15)
149
+ if completed.returncode == 0:
150
+ return {"action": action, "path": path, "executed": True, "absolute": absolute,
151
+ "message": f"已{verbs[action]}:{path}"}
152
+ return {"action": action, "path": path, "executed": False, "absolute": absolute,
153
+ "message": f"这台机器不能直接{verbs[action]},绝对路径:{absolute}"}
154
+
155
+ # -------------------------------------------------------------- progress
156
+
157
+ def items_source(self) -> str:
158
+ items = self.entry["items"]
159
+ if items:
160
+ return f"项目工具 {' '.join(items['command'])}(只读接口 list)"
161
+ return f"{self._case_root_text()}/*/CASE.md(默认 Case 工具)"
162
+
163
+ def _case_root_text(self) -> str:
164
+ work = self.profile.get("work") or {}
165
+ return str((work.get("case") or {}).get("root") or "cases")
166
+
167
+ def items(self, *, status: str | None = None, term: str | None = None) -> dict:
168
+ if self._items is None:
169
+ self._items = self._load_items()
170
+ rows = [item for item in self._items["items"]
171
+ if (not status or item["group"] == status) and (not term or _item_hits(term, item))]
172
+ return {**self._items, "items": rows}
173
+
174
+ def _load_items(self) -> dict:
175
+ adapter = self.entry["items"]
176
+ if adapter:
177
+ raw = self._run_items_tool(adapter)
178
+ status_map = adapter.get("status_map") or {}
179
+ rows = [self._normalize(item, status_map) for item in raw]
180
+ return {"items": rows, "invalid": [], "source": self.items_source(), "read_at": now_iso()}
181
+ if self.work_mode != "case-workspace":
182
+ raise EntryError("这个项目没有使用 Case 工作目录,也没有声明事项工具", "unavailable",
183
+ ["work.mode 为 legacy:事项由项目自己的方式管理",
184
+ "如需在本入口查看,可在 Profile 的 entry.items 声明事项工具(见 case-workspaces.md「项目入口适配」)"])
185
+ try:
186
+ project = case_tool.Project(self.root)
187
+ except case_tool.CaseError as exc:
188
+ raise EntryError(f"读不了事项:{exc}", "profile-invalid") from exc
189
+ rows, invalid = [], []
190
+ for directory in project.case_dirs():
191
+ path = directory / "CASE.md"
192
+ relative = path.relative_to(self.root).as_posix()
193
+ try:
194
+ data, body = case_tool.read_case(path)
195
+ except (case_tool.CaseError, OSError, UnicodeDecodeError) as exc:
196
+ invalid.append({"path": relative, "error": str(exc)})
197
+ continue
198
+ item = self._normalize({**data, "path": relative, "last_record": last_record(body)}, {})
199
+ item["deliverables"] = [self._resolve_deliverable(directory, value) for value in item["deliverables"]]
200
+ rows.append(item)
201
+ rows.sort(key=lambda item: item["updated_at"], reverse=True)
202
+ return {"items": rows, "invalid": invalid, "source": self.items_source(), "read_at": now_iso()}
203
+
204
+ def _resolve_deliverable(self, case_dir: Path, value: str) -> str:
205
+ """Deliverables may be written relative to the Case or to the project; show what exists."""
206
+ for base in (case_dir, self.root):
207
+ candidate = (base / value).resolve()
208
+ if candidate.exists() and (candidate == self.root or self.root in candidate.parents):
209
+ return candidate.relative_to(self.root).as_posix()
210
+ return value
211
+
212
+ def _run_items_tool(self, adapter: dict) -> list[dict]:
213
+ command = [*adapter["command"], "list", "--all", "--output", "json"]
214
+ try:
215
+ completed = subprocess.run(command, cwd=self.root, capture_output=True, text=True, check=False,
216
+ timeout=TOOL_TIMEOUT_SECONDS)
217
+ except (OSError, subprocess.TimeoutExpired) as exc:
218
+ raise EntryError(f"项目事项工具无法运行:{exc}", "unavailable",
219
+ [f"命令:{' '.join(command)}", "检查 Profile 的 entry.items.command"]) from exc
220
+ try:
221
+ data = json.loads(completed.stdout)
222
+ except ValueError as exc:
223
+ detail = (completed.stderr or completed.stdout).strip()[:200]
224
+ raise EntryError("项目事项工具的 list 没有返回 JSON", "unavailable",
225
+ [f"命令:{' '.join(command)}", detail or "(无输出)"]) from exc
226
+ rows = data.get("items", data.get("cases")) if isinstance(data, dict) else data
227
+ if completed.returncode != 0 or not isinstance(rows, list):
228
+ raise EntryError("项目事项工具的 list 返回格式不符合约定", "unavailable",
229
+ ["约定:list --all --output json 返回 {\"items\": [...]},见 case-workspaces.md"])
230
+ return [row for row in rows if isinstance(row, dict)]
231
+
232
+ @staticmethod
233
+ def _normalize(data: dict, status_map: dict) -> dict:
234
+ def text(key: str) -> str:
235
+ value = data.get(key)
236
+ return value if isinstance(value, str) else ""
237
+
238
+ def strings(key: str) -> list[str]:
239
+ value = data.get(key)
240
+ return [str(item) for item in value] if isinstance(value, list) else []
241
+
242
+ status = text("status")
243
+ group = status_map.get(status) or (status if status in GROUP_KEYS else "other")
244
+ record = data.get("last_record")
245
+ return {"id": text("id"), "title": text("title") or text("id"), "status": status, "group": group,
246
+ "goal": text("goal"), "next_action": text("next_action"), "waiting_for": text("waiting_for"),
247
+ "resume_when": text("resume_when"),
248
+ "last_record": record if isinstance(record, dict) and record.get("text") else None,
249
+ "deliverables": strings("deliverables"), "aliases": strings("aliases"),
250
+ "legacy_ids": strings("legacy_ids"), "updated_at": text("updated_at"), "path": text("path")}
251
+
252
+ def item(self, token: str) -> dict:
253
+ rows = self.items()["items"]
254
+ exact = [item for item in rows if token in (item["id"], *item["legacy_ids"], *item["aliases"])]
255
+ matches = exact or [item for item in rows if _item_hits(token, item)]
256
+ if not matches:
257
+ raise EntryError(f"没有找到事项:{token}", "not-found", ["试试更短的词,或在「看进展」里浏览全部"])
258
+ if len(matches) > 1:
259
+ raise EntryError(f"有 {len(matches)} 个事项都匹配“{token}”", "ambiguous",
260
+ [f"{item['id']} {item['title']}" for item in matches[:10]])
261
+ return {**matches[0], "source": self.items_source()}
262
+
263
+ # -------------------------------------------------------------- guide
264
+
265
+ def guide(self) -> dict:
266
+ prefix = self.command_prefix()
267
+ about = self.entry["about"] or _readme_intro(self.root) or \
268
+ "(项目还没有写简介:可以在 README.md 开头写一段,或在 Profile 的 entry.about 写一句话)"
269
+ where = [(item["label"], item["path"]) for item in self.entry["where"]]
270
+ if not where:
271
+ work = self.profile.get("work") or {}
272
+ if self.work_mode == "case-workspace":
273
+ where.append(("事项与各自资料", f"{self._case_root_text()}/"))
274
+ for root in ((work.get("assets") or {}).get("roots") or []):
275
+ where.append(("资料", f"{root}/"))
276
+ for label, relative in (("参考资料", "docs/refs"), ("草稿", "docs/drafts"), ("记忆", "memory")):
277
+ if (self.root / relative).is_dir():
278
+ where.append((label, f"{relative}/"))
279
+ start = self.entry["start"] or ["「看进展」先看进行中和等待中的事项", "需要哪份资料,用「找资料」输入关键词",
280
+ "做完一步,用「记录事项」追加一条记录"]
281
+ guide_file = self.entry["guide"]
282
+ if guide_file:
283
+ where.insert(0, ("项目首页", guide_file))
284
+ return {"name": self.name, "kind": self.overview()["kind"], "about": about,
285
+ "launcher": self.launcher(), "command": prefix, "where": where, "start": start,
286
+ "examples": [("按关键词和日期找资料", "1 → 关键词 2026-10", "find 关键词 2026-10"),
287
+ ("看某个事项现在到哪了", "2 → 输入事项名称或旧编号", "show 名称"),
288
+ ("检查项目和环境", "4", "health"),
289
+ ("不进菜单、给脚本用", "—", "progress --output json")],
290
+ "capabilities": self.capabilities(), "readme_mentions_entry": _readme_mentions(self)}
291
+
292
+ def capabilities(self) -> list[dict]:
293
+ index = self.index_state()["status"]
294
+ try:
295
+ self.items()
296
+ progress = ("available", "")
297
+ except EntryError as exc:
298
+ progress = ("unavailable", str(exc))
299
+ record = project_write.record_capability(self)
300
+ tools = project_write.load_tools(self)
301
+ if tools["error"]:
302
+ tools_state = ("unavailable", tools["error"])
303
+ elif tools["tools"]:
304
+ tools_state = ("available", f"{len(tools['tools'])} 个已声明")
305
+ else:
306
+ tools_state = ("unavailable", "项目没有声明专用工具")
307
+ index_reason = {"missing": "首次查找时建立索引", "stale": "查找时自动更新索引",
308
+ "broken": "索引损坏:查找时会尝试重建,也可在「修复与更新」里重建"}.get(index, "")
309
+ return [
310
+ {"id": "find", "title": "找资料", "state": "available", "reason": index_reason, "offline": True},
311
+ {"id": "progress", "title": "看进展", "state": progress[0], "reason": progress[1], "offline": True},
312
+ {"id": "guide", "title": "项目指南", "state": "available", "reason": "", "offline": True},
313
+ {"id": "health", "title": "检查项目与环境", "state": "available",
314
+ "reason": "联网检查使用缓存" if self.offline else "", "offline": True},
315
+ {"id": "fix", "title": "修复与更新", "state": "available",
316
+ "reason": "需要联网的项离线时只能预览", "offline": False},
317
+ {"id": "record", "title": "记录事项", "state": record[0], "reason": record[1], "offline": True},
318
+ {"id": "tools", "title": "项目工具", "state": tools_state[0], "reason": tools_state[1], "offline": True},
319
+ ]
320
+
321
+ # -------------------------------------------------------------- health
322
+
323
+ def health(self) -> dict:
324
+ findings: list[dict] = []
325
+ ok: list[str] = []
326
+ self._check_environment(findings, ok)
327
+ self._check_entry(findings, ok)
328
+ self._check_index(findings, ok)
329
+ self._check_items(findings, ok)
330
+ self._check_memory(findings, ok)
331
+ project_write.check_tools(self, findings, ok)
332
+ source = project_maintain.check_updates(self, findings, ok)
333
+ self._health = {"findings": findings, "ok": ok, "read_at": now_iso(), "update_source": source}
334
+ return self._health
335
+
336
+ def _check_environment(self, findings: list[dict], ok: list[str]) -> None:
337
+ ok.append(f"运行环境(Python {platform.python_version()},{platform.system() or sys.platform})")
338
+ if self.host is None:
339
+ findings.append({"id": "no-agent-pack", "level": "unavailable", "area": "环境",
340
+ "title": "找不到 agent-pack,插件、模板与运行时状态无法检查",
341
+ "reason": "当前运行方式没有携带 agent-pack", "impact": "找资料、看进展、指南不受影响",
342
+ "next": [{"label": "用 agenthelm 运行时打开(agenthelm runtime install)", "action": ""}]})
343
+
344
+ def _check_entry(self, findings: list[dict], ok: list[str]) -> None:
345
+ launcher = self.launcher()
346
+ if not self.entry["declared"] or not launcher:
347
+ findings.append({"id": "add-entry", "level": "suggest", "area": "入口", "title": "项目根目录还没有人用入口",
348
+ "reason": "Profile 没有 entry.launcher", "impact": "不知道命令的人不容易发现本菜单",
349
+ "next": [{"label": "生成根目录启动器(默认名 menu,可改)", "action": "fix:add-entry"}]})
350
+ return
351
+ path = self.root / launcher
352
+ if not path.is_file():
353
+ findings.append({"id": "update-content", "level": "suggest", "area": "入口",
354
+ "title": f"启动器 {launcher} 不存在", "reason": "Profile 声明了它,但根目录没有这个文件",
355
+ "impact": "只能用 agenthelm project 打开",
356
+ "next": [{"label": "按模板重新生成", "action": "fix:update-content"}]})
357
+ elif not os.access(path, os.X_OK):
358
+ findings.append({"id": "launcher-mode", "level": "suggest", "area": "入口",
359
+ "title": f"启动器 {launcher} 没有执行权限", "reason": "可能是复制或解压时丢了权限",
360
+ "impact": f"需要用 sh {launcher} 运行", "next": [{"label": f"运行 chmod +x {launcher}", "action": ""}]})
361
+ elif "agenthelm" not in path.read_text(encoding="utf-8", errors="replace"):
362
+ findings.append({"id": "launcher-custom", "level": "suggest", "area": "入口",
363
+ "title": f"./{launcher} 是项目自己的脚本", "reason": "文件里没有调用 agenthelm",
364
+ "impact": f"运行 ./{launcher} 可能打不开本菜单",
365
+ "next": [{"label": f"在脚本里调用 agenthelm project --project <项目根目录> \"$@\","
366
+ f"或用 fix add-entry --accept {launcher} 换成模板(先备份)", "action": ""}]})
367
+ elif self.via_launcher is not None and self.via_launcher < LAUNCHER_API:
368
+ findings.append({"id": "update-content", "level": "update", "area": "入口", "title": "启动器有新版本",
369
+ "reason": f"当前启动器接口 {self.via_launcher},模板为 {LAUNCHER_API}",
370
+ "impact": "旧启动器仍能用", "next": [{"label": "更新项目模板", "action": "fix:update-content"}]})
371
+ else:
372
+ ok.append(f"项目入口 ./{launcher}")
373
+ if not _readme_mentions(self):
374
+ findings.append({"id": "readme-entry", "level": "suggest", "area": "入口", "title": "README 没有提到人用入口",
375
+ "reason": f"README.md 里没有 ./{launcher}", "impact": "新接手的人可能不知道有这个菜单",
376
+ "next": [{"label": "在 README 末尾追加「怎样使用」(带标记,可删)", "action": "fix:add-entry"}]})
377
+
378
+ def _check_index(self, findings: list[dict], ok: list[str]) -> None:
379
+ state = self.index_state()
380
+ if state["status"] == "broken":
381
+ findings.append({"id": "rebuild-index", "level": "must", "area": "资料", "title": "资料索引损坏",
382
+ "reason": "用户缓存里的索引无法读取或格式不符", "impact": "找资料会先尝试重建;项目文件没有受影响",
383
+ "next": [{"label": "重建索引(只改用户缓存)", "action": "fix:rebuild-index"}]})
384
+ return
385
+ if state["status"] == "missing":
386
+ ok.append("资料索引(尚未建立,首次查找时建立)")
387
+ return
388
+ try:
389
+ report = assets.check(self.assets_project(), limit=5)
390
+ except (assets.AssetError, EntryError) as exc:
391
+ findings.append({"id": "rebuild-index", "level": "must", "area": "资料", "title": "资料巡检失败",
392
+ "reason": str(exc), "impact": "找资料可能不完整",
393
+ "next": [{"label": "重建索引(只改用户缓存)", "action": "fix:rebuild-index"}]})
394
+ return
395
+ broken = report["broken"].get("real-broken", [])
396
+ count = report["broken_counts"].get("real-broken", 0)
397
+ if count:
398
+ first = broken[0]
399
+ findings.append({"id": "broken-links", "level": "suggest", "area": "资料", "title": f"资料里有 {count} 处断链",
400
+ "reason": f"例如 {first['src']} 引用的 {first['raw']} 不存在",
401
+ "impact": "打开这些文件时链接失效;不影响其他资料",
402
+ "next": [{"label": "打开第一处修正", "action": f"open:{first['src']}"}]})
403
+ unclaimed = sum(len(paths) for paths in report["unclaimed"].values())
404
+ ok.append(f"资料索引({report['totals']['files']} 个文件,{hhmm(report['built_at'])} 更新"
405
+ + (f";{unclaimed} 份交付物未被事项引用" if unclaimed else "") + ")")
406
+
407
+ def _check_items(self, findings: list[dict], ok: list[str]) -> None:
408
+ try:
409
+ data = self.items()
410
+ except EntryError as exc:
411
+ findings.append({"id": "items-unavailable", "level": "unavailable", "area": "事项", "title": "看进展不可用",
412
+ "reason": str(exc), "impact": "其他功能不受影响",
413
+ "next": [{"label": step, "action": ""} for step in exc.next_steps[:1]]})
414
+ return
415
+ if data["invalid"]:
416
+ first = data["invalid"][0]
417
+ findings.append({"id": "invalid-items", "level": "suggest", "area": "事项",
418
+ "title": f"{len(data['invalid'])} 个事项记录读不出来",
419
+ "reason": f"{first['path']}:{first['error']}", "impact": "这些事项不会出现在「看进展」里",
420
+ "next": [{"label": "打开记录修正格式", "action": f"open:{first['path']}"}]})
421
+ else:
422
+ ok.append(f"事项记录({len(data['items'])} 个)")
423
+
424
+ def _check_memory(self, findings: list[dict], ok: list[str]) -> None:
425
+ memory = self.profile.get("memory")
426
+ if not isinstance(memory, dict) or memory.get("provider") != "project-orchestrator":
427
+ return
428
+ root = self.root / str(memory.get("root") or "memory")
429
+ if not root.is_dir():
430
+ return
431
+ script = Path(__file__).resolve().parent / "memory.py"
432
+ completed = subprocess.run([sys.executable, str(script), "check", "--project", str(self.root),
433
+ "--output", "json"], capture_output=True, text=True, check=False, timeout=60)
434
+ try:
435
+ report = json.loads(completed.stdout)
436
+ except ValueError:
437
+ return
438
+ problems = report.get("problems") or report.get("issues") or []
439
+ if problems:
440
+ first = problems[0]
441
+ findings.append({"id": "memory", "level": "suggest", "area": "记忆", "title": f"记忆条目有 {len(problems)} 个问题",
442
+ "reason": str(first.get("message") or first.get("detail") or first)[:160],
443
+ "impact": "只影响 AI Agent 召回记忆", "next": [{"label": "在 Agent 会话中运行记忆检查并处理", "action": ""}]})
444
+ else:
445
+ ok.append("记忆条目")
446
+
447
+ # -------------------------------------------------------------- maintenance and writes
448
+
449
+ def fix_plan(self, ids: list[str], choices: dict[str, str] | None = None) -> dict:
450
+ return project_maintain.plan(self, ids, choices or {})
451
+
452
+ def apply_fix(self, plan: dict, choices: dict[str, str]) -> dict:
453
+ return project_maintain.apply(self, plan, choices)
454
+
455
+ def record_preview(self, target: str | None, change: dict) -> dict:
456
+ return project_write.preview(self, target, change)
457
+
458
+ def record_apply(self, preview: dict) -> dict:
459
+ result = project_write.apply(self, preview)
460
+ self._items = None
461
+ return result
462
+
463
+ def tools(self) -> list[dict]:
464
+ return project_write.load_tools(self)["tools"]
465
+
466
+ def run_tool(self, tool_id: str, *, capture: bool = True) -> dict:
467
+ return project_write.run_tool(self, tool_id, capture=capture)
468
+
469
+
470
+ def hhmm(value: str | None) -> str:
471
+ return (value or "")[5:16].replace("T", " ")
472
+
473
+
474
+ def _item_hits(term: str, item: dict) -> bool:
475
+ fields = [item["id"], item["title"], item["goal"], *item["aliases"], *item["legacy_ids"]]
476
+ return term.lower() in " ".join(fields).lower()
477
+
478
+
479
+ def _open_command(action: str, target: Path) -> list[str] | None:
480
+ absolute = str(target)
481
+ if sys.platform == "darwin":
482
+ return {"open": ["open", absolute], "reveal": ["open", "-R", absolute], "copy": ["pbcopy"]}[action]
483
+ if sys.platform.startswith("linux"):
484
+ if action == "open":
485
+ return ["xdg-open", absolute]
486
+ if action == "reveal":
487
+ return ["xdg-open", str(target.parent)]
488
+ for command in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
489
+ if shutil.which(command[0]):
490
+ return command
491
+ return None
492
+
493
+
494
+ def _readme(root: Path) -> str:
495
+ for name in ("README.md", "readme.md", "README"):
496
+ path = root / name
497
+ if path.is_file() and not path.is_symlink():
498
+ try:
499
+ return path.read_text(encoding="utf-8")[:20000]
500
+ except (OSError, UnicodeDecodeError):
501
+ return ""
502
+ return ""
503
+
504
+
505
+ def _readme_intro(root: Path) -> str:
506
+ """The first plain paragraph after the title."""
507
+ paragraph: list[str] = []
508
+ text = re.sub(r"<!-- agenthelm:entry -->.*?<!-- /agenthelm:entry -->", "", _readme(root), flags=re.S)
509
+ for line in text.splitlines():
510
+ stripped = line.strip()
511
+ if stripped.startswith(("#", "<!--", "|", "```", "![", ">")) or not stripped:
512
+ if paragraph:
513
+ break
514
+ continue
515
+ paragraph.append(stripped)
516
+ return " ".join(paragraph)[:400]
517
+
518
+
519
+ def _readme_mentions(provider: RealProvider) -> bool:
520
+ launcher = provider.launcher()
521
+ text = _readme(provider.root)
522
+ return bool(text) and (f"./{launcher}" in text if launcher else "agenthelm project" in text)