@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1
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/package.json +1 -1
- package/runtime/index.json +6 -6
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/local-only.md +126 -0
- package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
- package/runtime/plugins/agent-pack/references/nudge.md +58 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
- package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
- package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
- package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
- package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agents-init/content.json +17 -2
- package/runtime/plugins/agents-init/plugin.json +1 -1
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
- package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
- package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
- package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
- package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
- package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
- package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
- package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
- package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/content.json +30 -3
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
- package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
- package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
- package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
- package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
- package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
- package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
- package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
- package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
- package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
- package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
|
@@ -18,10 +18,16 @@ import tempfile
|
|
|
18
18
|
from datetime import datetime
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Any
|
|
21
|
+
import case_structure as structure # noqa: E402
|
|
22
|
+
from i18n_orchestrator import _ # noqa: E402
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
import private_paths # noqa: E402
|
|
26
|
+
from i18n_orchestrator import _ # noqa: E402
|
|
21
27
|
|
|
22
28
|
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
|
23
29
|
DEFAULT_SCHEMA = PLUGIN_ROOT / "references" / "case.schema.json"
|
|
24
|
-
|
|
30
|
+
TEMPLATES = {"zh": PLUGIN_ROOT / "templates" / "CASE.md", "en": PLUGIN_ROOT / "templates" / "CASE.en.md"}
|
|
25
31
|
PROFILE = Path(".agents/moe.sakanano.agent-pack/project.json")
|
|
26
32
|
CASE_DIR_RE = re.compile(r"^CASE-([0-9]{8})-([0-9]{3})-([a-z0-9]+(?:-[a-z0-9]+)*)$")
|
|
27
33
|
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
@@ -30,7 +36,7 @@ CREDENTIAL_RE = re.compile(r"(credential|secret|token|password|passwd|private-?k
|
|
|
30
36
|
STATUS_ORDER = {"active": 0, "waiting": 1, "captured": 2, "suspended": 3, "done": 4, "abandoned": 5}
|
|
31
37
|
CLOSED = {"done", "abandoned"}
|
|
32
38
|
FIELD_ORDER = ("schema_version", "id", "title", "status", "sensitivity", "source", "goal", "done_when",
|
|
33
|
-
"current_state", "next_action", "waiting_for", "resume_when", "artifact_mode")
|
|
39
|
+
"current_state", "next_action", "waiting_for", "resume_when", "artifact_mode", "sop")
|
|
34
40
|
LIST_FIELDS = ("aliases", "legacy_ids", "tags", "deliverables", "evidence")
|
|
35
41
|
MAX_CASE_BYTES = 2 * 1024 * 1024
|
|
36
42
|
|
|
@@ -52,12 +58,12 @@ def _scalar(value: str, key: str) -> Any:
|
|
|
52
58
|
try:
|
|
53
59
|
parsed = json.loads(value)
|
|
54
60
|
except ValueError as exc:
|
|
55
|
-
raise CaseError(
|
|
61
|
+
raise CaseError(_("{0}: 字符串必须是 JSON 双引号格式").format(key), "frontmatter") from exc
|
|
56
62
|
if not isinstance(parsed, str):
|
|
57
|
-
raise CaseError(
|
|
63
|
+
raise CaseError(_("{0}: 只支持字符串").format(key), "frontmatter")
|
|
58
64
|
return parsed
|
|
59
65
|
if value[:1] in ("[", "{", "|", ">", "'", "&", "*", "!"):
|
|
60
|
-
raise CaseError(
|
|
66
|
+
raise CaseError(_("{0}: 不支持该 YAML 写法").format(key), "frontmatter")
|
|
61
67
|
return value
|
|
62
68
|
|
|
63
69
|
|
|
@@ -67,10 +73,10 @@ def parse_frontmatter(text: str) -> dict:
|
|
|
67
73
|
while index < len(lines):
|
|
68
74
|
line = lines[index]
|
|
69
75
|
if not line or line[0].isspace() or ":" not in line:
|
|
70
|
-
raise CaseError(
|
|
76
|
+
raise CaseError(_("frontmatter 第 {0} 行格式不支持").format(index + 1), "frontmatter")
|
|
71
77
|
key, value = line.split(":", 1)
|
|
72
78
|
if not KEY_RE.fullmatch(key) or key in data:
|
|
73
|
-
raise CaseError(
|
|
79
|
+
raise CaseError(_("frontmatter 键无效或重复:{0}").format(key), "frontmatter")
|
|
74
80
|
if value.strip():
|
|
75
81
|
data[key] = _scalar(value.strip(), key)
|
|
76
82
|
index += 1
|
|
@@ -85,16 +91,16 @@ def parse_frontmatter(text: str) -> dict:
|
|
|
85
91
|
|
|
86
92
|
def read_case(path: Path) -> tuple[dict, str]:
|
|
87
93
|
if path.is_symlink():
|
|
88
|
-
raise CaseError(
|
|
94
|
+
raise CaseError(_("拒绝符号链接:{0}").format(path.name), "symlink")
|
|
89
95
|
raw = path.read_bytes()
|
|
90
96
|
if len(raw) > MAX_CASE_BYTES:
|
|
91
|
-
raise CaseError("CASE.md 超过 2 MiB", "oversize")
|
|
97
|
+
raise CaseError(_("CASE.md 超过 2 MiB"), "oversize")
|
|
92
98
|
text = raw.decode("utf-8")
|
|
93
99
|
if not text.startswith("---\n"):
|
|
94
|
-
raise CaseError("CASE.md 必须以 --- 开头", "frontmatter")
|
|
100
|
+
raise CaseError(_("CASE.md 必须以 --- 开头"), "frontmatter")
|
|
95
101
|
end = text.find("\n---\n", 3)
|
|
96
102
|
if end < 0:
|
|
97
|
-
raise CaseError("CASE.md 缺少 frontmatter 结束标记", "frontmatter")
|
|
103
|
+
raise CaseError(_("CASE.md 缺少 frontmatter 结束标记"), "frontmatter")
|
|
98
104
|
return parse_frontmatter(text[4:end]), text[end + 5:]
|
|
99
105
|
|
|
100
106
|
|
|
@@ -119,13 +125,13 @@ def render_frontmatter(data: dict) -> str:
|
|
|
119
125
|
|
|
120
126
|
def validate_schema(data: dict, schema: dict) -> list[str]:
|
|
121
127
|
"""The keyword subset the default schema uses: const, enum, type, pattern, minLength, items, uniqueItems."""
|
|
122
|
-
errors = [
|
|
128
|
+
errors = [_("缺少字段:{0}").format(key) for key in schema.get("required", []) if key not in data]
|
|
123
129
|
properties = schema.get("properties", {})
|
|
124
130
|
for key, value in data.items():
|
|
125
131
|
rule = properties.get(key)
|
|
126
132
|
if rule is None:
|
|
127
133
|
if schema.get("additionalProperties") is False:
|
|
128
|
-
errors.append(
|
|
134
|
+
errors.append(_("未知字段:{0}").format(key))
|
|
129
135
|
continue
|
|
130
136
|
errors.extend(f"{key}: {problem}" for problem in _check(value, rule))
|
|
131
137
|
return errors
|
|
@@ -134,24 +140,24 @@ def validate_schema(data: dict, schema: dict) -> list[str]:
|
|
|
134
140
|
def _check(value: Any, rule: dict) -> list[str]:
|
|
135
141
|
problems = []
|
|
136
142
|
if "const" in rule and value != rule["const"]:
|
|
137
|
-
problems.append(
|
|
143
|
+
problems.append(_("必须为 {0!r}").format(rule['const']))
|
|
138
144
|
if "enum" in rule and value not in rule["enum"]:
|
|
139
|
-
problems.append("取值必须是 " + "、".join(map(str, rule["enum"])))
|
|
145
|
+
problems.append(_("取值必须是 ") + _("、").join(map(str, rule["enum"])))
|
|
140
146
|
kind = rule.get("type")
|
|
141
147
|
if kind == "string" and not isinstance(value, str):
|
|
142
|
-
problems.append("必须是字符串")
|
|
148
|
+
problems.append(_("必须是字符串"))
|
|
143
149
|
if kind == "array":
|
|
144
150
|
if not isinstance(value, list):
|
|
145
|
-
return problems + ["必须是列表"]
|
|
151
|
+
return problems + [_("必须是列表")]
|
|
146
152
|
if rule.get("uniqueItems") and len(value) != len(set(map(str, value))):
|
|
147
|
-
problems.append("列表有重复项")
|
|
153
|
+
problems.append(_("列表有重复项"))
|
|
148
154
|
for item in value:
|
|
149
155
|
problems.extend(_check(item, rule.get("items", {})))
|
|
150
156
|
if isinstance(value, str):
|
|
151
157
|
if len(value) < rule.get("minLength", 0):
|
|
152
|
-
problems.append("不能为空")
|
|
158
|
+
problems.append(_("不能为空"))
|
|
153
159
|
if "pattern" in rule and not re.search(rule["pattern"], value):
|
|
154
|
-
problems.append("格式不符合")
|
|
160
|
+
problems.append(_("格式不符合"))
|
|
155
161
|
return problems
|
|
156
162
|
|
|
157
163
|
|
|
@@ -164,12 +170,13 @@ class Project:
|
|
|
164
170
|
try:
|
|
165
171
|
profile = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
166
172
|
except FileNotFoundError as exc:
|
|
167
|
-
raise CaseError(
|
|
173
|
+
raise CaseError(_("缺少 Project Profile:{0}").format(PROFILE), "profile-missing") from exc
|
|
168
174
|
except (OSError, ValueError) as exc:
|
|
169
|
-
raise CaseError(
|
|
175
|
+
raise CaseError(_("Project Profile 无法解析:{0}").format(exc), "profile-invalid") from exc
|
|
176
|
+
self.language = structure.language_of(profile) # the language the project's files are written in
|
|
170
177
|
work = profile.get("work") or {}
|
|
171
178
|
if work.get("mode") != "case-workspace":
|
|
172
|
-
raise CaseError("项目未启用 case-workspace(Profile work.mode)", "mode")
|
|
179
|
+
raise CaseError(_("项目未启用 case-workspace(Profile work.mode)"), "mode")
|
|
173
180
|
case = work.get("case") or {}
|
|
174
181
|
self.case_root = self._inside(case.get("root") or "cases", "work.case.root")
|
|
175
182
|
self.project_command = case.get("command")
|
|
@@ -180,17 +187,17 @@ class Project:
|
|
|
180
187
|
def _inside(self, value: str, label: str) -> Path:
|
|
181
188
|
relative = Path(value)
|
|
182
189
|
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
|
183
|
-
raise CaseError(
|
|
190
|
+
raise CaseError(_("{0} 必须是项目内相对路径").format(label), "profile-invalid")
|
|
184
191
|
current = self.root
|
|
185
192
|
for part in relative.parts:
|
|
186
193
|
current = current / part
|
|
187
194
|
if current.is_symlink():
|
|
188
|
-
raise CaseError(
|
|
195
|
+
raise CaseError(_("{0} 不能经过符号链接").format(label), "symlink")
|
|
189
196
|
return self.root / relative
|
|
190
197
|
|
|
191
198
|
def require_writable(self) -> None:
|
|
192
199
|
if self.project_command:
|
|
193
|
-
raise CaseError(
|
|
200
|
+
raise CaseError(_("项目声明了自己的 Case 工具({0}),请用它修改 Case").format(self.project_command), "project-tool")
|
|
194
201
|
|
|
195
202
|
def case_dirs(self) -> list[Path]:
|
|
196
203
|
if not self.case_root.is_dir():
|
|
@@ -204,7 +211,7 @@ class Project:
|
|
|
204
211
|
lowered = term.lower()
|
|
205
212
|
matches = [path for path in self.case_dirs() if lowered in path.name.lower()]
|
|
206
213
|
if len(matches) != 1:
|
|
207
|
-
raise CaseError(
|
|
214
|
+
raise CaseError(_("找到 {0} 个匹配 {1!r} 的 Case").format(len(matches), term), "not-found" if not matches else "ambiguous")
|
|
208
215
|
return matches[0]
|
|
209
216
|
|
|
210
217
|
def relative(self, path: Path) -> str:
|
|
@@ -228,9 +235,9 @@ def write_atomic(path: Path, content: str) -> None:
|
|
|
228
235
|
def clean(value: str | None, label: str, *, required: bool = False) -> str:
|
|
229
236
|
value = (value or "").strip()
|
|
230
237
|
if any(character in value for character in "\r\n\x00"):
|
|
231
|
-
raise CaseError(
|
|
238
|
+
raise CaseError(_("{0} 只能是单行文本").format(label))
|
|
232
239
|
if required and not value:
|
|
233
|
-
raise CaseError(
|
|
240
|
+
raise CaseError(_("{0} 不能为空").format(label))
|
|
234
241
|
return value
|
|
235
242
|
|
|
236
243
|
|
|
@@ -239,7 +246,7 @@ def clean(value: str | None, label: str, *, required: bool = False) -> str:
|
|
|
239
246
|
def _new_case_fields(project: Project, args: argparse.Namespace) -> tuple[str, dict]:
|
|
240
247
|
slug = clean(args.slug, "--slug", required=True)
|
|
241
248
|
if not SLUG_RE.fullmatch(slug) or CREDENTIAL_RE.search(slug):
|
|
242
|
-
raise CaseError("--slug 只能用小写字母、数字和连字符,且不能含凭据类词语")
|
|
249
|
+
raise CaseError(_("--slug 只能用小写字母、数字和连字符,且不能含凭据类词语"))
|
|
243
250
|
status = args.status
|
|
244
251
|
fields = {
|
|
245
252
|
"title": clean(args.title, "--title", required=True),
|
|
@@ -247,7 +254,7 @@ def _new_case_fields(project: Project, args: argparse.Namespace) -> tuple[str, d
|
|
|
247
254
|
"next_action": clean(args.next_action, "--next-action", required=status == "active"),
|
|
248
255
|
"done_when": clean(args.done_when, "--done-when"),
|
|
249
256
|
"source": clean(args.source, "--source"),
|
|
250
|
-
"current_state": clean(args.current_state, "--current-state") or "
|
|
257
|
+
"current_state": clean(args.current_state, "--current-state") or structure.text("just_created", project.language),
|
|
251
258
|
}
|
|
252
259
|
return slug, fields
|
|
253
260
|
|
|
@@ -255,18 +262,21 @@ def _new_case_fields(project: Project, args: argparse.Namespace) -> tuple[str, d
|
|
|
255
262
|
def _render_new_case(project: Project, args: argparse.Namespace, fields: dict, case_id: str,
|
|
256
263
|
stamp: str) -> str:
|
|
257
264
|
data = {"schema_version": "1.0", "id": case_id, "title": fields["title"], "status": args.status,
|
|
258
|
-
"sensitivity": "shared", "source": fields["source"], "goal": fields["goal"],
|
|
265
|
+
"sensitivity": (getattr(args, "sensitivity", None) or "shared"), "source": fields["source"], "goal": fields["goal"],
|
|
259
266
|
"done_when": fields["done_when"], "current_state": fields["current_state"],
|
|
260
267
|
"next_action": fields["next_action"], "artifact_mode": "case-local",
|
|
268
|
+
"sop": (getattr(args, "sop", None) or ""),
|
|
261
269
|
"aliases": [], "legacy_ids": [], "tags": sorted(set(args.tag)), "deliverables": [], "evidence": [],
|
|
262
270
|
"created_at": stamp, "updated_at": stamp}
|
|
263
271
|
data = {key: value for key, value in data.items() if key in project.schema.get("properties", data)}
|
|
272
|
+
if not data.get("sop"):
|
|
273
|
+
data.pop("sop", None)
|
|
264
274
|
errors = validate_schema(data, project.schema)
|
|
265
275
|
if errors:
|
|
266
|
-
raise CaseError("新 Case 不符合 Schema:" + ";".join(errors), "schema")
|
|
267
|
-
body =
|
|
276
|
+
raise CaseError(_("新 Case 不符合 Schema:") + _(";").join(errors), "schema")
|
|
277
|
+
body = TEMPLATES[project.language].read_text(encoding="utf-8")
|
|
268
278
|
for key, value in {**fields, "created_at": stamp}.items():
|
|
269
|
-
body = body.replace("{{" + key + "}}", value or "
|
|
279
|
+
body = body.replace("{{" + key + "}}", value or structure.text("to_define", project.language))
|
|
270
280
|
return render_frontmatter(data) + "\n" + body
|
|
271
281
|
|
|
272
282
|
|
|
@@ -304,18 +314,31 @@ def create(project: Project, args: argparse.Namespace) -> dict:
|
|
|
304
314
|
except FileExistsError:
|
|
305
315
|
continue
|
|
306
316
|
else:
|
|
307
|
-
raise CaseError("今天的 Case 序号已用完", "sequence-exhausted")
|
|
317
|
+
raise CaseError(_("今天的 Case 序号已用完"), "sequence-exhausted")
|
|
308
318
|
try:
|
|
309
319
|
text = _render_new_case(project, args, fields, case_id, now())
|
|
310
320
|
except CaseError:
|
|
311
321
|
directory.rmdir()
|
|
312
322
|
raise
|
|
313
323
|
write_atomic(directory / "CASE.md", text)
|
|
314
|
-
|
|
324
|
+
result = {"ok": True, "id": case_id, "path": project.relative(directory / "CASE.md"), "status": args.status}
|
|
325
|
+
hints = sop_hints(project, "case.created", read_case(directory / "CASE.md")[0])
|
|
326
|
+
if hints:
|
|
327
|
+
result["sop_events"] = hints
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def sop_hints(project: Project, event: str, data: dict) -> list[dict]:
|
|
332
|
+
"""哪些已启用的 SOP 在监听这个事件;尽力而为,SOP 目录有问题时不能让 Case 的写入失败。"""
|
|
333
|
+
try:
|
|
334
|
+
import sop as sop_tool # noqa: PLC0415 - sop.py 反过来依赖本模块,延迟导入避免循环
|
|
335
|
+
return sop_tool.event_hints(project.root, event, data)
|
|
336
|
+
except Exception: # noqa: BLE001
|
|
337
|
+
return []
|
|
315
338
|
|
|
316
339
|
|
|
317
340
|
def summary(project: Project, directory: Path) -> dict:
|
|
318
|
-
data,
|
|
341
|
+
data, _body = read_case(directory / "CASE.md")
|
|
319
342
|
return {"id": data.get("id", directory.name), "title": data.get("title", ""), "status": data.get("status", ""),
|
|
320
343
|
"next_action": data.get("next_action", ""), "updated_at": data.get("updated_at", ""),
|
|
321
344
|
"path": project.relative(directory / "CASE.md")}
|
|
@@ -339,9 +362,11 @@ def list_cases(project: Project, args: argparse.Namespace) -> dict:
|
|
|
339
362
|
return {"ok": True, "cases": cases, "invalid": invalid}
|
|
340
363
|
|
|
341
364
|
|
|
342
|
-
def append_section(body: str, heading: str, line: str) -> str:
|
|
343
|
-
"""Append a bullet under
|
|
344
|
-
|
|
365
|
+
def append_section(body: str, heading: str, line: str, language: str = "zh") -> str:
|
|
366
|
+
"""Append a bullet under the section ``heading`` (either language's name, or a logical key), replacing the placeholder text.
|
|
367
|
+
|
|
368
|
+
The file's own spelling of the heading wins; a missing section is created in ``language``."""
|
|
369
|
+
marker = f"## {structure.resolve(body, heading, language)}\n"
|
|
345
370
|
start = body.find(marker)
|
|
346
371
|
if start < 0:
|
|
347
372
|
return body.rstrip("\n") + f"\n\n{marker}\n- {line}\n"
|
|
@@ -349,7 +374,7 @@ def append_section(body: str, heading: str, line: str) -> str:
|
|
|
349
374
|
next_heading = body.find("\n## ", section_start)
|
|
350
375
|
end = len(body) if next_heading < 0 else next_heading + 1
|
|
351
376
|
section = body[section_start:end].strip("\n")
|
|
352
|
-
if section in
|
|
377
|
+
if section in structure.PLACEHOLDERS:
|
|
353
378
|
section = ""
|
|
354
379
|
section = (section + "\n" if section else "") + f"- {line}"
|
|
355
380
|
return body[:section_start] + "\n" + section + "\n\n" + body[end:].lstrip("\n")
|
|
@@ -364,9 +389,9 @@ def plan_update(project: Project, args: argparse.Namespace) -> tuple[Path, str,
|
|
|
364
389
|
current = path.read_text(encoding="utf-8")
|
|
365
390
|
expected_digest = getattr(args, "expect_sha256", None)
|
|
366
391
|
if expected_digest and hashlib.sha256(current.encode("utf-8")).hexdigest() != expected_digest:
|
|
367
|
-
raise CaseError("Case 在预览之后被修改过(内容摘要不一致),请重新读取", "conflict")
|
|
392
|
+
raise CaseError(_("Case 在预览之后被修改过(内容摘要不一致),请重新读取"), "conflict")
|
|
368
393
|
if args.expect_updated_at and data.get("updated_at") != args.expect_updated_at:
|
|
369
|
-
raise CaseError("Case 已被其他人修改(updated_at 不一致),请重新读取", "conflict")
|
|
394
|
+
raise CaseError(_("Case 已被其他人修改(updated_at 不一致),请重新读取"), "conflict")
|
|
370
395
|
stamp = now()
|
|
371
396
|
changed = []
|
|
372
397
|
for flag, key in (("status", "status"), ("next_action", "next_action"), ("current_state", "current_state"),
|
|
@@ -382,45 +407,71 @@ def plan_update(project: Project, args: argparse.Namespace) -> tuple[Path, str,
|
|
|
382
407
|
if item not in data.setdefault(key, []):
|
|
383
408
|
data[key].append(item)
|
|
384
409
|
changed.append(key)
|
|
410
|
+
if getattr(args, "sensitivity", None):
|
|
411
|
+
if data.get("sensitivity", "shared") != args.sensitivity:
|
|
412
|
+
data["sensitivity"] = args.sensitivity
|
|
413
|
+
changed.append("sensitivity")
|
|
414
|
+
elif data.get("sensitivity") != "private" and _touches_private(project, directory, args):
|
|
415
|
+
data["sensitivity"] = "private" # 用到了私有目录里的内容:默认标为 private(要改回去用 --sensitivity shared)
|
|
416
|
+
changed.append("sensitivity")
|
|
385
417
|
if data.get("status") == "active" and not data.get("next_action"):
|
|
386
|
-
raise CaseError("active 的 Case 需要 next_action")
|
|
418
|
+
raise CaseError(_("active 的 Case 需要 next_action"))
|
|
387
419
|
if data.get("status") == "waiting" and not data.get("waiting_for"):
|
|
388
|
-
raise CaseError("waiting 的 Case 需要 waiting_for")
|
|
420
|
+
raise CaseError(_("waiting 的 Case 需要 waiting_for"))
|
|
421
|
+
language = project.language
|
|
389
422
|
if "current_state" in changed:
|
|
390
|
-
body = _replace_section(body, "
|
|
423
|
+
body = _replace_section(body, "current_state", data["current_state"], language)
|
|
391
424
|
if "next_action" in changed:
|
|
392
|
-
body = _replace_section(body, "
|
|
425
|
+
body = _replace_section(body, "next_action", data["next_action"] or structure.text("to_define", language), language)
|
|
393
426
|
if args.log:
|
|
394
|
-
|
|
395
|
-
|
|
427
|
+
changed.append(structure.resolve(body, "work_log", language))
|
|
428
|
+
body = append_section(body, "work_log", f"{stamp} {clean(args.log, '--log', required=True)}", language)
|
|
396
429
|
if args.decision:
|
|
397
|
-
|
|
398
|
-
|
|
430
|
+
changed.append(structure.resolve(body, "decisions", language))
|
|
431
|
+
body = append_section(body, "decisions", f"{stamp} {clean(args.decision, '--decision', required=True)}", language)
|
|
399
432
|
if args.result:
|
|
400
|
-
|
|
401
|
-
|
|
433
|
+
changed.append(structure.resolve(body, "result", language))
|
|
434
|
+
body = _replace_section(body, "result", clean(args.result, "--result", required=True), language)
|
|
402
435
|
if not changed:
|
|
403
|
-
raise CaseError("没有要更新的内容")
|
|
436
|
+
raise CaseError(_("没有要更新的内容"))
|
|
404
437
|
data["updated_at"] = stamp
|
|
405
438
|
errors = validate_schema(data, project.schema)
|
|
406
439
|
if errors:
|
|
407
|
-
raise CaseError("更新后不符合 Schema:" + ";".join(errors), "schema")
|
|
440
|
+
raise CaseError(_("更新后不符合 Schema:") + _(";").join(errors), "schema")
|
|
408
441
|
return path, current, render_frontmatter(data) + body, {
|
|
409
442
|
"ok": True, "id": data["id"], "changed": sorted(set(changed)), "updated_at": stamp,
|
|
410
443
|
"path": project.relative(path)}
|
|
411
444
|
|
|
412
445
|
|
|
446
|
+
def _touches_private(project: Project, directory: Path, args: argparse.Namespace) -> bool:
|
|
447
|
+
"""这次登记的交付物或证据是否在私有目录里(路径可以相对项目根,也可以相对 Case 目录)。"""
|
|
448
|
+
dirs = private_paths.declared(project.root)
|
|
449
|
+
if not dirs:
|
|
450
|
+
return False
|
|
451
|
+
for value in [*getattr(args, "add_deliverable", []), *getattr(args, "add_evidence", [])]:
|
|
452
|
+
for candidate in (value, str(directory / value)):
|
|
453
|
+
if private_paths.is_private(project.root, candidate, dirs):
|
|
454
|
+
return True
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
|
|
413
458
|
def update(project: Project, args: argparse.Namespace) -> dict:
|
|
414
459
|
path, current, text, result = plan_update(project, args)
|
|
415
460
|
# Re-read right before replacing: a write that landed after planning must not be lost.
|
|
416
461
|
if path.read_text(encoding="utf-8") != current:
|
|
417
|
-
raise CaseError("Case 刚被其他人修改,请重新读取", "conflict")
|
|
462
|
+
raise CaseError(_("Case 刚被其他人修改,请重新读取"), "conflict")
|
|
463
|
+
was_closed = read_case(path)[0].get("status") in CLOSED
|
|
418
464
|
write_atomic(path, text)
|
|
465
|
+
data, _unused = read_case(path)
|
|
466
|
+
if data.get("status") in CLOSED and not was_closed:
|
|
467
|
+
hints = sop_hints(project, "case.closed", data)
|
|
468
|
+
if hints:
|
|
469
|
+
result["sop_events"] = hints
|
|
419
470
|
return result
|
|
420
471
|
|
|
421
472
|
|
|
422
|
-
def _replace_section(body: str, heading: str, text: str) -> str:
|
|
423
|
-
marker = f"## {heading}\n"
|
|
473
|
+
def _replace_section(body: str, heading: str, text: str, language: str = "zh") -> str:
|
|
474
|
+
marker = f"## {structure.resolve(body, heading, language)}\n"
|
|
424
475
|
start = body.find(marker)
|
|
425
476
|
if start < 0:
|
|
426
477
|
return body.rstrip("\n") + f"\n\n{marker}\n{text}\n"
|
|
@@ -440,12 +491,12 @@ def validate(project: Project, args: argparse.Namespace) -> dict:
|
|
|
440
491
|
data, body = read_case(path)
|
|
441
492
|
errors = validate_schema(data, project.schema)
|
|
442
493
|
if data.get("id") != directory.name:
|
|
443
|
-
errors.append("id 与目录名不一致")
|
|
494
|
+
errors.append(_("id 与目录名不一致"))
|
|
444
495
|
if data.get("status") == "active" and not data.get("next_action"):
|
|
445
|
-
errors.append("active 的 Case 需要 next_action")
|
|
496
|
+
errors.append(_("active 的 Case 需要 next_action"))
|
|
446
497
|
seen.setdefault(data.get("id"), []).append(project.relative(path))
|
|
447
498
|
except FileNotFoundError:
|
|
448
|
-
errors = ["缺少 CASE.md"]
|
|
499
|
+
errors = [_("缺少 CASE.md")]
|
|
449
500
|
except (CaseError, OSError, UnicodeDecodeError) as exc:
|
|
450
501
|
errors = [str(exc)]
|
|
451
502
|
results.append({"path": project.relative(path), "valid": not errors, "errors": errors})
|
|
@@ -454,12 +505,12 @@ def validate(project: Project, args: argparse.Namespace) -> dict:
|
|
|
454
505
|
for result in results:
|
|
455
506
|
if result["path"] in paths:
|
|
456
507
|
result["valid"] = False
|
|
457
|
-
result["errors"].append(
|
|
508
|
+
result["errors"].append(_("重复的 id:{0}").format(case_id))
|
|
458
509
|
return {"ok": all(result["valid"] for result in results), "results": results}
|
|
459
510
|
|
|
460
511
|
|
|
461
512
|
def parser() -> argparse.ArgumentParser:
|
|
462
|
-
result = argparse.ArgumentParser(description=
|
|
513
|
+
result = argparse.ArgumentParser(description=_("默认 Case 工具:在 case-workspace 项目里新建、列出、查看、更新与校验 Case。"))
|
|
463
514
|
result.add_argument("--project", type=Path, default=Path.cwd())
|
|
464
515
|
result.add_argument("--output", choices=("text", "json"), default="text")
|
|
465
516
|
# The same options are accepted after the subcommand, where people tend to type them.
|
|
@@ -471,9 +522,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
471
522
|
def command(name: str, help_text: str) -> argparse.ArgumentParser:
|
|
472
523
|
return sub.add_parser(name, parents=[shared], help=help_text)
|
|
473
524
|
|
|
474
|
-
new = command("new", "新建 Case 目录与 CASE.md")
|
|
525
|
+
new = command("new", _("新建 Case 目录与 CASE.md"))
|
|
475
526
|
new.add_argument("--title", required=True)
|
|
476
|
-
new.add_argument("--slug", required=True, help="目录名后缀:小写字母、数字与连字符")
|
|
527
|
+
new.add_argument("--slug", required=True, help=_("目录名后缀:小写字母、数字与连字符"))
|
|
477
528
|
new.add_argument("--goal", required=True)
|
|
478
529
|
new.add_argument("--next-action")
|
|
479
530
|
new.add_argument("--status", choices=("active", "captured"), default="active")
|
|
@@ -481,12 +532,15 @@ def parser() -> argparse.ArgumentParser:
|
|
|
481
532
|
new.add_argument("--source")
|
|
482
533
|
new.add_argument("--current-state")
|
|
483
534
|
new.add_argument("--tag", action="append", default=[])
|
|
484
|
-
|
|
535
|
+
new.add_argument("--sensitivity", choices=("shared", "private"), default="shared",
|
|
536
|
+
help=_("用到私有目录(privacy.forbidden_default_reads,缺省 private/)里内容的 Case 用 private"))
|
|
537
|
+
new.add_argument("--sop", help=_("这个 Case 是 SOP 的一次执行:<sop-id>@<版本>(通常由 sop.py run 填写)"))
|
|
538
|
+
listing = command("list", _("列出 Case(默认不含已关闭)"))
|
|
485
539
|
listing.add_argument("--status", choices=tuple(STATUS_ORDER))
|
|
486
540
|
listing.add_argument("--all", action="store_true")
|
|
487
|
-
show = command("show", "查看一个 Case 的字段与正文")
|
|
541
|
+
show = command("show", _("查看一个 Case 的字段与正文"))
|
|
488
542
|
show.add_argument("case")
|
|
489
|
-
change = command("update", "更新状态、下一动作,追加工作记录、决策、交付物与证据")
|
|
543
|
+
change = command("update", _("更新状态、下一动作,追加工作记录、决策、交付物与证据"))
|
|
490
544
|
change.add_argument("case")
|
|
491
545
|
change.add_argument("--status", choices=tuple(STATUS_ORDER))
|
|
492
546
|
change.add_argument("--title")
|
|
@@ -494,34 +548,39 @@ def parser() -> argparse.ArgumentParser:
|
|
|
494
548
|
change.add_argument("--current-state")
|
|
495
549
|
change.add_argument("--waiting-for")
|
|
496
550
|
change.add_argument("--resume-when")
|
|
497
|
-
change.add_argument("--result", help="替换「结果」段")
|
|
498
|
-
change.add_argument("--log", help="向「工作记录」追加一条")
|
|
499
|
-
change.add_argument("--decision", help="向「决策记录」追加一条")
|
|
551
|
+
change.add_argument("--result", help=_("替换「结果」段"))
|
|
552
|
+
change.add_argument("--log", help=_("向「工作记录」追加一条"))
|
|
553
|
+
change.add_argument("--decision", help=_("向「决策记录」追加一条"))
|
|
500
554
|
change.add_argument("--add-deliverable", action="append", default=[])
|
|
501
555
|
change.add_argument("--add-evidence", action="append", default=[])
|
|
556
|
+
change.add_argument("--sensitivity", choices=("shared", "private"),
|
|
557
|
+
help=_("改 Case 的敏感度;登记私有目录里的交付物或证据时会自动改为 private"))
|
|
502
558
|
change.add_argument("--add-tag", action="append", default=[])
|
|
503
559
|
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")
|
|
560
|
+
change.add_argument("--expect-updated-at", help=_("只在 updated_at 等于该值时写入,避免覆盖并发修改"))
|
|
561
|
+
change.add_argument("--expect-sha256", help=_("只在 CASE.md 内容摘要等于该值时写入(比 updated_at 更严格)"))
|
|
562
|
+
check = command("validate", _("按 Schema 校验 Case"))
|
|
507
563
|
check.add_argument("case", nargs="?")
|
|
508
564
|
return result
|
|
509
565
|
|
|
510
566
|
|
|
511
567
|
def render_text(command: str, result: dict) -> str:
|
|
512
568
|
if command == "list":
|
|
513
|
-
lines = [f"{item['status']:9} {item['id']} {item['title']}" + (
|
|
569
|
+
lines = [f"{item['status']:9} {item['id']} {item['title']}" + (_("\n 下一步:{0}").format(item['next_action'])
|
|
514
570
|
if item["next_action"] else "")
|
|
515
571
|
for item in result["cases"]]
|
|
516
572
|
lines += [f"invalid {item['path']}: {item['error']}" for item in result["invalid"]]
|
|
517
|
-
return "\n".join(lines) or "没有 Case。"
|
|
573
|
+
return "\n".join(lines) or _("没有 Case。")
|
|
518
574
|
if command == "validate":
|
|
519
575
|
lines = [f"{'ok' if item['valid'] else 'FAIL'} {item['path']}" +
|
|
520
576
|
"".join(f"\n {error}" for error in item["errors"]) for item in result["results"]]
|
|
521
|
-
return "\n".join(lines) or "没有 Case。"
|
|
577
|
+
return "\n".join(lines) or _("没有 Case。")
|
|
522
578
|
if command == "show":
|
|
523
579
|
return result["content"]
|
|
524
|
-
|
|
580
|
+
text = json.dumps(result, ensure_ascii=False, indent=2)
|
|
581
|
+
for hint in result.get("sop_events") or []:
|
|
582
|
+
text += _("\n命中流程 {0}({1}):{2}\n 是否按它执行由用户决定;确认后运行:{3}").format(hint['id'], hint['title'], hint['trigger'], hint['command'])
|
|
583
|
+
return text
|
|
525
584
|
|
|
526
585
|
|
|
527
586
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -534,7 +593,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
534
593
|
result = list_cases(project, args)
|
|
535
594
|
elif args.command == "show":
|
|
536
595
|
directory = project.find(args.case)
|
|
537
|
-
data,
|
|
596
|
+
data, _body = read_case(directory / "CASE.md")
|
|
538
597
|
result = {"ok": True, "case": data, "path": project.relative(directory / "CASE.md"),
|
|
539
598
|
"content": (directory / "CASE.md").read_text(encoding="utf-8")}
|
|
540
599
|
elif args.command == "update":
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Case 正文的结构:小节名与默认文字,按项目文件语言(Project Profile 的 ``language``)。
|
|
2
|
+
|
|
3
|
+
文件里写什么语言是项目的属性,随项目同步,不随命令行界面语言(AGENTHELM_LANG)变化:``language`` 为 ``en`` 的项目
|
|
4
|
+
新建的 Case 用英文小节名与默认文字,缺省或 ``zh`` 的项目保持原来的中文。读取与更新时两种小节名都认,所以
|
|
5
|
+
旧 Case、换过语言的项目与手写的 Case 都能照常追加记录;找不到小节时按项目语言新建。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
LANGUAGES = ("zh", "en")
|
|
10
|
+
SECTIONS = {
|
|
11
|
+
"current_state": ("当前可信状态", "Current trusted state"),
|
|
12
|
+
"next_action": ("下一动作", "Next action"),
|
|
13
|
+
"decisions": ("决策记录", "Decision log"),
|
|
14
|
+
"work_log": ("工作记录", "Work log"),
|
|
15
|
+
"blockers": ("阻塞与恢复条件", "Blockers and resume conditions"),
|
|
16
|
+
"result": ("结果", "Result"),
|
|
17
|
+
}
|
|
18
|
+
# 默认文字:新建 Case 时写进去的字段占位。
|
|
19
|
+
TEXTS = {
|
|
20
|
+
"just_created": ("刚创建", "Just created"),
|
|
21
|
+
"to_define": ("待明确", "To be defined"),
|
|
22
|
+
}
|
|
23
|
+
# 模板里的空小节占位;第一次追加记录时被替换掉。
|
|
24
|
+
PLACEHOLDERS = ("暂无。", "无。", "待执行。", "None yet.", "None.", "Pending.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def language_of(profile: dict) -> str:
|
|
28
|
+
"""The project's file language; projects that predate the field stay Chinese."""
|
|
29
|
+
value = profile.get("language")
|
|
30
|
+
return value if value in LANGUAGES else "zh"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def names(key: str) -> tuple[str, ...]:
|
|
34
|
+
return SECTIONS[key]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def key_of(name: str) -> str:
|
|
38
|
+
"""The logical section for a logical key or for a heading in either language."""
|
|
39
|
+
if name in SECTIONS:
|
|
40
|
+
return name
|
|
41
|
+
for key, variants in SECTIONS.items():
|
|
42
|
+
if name in variants:
|
|
43
|
+
return key
|
|
44
|
+
raise KeyError(name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def heading(key: str, language: str) -> str:
|
|
48
|
+
return SECTIONS[key][LANGUAGES.index(language)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def existing_heading(body: str, key: str) -> str | None:
|
|
52
|
+
"""The heading of this section as the file spells it, if present in either language."""
|
|
53
|
+
for name in SECTIONS[key]:
|
|
54
|
+
if f"## {name}\n" in body or body.endswith(f"## {name}"):
|
|
55
|
+
return name
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve(body: str, name: str, language: str) -> str:
|
|
60
|
+
"""The heading to use for a section: the file's own spelling, else the project language's.
|
|
61
|
+
|
|
62
|
+
A heading outside the Case vocabulary (for example the ones SOP adds) is used as written."""
|
|
63
|
+
try:
|
|
64
|
+
key = key_of(name)
|
|
65
|
+
except KeyError:
|
|
66
|
+
return name
|
|
67
|
+
return existing_heading(body, key) or heading(key, language)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def text(key: str, language: str) -> str:
|
|
71
|
+
return TEXTS[key][LANGUAGES.index(language)]
|