@sakanano-moe/agenthelm 6.5.2 → 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.
- package/bin/agenthelm.js +1 -1
- package/package.json +2 -2
- package/runtime/index.json +80 -0
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
- package/runtime/plugins/agent-pack/.mcp.json +12 -0
- package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
- package/runtime/plugins/agent-pack/mcp.json +14 -0
- package/runtime/plugins/agent-pack/plugin.json +20 -0
- package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
- package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +111 -28
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
- package/runtime/plugins/agents-init/content.json +31 -0
- package/runtime/plugins/agents-init/plugin.json +19 -0
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
- package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
- package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
- package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
- package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/content.json +63 -0
- package/runtime/plugins/opinion-manager/plugin.json +18 -0
- package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
- package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
- package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
- package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
- package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
- package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
- package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
- package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
- package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
- package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
- package/runtime/plugins/project-orchestrator/content.json +37 -0
- package/runtime/plugins/project-orchestrator/plugin.json +19 -0
- package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
- package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
- package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
- /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
- /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
- /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)
|