@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.
- 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} +42 -1
- 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,557 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""默认 Case 工具:在 case-workspace 项目里新建、列出、查看、更新与校验 Case。
|
|
3
|
+
|
|
4
|
+
这是 agenthelm 的官方样例实现。项目在 Project Profile 的 ``work.case.command``
|
|
5
|
+
声明了自己的 Case 工具时,本工具只做只读的 list / show / validate,写操作让位给
|
|
6
|
+
项目工具;``work.case.schema`` 可替换随包的默认 Schema。所有写入为原子替换,
|
|
7
|
+
Case 根目录与 CASE.md 拒绝符号链接。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
DEFAULT_SCHEMA = PLUGIN_ROOT / "references" / "case.schema.json"
|
|
24
|
+
TEMPLATE = PLUGIN_ROOT / "templates" / "CASE.md"
|
|
25
|
+
PROFILE = Path(".agents/moe.sakanano.agent-pack/project.json")
|
|
26
|
+
CASE_DIR_RE = re.compile(r"^CASE-([0-9]{8})-([0-9]{3})-([a-z0-9]+(?:-[a-z0-9]+)*)$")
|
|
27
|
+
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
28
|
+
KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
29
|
+
CREDENTIAL_RE = re.compile(r"(credential|secret|token|password|passwd|private-?key)", re.IGNORECASE)
|
|
30
|
+
STATUS_ORDER = {"active": 0, "waiting": 1, "captured": 2, "suspended": 3, "done": 4, "abandoned": 5}
|
|
31
|
+
CLOSED = {"done", "abandoned"}
|
|
32
|
+
FIELD_ORDER = ("schema_version", "id", "title", "status", "sensitivity", "source", "goal", "done_when",
|
|
33
|
+
"current_state", "next_action", "waiting_for", "resume_when", "artifact_mode")
|
|
34
|
+
LIST_FIELDS = ("aliases", "legacy_ids", "tags", "deliverables", "evidence")
|
|
35
|
+
MAX_CASE_BYTES = 2 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CaseError(Exception):
|
|
39
|
+
def __init__(self, message: str, code: str = "invalid-request") -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------- frontmatter
|
|
45
|
+
|
|
46
|
+
def _scalar(value: str, key: str) -> Any:
|
|
47
|
+
if value == "[]":
|
|
48
|
+
return []
|
|
49
|
+
if value in ("true", "false"):
|
|
50
|
+
return value == "true"
|
|
51
|
+
if value.startswith('"'):
|
|
52
|
+
try:
|
|
53
|
+
parsed = json.loads(value)
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise CaseError(f"{key}: 字符串必须是 JSON 双引号格式", "frontmatter") from exc
|
|
56
|
+
if not isinstance(parsed, str):
|
|
57
|
+
raise CaseError(f"{key}: 只支持字符串", "frontmatter")
|
|
58
|
+
return parsed
|
|
59
|
+
if value[:1] in ("[", "{", "|", ">", "'", "&", "*", "!"):
|
|
60
|
+
raise CaseError(f"{key}: 不支持该 YAML 写法", "frontmatter")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_frontmatter(text: str) -> dict:
|
|
65
|
+
"""Strict subset: top-level ``key: scalar`` and ``key:`` followed by `` - item`` lists."""
|
|
66
|
+
lines, data, index = text.splitlines(), {}, 0
|
|
67
|
+
while index < len(lines):
|
|
68
|
+
line = lines[index]
|
|
69
|
+
if not line or line[0].isspace() or ":" not in line:
|
|
70
|
+
raise CaseError(f"frontmatter 第 {index + 1} 行格式不支持", "frontmatter")
|
|
71
|
+
key, value = line.split(":", 1)
|
|
72
|
+
if not KEY_RE.fullmatch(key) or key in data:
|
|
73
|
+
raise CaseError(f"frontmatter 键无效或重复:{key}", "frontmatter")
|
|
74
|
+
if value.strip():
|
|
75
|
+
data[key] = _scalar(value.strip(), key)
|
|
76
|
+
index += 1
|
|
77
|
+
continue
|
|
78
|
+
items, index = [], index + 1
|
|
79
|
+
while index < len(lines) and lines[index].startswith(" - "):
|
|
80
|
+
items.append(_scalar(lines[index][4:], key))
|
|
81
|
+
index += 1
|
|
82
|
+
data[key] = items
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_case(path: Path) -> tuple[dict, str]:
|
|
87
|
+
if path.is_symlink():
|
|
88
|
+
raise CaseError(f"拒绝符号链接:{path.name}", "symlink")
|
|
89
|
+
raw = path.read_bytes()
|
|
90
|
+
if len(raw) > MAX_CASE_BYTES:
|
|
91
|
+
raise CaseError("CASE.md 超过 2 MiB", "oversize")
|
|
92
|
+
text = raw.decode("utf-8")
|
|
93
|
+
if not text.startswith("---\n"):
|
|
94
|
+
raise CaseError("CASE.md 必须以 --- 开头", "frontmatter")
|
|
95
|
+
end = text.find("\n---\n", 3)
|
|
96
|
+
if end < 0:
|
|
97
|
+
raise CaseError("CASE.md 缺少 frontmatter 结束标记", "frontmatter")
|
|
98
|
+
return parse_frontmatter(text[4:end]), text[end + 5:]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_frontmatter(data: dict) -> str:
|
|
102
|
+
lines = ["---"]
|
|
103
|
+
keys = [key for key in FIELD_ORDER if key in data]
|
|
104
|
+
keys += sorted(key for key in data if key not in FIELD_ORDER and key not in ("created_at", "updated_at"))
|
|
105
|
+
keys += [key for key in ("created_at", "updated_at") if key in data]
|
|
106
|
+
for key in keys:
|
|
107
|
+
value = data[key]
|
|
108
|
+
if isinstance(value, list):
|
|
109
|
+
lines.append(f"{key}: []" if not value else f"{key}:")
|
|
110
|
+
lines.extend(f" - {json.dumps(item, ensure_ascii=False)}" for item in value)
|
|
111
|
+
elif isinstance(value, bool):
|
|
112
|
+
lines.append(f"{key}: {'true' if value else 'false'}")
|
|
113
|
+
else:
|
|
114
|
+
lines.append(f"{key}: {json.dumps(str(value), ensure_ascii=False)}")
|
|
115
|
+
return "\n".join(lines) + "\n---\n"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------- schema
|
|
119
|
+
|
|
120
|
+
def validate_schema(data: dict, schema: dict) -> list[str]:
|
|
121
|
+
"""The keyword subset the default schema uses: const, enum, type, pattern, minLength, items, uniqueItems."""
|
|
122
|
+
errors = [f"缺少字段:{key}" for key in schema.get("required", []) if key not in data]
|
|
123
|
+
properties = schema.get("properties", {})
|
|
124
|
+
for key, value in data.items():
|
|
125
|
+
rule = properties.get(key)
|
|
126
|
+
if rule is None:
|
|
127
|
+
if schema.get("additionalProperties") is False:
|
|
128
|
+
errors.append(f"未知字段:{key}")
|
|
129
|
+
continue
|
|
130
|
+
errors.extend(f"{key}: {problem}" for problem in _check(value, rule))
|
|
131
|
+
return errors
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _check(value: Any, rule: dict) -> list[str]:
|
|
135
|
+
problems = []
|
|
136
|
+
if "const" in rule and value != rule["const"]:
|
|
137
|
+
problems.append(f"必须为 {rule['const']!r}")
|
|
138
|
+
if "enum" in rule and value not in rule["enum"]:
|
|
139
|
+
problems.append("取值必须是 " + "、".join(map(str, rule["enum"])))
|
|
140
|
+
kind = rule.get("type")
|
|
141
|
+
if kind == "string" and not isinstance(value, str):
|
|
142
|
+
problems.append("必须是字符串")
|
|
143
|
+
if kind == "array":
|
|
144
|
+
if not isinstance(value, list):
|
|
145
|
+
return problems + ["必须是列表"]
|
|
146
|
+
if rule.get("uniqueItems") and len(value) != len(set(map(str, value))):
|
|
147
|
+
problems.append("列表有重复项")
|
|
148
|
+
for item in value:
|
|
149
|
+
problems.extend(_check(item, rule.get("items", {})))
|
|
150
|
+
if isinstance(value, str):
|
|
151
|
+
if len(value) < rule.get("minLength", 0):
|
|
152
|
+
problems.append("不能为空")
|
|
153
|
+
if "pattern" in rule and not re.search(rule["pattern"], value):
|
|
154
|
+
problems.append("格式不符合")
|
|
155
|
+
return problems
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------- project
|
|
159
|
+
|
|
160
|
+
class Project:
|
|
161
|
+
def __init__(self, root: Path) -> None:
|
|
162
|
+
self.root = root.expanduser().resolve()
|
|
163
|
+
profile_path = self.root / PROFILE
|
|
164
|
+
try:
|
|
165
|
+
profile = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
166
|
+
except FileNotFoundError as exc:
|
|
167
|
+
raise CaseError(f"缺少 Project Profile:{PROFILE}", "profile-missing") from exc
|
|
168
|
+
except (OSError, ValueError) as exc:
|
|
169
|
+
raise CaseError(f"Project Profile 无法解析:{exc}", "profile-invalid") from exc
|
|
170
|
+
work = profile.get("work") or {}
|
|
171
|
+
if work.get("mode") != "case-workspace":
|
|
172
|
+
raise CaseError("项目未启用 case-workspace(Profile work.mode)", "mode")
|
|
173
|
+
case = work.get("case") or {}
|
|
174
|
+
self.case_root = self._inside(case.get("root") or "cases", "work.case.root")
|
|
175
|
+
self.project_command = case.get("command")
|
|
176
|
+
schema_value = case.get("schema")
|
|
177
|
+
schema_path = self._inside(schema_value, "work.case.schema") if schema_value else DEFAULT_SCHEMA
|
|
178
|
+
self.schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
179
|
+
|
|
180
|
+
def _inside(self, value: str, label: str) -> Path:
|
|
181
|
+
relative = Path(value)
|
|
182
|
+
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
|
183
|
+
raise CaseError(f"{label} 必须是项目内相对路径", "profile-invalid")
|
|
184
|
+
current = self.root
|
|
185
|
+
for part in relative.parts:
|
|
186
|
+
current = current / part
|
|
187
|
+
if current.is_symlink():
|
|
188
|
+
raise CaseError(f"{label} 不能经过符号链接", "symlink")
|
|
189
|
+
return self.root / relative
|
|
190
|
+
|
|
191
|
+
def require_writable(self) -> None:
|
|
192
|
+
if self.project_command:
|
|
193
|
+
raise CaseError(f"项目声明了自己的 Case 工具({self.project_command}),请用它修改 Case", "project-tool")
|
|
194
|
+
|
|
195
|
+
def case_dirs(self) -> list[Path]:
|
|
196
|
+
if not self.case_root.is_dir():
|
|
197
|
+
return []
|
|
198
|
+
return sorted(path for path in self.case_root.iterdir()
|
|
199
|
+
if path.is_dir() and not path.is_symlink() and CASE_DIR_RE.fullmatch(path.name))
|
|
200
|
+
|
|
201
|
+
def find(self, term: str) -> Path:
|
|
202
|
+
matches = [path for path in self.case_dirs() if path.name == term]
|
|
203
|
+
if not matches:
|
|
204
|
+
lowered = term.lower()
|
|
205
|
+
matches = [path for path in self.case_dirs() if lowered in path.name.lower()]
|
|
206
|
+
if len(matches) != 1:
|
|
207
|
+
raise CaseError(f"找到 {len(matches)} 个匹配 {term!r} 的 Case", "not-found" if not matches else "ambiguous")
|
|
208
|
+
return matches[0]
|
|
209
|
+
|
|
210
|
+
def relative(self, path: Path) -> str:
|
|
211
|
+
return path.relative_to(self.root).as_posix()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def now() -> str:
|
|
215
|
+
return datetime.now().astimezone().replace(microsecond=0).isoformat()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def write_atomic(path: Path, content: str) -> None:
|
|
219
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.",
|
|
220
|
+
suffix=".tmp", delete=False) as handle:
|
|
221
|
+
handle.write(content)
|
|
222
|
+
handle.flush()
|
|
223
|
+
os.fsync(handle.fileno())
|
|
224
|
+
temporary = Path(handle.name)
|
|
225
|
+
os.replace(temporary, path)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def clean(value: str | None, label: str, *, required: bool = False) -> str:
|
|
229
|
+
value = (value or "").strip()
|
|
230
|
+
if any(character in value for character in "\r\n\x00"):
|
|
231
|
+
raise CaseError(f"{label} 只能是单行文本")
|
|
232
|
+
if required and not value:
|
|
233
|
+
raise CaseError(f"{label} 不能为空")
|
|
234
|
+
return value
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------- commands
|
|
238
|
+
|
|
239
|
+
def _new_case_fields(project: Project, args: argparse.Namespace) -> tuple[str, dict]:
|
|
240
|
+
slug = clean(args.slug, "--slug", required=True)
|
|
241
|
+
if not SLUG_RE.fullmatch(slug) or CREDENTIAL_RE.search(slug):
|
|
242
|
+
raise CaseError("--slug 只能用小写字母、数字和连字符,且不能含凭据类词语")
|
|
243
|
+
status = args.status
|
|
244
|
+
fields = {
|
|
245
|
+
"title": clean(args.title, "--title", required=True),
|
|
246
|
+
"goal": clean(args.goal, "--goal", required=True),
|
|
247
|
+
"next_action": clean(args.next_action, "--next-action", required=status == "active"),
|
|
248
|
+
"done_when": clean(args.done_when, "--done-when"),
|
|
249
|
+
"source": clean(args.source, "--source"),
|
|
250
|
+
"current_state": clean(args.current_state, "--current-state") or "刚创建",
|
|
251
|
+
}
|
|
252
|
+
return slug, fields
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _render_new_case(project: Project, args: argparse.Namespace, fields: dict, case_id: str,
|
|
256
|
+
stamp: str) -> str:
|
|
257
|
+
data = {"schema_version": "1.0", "id": case_id, "title": fields["title"], "status": args.status,
|
|
258
|
+
"sensitivity": "shared", "source": fields["source"], "goal": fields["goal"],
|
|
259
|
+
"done_when": fields["done_when"], "current_state": fields["current_state"],
|
|
260
|
+
"next_action": fields["next_action"], "artifact_mode": "case-local",
|
|
261
|
+
"aliases": [], "legacy_ids": [], "tags": sorted(set(args.tag)), "deliverables": [], "evidence": [],
|
|
262
|
+
"created_at": stamp, "updated_at": stamp}
|
|
263
|
+
data = {key: value for key, value in data.items() if key in project.schema.get("properties", data)}
|
|
264
|
+
errors = validate_schema(data, project.schema)
|
|
265
|
+
if errors:
|
|
266
|
+
raise CaseError("新 Case 不符合 Schema:" + ";".join(errors), "schema")
|
|
267
|
+
body = TEMPLATE.read_text(encoding="utf-8")
|
|
268
|
+
for key, value in {**fields, "created_at": stamp}.items():
|
|
269
|
+
body = body.replace("{{" + key + "}}", value or "待明确")
|
|
270
|
+
return render_frontmatter(data) + "\n" + body
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _today_sequences(project: Project, today: str) -> list[int]:
|
|
274
|
+
if not project.case_root.is_dir():
|
|
275
|
+
return []
|
|
276
|
+
return [int(match.group(2)) for path in project.case_root.iterdir()
|
|
277
|
+
if (match := CASE_DIR_RE.fullmatch(path.name)) and match.group(1) == today]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def plan_create(project: Project, args: argparse.Namespace) -> tuple[str, str]:
|
|
281
|
+
"""The Case id and CASE.md text ``create`` would write now; writes nothing.
|
|
282
|
+
|
|
283
|
+
The id is a forecast: a concurrent create may take the sequence first, and then
|
|
284
|
+
``create`` uses the next free one.
|
|
285
|
+
"""
|
|
286
|
+
project.require_writable()
|
|
287
|
+
slug, fields = _new_case_fields(project, args)
|
|
288
|
+
today = datetime.now().astimezone().strftime("%Y%m%d")
|
|
289
|
+
case_id = f"CASE-{today}-{max(_today_sequences(project, today), default=0) + 1:03d}-{slug}"
|
|
290
|
+
return case_id, _render_new_case(project, args, fields, case_id, now())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def create(project: Project, args: argparse.Namespace) -> dict:
|
|
294
|
+
project.require_writable()
|
|
295
|
+
slug, fields = _new_case_fields(project, args)
|
|
296
|
+
project.case_root.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
today = datetime.now().astimezone().strftime("%Y%m%d")
|
|
298
|
+
for sequence in range(max(_today_sequences(project, today), default=0) + 1, 1000):
|
|
299
|
+
case_id = f"CASE-{today}-{sequence:03d}-{slug}"
|
|
300
|
+
directory = project.case_root / case_id
|
|
301
|
+
try:
|
|
302
|
+
directory.mkdir()
|
|
303
|
+
break
|
|
304
|
+
except FileExistsError:
|
|
305
|
+
continue
|
|
306
|
+
else:
|
|
307
|
+
raise CaseError("今天的 Case 序号已用完", "sequence-exhausted")
|
|
308
|
+
try:
|
|
309
|
+
text = _render_new_case(project, args, fields, case_id, now())
|
|
310
|
+
except CaseError:
|
|
311
|
+
directory.rmdir()
|
|
312
|
+
raise
|
|
313
|
+
write_atomic(directory / "CASE.md", text)
|
|
314
|
+
return {"ok": True, "id": case_id, "path": project.relative(directory / "CASE.md"), "status": args.status}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def summary(project: Project, directory: Path) -> dict:
|
|
318
|
+
data, _ = read_case(directory / "CASE.md")
|
|
319
|
+
return {"id": data.get("id", directory.name), "title": data.get("title", ""), "status": data.get("status", ""),
|
|
320
|
+
"next_action": data.get("next_action", ""), "updated_at": data.get("updated_at", ""),
|
|
321
|
+
"path": project.relative(directory / "CASE.md")}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def list_cases(project: Project, args: argparse.Namespace) -> dict:
|
|
325
|
+
cases, invalid = [], []
|
|
326
|
+
for directory in project.case_dirs():
|
|
327
|
+
try:
|
|
328
|
+
item = summary(project, directory)
|
|
329
|
+
except (CaseError, OSError, UnicodeDecodeError) as exc:
|
|
330
|
+
invalid.append({"path": project.relative(directory), "error": str(exc)})
|
|
331
|
+
continue
|
|
332
|
+
if args.status and item["status"] != args.status:
|
|
333
|
+
continue
|
|
334
|
+
if not args.all and not args.status and item["status"] in CLOSED:
|
|
335
|
+
continue
|
|
336
|
+
cases.append(item)
|
|
337
|
+
cases.sort(key=lambda item: item["updated_at"], reverse=True)
|
|
338
|
+
cases.sort(key=lambda item: STATUS_ORDER.get(item["status"], 99))
|
|
339
|
+
return {"ok": True, "cases": cases, "invalid": invalid}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def append_section(body: str, heading: str, line: str) -> str:
|
|
343
|
+
"""Append a bullet under ``## heading``, replacing the template's placeholder text."""
|
|
344
|
+
marker = f"## {heading}\n"
|
|
345
|
+
start = body.find(marker)
|
|
346
|
+
if start < 0:
|
|
347
|
+
return body.rstrip("\n") + f"\n\n{marker}\n- {line}\n"
|
|
348
|
+
section_start = start + len(marker)
|
|
349
|
+
next_heading = body.find("\n## ", section_start)
|
|
350
|
+
end = len(body) if next_heading < 0 else next_heading + 1
|
|
351
|
+
section = body[section_start:end].strip("\n")
|
|
352
|
+
if section in ("暂无。", "无。", "待执行。"):
|
|
353
|
+
section = ""
|
|
354
|
+
section = (section + "\n" if section else "") + f"- {line}"
|
|
355
|
+
return body[:section_start] + "\n" + section + "\n\n" + body[end:].lstrip("\n")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def plan_update(project: Project, args: argparse.Namespace) -> tuple[Path, str, str, dict]:
|
|
359
|
+
"""(path, current text, new text, result) for an update; writes nothing."""
|
|
360
|
+
project.require_writable()
|
|
361
|
+
directory = project.find(args.case)
|
|
362
|
+
path = directory / "CASE.md"
|
|
363
|
+
data, body = read_case(path)
|
|
364
|
+
current = path.read_text(encoding="utf-8")
|
|
365
|
+
expected_digest = getattr(args, "expect_sha256", None)
|
|
366
|
+
if expected_digest and hashlib.sha256(current.encode("utf-8")).hexdigest() != expected_digest:
|
|
367
|
+
raise CaseError("Case 在预览之后被修改过(内容摘要不一致),请重新读取", "conflict")
|
|
368
|
+
if args.expect_updated_at and data.get("updated_at") != args.expect_updated_at:
|
|
369
|
+
raise CaseError("Case 已被其他人修改(updated_at 不一致),请重新读取", "conflict")
|
|
370
|
+
stamp = now()
|
|
371
|
+
changed = []
|
|
372
|
+
for flag, key in (("status", "status"), ("next_action", "next_action"), ("current_state", "current_state"),
|
|
373
|
+
("waiting_for", "waiting_for"), ("resume_when", "resume_when"), ("title", "title")):
|
|
374
|
+
value = getattr(args, flag)
|
|
375
|
+
if value is not None:
|
|
376
|
+
data[key] = clean(value, f"--{flag.replace('_', '-')}")
|
|
377
|
+
changed.append(key)
|
|
378
|
+
for flag, key in (("add_deliverable", "deliverables"), ("add_evidence", "evidence"), ("add_tag", "tags"),
|
|
379
|
+
("add_alias", "aliases")):
|
|
380
|
+
for value in getattr(args, flag):
|
|
381
|
+
item = clean(value, f"--{flag.replace('_', '-')}", required=True)
|
|
382
|
+
if item not in data.setdefault(key, []):
|
|
383
|
+
data[key].append(item)
|
|
384
|
+
changed.append(key)
|
|
385
|
+
if data.get("status") == "active" and not data.get("next_action"):
|
|
386
|
+
raise CaseError("active 的 Case 需要 next_action")
|
|
387
|
+
if data.get("status") == "waiting" and not data.get("waiting_for"):
|
|
388
|
+
raise CaseError("waiting 的 Case 需要 waiting_for")
|
|
389
|
+
if "current_state" in changed:
|
|
390
|
+
body = _replace_section(body, "当前可信状态", data["current_state"])
|
|
391
|
+
if "next_action" in changed:
|
|
392
|
+
body = _replace_section(body, "下一动作", data["next_action"] or "待明确")
|
|
393
|
+
if args.log:
|
|
394
|
+
body = append_section(body, "工作记录", f"{stamp} {clean(args.log, '--log', required=True)}")
|
|
395
|
+
changed.append("工作记录")
|
|
396
|
+
if args.decision:
|
|
397
|
+
body = append_section(body, "决策记录", f"{stamp} {clean(args.decision, '--decision', required=True)}")
|
|
398
|
+
changed.append("决策记录")
|
|
399
|
+
if args.result:
|
|
400
|
+
body = _replace_section(body, "结果", clean(args.result, "--result", required=True))
|
|
401
|
+
changed.append("结果")
|
|
402
|
+
if not changed:
|
|
403
|
+
raise CaseError("没有要更新的内容")
|
|
404
|
+
data["updated_at"] = stamp
|
|
405
|
+
errors = validate_schema(data, project.schema)
|
|
406
|
+
if errors:
|
|
407
|
+
raise CaseError("更新后不符合 Schema:" + ";".join(errors), "schema")
|
|
408
|
+
return path, current, render_frontmatter(data) + body, {
|
|
409
|
+
"ok": True, "id": data["id"], "changed": sorted(set(changed)), "updated_at": stamp,
|
|
410
|
+
"path": project.relative(path)}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def update(project: Project, args: argparse.Namespace) -> dict:
|
|
414
|
+
path, current, text, result = plan_update(project, args)
|
|
415
|
+
# Re-read right before replacing: a write that landed after planning must not be lost.
|
|
416
|
+
if path.read_text(encoding="utf-8") != current:
|
|
417
|
+
raise CaseError("Case 刚被其他人修改,请重新读取", "conflict")
|
|
418
|
+
write_atomic(path, text)
|
|
419
|
+
return result
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _replace_section(body: str, heading: str, text: str) -> str:
|
|
423
|
+
marker = f"## {heading}\n"
|
|
424
|
+
start = body.find(marker)
|
|
425
|
+
if start < 0:
|
|
426
|
+
return body.rstrip("\n") + f"\n\n{marker}\n{text}\n"
|
|
427
|
+
section_start = start + len(marker)
|
|
428
|
+
next_heading = body.find("\n## ", section_start)
|
|
429
|
+
end = len(body) if next_heading < 0 else next_heading + 1
|
|
430
|
+
return body[:section_start] + "\n" + text + "\n\n" + body[end:].lstrip("\n")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def validate(project: Project, args: argparse.Namespace) -> dict:
|
|
434
|
+
directories = [project.find(args.case)] if args.case else project.case_dirs()
|
|
435
|
+
results, seen = [], {}
|
|
436
|
+
for directory in directories:
|
|
437
|
+
path = directory / "CASE.md"
|
|
438
|
+
errors: list[str] = []
|
|
439
|
+
try:
|
|
440
|
+
data, body = read_case(path)
|
|
441
|
+
errors = validate_schema(data, project.schema)
|
|
442
|
+
if data.get("id") != directory.name:
|
|
443
|
+
errors.append("id 与目录名不一致")
|
|
444
|
+
if data.get("status") == "active" and not data.get("next_action"):
|
|
445
|
+
errors.append("active 的 Case 需要 next_action")
|
|
446
|
+
seen.setdefault(data.get("id"), []).append(project.relative(path))
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
errors = ["缺少 CASE.md"]
|
|
449
|
+
except (CaseError, OSError, UnicodeDecodeError) as exc:
|
|
450
|
+
errors = [str(exc)]
|
|
451
|
+
results.append({"path": project.relative(path), "valid": not errors, "errors": errors})
|
|
452
|
+
for case_id, paths in seen.items():
|
|
453
|
+
if len(paths) > 1:
|
|
454
|
+
for result in results:
|
|
455
|
+
if result["path"] in paths:
|
|
456
|
+
result["valid"] = False
|
|
457
|
+
result["errors"].append(f"重复的 id:{case_id}")
|
|
458
|
+
return {"ok": all(result["valid"] for result in results), "results": results}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def parser() -> argparse.ArgumentParser:
|
|
462
|
+
result = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
463
|
+
result.add_argument("--project", type=Path, default=Path.cwd())
|
|
464
|
+
result.add_argument("--output", choices=("text", "json"), default="text")
|
|
465
|
+
# The same options are accepted after the subcommand, where people tend to type them.
|
|
466
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
467
|
+
shared.add_argument("--project", type=Path, default=argparse.SUPPRESS)
|
|
468
|
+
shared.add_argument("--output", choices=("text", "json"), default=argparse.SUPPRESS)
|
|
469
|
+
sub = result.add_subparsers(dest="command", required=True)
|
|
470
|
+
|
|
471
|
+
def command(name: str, help_text: str) -> argparse.ArgumentParser:
|
|
472
|
+
return sub.add_parser(name, parents=[shared], help=help_text)
|
|
473
|
+
|
|
474
|
+
new = command("new", "新建 Case 目录与 CASE.md")
|
|
475
|
+
new.add_argument("--title", required=True)
|
|
476
|
+
new.add_argument("--slug", required=True, help="目录名后缀:小写字母、数字与连字符")
|
|
477
|
+
new.add_argument("--goal", required=True)
|
|
478
|
+
new.add_argument("--next-action")
|
|
479
|
+
new.add_argument("--status", choices=("active", "captured"), default="active")
|
|
480
|
+
new.add_argument("--done-when")
|
|
481
|
+
new.add_argument("--source")
|
|
482
|
+
new.add_argument("--current-state")
|
|
483
|
+
new.add_argument("--tag", action="append", default=[])
|
|
484
|
+
listing = command("list", "列出 Case(默认不含已关闭)")
|
|
485
|
+
listing.add_argument("--status", choices=tuple(STATUS_ORDER))
|
|
486
|
+
listing.add_argument("--all", action="store_true")
|
|
487
|
+
show = command("show", "查看一个 Case 的字段与正文")
|
|
488
|
+
show.add_argument("case")
|
|
489
|
+
change = command("update", "更新状态、下一动作,追加工作记录、决策、交付物与证据")
|
|
490
|
+
change.add_argument("case")
|
|
491
|
+
change.add_argument("--status", choices=tuple(STATUS_ORDER))
|
|
492
|
+
change.add_argument("--title")
|
|
493
|
+
change.add_argument("--next-action")
|
|
494
|
+
change.add_argument("--current-state")
|
|
495
|
+
change.add_argument("--waiting-for")
|
|
496
|
+
change.add_argument("--resume-when")
|
|
497
|
+
change.add_argument("--result", help="替换「结果」段")
|
|
498
|
+
change.add_argument("--log", help="向「工作记录」追加一条")
|
|
499
|
+
change.add_argument("--decision", help="向「决策记录」追加一条")
|
|
500
|
+
change.add_argument("--add-deliverable", action="append", default=[])
|
|
501
|
+
change.add_argument("--add-evidence", action="append", default=[])
|
|
502
|
+
change.add_argument("--add-tag", action="append", default=[])
|
|
503
|
+
change.add_argument("--add-alias", action="append", default=[])
|
|
504
|
+
change.add_argument("--expect-updated-at", help="只在 updated_at 等于该值时写入,避免覆盖并发修改")
|
|
505
|
+
change.add_argument("--expect-sha256", help="只在 CASE.md 内容摘要等于该值时写入(比 updated_at 更严格)")
|
|
506
|
+
check = command("validate", "按 Schema 校验 Case")
|
|
507
|
+
check.add_argument("case", nargs="?")
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def render_text(command: str, result: dict) -> str:
|
|
512
|
+
if command == "list":
|
|
513
|
+
lines = [f"{item['status']:9} {item['id']} {item['title']}" + (f"\n 下一步:{item['next_action']}"
|
|
514
|
+
if item["next_action"] else "")
|
|
515
|
+
for item in result["cases"]]
|
|
516
|
+
lines += [f"invalid {item['path']}: {item['error']}" for item in result["invalid"]]
|
|
517
|
+
return "\n".join(lines) or "没有 Case。"
|
|
518
|
+
if command == "validate":
|
|
519
|
+
lines = [f"{'ok' if item['valid'] else 'FAIL'} {item['path']}" +
|
|
520
|
+
"".join(f"\n {error}" for error in item["errors"]) for item in result["results"]]
|
|
521
|
+
return "\n".join(lines) or "没有 Case。"
|
|
522
|
+
if command == "show":
|
|
523
|
+
return result["content"]
|
|
524
|
+
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def main(argv: list[str] | None = None) -> int:
|
|
528
|
+
args = parser().parse_args(argv)
|
|
529
|
+
try:
|
|
530
|
+
project = Project(args.project)
|
|
531
|
+
if args.command == "new":
|
|
532
|
+
result = create(project, args)
|
|
533
|
+
elif args.command == "list":
|
|
534
|
+
result = list_cases(project, args)
|
|
535
|
+
elif args.command == "show":
|
|
536
|
+
directory = project.find(args.case)
|
|
537
|
+
data, _ = read_case(directory / "CASE.md")
|
|
538
|
+
result = {"ok": True, "case": data, "path": project.relative(directory / "CASE.md"),
|
|
539
|
+
"content": (directory / "CASE.md").read_text(encoding="utf-8")}
|
|
540
|
+
elif args.command == "update":
|
|
541
|
+
result = update(project, args)
|
|
542
|
+
else:
|
|
543
|
+
result = validate(project, args)
|
|
544
|
+
except CaseError as exc:
|
|
545
|
+
payload = {"ok": False, "code": exc.code, "error": str(exc)}
|
|
546
|
+
if args.output == "json":
|
|
547
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
548
|
+
else:
|
|
549
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
550
|
+
return 2
|
|
551
|
+
print(json.dumps(result, ensure_ascii=False, indent=2) if args.output == "json"
|
|
552
|
+
else render_text(args.command, result))
|
|
553
|
+
return 0 if result.get("ok") else 1
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
if __name__ == "__main__":
|
|
557
|
+
raise SystemExit(main())
|