@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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/runtime/index.json +6 -6
  3. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
  4. package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
  5. package/runtime/plugins/agent-pack/plugin.json +1 -1
  6. package/runtime/plugins/agent-pack/references/local-only.md +126 -0
  7. package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
  8. package/runtime/plugins/agent-pack/references/nudge.md +58 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
  12. package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
  13. package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
  14. package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
  15. package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
  16. package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
  17. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
  18. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
  19. package/runtime/plugins/agents-init/content.json +17 -2
  20. package/runtime/plugins/agents-init/plugin.json +1 -1
  21. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
  23. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
  24. package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
  25. package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
  26. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
  27. package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
  28. package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
  29. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
  30. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
  31. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
  32. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
  33. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
  34. package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
  35. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
  36. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  37. package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
  38. package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
  39. package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
  40. package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
  41. package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
  42. package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
  43. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
  44. package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
  45. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
  46. package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
  47. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
  48. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
  49. package/runtime/plugins/project-orchestrator/content.json +30 -3
  50. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  51. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
  52. package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
  53. package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
  54. package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
  55. package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
  56. package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
  57. package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
  58. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
  59. package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
  60. package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
  61. package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
  62. package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
  63. package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
  64. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
  65. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
  66. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
  67. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
  68. package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
  69. package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
  70. package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
  71. package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
  72. package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
  73. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
  74. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
  75. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
  76. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
  77. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
  78. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
  79. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
  80. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
  81. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
  82. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
  83. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
  84. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
  85. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
  86. 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
- TEMPLATE = PLUGIN_ROOT / "templates" / "CASE.md"
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(f"{key}: 字符串必须是 JSON 双引号格式", "frontmatter") from exc
61
+ raise CaseError(_("{0}: 字符串必须是 JSON 双引号格式").format(key), "frontmatter") from exc
56
62
  if not isinstance(parsed, str):
57
- raise CaseError(f"{key}: 只支持字符串", "frontmatter")
63
+ raise CaseError(_("{0}: 只支持字符串").format(key), "frontmatter")
58
64
  return parsed
59
65
  if value[:1] in ("[", "{", "|", ">", "'", "&", "*", "!"):
60
- raise CaseError(f"{key}: 不支持该 YAML 写法", "frontmatter")
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(f"frontmatter 第 {index + 1} 行格式不支持", "frontmatter")
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(f"frontmatter 键无效或重复:{key}", "frontmatter")
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(f"拒绝符号链接:{path.name}", "symlink")
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 = [f"缺少字段:{key}" for key in schema.get("required", []) if key not in data]
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(f"未知字段:{key}")
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(f"必须为 {rule['const']!r}")
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(f"缺少 Project Profile:{PROFILE}", "profile-missing") from exc
173
+ raise CaseError(_("缺少 Project Profile:{0}").format(PROFILE), "profile-missing") from exc
168
174
  except (OSError, ValueError) as exc:
169
- raise CaseError(f"Project Profile 无法解析:{exc}", "profile-invalid") from exc
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(f"{label} 必须是项目内相对路径", "profile-invalid")
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(f"{label} 不能经过符号链接", "symlink")
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(f"项目声明了自己的 Case 工具({self.project_command}),请用它修改 Case", "project-tool")
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(f"找到 {len(matches)} 个匹配 {term!r} 的 Case", "not-found" if not matches else "ambiguous")
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(f"{label} 只能是单行文本")
238
+ raise CaseError(_("{0} 只能是单行文本").format(label))
232
239
  if required and not value:
233
- raise CaseError(f"{label} 不能为空")
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 = TEMPLATE.read_text(encoding="utf-8")
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
- return {"ok": True, "id": case_id, "path": project.relative(directory / "CASE.md"), "status": args.status}
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, _ = read_case(directory / "CASE.md")
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 ``## heading``, replacing the template's placeholder text."""
344
- marker = f"## {heading}\n"
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, "当前可信状态", data["current_state"])
423
+ body = _replace_section(body, "current_state", data["current_state"], language)
391
424
  if "next_action" in changed:
392
- body = _replace_section(body, "下一动作", data["next_action"] or "待明确")
425
+ body = _replace_section(body, "next_action", data["next_action"] or structure.text("to_define", language), language)
393
426
  if args.log:
394
- body = append_section(body, "工作记录", f"{stamp} {clean(args.log, '--log', required=True)}")
395
- changed.append("工作记录")
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
- body = append_section(body, "决策记录", f"{stamp} {clean(args.decision, '--decision', required=True)}")
398
- changed.append("决策记录")
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
- body = _replace_section(body, "结果", clean(args.result, "--result", required=True))
401
- changed.append("结果")
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(f"重复的 id:{case_id}")
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=__doc__.split("\n\n")[0])
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
- listing = command("list", "列出 Case(默认不含已关闭)")
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']}" + (f"\n 下一步:{item['next_action']}"
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
- return json.dumps(result, ensure_ascii=False, indent=2)
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, _ = read_case(directory / "CASE.md")
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)]