@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,363 @@
1
+ #!/usr/bin/env python3
2
+ """条目式项目记忆:一条一文件,索引实时生成;只依赖标准库。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from datetime import date, timedelta
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ PROJECT_PROFILE = Path(".agents") / "moe.sakanano.agent-pack" / "project.json"
17
+ DEFAULT_ROOT = "memory"
18
+ TYPES = ("reference", "project", "assumption", "conflict")
19
+ STATUSES = ("active", "retired")
20
+ SCALARS = ("schema_version", "id", "type", "status", "description", "created", "verified_at", "review_after", "supersedes", "promoted_to")
21
+ REQUIRED = ("schema_version", "id", "type", "status", "description", "created", "verified_at")
22
+ SUMMARY_SECTIONS = ("待确认假设", "未解决冲突", "Opinion 演化候选")
23
+ IDENTITY = re.compile(r"MEM-\d{8}-\d{3}-[a-z0-9]+(?:-[a-z0-9]+)*\Z")
24
+ SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
25
+ DUE_MARK = "可能过期"
26
+
27
+
28
+ class EntryError(ValueError):
29
+ pass
30
+
31
+
32
+ def fail(message: str) -> None:
33
+ raise EntryError(message)
34
+
35
+
36
+ def confined(root: Path, relative: str | Path) -> Path:
37
+ if Path(relative).is_absolute() or ".." in Path(relative).parts:
38
+ fail(f"路径必须是项目内的相对路径:{relative}")
39
+ path = root / relative
40
+ if any(item.is_symlink() for item in (path, *path.parents) if root in (item, *item.parents)):
41
+ fail(f"拒绝使用符号链接:{relative}")
42
+ return path
43
+
44
+
45
+ def parse_date(value: str, label: str) -> date:
46
+ try:
47
+ return date.fromisoformat(value)
48
+ except (TypeError, ValueError):
49
+ fail(f"{label} 必须为 YYYY-MM-DD:{value!r}")
50
+
51
+
52
+ def read_json(path: Path) -> dict[str, Any]:
53
+ value = json.loads(path.read_text(encoding="utf-8"))
54
+ if not isinstance(value, dict):
55
+ fail(f"JSON 根节点必须是对象:{path.name}")
56
+ return value
57
+
58
+
59
+ def settings(args: argparse.Namespace) -> tuple[Path, Path, dict[str, Any]]:
60
+ """解析项目根、记忆目录与参数;命令行优先,其次读取 Project Profile 的 memory 段。"""
61
+ project = args.project.resolve()
62
+ if not project.is_dir():
63
+ fail(f"项目目录不存在:{project}")
64
+ declared: dict[str, Any] = {}
65
+ profile = project / PROJECT_PROFILE
66
+ if profile.is_file():
67
+ declared = read_json(profile).get("memory") or {}
68
+ root = confined(project, args.root or declared.get("root") or DEFAULT_ROOT)
69
+ policy_path = args.policy or declared.get("policy")
70
+ policy = read_json(confined(project, policy_path)) if policy_path else {}
71
+ return project, root, policy
72
+
73
+
74
+ def parse_entry(text: str) -> tuple[dict[str, Any], str]:
75
+ """解析受限的 frontmatter:标量为 JSON 字符串,sources 为字符串列表。"""
76
+ if not text.startswith("---\n") or "\n---\n" not in text[4:]:
77
+ fail("缺少 frontmatter")
78
+ header, body = text[4:].split("\n---\n", 1)
79
+ meta: dict[str, Any] = {}
80
+ current: str | None = None
81
+ for line in header.splitlines():
82
+ if line.startswith(" - ") and current == "sources":
83
+ meta["sources"].append(json.loads(line[4:]))
84
+ continue
85
+ key, separator, raw = line.partition(":")
86
+ if not separator or key != key.strip() or not key:
87
+ fail(f"无法解析的 frontmatter 行:{line!r}")
88
+ current = key
89
+ if key == "sources":
90
+ if raw.strip() not in ("", "[]"):
91
+ fail("sources 必须写成列表")
92
+ meta["sources"] = []
93
+ else:
94
+ value = json.loads(raw.strip())
95
+ if not isinstance(value, str):
96
+ fail(f"{key} 必须是字符串")
97
+ meta[key] = value
98
+ return meta, body
99
+
100
+
101
+ def render_entry(meta: dict[str, Any], body: str) -> str:
102
+ lines = ["---"]
103
+ for key in SCALARS:
104
+ if key in meta:
105
+ lines.append(f"{key}: {json.dumps(meta[key], ensure_ascii=False)}")
106
+ lines.append("sources:" if meta.get("sources") else "sources: []")
107
+ lines.extend(f" - {json.dumps(item, ensure_ascii=False)}" for item in meta.get("sources", []))
108
+ lines.append("---")
109
+ return "\n".join(lines) + "\n" + body
110
+
111
+
112
+ def load_entries(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
113
+ entries: list[dict[str, Any]] = []
114
+ problems: list[dict[str, str]] = []
115
+ for path in sorted(root.glob("MEM-*.md")) if root.is_dir() else []:
116
+ try:
117
+ meta, body = parse_entry(path.read_text(encoding="utf-8"))
118
+ except (EntryError, json.JSONDecodeError, UnicodeDecodeError) as exc:
119
+ problems.append({"code": "unreadable", "entry": path.name, "message": str(exc)})
120
+ continue
121
+ entries.append({"path": path, "meta": meta, "body": body})
122
+ return entries, problems
123
+
124
+
125
+ def due_date(meta: dict[str, Any], policy: dict[str, Any]) -> date | None:
126
+ if meta.get("review_after"):
127
+ return parse_date(meta["review_after"], "review_after")
128
+ interval = policy.get("review_interval_days", {}).get(meta.get("type"))
129
+ if interval and meta.get("verified_at"):
130
+ return parse_date(meta["verified_at"], "verified_at") + timedelta(days=int(interval))
131
+ return None
132
+
133
+
134
+ def is_due(meta: dict[str, Any], policy: dict[str, Any], today: date) -> bool:
135
+ deadline = due_date(meta, policy)
136
+ return deadline is not None and deadline < today
137
+
138
+
139
+ def index_lines(entries: list[dict[str, Any]], policy: dict[str, Any], today: date) -> list[str]:
140
+ lines = []
141
+ for entry in entries:
142
+ meta = entry["meta"]
143
+ if meta.get("status") != "active":
144
+ continue
145
+ try:
146
+ mark = f"|{DUE_MARK}" if is_due(meta, policy, today) else ""
147
+ except EntryError:
148
+ mark = ""
149
+ lines.append(f"- `{meta.get('id', entry['path'].stem)}`({meta.get('type', '?')}):{meta.get('description', '')}{mark}")
150
+ return lines
151
+
152
+
153
+ def check_entry(project: Path, entry: dict[str, Any], policy: dict[str, Any]) -> list[dict[str, str]]:
154
+ meta, name = entry["meta"], entry["path"].name
155
+ found: list[dict[str, str]] = []
156
+
157
+ def problem(code: str, message: str) -> None:
158
+ found.append({"code": code, "entry": name, "message": message})
159
+
160
+ for key in REQUIRED:
161
+ if not str(meta.get(key, "")).strip():
162
+ problem("missing-field", f"缺少字段 {key}")
163
+ unknown = set(meta) - set(SCALARS) - {"sources"}
164
+ if unknown:
165
+ problem("unknown-field", f"未知字段:{'、'.join(sorted(unknown))}")
166
+ if meta.get("id") and (not IDENTITY.fullmatch(meta["id"]) or meta["id"] != entry["path"].stem):
167
+ problem("id-mismatch", "编号格式无效或与文件名不一致")
168
+ types = tuple(policy.get("types", TYPES))
169
+ if meta.get("type") and meta["type"] not in types:
170
+ problem("invalid-type", f"type 必须为:{'、'.join(types)}")
171
+ if meta.get("status") and meta["status"] not in STATUSES:
172
+ problem("invalid-status", f"status 必须为:{'、'.join(STATUSES)}")
173
+ for key in ("created", "verified_at", "review_after"):
174
+ if meta.get(key):
175
+ try:
176
+ parse_date(meta[key], key)
177
+ except EntryError as exc:
178
+ problem("invalid-date", str(exc))
179
+ if meta.get("type") == "assumption" and meta.get("status") == "active" and not meta.get("review_after"):
180
+ problem("assumption-without-review", "未确认的假设必须设置 review_after")
181
+ if meta.get("status") == "active" and meta.get("promoted_to"):
182
+ problem("promoted-still-active", "已晋升的条目应当退役")
183
+ limit = policy.get("index", {}).get("max_description_chars")
184
+ if limit and len(meta.get("description", "")) > int(limit):
185
+ problem("description-too-long", f"description 超过 {limit} 字符")
186
+ if meta.get("status") == "active":
187
+ for source in meta.get("sources", []):
188
+ try:
189
+ missing = not confined(project, source).exists()
190
+ except EntryError as exc:
191
+ problem("invalid-source", str(exc))
192
+ continue
193
+ if missing:
194
+ problem("missing-source", f"来源路径不存在:{source}")
195
+ return found
196
+
197
+
198
+ def check_budgets(project: Path, lines: list[str], policy: dict[str, Any]) -> list[dict[str, str]]:
199
+ found: list[dict[str, str]] = []
200
+ index = policy.get("index", {})
201
+ size = len("\n".join(lines).encode("utf-8"))
202
+ if index.get("max_lines") and len(lines) > int(index["max_lines"]):
203
+ found.append({"code": "index-too-long", "entry": "", "message": f"索引 {len(lines)} 行,超过 {index['max_lines']} 行"})
204
+ if index.get("max_bytes") and size > int(index["max_bytes"]):
205
+ found.append({"code": "index-too-large", "entry": "", "message": f"索引 {size} 字节,超过 {index['max_bytes']} 字节"})
206
+ bundle = policy.get("startup_bundle", {})
207
+ if bundle.get("max_bytes") and bundle.get("files"):
208
+ total = sum(confined(project, item).stat().st_size for item in bundle["files"] if confined(project, item).is_file()) + size
209
+ if total > int(bundle["max_bytes"]):
210
+ found.append({"code": "startup-too-large", "entry": "", "message": f"启动层合计 {total} 字节,超过 {bundle['max_bytes']} 字节"})
211
+ summary = policy.get("chat_summary", {})
212
+ path = confined(project, summary.get("path", ".agent-doc/chat-summary.md"))
213
+ if path.is_file():
214
+ text = path.read_text(encoding="utf-8")
215
+ headings = [line[3:].strip() for line in text.splitlines() if line.startswith("## ")]
216
+ for section in summary.get("required_sections", SUMMARY_SECTIONS):
217
+ # 按前缀匹配,项目可以在固定小节名之后补充说明。
218
+ if not any(heading.startswith(section) for heading in headings):
219
+ found.append({"code": "summary-section-missing", "entry": path.name, "message": f"缺少小节:{section}"})
220
+ if summary.get("max_bytes") and len(text.encode("utf-8")) > int(summary["max_bytes"]):
221
+ found.append({"code": "summary-too-large", "entry": path.name, "message": f"超过 {summary['max_bytes']} 字节"})
222
+ return found
223
+
224
+
225
+ def command_check(project: Path, root: Path, policy: dict[str, Any], today: date) -> dict[str, Any]:
226
+ entries, problems = load_entries(root)
227
+ seen: dict[str, str] = {}
228
+ for entry in entries:
229
+ problems.extend(check_entry(project, entry, policy))
230
+ identity = entry["meta"].get("id")
231
+ if identity in seen:
232
+ problems.append({"code": "duplicate-id", "entry": entry["path"].name, "message": f"与 {seen[identity]} 编号重复"})
233
+ seen.setdefault(identity, entry["path"].name)
234
+ lines = index_lines(entries, policy, today)
235
+ problems.extend(check_budgets(project, lines, policy))
236
+ active = [entry["meta"] for entry in entries if entry["meta"].get("status") == "active"]
237
+ due = []
238
+ for meta in active:
239
+ try:
240
+ if is_due(meta, policy, today):
241
+ due.append(meta.get("id"))
242
+ except EntryError:
243
+ continue
244
+ return {"ok": not problems, "entries": len(entries), "active": len(active), "index_lines": len(lines),
245
+ "index_bytes": len("\n".join(lines).encode("utf-8")), "due": due, "problems": problems}
246
+
247
+
248
+ def command_create(args: argparse.Namespace, project: Path, root: Path, policy: dict[str, Any], today: date) -> dict[str, Any]:
249
+ types = tuple(policy.get("types", TYPES))
250
+ if args.type not in types:
251
+ fail(f"--type 必须为:{'、'.join(types)}")
252
+ if not args.slug or not SLUG.fullmatch(args.slug):
253
+ fail("--slug 只能包含小写字母、数字和连字符")
254
+ if not args.description or not args.description.strip():
255
+ fail("--description 说明何时应当想起这条记忆,不能为空")
256
+ body = confined(project, args.body_file).read_text(encoding="utf-8") if args.body_file else ""
257
+ if not body.strip():
258
+ fail("--body-file 必须提供非空正文")
259
+ created = parse_date(args.date, "--date") if args.date else today
260
+ if args.review_after:
261
+ parse_date(args.review_after, "--review-after")
262
+ stamp = created.strftime("%Y%m%d")
263
+ taken = {int(path.name[13:16]) for path in root.glob(f"MEM-{stamp}-*.md")} if root.is_dir() else set()
264
+ identity = f"MEM-{stamp}-{max(taken, default=0) + 1:03d}-{args.slug}"
265
+ meta = {"schema_version": "1.0", "id": identity, "type": args.type, "status": "active", "description": args.description.strip(),
266
+ "created": created.isoformat(), "verified_at": created.isoformat(), "review_after": args.review_after or "",
267
+ "supersedes": args.supersedes or "", "promoted_to": "", "sources": list(args.source)}
268
+ path = root / f"{identity}.md"
269
+ problems = check_entry(project, {"path": path, "meta": meta, "body": body}, policy)
270
+ if problems:
271
+ fail(";".join(item["message"] for item in problems))
272
+ content = render_entry(meta, "\n" + body.strip() + "\n")
273
+ if args.apply:
274
+ root.mkdir(parents=True, exist_ok=True)
275
+ with open(path, "x", encoding="utf-8") as handle:
276
+ handle.write(content)
277
+ return {"ok": True, "status": "written" if args.apply else "preview", "path": path.relative_to(project).as_posix(), "content": content}
278
+
279
+
280
+ def command_update(args: argparse.Namespace, project: Path, root: Path, today: date) -> dict[str, Any]:
281
+ if not args.id:
282
+ fail(f"{args.action} 需要 --id")
283
+ path = root / f"{args.id}.md"
284
+ if not IDENTITY.fullmatch(args.id) or not path.is_file():
285
+ fail(f"找不到记忆条目:{args.id}")
286
+ meta, body = parse_entry(path.read_text(encoding="utf-8"))
287
+ if args.action == "retire":
288
+ if not args.reason and not args.promoted_to:
289
+ fail("retire 需要 --reason 或 --promoted-to")
290
+ meta["status"] = "retired"
291
+ meta["promoted_to"] = args.promoted_to or meta.get("promoted_to", "")
292
+ body = body.rstrip() + f"\n\n**退役**:{today.isoformat()},{args.reason or '已晋升到 ' + args.promoted_to}\n"
293
+ else:
294
+ meta["verified_at"] = today.isoformat()
295
+ if args.review_after:
296
+ parse_date(args.review_after, "--review-after")
297
+ meta["review_after"] = args.review_after
298
+ content = render_entry(meta, body)
299
+ if args.apply:
300
+ temporary = path.with_name(f".{path.name}.tmp")
301
+ temporary.write_text(content, encoding="utf-8")
302
+ os.replace(temporary, path)
303
+ return {"ok": True, "status": "written" if args.apply else "preview", "path": path.relative_to(project).as_posix(), "content": content}
304
+
305
+
306
+ def build_parser() -> argparse.ArgumentParser:
307
+ parser = argparse.ArgumentParser(description="条目式项目记忆:生成索引、检查、创建、核实与退役")
308
+ parser.add_argument("action", choices=("index", "list", "check", "create", "verify", "retire"))
309
+ parser.add_argument("--project", type=Path, default=Path.cwd())
310
+ parser.add_argument("--root", help="记忆目录,默认取 Project Profile 的 memory.root,再默认 memory")
311
+ parser.add_argument("--policy", help="项目自己的参数文件;未提供时不做体积与间隔检查")
312
+ parser.add_argument("--today", help="用于测试与回放的日期,YYYY-MM-DD")
313
+ parser.add_argument("--output", choices=("json", "markdown"), default="markdown")
314
+ parser.add_argument("--apply", action="store_true", help="create、verify、retire 默认只预览")
315
+ parser.add_argument("--id")
316
+ parser.add_argument("--type")
317
+ parser.add_argument("--slug")
318
+ parser.add_argument("--description", help="一句话说明何时应当想起这条记忆")
319
+ parser.add_argument("--body-file", help="项目内的正文文件")
320
+ parser.add_argument("--source", action="append", default=[], help="项目内的来源路径,可重复")
321
+ parser.add_argument("--review-after", help="复核日期,YYYY-MM-DD")
322
+ parser.add_argument("--supersedes")
323
+ parser.add_argument("--date", help="create 的创建日期,默认今天")
324
+ parser.add_argument("--reason")
325
+ parser.add_argument("--promoted-to")
326
+ return parser
327
+
328
+
329
+ def run(args: argparse.Namespace) -> tuple[int, str]:
330
+ project, root, policy = settings(args)
331
+ today = parse_date(args.today, "--today") if args.today else date.today()
332
+ if args.action == "index":
333
+ entries, _ = load_entries(root)
334
+ lines = index_lines(entries, policy, today)
335
+ if args.output == "json":
336
+ return 0, json.dumps({"ok": True, "lines": lines}, ensure_ascii=False, indent=2) + "\n"
337
+ return 0, "\n".join(lines) + ("\n" if lines else "")
338
+ if args.action == "list":
339
+ entries, problems = load_entries(root)
340
+ value = [{**entry["meta"], "path": entry["path"].relative_to(project).as_posix()} for entry in entries]
341
+ return 0, json.dumps({"ok": True, "entries": value, "problems": problems}, ensure_ascii=False, indent=2) + "\n"
342
+ if args.action == "check":
343
+ result = command_check(project, root, policy, today)
344
+ return (0 if result["ok"] else 1), json.dumps(result, ensure_ascii=False, indent=2) + "\n"
345
+ if args.action == "create":
346
+ result = command_create(args, project, root, policy, today)
347
+ else:
348
+ result = command_update(args, project, root, today)
349
+ return 0, json.dumps(result, ensure_ascii=False, indent=2) + "\n"
350
+
351
+
352
+ def main(argv: list[str] | None = None) -> int:
353
+ args = build_parser().parse_args(argv)
354
+ try:
355
+ code, output = run(args)
356
+ except (ValueError, OSError) as exc:
357
+ code, output = 2, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2) + "\n"
358
+ sys.stdout.write(output)
359
+ return code
360
+
361
+
362
+ if __name__ == "__main__":
363
+ raise SystemExit(main())
@@ -0,0 +1,107 @@
1
+ """项目入口的公共约定:错误、接口版本、状态分组与 Profile 中的 ``entry`` 段。
2
+
3
+ 演示与真实两种数据来源(project_demo / project_real)都只依赖这里,界面层(project_cli /
4
+ project_menu)不关心数据从哪来。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ # 运行时支持的项目入口接口版本范围;项目在 Profile 的 entry.api 声明自己需要的版本。
14
+ RUNTIME_API = (1, 1)
15
+ # 当前启动器模板的接口版本;旧启动器仍可用,只提示更新。
16
+ LAUNCHER_API = 1
17
+ PROFILE = Path(".agents/moe.sakanano.agent-pack/project.json")
18
+ DEFAULT_TOOLS = Path(".agents/moe.sakanano.agent-pack/tools.json")
19
+ DEFAULT_LAUNCHER = "menu"
20
+ LAUNCHER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
21
+ # 默认 Case 状态即通用分组;项目可在 entry.items.status_map 把自己的状态名映射过来。
22
+ GROUP_KEYS = ("active", "waiting", "captured", "suspended", "done", "abandoned")
23
+
24
+
25
+ class EntryError(Exception):
26
+ """预期内的失败:``code`` 决定退出码,``next_steps`` 告诉人下一步。"""
27
+
28
+ def __init__(self, message: str, code: str = "usage", next_steps: list[str] | None = None) -> None:
29
+ super().__init__(message)
30
+ self.code = code
31
+ self.next_steps = next_steps or []
32
+
33
+
34
+ def find_profile_root(start: Path | None, explicit: bool) -> Path:
35
+ base = (start or Path.cwd()).expanduser().resolve()
36
+ candidates = [base] if explicit else [base, *base.parents]
37
+ for candidate in candidates:
38
+ if (candidate / PROFILE).is_file():
39
+ return candidate
40
+ where = str(base) if explicit else f"{base} 及其上级目录"
41
+ raise EntryError(f"{where}里没有 agenthelm 项目(缺少 {PROFILE})", "not-a-project",
42
+ ["到项目目录里运行,或用 --project <目录> 指定",
43
+ "新项目:用 agents-init 初始化(会生成项目入口)",
44
+ "只想看看效果:agenthelm project --demo study"])
45
+
46
+
47
+ def read_profile(root: Path) -> dict:
48
+ path = root / PROFILE
49
+ if path.is_symlink():
50
+ raise EntryError(f"拒绝符号链接:{PROFILE}", "profile-invalid")
51
+ try:
52
+ data = json.loads(path.read_text(encoding="utf-8"))
53
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
54
+ raise EntryError(f"Project Profile 无法解析:{exc}", "profile-invalid",
55
+ [f"检查 {PROFILE} 是否为合法 JSON"]) from exc
56
+ if not isinstance(data, dict):
57
+ raise EntryError("Project Profile 根节点必须是对象", "profile-invalid")
58
+ return data
59
+
60
+
61
+ def inside(root: Path, value: Any, label: str) -> Path:
62
+ """A project-relative path that stays inside the project and passes no symlink."""
63
+ if not isinstance(value, str) or not value:
64
+ raise EntryError(f"{label} 必须是项目内相对路径", "profile-invalid")
65
+ relative = Path(value)
66
+ if relative.is_absolute() or ".." in relative.parts:
67
+ raise EntryError(f"{label} 必须是项目内相对路径:{value}", "profile-invalid")
68
+ current = root
69
+ for part in relative.parts:
70
+ current = current / part
71
+ if current.is_symlink():
72
+ raise EntryError(f"{label} 不能经过符号链接:{value}", "profile-invalid")
73
+ return root / relative
74
+
75
+
76
+ def entry_section(profile: dict) -> dict:
77
+ """The optional ``entry`` block with defaults; invalid values fail loudly instead of being guessed."""
78
+ entry = profile.get("entry")
79
+ if entry is None:
80
+ return {"declared": False, "api": 1, "launcher": None, "about": "", "guide": None, "tools": None,
81
+ "items": None, "where": [], "start": []}
82
+ if not isinstance(entry, dict):
83
+ raise EntryError("Profile 的 entry 必须是对象", "profile-invalid")
84
+ api = entry.get("api", 1)
85
+ if not isinstance(api, int) or isinstance(api, bool) or api < 1:
86
+ raise EntryError("entry.api 必须是正整数", "profile-invalid")
87
+ launcher = entry.get("launcher")
88
+ if launcher is not None and (not isinstance(launcher, str) or not LAUNCHER_RE.fullmatch(launcher)):
89
+ raise EntryError("entry.launcher 只能是根目录下的文件名(字母、数字、. _ -)", "profile-invalid")
90
+ items = entry.get("items")
91
+ if items is not None:
92
+ command = items.get("command") if isinstance(items, dict) else None
93
+ if not (isinstance(command, list) and command and all(isinstance(part, str) and part for part in command)):
94
+ raise EntryError("entry.items.command 必须是非空的参数列表(不经过 shell)", "profile-invalid")
95
+ status_map = items.get("status_map") or {}
96
+ if not isinstance(status_map, dict) or not all(value in GROUP_KEYS for value in status_map.values()):
97
+ raise EntryError("entry.items.status_map 的值只能是 " + " / ".join(GROUP_KEYS), "profile-invalid")
98
+ where = entry.get("where") or []
99
+ start = entry.get("start") or []
100
+ if not (isinstance(where, list) and all(isinstance(item, dict) and isinstance(item.get("label"), str)
101
+ and isinstance(item.get("path"), str) for item in where)):
102
+ raise EntryError("entry.where 必须是 {label, path} 列表", "profile-invalid")
103
+ if not (isinstance(start, list) and all(isinstance(item, str) for item in start)):
104
+ raise EntryError("entry.start 必须是字符串列表", "profile-invalid")
105
+ return {"declared": True, "api": api, "launcher": launcher, "about": str(entry.get("about") or ""),
106
+ "guide": entry.get("guide"), "tools": entry.get("tools"), "items": items,
107
+ "where": where, "start": start}