@sakanano-moe/agenthelm 6.8.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 +228 -147
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +64 -59
- 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 +4 -4
- 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 +26 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +399 -80
- 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 +210 -76
- 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 +210 -62
- 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 +4 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +2 -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 +222 -189
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +241 -204
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +145 -89
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +184 -105
- 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
|
@@ -0,0 +1,1670 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SOP(Standard Operating Procedure,标准作业流程)管理:一个 SOP 一个目录,索引实时生成;只依赖标准库。
|
|
3
|
+
|
|
4
|
+
sop.py list / index / show <SOP> / validate [<SOP>]
|
|
5
|
+
sop.py interview [<SOP>] 创建与配置时要和用户逐项谈清的事(编号问题清单)
|
|
6
|
+
sop.py defaults [--set 键=值 …] 本项目的使用习惯(出错怎么办、中间产物放哪等),一次谈好,新 SOP 沿用
|
|
7
|
+
sop.py preflight <SOP> 执行前检查所需的命令、模块、环境变量、技能、MCP 是否就绪
|
|
8
|
+
sop.py new / activate / revise / retire / restore 创建与版本管理(默认只预览,--apply 写入)
|
|
9
|
+
sop.py run <SOP> / step <Case> <n> / status <Case> 一次执行 = 一个 Case,逐步记录
|
|
10
|
+
sop.py match <话> / due / events <事件> 触发:关键词预筛、到期、事件
|
|
11
|
+
|
|
12
|
+
SOP 是用户自己的步骤序列,不放宽任何授权:执行到写入、外发、删除类步骤,仍按用户当次授权。
|
|
13
|
+
一次执行的状态只记在 Case 里(Case 是唯一持久工作容器),SOP 目录里不保存执行状态。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import hashlib
|
|
19
|
+
import importlib.util
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import date, datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
30
|
+
import case as case_tool # noqa: E402
|
|
31
|
+
import case_structure # noqa: E402
|
|
32
|
+
import private_paths # noqa: E402
|
|
33
|
+
import sop_structure as vocab # noqa: E402
|
|
34
|
+
from i18n_orchestrator import _, N_ # noqa: E402
|
|
35
|
+
|
|
36
|
+
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
|
37
|
+
TEMPLATE_DIR = PLUGIN_ROOT / "sop-templates"
|
|
38
|
+
PROFILE = case_tool.PROFILE
|
|
39
|
+
DEFAULT_ROOT = "sops"
|
|
40
|
+
DEFAULTS_FILE = "defaults.json"
|
|
41
|
+
SNAPSHOTS = ".versions"
|
|
42
|
+
MAX_BYTES = 1024 * 1024
|
|
43
|
+
MAX_INDEX_ENTRIES = 30
|
|
44
|
+
|
|
45
|
+
ID_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
|
|
46
|
+
VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)\Z")
|
|
47
|
+
REF_RE = re.compile(r"([a-z0-9]+(?:-[a-z0-9]+)*)@(\d+\.\d+\.\d+)\Z")
|
|
48
|
+
SCHEDULE_RE = re.compile(r"(daily|weekly:(mon|tue|wed|thu|fri|sat|sun)|monthly:([1-9]|1\d|2[0-8])|every:[1-9]\d{0,2}d)\Z")
|
|
49
|
+
WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
50
|
+
EVENTS = ("case.created", "case.closed")
|
|
51
|
+
PLACEHOLDER = vocab.PLACEHOLDERS[0] # 中文占位标记;英文的 SOP 用 vocab.PLACEHOLDERS[1],读取时两种都认
|
|
52
|
+
|
|
53
|
+
STATUSES = ("draft", "active", "retired")
|
|
54
|
+
SCALARS = ("schema_version", "id", "title", "status", "version", "description", "trigger", "exclude", "schedule",
|
|
55
|
+
"confirm", "creates_case", "on_error", "max_retries", "on_missing_tool", "artifact_dir", "artifact_keep",
|
|
56
|
+
"resume", "confirm_costly", "created", "verified_at", "review_after", "origin")
|
|
57
|
+
LISTS = ("aliases", "examples_hit", "examples_miss", "on_events", "event_tags", "outputs", "sources", "decided")
|
|
58
|
+
REQUIRED = ("schema_version", "id", "title", "status", "version", "description", "created")
|
|
59
|
+
|
|
60
|
+
# 用户习惯:每项给出可选值、缺省、以及创建时必须谈的问题。第一个标注推荐的是我们的倾向,用户可改。
|
|
61
|
+
CHOICES: dict[str, dict[str, Any]] = {
|
|
62
|
+
"on_error": {"values": ("ask", "stop", "retry", "skip-step"), "default": "ask",
|
|
63
|
+
"labels": {"ask": N_("停下来告诉我,由我决定(推荐)"), "stop": N_("立即停止并记录现场,不再继续"),
|
|
64
|
+
"retry": N_("自动重试 max_retries 次,仍失败再问我"), "skip-step": N_("跳过该步继续,事后列出所有跳过的步骤")}},
|
|
65
|
+
"max_retries": {"default": "2"},
|
|
66
|
+
"on_missing_tool": {"values": ("ask", "stop", "skip-step", "alternative"), "default": "ask",
|
|
67
|
+
"labels": {"ask": N_("停下来,列出缺什么、怎么装,等我处理(推荐)"), "stop": N_("停止,不继续执行"),
|
|
68
|
+
"skip-step": N_("跳过用到该工具的步骤"), "alternative": N_("改用我声明的替代方案;没有替代就问我")}},
|
|
69
|
+
"artifact_dir": {"default": ""},
|
|
70
|
+
"artifact_keep": {"values": ("all", "final-only", "on-error", "ask"), "default": "ask",
|
|
71
|
+
"labels": {"all": N_("全部保留"), "final-only": N_("成功后只保留最终产物,清理登记过的中间产物"),
|
|
72
|
+
"on-error": N_("成功后清理中间产物,失败时保留以便排查"), "ask": N_("每次完成后问我(推荐)")}},
|
|
73
|
+
"resume": {"values": ("from-failed-step", "restart", "ask"), "default": "ask",
|
|
74
|
+
"labels": {"from-failed-step": N_("从失败的那一步继续,已完成的步骤不重做(推荐)"), "restart": N_("从头重做"),
|
|
75
|
+
"ask": N_("每次问我")}},
|
|
76
|
+
"confirm_costly": {"values": ("ask", "never"), "default": "ask",
|
|
77
|
+
"labels": {"ask": N_("调用付费 API、下载大文件、预计耗时数分钟以上的步骤前先问我(推荐)"),
|
|
78
|
+
"never": N_("不用问,直接执行")}},
|
|
79
|
+
"confirm": {"values": ("ask", "auto"), "default": "ask",
|
|
80
|
+
"labels": {"ask": N_("命中触发后先问我是否按它执行(推荐)"), "auto": N_("命中后直接执行(只适合没有外部副作用的流程)")}},
|
|
81
|
+
"creates_case": {"values": ("always", "ask", "never"), "default": "always"},
|
|
82
|
+
}
|
|
83
|
+
# 创建时必须与用户确认的事项(编号顺序即提问顺序)。defaults 项表示可由项目习惯(defaults.json)代为回答。
|
|
84
|
+
DECISIONS = (
|
|
85
|
+
("trigger", None, N_("什么情况下应该用这个流程?请写:①触发场景(一句话);②明确不该触发的相近场景;"
|
|
86
|
+
"③有没有固定周期(每天 / 每周几 / 每月几号 / 每 N 天),或由事件触发(新建 Case / 关闭 Case;"
|
|
87
|
+
"要不要只对带某些标签的 Case 触发,否则每个 Case 新建或关闭都会提示);"
|
|
88
|
+
"④再给 1–2 句你平时会怎么说的话(用来校准识别)。")),
|
|
89
|
+
("requires", None, N_("这个流程用到哪些程序、Python 模块、脚本、模型、账号或 MCP?逐项确认:名称、做什么用、"
|
|
90
|
+
"没有时怎么安装、有没有替代方案、缺了怎么办。API 密钥只写环境变量名,不写密钥本身。")),
|
|
91
|
+
("on_error", ("on_error",), N_("某一步出错时怎么办?")),
|
|
92
|
+
("on_missing_tool", ("on_missing_tool",), N_("执行前发现缺程序、缺模型或缺账号时怎么办?(AI 不会在没有你同意时自行安装软件)")),
|
|
93
|
+
("artifacts", ("artifact_keep",), N_("中间产物(如下载的视频、转出的音频、临时文本)放在哪里、完成后怎么处理?"
|
|
94
|
+
"缺省放在本次 Case 的 data/ 目录;体积很大的文件可以指定项目外的目录。")),
|
|
95
|
+
("resume", ("resume",), N_("中途失败后重新执行时,从哪里开始?")),
|
|
96
|
+
("confirm_costly", ("confirm_costly",), N_("遇到花钱、占用大量时间或网络流量的步骤,要不要先问你?")),
|
|
97
|
+
("scope", None, N_("这个流程会读取哪些事项或资料?是每次由你指定,还是固定范围(不要默认纳入全部)?会不会用到私有目录"
|
|
98
|
+
"(private/)里的内容?用到的话,每次读取前都会请你确认,执行记录自动标为 private,内容默认不进 git。"
|
|
99
|
+
"产物会放进哪个项目,那个项目会不会公开?")),
|
|
100
|
+
)
|
|
101
|
+
# 习惯项 -> 它所属的“创建时必须确认”事项;设置这些项等于用户确认了对应事项。
|
|
102
|
+
FIELD_DECISION = {"on_error": "on_error", "max_retries": "on_error", "on_missing_tool": "on_missing_tool",
|
|
103
|
+
"artifact_keep": "artifacts", "artifact_dir": "artifacts", "resume": "resume",
|
|
104
|
+
"confirm_costly": "confirm_costly"}
|
|
105
|
+
DECISION_OPTIONS = {"on_error": "on_error", "on_missing_tool": "on_missing_tool", "artifacts": "artifact_keep",
|
|
106
|
+
"resume": "resume", "confirm_costly": "confirm_costly"}
|
|
107
|
+
|
|
108
|
+
KIND_LABEL = {"command": N_("命令"), "module": N_("Python 模块"), "env": N_("环境变量"), "path": N_("路径"), "skill": N_("技能"),
|
|
109
|
+
"mcp": "MCP", "manual": N_("手动确认")}
|
|
110
|
+
SECRET_PATTERNS = (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"),
|
|
111
|
+
re.compile(r"AKIA[0-9A-Z]{16}"), re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
|
|
112
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
113
|
+
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*[\"']?[A-Za-z0-9/+_\-]{16,}"))
|
|
114
|
+
MARKS = {" ": N_("待办"), "x": N_("完成"), "-": N_("跳过"), "!": N_("失败")}
|
|
115
|
+
CHECK_RE = re.compile(r"^- \[( |x|-|!)\] (\d+)\. (.*)$")
|
|
116
|
+
SOP_HOME_ENV = "AGENTHELM_SOP_HOME"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SopError(Exception):
|
|
120
|
+
def __init__(self, message: str, code: str = "usage", next_steps: list[str] | None = None) -> None:
|
|
121
|
+
super().__init__(message)
|
|
122
|
+
self.code = code
|
|
123
|
+
self.next_steps = next_steps or []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def fail(message: str, code: str = "usage", next_steps: list[str] | None = None) -> None:
|
|
127
|
+
raise SopError(message, code, next_steps)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------- 路径与读写
|
|
131
|
+
|
|
132
|
+
def sha(text: str) -> str:
|
|
133
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def today() -> date:
|
|
137
|
+
return datetime.now().astimezone().date()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_profile(project: Path) -> dict:
|
|
141
|
+
path = project / PROFILE
|
|
142
|
+
if not path.is_file():
|
|
143
|
+
return {}
|
|
144
|
+
try:
|
|
145
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
return {}
|
|
148
|
+
return value if isinstance(value, dict) else {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def inside(project: Path, relative: str, label: str) -> Path:
|
|
152
|
+
path = Path(relative)
|
|
153
|
+
if path.is_absolute() or ".." in path.parts or not path.parts:
|
|
154
|
+
fail(_("{0} 必须是项目内的相对路径:{1}").format(label, relative))
|
|
155
|
+
current = project
|
|
156
|
+
for part in path.parts:
|
|
157
|
+
current = current / part
|
|
158
|
+
if current.is_symlink():
|
|
159
|
+
fail(_("{0} 不能经过符号链接:{1}").format(label, relative))
|
|
160
|
+
return project / path
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def project_root(project: Path) -> Path:
|
|
164
|
+
section = read_profile(project).get("sop")
|
|
165
|
+
declared = section.get("root") if isinstance(section, dict) else None
|
|
166
|
+
return inside(project, declared or DEFAULT_ROOT, "sop.root")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def user_root() -> Path:
|
|
170
|
+
if os.environ.get(SOP_HOME_ENV):
|
|
171
|
+
return Path(os.environ[SOP_HOME_ENV]).expanduser()
|
|
172
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
173
|
+
return (Path(base) if base else Path.home() / ".config") / "agenthelm" / "sops"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_text(path: Path, text: str) -> None:
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
case_tool.write_atomic(path, text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def display(path: Path, project: Path) -> str:
|
|
182
|
+
try:
|
|
183
|
+
return path.relative_to(project).as_posix()
|
|
184
|
+
except ValueError:
|
|
185
|
+
home = Path.home()
|
|
186
|
+
try:
|
|
187
|
+
return "~/" + path.relative_to(home).as_posix()
|
|
188
|
+
except ValueError:
|
|
189
|
+
return str(path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------- 文件格式
|
|
193
|
+
|
|
194
|
+
def parse_sop_text(text: str) -> tuple[dict, str]:
|
|
195
|
+
if not text.startswith("---\n"):
|
|
196
|
+
fail(_("SOP.md 必须以 --- 开头"), "invalid")
|
|
197
|
+
end = text.find("\n---\n", 3)
|
|
198
|
+
if end < 0:
|
|
199
|
+
fail(_("SOP.md 缺少 frontmatter 结束标记"), "invalid")
|
|
200
|
+
try:
|
|
201
|
+
meta = case_tool.parse_frontmatter(text[4:end])
|
|
202
|
+
except case_tool.CaseError as exc:
|
|
203
|
+
raise SopError(str(exc), "invalid") from exc
|
|
204
|
+
return meta, text[end + 5:]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def as_text(value: Any) -> str:
|
|
208
|
+
return "true" if value is True else "false" if value is False else str(value)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_sop(meta: dict, body: str) -> str:
|
|
212
|
+
lines = ["---"]
|
|
213
|
+
for key in SCALARS:
|
|
214
|
+
if key in meta and (meta[key] != "" or key in REQUIRED):
|
|
215
|
+
lines.append(f"{key}: {json.dumps(as_text(meta[key]), ensure_ascii=False)}")
|
|
216
|
+
for key in LISTS:
|
|
217
|
+
if key in meta:
|
|
218
|
+
items = meta[key]
|
|
219
|
+
lines.append(f"{key}: []" if not items else f"{key}:")
|
|
220
|
+
lines.extend(f" - {json.dumps(item, ensure_ascii=False)}" for item in items)
|
|
221
|
+
lines.append("---")
|
|
222
|
+
return "\n".join(lines) + "\n" + body
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_dir(directory: Path, scope: str) -> dict:
|
|
226
|
+
sop = {"id": directory.name, "scope": scope, "dir": directory, "path": directory / "SOP.md",
|
|
227
|
+
"meta": {}, "body": "", "text": "", "error": None}
|
|
228
|
+
try:
|
|
229
|
+
if sop["path"].is_symlink() or directory.is_symlink():
|
|
230
|
+
fail(_("拒绝符号链接"), "invalid")
|
|
231
|
+
raw = sop["path"].read_bytes()
|
|
232
|
+
if len(raw) > MAX_BYTES:
|
|
233
|
+
fail(_("SOP.md 超过 1 MiB"), "invalid")
|
|
234
|
+
sop["text"] = raw.decode("utf-8")
|
|
235
|
+
sop["meta"], sop["body"] = parse_sop_text(sop["text"])
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
sop["error"] = _("缺少 SOP.md")
|
|
238
|
+
except (SopError, UnicodeDecodeError, OSError) as exc:
|
|
239
|
+
sop["error"] = str(exc)
|
|
240
|
+
return sop
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_all(project: Path | None, *, user: bool = True) -> list[dict]:
|
|
244
|
+
"""项目级在前;同 id 时项目级覆盖用户级(被覆盖的用户级记为 shadowed)。"""
|
|
245
|
+
found: list[dict] = []
|
|
246
|
+
roots = ([("project", project_root(project))] if project else []) + ([("user", user_root())] if user else [])
|
|
247
|
+
seen: set[str] = set()
|
|
248
|
+
for scope, root in roots:
|
|
249
|
+
if not root.is_dir():
|
|
250
|
+
continue
|
|
251
|
+
for directory in sorted(root.iterdir()):
|
|
252
|
+
if not directory.is_dir() or directory.name.startswith((".", "_")):
|
|
253
|
+
continue
|
|
254
|
+
sop = load_dir(directory, scope)
|
|
255
|
+
sop["shadowed"] = directory.name in seen
|
|
256
|
+
seen.add(directory.name)
|
|
257
|
+
found.append(sop)
|
|
258
|
+
return found
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def find_sop(sops: list[dict], token: str) -> dict:
|
|
262
|
+
wanted, _unused, _unused = token.partition("@")
|
|
263
|
+
live = [sop for sop in sops if not sop["shadowed"]]
|
|
264
|
+
exact = [sop for sop in live if sop["id"] == wanted]
|
|
265
|
+
matches = exact or [sop for sop in live if wanted.lower() in sop["id"] or wanted in str(sop["meta"].get("title", ""))]
|
|
266
|
+
if not matches:
|
|
267
|
+
fail(_("没有找到 SOP:{0}").format(token), "not-found", [_("sop.py list 查看全部")])
|
|
268
|
+
if len(matches) > 1:
|
|
269
|
+
fail(_("有 {0} 个 SOP 匹配“{1}”").format(len(matches), token), "ambiguous", [sop["id"] for sop in matches[:10]])
|
|
270
|
+
return matches[0]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------- 正文解析
|
|
274
|
+
|
|
275
|
+
def split_sections(body: str) -> dict[str, str]:
|
|
276
|
+
result: dict[str, list[str]] = {}
|
|
277
|
+
current: str | None = None
|
|
278
|
+
fenced = False
|
|
279
|
+
for line in body.splitlines():
|
|
280
|
+
if line.lstrip().startswith("```"):
|
|
281
|
+
fenced = not fenced
|
|
282
|
+
match = None if fenced else re.match(r"^##\s+(.+?)\s*$", line)
|
|
283
|
+
if match:
|
|
284
|
+
current = match.group(1)
|
|
285
|
+
result.setdefault(current, [])
|
|
286
|
+
elif current is not None:
|
|
287
|
+
result[current].append(line)
|
|
288
|
+
return {key: "\n".join(lines).strip("\n") for key, lines in result.items()}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def split_list(value: str) -> list[str]:
|
|
292
|
+
return [item.strip() for item in re.split(r"[、,,;;]", value) if item.strip() and item.strip().lower() not in vocab.LIST_NONE]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_steps(text: str, language: str = "zh") -> tuple[list[dict], list[str]]:
|
|
296
|
+
steps: list[dict] = []
|
|
297
|
+
problems: list[str] = []
|
|
298
|
+
for line in text.splitlines():
|
|
299
|
+
head = re.match(r"^(\d+)[.、)]\s*(.+?)\s*$", line)
|
|
300
|
+
if head:
|
|
301
|
+
steps.append({"n": int(head.group(1)), "title": head.group(2), "tools": [], "inputs": "", "intermediates": [],
|
|
302
|
+
"finals": [], "checkpoint": "", "on_failure": "", "confirm": False, "note": ""})
|
|
303
|
+
continue
|
|
304
|
+
if not line.strip():
|
|
305
|
+
continue
|
|
306
|
+
if not steps:
|
|
307
|
+
continue # 第一个编号步骤之前的说明文字不参与解析
|
|
308
|
+
sub = re.match(r"^\s+[-*]\s*([^::]{1,32})[::]\s*(.*?)\s*$", line)
|
|
309
|
+
if not sub:
|
|
310
|
+
steps[-1]["note"] = (steps[-1]["note"] + " " + line.strip()).strip()
|
|
311
|
+
continue
|
|
312
|
+
key = vocab.step_key(sub.group(1))
|
|
313
|
+
if key is None:
|
|
314
|
+
problems.append(_("步骤 {0} 有未知字段“{1}”(可用:{2})").format(
|
|
315
|
+
steps[-1]['n'], sub.group(1), _('、').join(vocab.name(vocab.STEP_KEYS, item, language) for item in vocab.STEP_KEYS)))
|
|
316
|
+
elif key in ("tools", "intermediates", "finals"):
|
|
317
|
+
steps[-1][key] = split_list(sub.group(2))
|
|
318
|
+
elif key == "confirm":
|
|
319
|
+
steps[-1][key] = sub.group(2).strip().lower() in vocab.TRUE_WORDS
|
|
320
|
+
else:
|
|
321
|
+
steps[-1][key] = sub.group(2)
|
|
322
|
+
for index, step in enumerate(steps, 1):
|
|
323
|
+
if step["n"] != index:
|
|
324
|
+
problems.append(_("步骤编号应从 1 连续递增:第 {0} 个步骤写成了 {1}").format(index, step['n']))
|
|
325
|
+
break
|
|
326
|
+
return steps, problems
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_requirements(text: str, language: str = "zh") -> tuple[list[dict], list[str]]:
|
|
330
|
+
reqs: list[dict] = []
|
|
331
|
+
problems: list[str] = []
|
|
332
|
+
for line in text.splitlines():
|
|
333
|
+
stripped = line.strip()
|
|
334
|
+
if not stripped or stripped.rstrip("。.").lower() in vocab.NONE_WORDS:
|
|
335
|
+
continue
|
|
336
|
+
if not stripped.startswith("- "):
|
|
337
|
+
continue
|
|
338
|
+
parts = [part.strip() for part in re.split(r"\s*|\s*|\s+\|\s+", stripped[2:]) if part.strip()]
|
|
339
|
+
head = re.match(r"^([^::]{1,32})[::]\s*(.+)$", parts[0]) if parts else None
|
|
340
|
+
if not head or vocab.kind_key(head.group(1)) is None:
|
|
341
|
+
problems.append(_("无法识别的前置条件:{0}(写法:- 命令:名称|用途:…|安装:…|缺失时:询问用户)").format(stripped[:40]))
|
|
342
|
+
continue
|
|
343
|
+
req = {"kind": vocab.kind_key(head.group(1)), "name": head.group(2).strip().strip("`"), "purpose": "", "install": "",
|
|
344
|
+
"alternative": "", "if_missing": "", "version": "", "when": ""}
|
|
345
|
+
for part in parts[1:]:
|
|
346
|
+
pair = re.match(r"^([^::]{1,32})[::]\s*(.*)$", part)
|
|
347
|
+
key = vocab.req_key(pair.group(1)) if pair else None
|
|
348
|
+
if key is None:
|
|
349
|
+
problems.append(_("{0}:未知字段“{1}”(可用:{2})").format(
|
|
350
|
+
req['name'], part[:20], _('、').join(vocab.name(vocab.REQ_KEYS, item, language) for item in vocab.REQ_KEYS)))
|
|
351
|
+
elif key == "if_missing":
|
|
352
|
+
value = vocab.missing_value(pair.group(2))
|
|
353
|
+
if value is None:
|
|
354
|
+
problems.append(_("{0}:缺失时只能写 {1}").format(
|
|
355
|
+
req['name'], _('、').join(vocab.name(vocab.MISSING, item, language) for item in vocab.MISSING)))
|
|
356
|
+
else:
|
|
357
|
+
req[key] = value
|
|
358
|
+
else:
|
|
359
|
+
req[key] = pair.group(2).strip()
|
|
360
|
+
reqs.append(req)
|
|
361
|
+
return reqs, problems
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def parsed(sop: dict, default: str = "zh") -> dict:
|
|
365
|
+
sections = split_sections(sop["body"])
|
|
366
|
+
language = vocab.detect(sop["body"], default)
|
|
367
|
+
steps, step_problems = parse_steps(vocab.section_text(sections, "steps"), language)
|
|
368
|
+
reqs, req_problems = parse_requirements(vocab.section_text(sections, "requires"), language)
|
|
369
|
+
return {"language": language, "sections": sections, "steps": steps, "step_problems": step_problems, "requires": reqs,
|
|
370
|
+
"require_problems": req_problems}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------- 习惯与缺口
|
|
374
|
+
|
|
375
|
+
def defaults_path(project: Path | None) -> Path | None:
|
|
376
|
+
return project_root(project) / DEFAULTS_FILE if project else None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def read_defaults(project: Path | None) -> dict:
|
|
380
|
+
path = defaults_path(project)
|
|
381
|
+
if not path or not path.is_file():
|
|
382
|
+
return {"conventions": {}, "decided_at": ""}
|
|
383
|
+
try:
|
|
384
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
385
|
+
except (OSError, ValueError) as exc:
|
|
386
|
+
raise SopError(_("{0} 无法解析:{1}").format(DEFAULTS_FILE, exc), "invalid") from exc
|
|
387
|
+
conventions = data.get("conventions") if isinstance(data, dict) else None
|
|
388
|
+
if not isinstance(conventions, dict):
|
|
389
|
+
fail(_("{0} 的 conventions 必须是对象").format(DEFAULTS_FILE), "invalid")
|
|
390
|
+
return {"conventions": {key: str(value) for key, value in conventions.items() if key in CHOICES},
|
|
391
|
+
"decided_at": str(data.get("decided_at", ""))}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def check_choice(key: str, value: str) -> None:
|
|
395
|
+
spec = CHOICES.get(key)
|
|
396
|
+
if spec is None:
|
|
397
|
+
fail(_("未知的习惯项:{0}(可用:{1})").format(key, _('、').join(CHOICES)))
|
|
398
|
+
if "values" in spec and value not in spec["values"]:
|
|
399
|
+
fail(_("{0} 只能是 {1}:{2}").format(key, ' / '.join(spec['values']), value))
|
|
400
|
+
if key == "max_retries" and not re.fullmatch(r"[1-9]", value):
|
|
401
|
+
fail(_("max_retries 只能是 1–9"))
|
|
402
|
+
if key == "artifact_dir" and ("\n" in value or "\x00" in value):
|
|
403
|
+
fail(_("artifact_dir 只能是单行路径"))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def effective(meta: dict, defaults: dict) -> dict[str, dict]:
|
|
407
|
+
"""每项习惯的最终取值与来源:sop(该 SOP 自己确认的)/ project-default(项目习惯)/ builtin(未确认的内置缺省)。"""
|
|
408
|
+
decided = set(meta.get("decided") or [])
|
|
409
|
+
own = FIELD_DECISION
|
|
410
|
+
result = {}
|
|
411
|
+
for key, spec in CHOICES.items():
|
|
412
|
+
if key in own and own[key] in decided and meta.get(key) not in (None, ""):
|
|
413
|
+
result[key] = {"value": as_text(meta[key]), "source": "sop"}
|
|
414
|
+
elif key not in own and meta.get(key) not in (None, ""):
|
|
415
|
+
result[key] = {"value": as_text(meta[key]), "source": "sop"}
|
|
416
|
+
elif key in defaults["conventions"]:
|
|
417
|
+
result[key] = {"value": defaults["conventions"][key], "source": "project-default"}
|
|
418
|
+
elif key in own and own[key] in decided:
|
|
419
|
+
result[key] = {"value": spec["default"], "source": "sop"}
|
|
420
|
+
else:
|
|
421
|
+
result[key] = {"value": spec["default"], "source": "builtin"}
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def gaps(meta: dict, defaults: dict) -> list[dict]:
|
|
426
|
+
decided = set(meta.get("decided") or [])
|
|
427
|
+
found = []
|
|
428
|
+
for key, fields, question in DECISIONS:
|
|
429
|
+
if key in decided or (fields and all(field in defaults["conventions"] for field in fields)):
|
|
430
|
+
continue
|
|
431
|
+
found.append({"key": key, "question": _(question), "options": options_for(key)})
|
|
432
|
+
return found
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def options_for(key: str) -> list[dict]:
|
|
436
|
+
field = DECISION_OPTIONS.get(key)
|
|
437
|
+
if not field:
|
|
438
|
+
return []
|
|
439
|
+
spec = CHOICES[field]
|
|
440
|
+
return [{"value": value, "label": _(spec["labels"][value]), "recommended": "推荐" in spec["labels"][value]}
|
|
441
|
+
for value in spec["values"]]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------- 校验
|
|
445
|
+
|
|
446
|
+
def issue(level: str, code: str, message: str) -> dict:
|
|
447
|
+
return {"level": level, "code": code, "message": message}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def check_sop(sop: dict, defaults: dict, *, strict: bool | None = None, default_language: str = "zh") -> list[dict]:
|
|
451
|
+
"""strict 缺省按 status:active 严格;draft / retired 只提示。"""
|
|
452
|
+
if sop["error"]:
|
|
453
|
+
return [issue("error", "unreadable", sop["error"])]
|
|
454
|
+
meta = sop["meta"]
|
|
455
|
+
strict = meta.get("status") == "active" if strict is None else strict
|
|
456
|
+
level = "error" if strict else "warn"
|
|
457
|
+
found: list[dict] = []
|
|
458
|
+
add = lambda lvl, code, message: found.append(issue(lvl, code, message)) # noqa: E731
|
|
459
|
+
|
|
460
|
+
allowed = set(SCALARS) | set(LISTS)
|
|
461
|
+
unknown = sorted(set(meta) - allowed)
|
|
462
|
+
if unknown:
|
|
463
|
+
add("error", "unknown-field", _("未知字段:{0}").format(_('、').join(unknown)))
|
|
464
|
+
for key in REQUIRED:
|
|
465
|
+
if not str(meta.get(key, "")).strip():
|
|
466
|
+
add("error", "missing-field", _("缺少字段 {0}").format(key))
|
|
467
|
+
if meta.get("schema_version") not in (None, "1.0"):
|
|
468
|
+
add("error", "schema-version", _("schema_version 必须为 1.0"))
|
|
469
|
+
if meta.get("id") and (not ID_RE.fullmatch(str(meta["id"])) or meta["id"] != sop["id"]):
|
|
470
|
+
add("error", "id-mismatch", _("id 必须是小写字母、数字与连字符,且与目录名一致(目录:{0})").format(sop['id']))
|
|
471
|
+
if meta.get("status") and meta["status"] not in STATUSES:
|
|
472
|
+
add("error", "invalid-status", _("status 必须为:{0}").format(_('、').join(STATUSES)))
|
|
473
|
+
if meta.get("version") and not VERSION_RE.fullmatch(str(meta["version"])):
|
|
474
|
+
add("error", "invalid-version", _("version 必须是 主.次.修订,例如 1.0.0"))
|
|
475
|
+
for key in ("confirm", "creates_case", "on_error", "on_missing_tool", "artifact_keep", "resume", "confirm_costly"):
|
|
476
|
+
value = meta.get(key)
|
|
477
|
+
if value not in (None, "") and value not in CHOICES[key]["values"]:
|
|
478
|
+
add("error", "invalid-choice", _("{0} 只能是:{1}").format(key, ' / '.join(CHOICES[key]['values'])))
|
|
479
|
+
if meta.get("max_retries") not in (None, "") and not re.fullmatch(r"[1-9]", str(meta["max_retries"])):
|
|
480
|
+
add("error", "invalid-choice", _("max_retries 只能是 1–9"))
|
|
481
|
+
if meta.get("schedule") and not SCHEDULE_RE.fullmatch(str(meta["schedule"])):
|
|
482
|
+
add("error", "invalid-schedule", _("schedule 写法:daily / weekly:mon..sun / monthly:1..28 / every:N天(如 every:14d)"))
|
|
483
|
+
for event in meta.get("on_events") or []:
|
|
484
|
+
if event not in EVENTS:
|
|
485
|
+
add("error", "invalid-event", _("on_events 只支持:{0}").format(_('、').join(EVENTS)))
|
|
486
|
+
if meta.get("event_tags") and not meta.get("on_events"):
|
|
487
|
+
add("warn", "event-tags-without-event", _("设了 event_tags 但没有 on_events:标签过滤不会生效"))
|
|
488
|
+
for key in ("created", "verified_at", "review_after"):
|
|
489
|
+
if meta.get(key):
|
|
490
|
+
try:
|
|
491
|
+
date.fromisoformat(str(meta[key]))
|
|
492
|
+
except ValueError:
|
|
493
|
+
add("error", "invalid-date", _("{0} 必须为 YYYY-MM-DD").format(key))
|
|
494
|
+
for item in meta.get("decided") or []:
|
|
495
|
+
if item not in {key for key, _unused, _unused in DECISIONS}:
|
|
496
|
+
add("error", "invalid-decided", _("decided 里有未知项:{0}").format(item))
|
|
497
|
+
if meta.get("schedule") and meta.get("creates_case") == "never":
|
|
498
|
+
add("warn", "schedule-without-case", _("设了周期但 creates_case=never:没有执行记录,无法判断是否到期"))
|
|
499
|
+
if len(str(meta.get("description", ""))) > 80:
|
|
500
|
+
add("warn", "description-long", _("description 超过 80 字符:索引里每个 SOP 只占一行"))
|
|
501
|
+
if meta.get("review_after"):
|
|
502
|
+
try:
|
|
503
|
+
if date.fromisoformat(str(meta["review_after"])) < today():
|
|
504
|
+
add("warn", "review-due", _("已超过复核日期 {0}:核对后更新 verified_at / review_after").format(meta['review_after']))
|
|
505
|
+
except ValueError:
|
|
506
|
+
pass
|
|
507
|
+
if strict and not str(meta.get("trigger", "")).strip():
|
|
508
|
+
add("error", "missing-trigger", _("active 的 SOP 必须写 trigger(什么情况下用它)"))
|
|
509
|
+
|
|
510
|
+
info = parsed(sop, default_language)
|
|
511
|
+
for key in vocab.STRICT_SECTIONS:
|
|
512
|
+
if not vocab.section_text(info["sections"], key).strip():
|
|
513
|
+
add(level, "missing-section", _("缺少或为空的小节:## {0}").format(vocab.name(vocab.SECTIONS, key, info["language"])))
|
|
514
|
+
for problem in info["step_problems"]:
|
|
515
|
+
add(level, "step-format", problem)
|
|
516
|
+
for problem in info["require_problems"]:
|
|
517
|
+
add(level, "requirement-format", problem)
|
|
518
|
+
if strict and not info["steps"]:
|
|
519
|
+
add("error", "no-steps", _("## {0} 下至少要有一个编号步骤").format(vocab.name(vocab.SECTIONS, "steps", info["language"])))
|
|
520
|
+
names = {req["name"].lower() for req in info["requires"]}
|
|
521
|
+
for req in info["requires"]:
|
|
522
|
+
if req["alternative"] and req["alternative"].lower() not in names:
|
|
523
|
+
add("error", "unknown-alternative", _("{0} 的替代项“{1}”没有在前置条件里声明").format(req['name'], req['alternative']))
|
|
524
|
+
for step in info["steps"]:
|
|
525
|
+
for tool in step["tools"]:
|
|
526
|
+
if tool.lower() not in names:
|
|
527
|
+
add(level, "undeclared-tool", _("步骤 {0} 用到“{1}”,但前置条件里没有声明(会让执行前检查漏掉它)").format(step['n'], tool))
|
|
528
|
+
if vocab.count_placeholders(sop["text"]):
|
|
529
|
+
marker = next(item for item in vocab.PLACEHOLDERS if item in sop["text"])
|
|
530
|
+
add(level, "placeholder", _("还有 {0} 处{1}需要填写").format(vocab.count_placeholders(sop["text"]), marker))
|
|
531
|
+
if any(pattern.search(sop["text"]) for pattern in SECRET_PATTERNS):
|
|
532
|
+
add("error", "secret", _("SOP 里疑似写入了密钥或口令:只写环境变量名,不写密钥本身"))
|
|
533
|
+
if strict:
|
|
534
|
+
for gap in gaps(meta, defaults):
|
|
535
|
+
add("error", "undecided", _("尚未与用户确认:{0}(sop.py interview {1} 查看问题)").format(gap['key'], sop['id']))
|
|
536
|
+
snapshot = sop["dir"] / SNAPSHOTS / f"{meta.get('version', '')}.md"
|
|
537
|
+
if snapshot.is_file():
|
|
538
|
+
if snapshot.read_text(encoding="utf-8") != sop["text"]:
|
|
539
|
+
add("warn", "version-not-bumped", _("内容比版本快照新,但版本号没升:用 sop.py revise --bump … 记录这次修改"))
|
|
540
|
+
elif meta.get("status") == "active":
|
|
541
|
+
add("warn", "no-snapshot", _("active 的 SOP 缺少当前版本的快照:运行 sop.py revise 或 activate 补上"))
|
|
542
|
+
for name in sorted(path.name for path in sop["dir"].iterdir() if path.is_file()) if sop["dir"].is_dir() else []:
|
|
543
|
+
if name != "SOP.md" and re.match(r"^SOP\b.*\.md$", name):
|
|
544
|
+
add("warn", "conflict-copy", _("{0} 像是同步盘留下的冲突副本:合并到 SOP.md 后删除").format(name))
|
|
545
|
+
return found
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def check_all(sops: list[dict], defaults: dict, project: Path | None) -> list[dict]:
|
|
549
|
+
results = []
|
|
550
|
+
active_alias: dict[str, str] = {}
|
|
551
|
+
language = vocab.language_of(read_profile(project)) if project else "zh"
|
|
552
|
+
for sop in sops:
|
|
553
|
+
issues = check_sop(sop, defaults, default_language=language)
|
|
554
|
+
if project and not sop["error"]:
|
|
555
|
+
for source in sop["meta"].get("sources") or []:
|
|
556
|
+
try:
|
|
557
|
+
if not inside(project, source, "sources").exists():
|
|
558
|
+
issues.append(issue("warn", "missing-source", _("来源路径不存在:{0}").format(source)))
|
|
559
|
+
except SopError as exc:
|
|
560
|
+
issues.append(issue("warn", "invalid-source", str(exc)))
|
|
561
|
+
if sop["meta"].get("status") == "active" and not sop["shadowed"]:
|
|
562
|
+
for name in [str(sop["meta"].get("title", "")), *(sop["meta"].get("aliases") or [])]:
|
|
563
|
+
other = active_alias.setdefault(name.lower(), sop["id"]) if name else sop["id"]
|
|
564
|
+
if other != sop["id"]:
|
|
565
|
+
issues.append(issue("warn", "trigger-overlap", _("“{0}”同时是 {1} 的名称或别名,识别时会混淆").format(name, other)))
|
|
566
|
+
if sop["shadowed"]:
|
|
567
|
+
issues.append(issue("warn", "shadowed", _("同 id 的项目级 SOP 覆盖了这份用户级 SOP")))
|
|
568
|
+
results.append({"id": sop["id"], "scope": sop["scope"], "status": sop["meta"].get("status", "?"),
|
|
569
|
+
"issues": issues, "valid": not any(item["level"] == "error" for item in issues)})
|
|
570
|
+
if sum(1 for sop in sops if sop["meta"].get("status") == "active" and not sop["shadowed"]) > MAX_INDEX_ENTRIES:
|
|
571
|
+
results.append({"id": "(index)", "scope": "", "status": "", "valid": True,
|
|
572
|
+
"issues": [issue("warn", "index-budget", _("active 的 SOP 超过 {0} 个:索引会占用过多上下文,建议退役不用的").format(MAX_INDEX_ENTRIES))]})
|
|
573
|
+
return results
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ---------------------------------------------------------------- 触发:索引、匹配、周期
|
|
577
|
+
|
|
578
|
+
def index_line(sop: dict, due: dict | None = None) -> str:
|
|
579
|
+
meta = sop["meta"]
|
|
580
|
+
parts = [_("- `{0}`@{1}:{2}").format(sop['id'], meta.get('version', '?'), meta.get('description', ''))]
|
|
581
|
+
if meta.get("trigger"):
|
|
582
|
+
parts.append(_("触发:{0}").format(meta['trigger']))
|
|
583
|
+
if meta.get("exclude"):
|
|
584
|
+
parts.append(_("不触发:{0}").format(meta['exclude']))
|
|
585
|
+
if meta.get("schedule"):
|
|
586
|
+
parts.append(_("周期:{0}").format(meta['schedule']))
|
|
587
|
+
if meta.get("on_events"):
|
|
588
|
+
tags = meta.get("event_tags") or []
|
|
589
|
+
parts.append(_("事件:") + _("、").join(meta["on_events"]) + (_("(仅标签 {0})").format(_('、').join(tags)) if tags else ""))
|
|
590
|
+
if (meta.get("confirm") or "ask") == "auto":
|
|
591
|
+
parts.append(_("命中即执行"))
|
|
592
|
+
if due and due.get("due"):
|
|
593
|
+
parts.append(_("已到期"))
|
|
594
|
+
return _("|").join(parts)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def next_occurrence(schedule: str, base: date) -> date:
|
|
598
|
+
kind, _unused, arg = schedule.partition(":")
|
|
599
|
+
if kind == "daily":
|
|
600
|
+
return base + timedelta(days=1)
|
|
601
|
+
if kind == "every":
|
|
602
|
+
return base + timedelta(days=int(arg.rstrip("d")))
|
|
603
|
+
if kind == "weekly":
|
|
604
|
+
day = base + timedelta(days=1)
|
|
605
|
+
while day.weekday() != WEEKDAYS.index(arg):
|
|
606
|
+
day += timedelta(days=1)
|
|
607
|
+
return day
|
|
608
|
+
day = base + timedelta(days=1) # monthly
|
|
609
|
+
while day.day != int(arg):
|
|
610
|
+
day += timedelta(days=1)
|
|
611
|
+
return day
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def last_runs(project: Path) -> tuple[dict[str, dict], str | None]:
|
|
615
|
+
try:
|
|
616
|
+
cases = case_tool.Project(project)
|
|
617
|
+
except case_tool.CaseError as exc:
|
|
618
|
+
return {}, str(exc)
|
|
619
|
+
runs: dict[str, dict] = {}
|
|
620
|
+
for directory in cases.case_dirs():
|
|
621
|
+
try:
|
|
622
|
+
data, _unused = case_tool.read_case(directory / "CASE.md")
|
|
623
|
+
except (case_tool.CaseError, OSError, UnicodeDecodeError):
|
|
624
|
+
continue
|
|
625
|
+
sop_id, _unused, version = str(data.get("sop") or "").partition("@")
|
|
626
|
+
if not sop_id:
|
|
627
|
+
continue
|
|
628
|
+
stamp = str(data.get("created_at", ""))[:10]
|
|
629
|
+
if sop_id not in runs or stamp > runs[sop_id]["date"]:
|
|
630
|
+
runs[sop_id] = {"date": stamp, "case": data.get("id", directory.name), "status": data.get("status", ""),
|
|
631
|
+
"version": version}
|
|
632
|
+
return runs, None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def due_info(sop: dict, runs: dict[str, dict], now: date) -> dict | None:
|
|
636
|
+
schedule = sop["meta"].get("schedule")
|
|
637
|
+
if not schedule or sop["meta"].get("status") != "active":
|
|
638
|
+
return None
|
|
639
|
+
last = runs.get(sop["id"])
|
|
640
|
+
try:
|
|
641
|
+
base = date.fromisoformat(last["date"] if last else str(sop["meta"].get("created", "")))
|
|
642
|
+
except ValueError:
|
|
643
|
+
return None
|
|
644
|
+
following = next_occurrence(schedule, base)
|
|
645
|
+
return {"id": sop["id"], "title": sop["meta"].get("title", ""), "schedule": schedule,
|
|
646
|
+
"last_run": last["date"] if last else "", "last_case": last["case"] if last else "",
|
|
647
|
+
"next": following.isoformat(), "due": following <= now,
|
|
648
|
+
"overdue_days": max(0, (now - following).days)}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def event_hints(project: Path, event: str, case_data: dict, sops: list[dict] | None = None) -> list[dict]:
|
|
652
|
+
"""某个 Case 发生事件时,哪些已启用的 SOP 在监听。SOP 自己的执行 Case 不再触发事件,避免循环;
|
|
653
|
+
SOP 声明了 event_tags 时,只对带其中任一标签的 Case 触发。命中只是提示,是否执行默认先问用户。"""
|
|
654
|
+
if case_data.get("sop"):
|
|
655
|
+
return []
|
|
656
|
+
tags = set(case_data.get("tags") or [])
|
|
657
|
+
hints = []
|
|
658
|
+
for sop in sops if sops is not None else load_all(project):
|
|
659
|
+
meta = sop["meta"]
|
|
660
|
+
if sop["shadowed"] or meta.get("status") != "active" or event not in (meta.get("on_events") or []):
|
|
661
|
+
continue
|
|
662
|
+
wanted = set(meta.get("event_tags") or [])
|
|
663
|
+
if wanted and not wanted & tags:
|
|
664
|
+
continue
|
|
665
|
+
case_id = str(case_data.get("id", ""))
|
|
666
|
+
hints.append({"id": sop["id"], "title": meta.get("title", ""), "version": meta.get("version", ""),
|
|
667
|
+
"trigger": meta.get("trigger", ""), "confirm": meta.get("confirm") or "ask", "event": event,
|
|
668
|
+
"case": case_id, "command": f"sop run {sop['id']} --event {event} --event-case {case_id}"})
|
|
669
|
+
return hints
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def bigrams(text: str) -> set[str]:
|
|
673
|
+
compact = re.sub(r"\s+", "", text.lower())
|
|
674
|
+
return {compact[i:i + 2] for i in range(len(compact) - 1)} or {compact}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def match_sops(sops: list[dict], text: str) -> list[dict]:
|
|
678
|
+
"""关键词预筛:名称 / 别名命中,或与示例话术足够相似。最终是否触发由 Agent 对照 trigger / exclude 判断。"""
|
|
679
|
+
lowered = text.lower()
|
|
680
|
+
found = []
|
|
681
|
+
for sop in sops:
|
|
682
|
+
if sop["meta"].get("status") != "active" or sop["shadowed"]:
|
|
683
|
+
continue
|
|
684
|
+
meta, reasons = sop["meta"], []
|
|
685
|
+
for name in [str(meta.get("title", "")), sop["id"], *(meta.get("aliases") or [])]:
|
|
686
|
+
if name and name.lower() in lowered:
|
|
687
|
+
reasons.append(_("提到“{0}”").format(name))
|
|
688
|
+
for example in meta.get("examples_hit") or []:
|
|
689
|
+
score = len(bigrams(text) & bigrams(example)) / max(1, len(bigrams(text) | bigrams(example)))
|
|
690
|
+
if score >= 0.45:
|
|
691
|
+
reasons.append(_("与示例话术“{0}”相似({1:.2f})").format(example, score))
|
|
692
|
+
excluded = [example for example in (meta.get("examples_miss") or [])
|
|
693
|
+
if len(bigrams(text) & bigrams(example)) / max(1, len(bigrams(text) | bigrams(example))) >= 0.6]
|
|
694
|
+
if reasons:
|
|
695
|
+
found.append({"id": sop["id"], "title": meta.get("title", ""), "version": meta.get("version", ""),
|
|
696
|
+
"reasons": reasons, "maybe_excluded": bool(excluded) or bool(meta.get("exclude")),
|
|
697
|
+
"exclude": meta.get("exclude", ""), "confirm": meta.get("confirm") or "ask"})
|
|
698
|
+
return found
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# ---------------------------------------------------------------- 执行前检查
|
|
702
|
+
|
|
703
|
+
def lookup_skill(name: str, project: Path) -> str | None:
|
|
704
|
+
home = Path.home()
|
|
705
|
+
for base in (project / ".agents" / "skills", home / ".claude" / "skills", home / ".agents" / "skills",
|
|
706
|
+
home / ".codex" / "skills", home / ".cursor" / "skills"):
|
|
707
|
+
if (base / name / "SKILL.md").is_file():
|
|
708
|
+
return str(base / name)
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def lookup_mcp(name: str, project: Path) -> str | None:
|
|
713
|
+
for path in (project / ".agents" / "mcp.json", Path.home() / ".claude.json", project / ".mcp.json"):
|
|
714
|
+
try:
|
|
715
|
+
servers = json.loads(path.read_text(encoding="utf-8")).get("mcpServers") or {}
|
|
716
|
+
except (OSError, ValueError, AttributeError):
|
|
717
|
+
continue
|
|
718
|
+
if isinstance(servers, dict) and name in servers:
|
|
719
|
+
return str(path)
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def check_requirement(req: dict, project: Path) -> tuple[str, str]:
|
|
724
|
+
"""(状态, 说明)。ok 已确认可用;missing 确认缺失;unknown 本工具无法确认,必须由 Agent 或用户核对。值从不输出。"""
|
|
725
|
+
kind, name = req["kind"], req["name"]
|
|
726
|
+
if kind == "command":
|
|
727
|
+
if "/" in name:
|
|
728
|
+
path = (project / name) if not Path(name).expanduser().is_absolute() else Path(name).expanduser()
|
|
729
|
+
return ("ok", str(path)) if path.is_file() and os.access(path, os.X_OK) else ("missing", _("不存在或不可执行:{0}").format(name))
|
|
730
|
+
found = shutil.which(name)
|
|
731
|
+
return ("ok", found) if found else ("missing", _("PATH 上找不到"))
|
|
732
|
+
if kind == "module":
|
|
733
|
+
try:
|
|
734
|
+
found_spec = importlib.util.find_spec(name.split(".")[0])
|
|
735
|
+
except (ImportError, ValueError):
|
|
736
|
+
found_spec = None
|
|
737
|
+
return ("ok", _("运行本工具的 Python 里可导入;若流程用别的 Python 环境,请人工确认")) if found_spec \
|
|
738
|
+
else ("unknown", _("运行本工具的 Python 里找不到;若流程用别的 Python 环境,请到那个环境里确认"))
|
|
739
|
+
if kind == "env":
|
|
740
|
+
return ("ok", _("已设置(值不显示)")) if os.environ.get(name) else ("missing", _("当前环境没有设置或为空"))
|
|
741
|
+
if kind == "path":
|
|
742
|
+
path = Path(name).expanduser()
|
|
743
|
+
path = path if path.is_absolute() else project / path
|
|
744
|
+
return ("ok", str(path)) if path.exists() else ("missing", _("不存在:{0}").format(path))
|
|
745
|
+
if kind == "skill":
|
|
746
|
+
found = lookup_skill(name, project)
|
|
747
|
+
return ("ok", found) if found else ("unknown", _("常见位置没找到;请对照当前会话的可用技能列表确认"))
|
|
748
|
+
if kind == "mcp":
|
|
749
|
+
found = lookup_mcp(name, project)
|
|
750
|
+
return ("ok", found) if found else ("unknown", _("配置文件里没找到;请对照当前会话的可用工具确认"))
|
|
751
|
+
return "unknown", _("需要用户确认")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def preflight(sop: dict, project: Path, defaults: dict) -> dict:
|
|
755
|
+
info = parsed(sop)
|
|
756
|
+
habit = effective(sop["meta"], defaults)
|
|
757
|
+
used: dict[str, list[int]] = {}
|
|
758
|
+
for step in info["steps"]:
|
|
759
|
+
for tool in step["tools"]:
|
|
760
|
+
used.setdefault(tool.lower(), []).append(step["n"])
|
|
761
|
+
rows = []
|
|
762
|
+
for req in info["requires"]:
|
|
763
|
+
status, detail = check_requirement(req, project)
|
|
764
|
+
rows.append({**req, "label": _(KIND_LABEL[req["kind"]]), "status": status, "detail": detail,
|
|
765
|
+
"if_missing": req["if_missing"] or habit["on_missing_tool"]["value"],
|
|
766
|
+
"used_by_steps": used.get(req["name"].lower(), [])})
|
|
767
|
+
by_name = {row["name"].lower(): row for row in rows}
|
|
768
|
+
primaries: dict[str, list[dict]] = {}
|
|
769
|
+
for row in rows:
|
|
770
|
+
if row["alternative"]:
|
|
771
|
+
primaries.setdefault(row["alternative"].lower(), []).append(row)
|
|
772
|
+
for row in rows:
|
|
773
|
+
row["optional"] = row["status"] != "ok" and bool(primaries.get(row["name"].lower())) \
|
|
774
|
+
and all(item["status"] == "ok" for item in primaries[row["name"].lower()])
|
|
775
|
+
alternative = by_name.get(row["alternative"].lower()) if row["alternative"] else None
|
|
776
|
+
row["alternative_status"] = alternative["status"] if alternative else ""
|
|
777
|
+
# 只有用户约定“缺失时使用替代”,且替代项就绪,才算已解决;否则仍要按约定询问或停止。
|
|
778
|
+
row["resolved_by"] = alternative["name"] if row["status"] != "ok" and row["if_missing"] == "alternative" \
|
|
779
|
+
and alternative and alternative["status"] == "ok" else ""
|
|
780
|
+
open_rows = [row for row in rows if row["status"] != "ok" and not row["optional"] and not row["resolved_by"]]
|
|
781
|
+
# 带“仅当”的前置条件只在本次输入符合条件时才需要:工具无法判断输入,列出来由 Agent 对照本次输入判断。
|
|
782
|
+
conditional = [row for row in open_rows if row["when"]]
|
|
783
|
+
blocking = [row for row in open_rows if not row["when"]]
|
|
784
|
+
counts = {"ok": sum(row["status"] == "ok" for row in rows),
|
|
785
|
+
"missing": sum(row["status"] == "missing" for row in blocking),
|
|
786
|
+
"unknown": sum(row["status"] == "unknown" for row in blocking),
|
|
787
|
+
"conditional": len(conditional)}
|
|
788
|
+
return {"id": sop["id"], "version": sop["meta"].get("version", ""), "requires": rows, "counts": counts,
|
|
789
|
+
"ready": not blocking, "conditional": [{"name": row["name"], "label": row["label"], "when": row["when"],
|
|
790
|
+
"status": row["status"], "install": row["install"]} for row in conditional],
|
|
791
|
+
"on_missing_tool": habit["on_missing_tool"]["value"]}
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# ---------------------------------------------------------------- 写入计划
|
|
795
|
+
|
|
796
|
+
def apply_writes(writes: list[dict]) -> list[str]:
|
|
797
|
+
"""writes: {path, content, expect(None 表示必须不存在,str 表示当前内容的 sha256)};写前逐个核对,写后读回。"""
|
|
798
|
+
for item in writes:
|
|
799
|
+
path: Path = item["path"]
|
|
800
|
+
exists = path.exists()
|
|
801
|
+
if item["expect"] is None and exists:
|
|
802
|
+
fail(_("已存在,拒绝覆盖:{0}").format(path.name), "conflict")
|
|
803
|
+
if item["expect"] is not None and (not exists or sha(path.read_text(encoding="utf-8")) != item["expect"]):
|
|
804
|
+
fail(_("{0} 在预览之后被修改过,请重新预览").format(path.name), "conflict")
|
|
805
|
+
for item in writes:
|
|
806
|
+
write_text(item["path"], item["content"])
|
|
807
|
+
for item in writes:
|
|
808
|
+
if item["path"].read_text(encoding="utf-8") != item["content"]:
|
|
809
|
+
fail(_("写入后读回不一致:{0}").format(item['path'].name), "failed")
|
|
810
|
+
return [str(item["path"]) for item in writes]
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def plan_result(project: Path, writes: list[dict], apply: bool, extra: dict) -> dict:
|
|
814
|
+
result = {"applied": False, "writes": [{"path": display(item["path"], project),
|
|
815
|
+
"action": "create" if item["expect"] is None else "update",
|
|
816
|
+
"sha256_before": item["expect"]} for item in writes], **extra}
|
|
817
|
+
if apply:
|
|
818
|
+
apply_writes(writes)
|
|
819
|
+
result["applied"] = True
|
|
820
|
+
result["verify"] = _("已写入并读回核对:{0} 个文件").format(len(writes))
|
|
821
|
+
return result
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def snapshot_write(sop_dir: Path, version: str, text: str) -> dict | None:
|
|
825
|
+
path = sop_dir / SNAPSHOTS / f"{version}.md"
|
|
826
|
+
if path.exists():
|
|
827
|
+
return None if path.read_text(encoding="utf-8") == text else {"path": path, "content": text, "expect": sha(path.read_text(encoding="utf-8"))}
|
|
828
|
+
return {"path": path, "content": text, "expect": None}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def bump(version: str, part: str) -> str:
|
|
832
|
+
major, minor, patch = map(int, VERSION_RE.fullmatch(version).groups())
|
|
833
|
+
return {"major": f"{major + 1}.0.0", "minor": f"{major}.{minor + 1}.0", "patch": f"{major}.{minor}.{patch + 1}"}[part]
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def append_revision(body: str, line: str, default: str = "zh") -> str:
|
|
837
|
+
"""The revision log lives under the heading the file itself uses (detected from its headings)."""
|
|
838
|
+
language = vocab.detect(body, default)
|
|
839
|
+
return case_tool.append_section(body, vocab.name(vocab.SECTIONS, "revisions", language), line, language)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def revision_line(body: str, version: str, note: str, default: str = "zh") -> str:
|
|
843
|
+
return vocab.text("revision_line", vocab.detect(body, default), date=today().isoformat(), version=version, note=note)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# ---------------------------------------------------------------- 命令
|
|
847
|
+
|
|
848
|
+
class Context:
|
|
849
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
850
|
+
self.project = args.project.expanduser().resolve()
|
|
851
|
+
self.args = args
|
|
852
|
+
self.defaults = read_defaults(self.project)
|
|
853
|
+
self.language = vocab.language_of(read_profile(self.project))
|
|
854
|
+
|
|
855
|
+
def sops(self, *, user: bool = True) -> list[dict]:
|
|
856
|
+
return load_all(self.project, user=user)
|
|
857
|
+
|
|
858
|
+
def one(self, token: str) -> dict:
|
|
859
|
+
return find_sop(self.sops(), token)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def cmd_list(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
863
|
+
sops = ctx.sops()
|
|
864
|
+
runs, _unused = last_runs(ctx.project)
|
|
865
|
+
rows = []
|
|
866
|
+
for sop in sops:
|
|
867
|
+
meta = sop["meta"]
|
|
868
|
+
if meta.get("status") == "retired" and not ctx.args.all:
|
|
869
|
+
continue
|
|
870
|
+
if ctx.args.scope and sop["scope"] != ctx.args.scope:
|
|
871
|
+
continue
|
|
872
|
+
due = due_info(sop, runs, today())
|
|
873
|
+
rows.append({"id": sop["id"], "title": meta.get("title", ""), "status": meta.get("status", "?"),
|
|
874
|
+
"version": meta.get("version", ""), "scope": sop["scope"], "schedule": meta.get("schedule", ""),
|
|
875
|
+
"due": bool(due and due["due"]), "shadowed": sop["shadowed"], "error": sop["error"]})
|
|
876
|
+
lines = [_("{0:8} {1}@{2} {3}({4})").format(row['status'], row['id'], row['version'], row['title'], _('项目') if row['scope'] == 'project' else _('用户'))
|
|
877
|
+
+ (_(" 周期 {0}").format(row['schedule']) if row["schedule"] else "") + (_(" 已到期") if row["due"] else "")
|
|
878
|
+
+ (_(" 读取失败:{0}").format(row['error']) if row["error"] else "") for row in rows]
|
|
879
|
+
return 0, {"sops": rows}, lines or [_("还没有 SOP。用 sop new 创建;先看 sop interview 要谈清哪些事。")]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def cmd_index(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
883
|
+
runs, _unused = last_runs(ctx.project)
|
|
884
|
+
sops = ctx.sops()
|
|
885
|
+
active = [sop for sop in sops if sop["meta"].get("status") == "active" and not sop["shadowed"] and not sop["error"]]
|
|
886
|
+
lines = [index_line(sop, due_info(sop, runs, today())) for sop in active]
|
|
887
|
+
drafts = sum(1 for sop in sops if sop["meta"].get("status") == "draft")
|
|
888
|
+
return 0, {"entries": [{"id": sop["id"], "line": line} for sop, line in zip(active, lines)], "drafts": drafts}, \
|
|
889
|
+
lines or [_("没有 active 的 SOP。")]
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def cmd_show(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
893
|
+
sop = ctx.one(ctx.args.sop)
|
|
894
|
+
if sop["error"]:
|
|
895
|
+
fail(_("{0} 无法读取:{1}").format(sop['id'], sop['error']), "invalid")
|
|
896
|
+
info = parsed(sop, ctx.language)
|
|
897
|
+
habit = effective(sop["meta"], ctx.defaults)
|
|
898
|
+
data = {"id": sop["id"], "scope": sop["scope"], "path": display(sop["path"], ctx.project), "meta": sop["meta"],
|
|
899
|
+
"steps": info["steps"], "requires": info["requires"], "conventions": habit,
|
|
900
|
+
"gaps": gaps(sop["meta"], ctx.defaults), "issues": check_sop(sop, ctx.defaults, default_language=ctx.language),
|
|
901
|
+
"private": private_paths.mentions(sop["text"], private_paths.declared(ctx.project))}
|
|
902
|
+
meta = sop["meta"]
|
|
903
|
+
lines = [_("{0}({1}@{2},{3},{4}级)").format(meta.get('title'), sop['id'], meta.get('version'), meta.get('status'), _('项目') if sop['scope'] == 'project' else _('用户')),
|
|
904
|
+
_("文件:{0}").format(data['path']), _("触发:{0}").format(meta.get('trigger', '')), _("不触发:{0}").format(meta.get('exclude', ''))]
|
|
905
|
+
lines += ["", _("步骤:")] + [f" {step['n']}. {step['title']}" + (_("(工具:{0})").format(_('、').join(step['tools'])) if step["tools"] else "")
|
|
906
|
+
for step in info["steps"]]
|
|
907
|
+
lines += ["", _("前置条件:")] + [_(" {0}:{1}").format(_(KIND_LABEL[req['kind']]), req['name'])
|
|
908
|
+
+ (_("({0})").format(req['purpose']) if req["purpose"] else "") for req in info["requires"]]
|
|
909
|
+
lines += ["", _("执行约定(来源):")] + [_(" {0} = {1}({2})").format(key, item['value'] or _('(Case 的 data/)'), item['source'])
|
|
910
|
+
for key, item in habit.items() if key in ("on_error", "on_missing_tool", "artifact_dir", "artifact_keep", "resume", "confirm_costly")]
|
|
911
|
+
if data["gaps"]:
|
|
912
|
+
lines += ["", _("尚有 {0} 项没有与用户确认:").format(len(data['gaps'])) + _("、").join(gap["key"] for gap in data["gaps"])]
|
|
913
|
+
return 0, data, lines
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def cmd_validate(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
917
|
+
sops = [ctx.one(ctx.args.sop)] if ctx.args.sop else ctx.sops()
|
|
918
|
+
results = check_all(sops, ctx.defaults, ctx.project)
|
|
919
|
+
lines = []
|
|
920
|
+
for result in results:
|
|
921
|
+
mark = "ok " if result["valid"] else "FAIL"
|
|
922
|
+
lines.append(_("{0} {1}({2})").format(mark, result['id'], result['status']))
|
|
923
|
+
lines += [f" {item['level']}: {item['message']}" for item in result["issues"]]
|
|
924
|
+
bad = [result for result in results if not result["valid"]]
|
|
925
|
+
return (1 if bad else 0), {"ok": not bad, "results": results}, lines or [_("还没有 SOP。")]
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def cmd_interview(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
929
|
+
"""没有 SOP 参数:完整问题清单;给了 SOP:只列还没谈的。编号问题,Agent 应打包成一次提问。"""
|
|
930
|
+
if ctx.args.sop:
|
|
931
|
+
sop = ctx.one(ctx.args.sop)
|
|
932
|
+
open_keys = {gap["key"] for gap in gaps(sop["meta"], ctx.defaults)}
|
|
933
|
+
habit = effective(sop["meta"], ctx.defaults)
|
|
934
|
+
else:
|
|
935
|
+
sop, open_keys, habit = None, {key for key, _unused, _unused in DECISIONS}, effective({}, ctx.defaults)
|
|
936
|
+
items = []
|
|
937
|
+
for number, (key, fields, question) in enumerate(DECISIONS, 1):
|
|
938
|
+
field = DECISION_OPTIONS.get(key)
|
|
939
|
+
current = habit[field] if field else None
|
|
940
|
+
covered = bool(fields) and all(field in ctx.defaults["conventions"] for field in fields)
|
|
941
|
+
if sop is not None and key not in open_keys:
|
|
942
|
+
continue
|
|
943
|
+
items.append({"no": number, "key": key, "question": _(question), "options": options_for(key),
|
|
944
|
+
"current": current["value"] if current else None,
|
|
945
|
+
"project_default": covered, "needs_answer": key in open_keys and not (covered and sop is None)})
|
|
946
|
+
lines = []
|
|
947
|
+
for item in items:
|
|
948
|
+
lines.append(f"{item['no']}. {item['question']}")
|
|
949
|
+
for option in item["options"]:
|
|
950
|
+
lines.append(_(" - {0}:{1}").format(option['value'], option['label']))
|
|
951
|
+
if item["project_default"]:
|
|
952
|
+
lines.append(_(" (本项目习惯已定为 {0};沿用则无需再问)").format(item['current']))
|
|
953
|
+
return (1 if sop is not None and items else 0), {"questions": items, "sop": sop["id"] if sop else None}, \
|
|
954
|
+
lines or [_("该 SOP 的所有事项都已确认。")]
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def cmd_defaults(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
958
|
+
current = ctx.defaults
|
|
959
|
+
updates: dict[str, str] = {}
|
|
960
|
+
for pair in ctx.args.set or []:
|
|
961
|
+
key, separator, value = pair.partition("=")
|
|
962
|
+
if not separator:
|
|
963
|
+
fail(_("--set 需要 键=值:{0}").format(pair))
|
|
964
|
+
check_choice(key.strip(), value.strip())
|
|
965
|
+
updates[key.strip()] = value.strip()
|
|
966
|
+
merged = {**current["conventions"], **updates}
|
|
967
|
+
lines = [f"{key} = {value or _('(Case 的 data/ 目录)')}" for key, value in merged.items()] or [_("本项目还没有记录使用习惯。")]
|
|
968
|
+
if not updates:
|
|
969
|
+
return 0, {"conventions": merged, "decided_at": current["decided_at"]}, lines
|
|
970
|
+
path = defaults_path(ctx.project)
|
|
971
|
+
content = json.dumps({"schema_version": "1.0", "decided_at": today().isoformat(), "conventions": merged},
|
|
972
|
+
ensure_ascii=False, indent=2) + "\n"
|
|
973
|
+
expect = sha(path.read_text(encoding="utf-8")) if path.is_file() else None
|
|
974
|
+
result = plan_result(ctx.project, [{"path": path, "content": content, "expect": expect}], ctx.args.apply,
|
|
975
|
+
{"conventions": merged})
|
|
976
|
+
return 0, result, lines + ["", _("已写入。新 SOP 创建时沿用这些习惯。") if result["applied"] else _("以上为预览,没有写入。确认后加 --apply。")]
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def cmd_preflight(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
980
|
+
sop = ctx.one(ctx.args.sop)
|
|
981
|
+
report = preflight(sop, ctx.project, ctx.defaults)
|
|
982
|
+
lines = [_("{0}@{1} 执行前检查:{2} 项就绪,{3} 项缺失,{4} 项需要核对").format(sop['id'], report['version'], report['counts']['ok'], report['counts']['missing'], report['counts']['unknown']) + (_(",{0} 项条件项待判断").format(report['counts']['conditional']) if report["counts"]["conditional"] else "")]
|
|
983
|
+
for row in report["requires"]:
|
|
984
|
+
tag = _("不需要") if row["optional"] else _("已由替代项解决") if row["resolved_by"] else \
|
|
985
|
+
_("条件项·{0}").format({'missing': _('缺失'), 'unknown': _('待核对')}[row['status']]) if row["when"] and row["status"] != "ok" else \
|
|
986
|
+
{"ok": _("就绪"), "missing": _("缺失"), "unknown": _("待核对")}[row["status"]]
|
|
987
|
+
lines.append(_(" [{0}] {1}:{2}").format(tag, row['label'], row['name'])
|
|
988
|
+
+ (_("({0})").format(row['purpose']) if row["purpose"] else "") + (f" — {row['detail']}" if row["status"] != "ok" else "")
|
|
989
|
+
+ (_("|仅当:{0}").format(row['when']) if row["when"] else ""))
|
|
990
|
+
if row["status"] != "ok" and not row["optional"] and not row["resolved_by"]:
|
|
991
|
+
if row["install"]:
|
|
992
|
+
lines.append(_(" 安装/获取方式:{0}").format(row['install']))
|
|
993
|
+
if row["alternative"]:
|
|
994
|
+
lines.append(_(" 替代方案:{0}").format(row['alternative']))
|
|
995
|
+
lines.append(_(" 缺失时的约定:{0}").format(row['if_missing']))
|
|
996
|
+
if report["counts"]["conditional"]:
|
|
997
|
+
lines.append(_("其中 {0} 项是条件项:只有本次输入符合“仅当”的描述才需要,请对照本次输入判断;符合就按缺失处理。").format(report['counts']['conditional']))
|
|
998
|
+
if not report["ready"]:
|
|
999
|
+
lines.append(_("需要先处理缺失与待核对项;不要在用户未同意时自行安装软件。"))
|
|
1000
|
+
return (0 if report["ready"] else 1), report, lines
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def template_source(name: str, language: str = "zh") -> tuple[str, dict[str, str]]:
|
|
1004
|
+
"""The sample's SOP.md and its extra files in the given language.
|
|
1005
|
+
|
|
1006
|
+
``SOP.en.md`` and ``<name>.en.<ext>`` are the English variants of ``SOP.md`` and ``<name>.<ext>``; a sample
|
|
1007
|
+
without an English variant falls back to the Chinese one."""
|
|
1008
|
+
base = TEMPLATE_DIR / name
|
|
1009
|
+
if not ID_RE.fullmatch(name) or not (base / "SOP.md").is_file():
|
|
1010
|
+
names = sorted(path.name for path in TEMPLATE_DIR.iterdir() if (path / "SOP.md").is_file()) if TEMPLATE_DIR.is_dir() else []
|
|
1011
|
+
fail(_("没有这个样例:{0}").format(name), "not-found", [_("可用:") + _("、").join(names)])
|
|
1012
|
+
english = language == "en"
|
|
1013
|
+
|
|
1014
|
+
def variant(relative: Path) -> Path:
|
|
1015
|
+
if not english:
|
|
1016
|
+
return relative
|
|
1017
|
+
candidate = relative.with_name(relative.stem + ".en" + relative.suffix)
|
|
1018
|
+
return candidate if (base / candidate).is_file() else relative
|
|
1019
|
+
|
|
1020
|
+
extra = {}
|
|
1021
|
+
for path in sorted(base.rglob("*")):
|
|
1022
|
+
relative = path.relative_to(base)
|
|
1023
|
+
if not path.is_file() or relative.name in ("SOP.md", "SOP.en.md") or relative.stem.endswith(".en"):
|
|
1024
|
+
continue
|
|
1025
|
+
extra[relative.as_posix()] = (base / variant(relative)).read_text(encoding="utf-8")
|
|
1026
|
+
return (base / variant(Path("SOP.md"))).read_text(encoding="utf-8"), extra
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def cmd_new(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1030
|
+
args = ctx.args
|
|
1031
|
+
slug = (args.slug or "").strip()
|
|
1032
|
+
if not ID_RE.fullmatch(slug):
|
|
1033
|
+
fail(_("--slug 只能用小写字母、数字和连字符,例如 weekly-review"))
|
|
1034
|
+
root = user_root() if args.scope == "user" else project_root(ctx.project)
|
|
1035
|
+
target = root / slug
|
|
1036
|
+
if target.exists():
|
|
1037
|
+
fail(_("已存在:{0}").format(display(target, ctx.project)), "conflict")
|
|
1038
|
+
extra: dict[str, str] = {}
|
|
1039
|
+
stamp = today().isoformat()
|
|
1040
|
+
language = args.language or ctx.language
|
|
1041
|
+
if args.from_file:
|
|
1042
|
+
text = Path(args.from_file).expanduser().read_text(encoding="utf-8")
|
|
1043
|
+
meta, body = parse_sop_text(text)
|
|
1044
|
+
elif args.from_case:
|
|
1045
|
+
meta, body = parse_sop_text(template_source("blank", language)[0])
|
|
1046
|
+
meta, body = from_case(ctx, args.from_case, meta, body, language)
|
|
1047
|
+
else:
|
|
1048
|
+
text, extra = template_source(args.template, language)
|
|
1049
|
+
meta, body = parse_sop_text(text)
|
|
1050
|
+
previous = str(meta.get("title") or "")
|
|
1051
|
+
title = args.title or (previous if previous and "{{" not in previous else slug)
|
|
1052
|
+
meta.update({"schema_version": "1.0", "id": slug, "title": title, "status": "draft", "created": stamp,
|
|
1053
|
+
"version": str(meta.get("version") or "1.0.0")})
|
|
1054
|
+
meta.pop("verified_at", None)
|
|
1055
|
+
meta["decided"] = []
|
|
1056
|
+
meta.setdefault("origin", f"template:{args.template}" if not (args.from_file or args.from_case) else "")
|
|
1057
|
+
def fill(value: str) -> str:
|
|
1058
|
+
return value.replace("{{title}}", title).replace("{{id}}", slug).replace("{{date}}", stamp)
|
|
1059
|
+
|
|
1060
|
+
body = fill(body)
|
|
1061
|
+
text = render_sop(meta, body)
|
|
1062
|
+
writes = [{"path": target / "SOP.md", "content": text, "expect": None}]
|
|
1063
|
+
writes += [{"path": target / relative, "content": fill(content), "expect": None} for relative, content in extra.items()]
|
|
1064
|
+
issues = check_sop({"id": slug, "scope": args.scope, "dir": target, "path": target / "SOP.md", "meta": meta,
|
|
1065
|
+
"body": body, "text": text, "error": None, "shadowed": False}, ctx.defaults, strict=False, default_language=language)
|
|
1066
|
+
result = plan_result(ctx.project, writes, args.apply, {"id": slug, "status": "draft", "issues": issues,
|
|
1067
|
+
"next": [_("sop.py interview {0}(谈清 {1} 项)").format(slug, len(gaps(meta, ctx.defaults))),
|
|
1068
|
+
_("填完后 sop.py validate,再 sop.py activate")]})
|
|
1069
|
+
lines = [_("草稿 {0}({1} 级)→ {2}").format(slug, args.scope, display(target / 'SOP.md', ctx.project))] + \
|
|
1070
|
+
[f" {item['level']}: {item['message']}" for item in issues] + \
|
|
1071
|
+
["", result.get("verify") or _("以上为预览,没有写入。确认后加 --apply。草稿状态不会被触发。")]
|
|
1072
|
+
return 0, result, lines
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def from_case(ctx: Context, token: str, meta: dict, body: str, language: str) -> tuple[dict, str]:
|
|
1076
|
+
try:
|
|
1077
|
+
cases = case_tool.Project(ctx.project)
|
|
1078
|
+
directory = cases.find(token)
|
|
1079
|
+
data, case_body = case_tool.read_case(directory / "CASE.md")
|
|
1080
|
+
except case_tool.CaseError as exc:
|
|
1081
|
+
raise SopError(_("读不了 Case:{0}").format(exc), "unavailable") from exc
|
|
1082
|
+
heading = case_structure.existing_heading(case_body, "work_log") or case_structure.heading("work_log", language)
|
|
1083
|
+
marker = f"## {heading}\n"
|
|
1084
|
+
start = case_body.find(marker)
|
|
1085
|
+
end = case_body.find("\n## ", start + len(marker)) if start >= 0 else -1
|
|
1086
|
+
created = vocab.TEXTS["case_created_marker"]
|
|
1087
|
+
records = [re.sub(r"^\d{4}-\d{2}-\d{2}T\S+\s+", "", line[2:].strip()) for line in
|
|
1088
|
+
(case_body[start + len(marker):None if end < 0 else end].splitlines() if start >= 0 else []) if line.startswith("- ")]
|
|
1089
|
+
placeholder = vocab.PLACEHOLDERS[vocab.LANGUAGES.index(language)]
|
|
1090
|
+
steps = [vocab.text("from_case_step", language, index=index, text=text, p=placeholder, case=data.get("id"))
|
|
1091
|
+
for index, text in enumerate([r for r in records if not any(item in r for item in created)], 1)]
|
|
1092
|
+
meta["description"] = vocab.text("from_case_description", language, p=placeholder, case=data.get("id"))
|
|
1093
|
+
meta["sources"] = [cases.relative(directory / "CASE.md")]
|
|
1094
|
+
meta["origin"] = f"case:{data.get('id')}"
|
|
1095
|
+
sections = split_sections(body)
|
|
1096
|
+
chosen = {key: vocab.section_text(sections, key) or placeholder for key in vocab.SECTIONS}
|
|
1097
|
+
chosen["steps"] = "\n".join(steps) or f"1. {placeholder}"
|
|
1098
|
+
return meta, "# {{title}}\n\n" + "".join(f"## {vocab.name(vocab.SECTIONS, key, language)}\n\n{chosen[key]}\n\n" for key in vocab.SECTIONS)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def set_status_plan(ctx: Context, sop: dict, status: str, note: str) -> dict:
|
|
1102
|
+
meta = dict(sop["meta"])
|
|
1103
|
+
meta["status"] = status
|
|
1104
|
+
if status == "active":
|
|
1105
|
+
meta["verified_at"] = today().isoformat()
|
|
1106
|
+
body = append_revision(sop["body"], revision_line(sop["body"], str(meta["version"]), note, ctx.language), ctx.language)
|
|
1107
|
+
text = render_sop(meta, body)
|
|
1108
|
+
writes = [{"path": sop["path"], "content": text, "expect": sha(sop["text"])}]
|
|
1109
|
+
snapshot = snapshot_write(sop["dir"], str(meta["version"]), text)
|
|
1110
|
+
if snapshot and status == "active":
|
|
1111
|
+
writes.append(snapshot)
|
|
1112
|
+
return {"writes": writes, "meta": meta, "text": text}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def cmd_activate(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1116
|
+
sop = ctx.one(ctx.args.sop)
|
|
1117
|
+
if sop["error"]:
|
|
1118
|
+
fail(_("无法读取:{0}").format(sop['error']), "invalid")
|
|
1119
|
+
if sop["meta"].get("status") == "active":
|
|
1120
|
+
fail(_("已经是 active"), "conflict")
|
|
1121
|
+
probe = dict(sop, meta={**sop["meta"], "status": "active"})
|
|
1122
|
+
blockers = [item for item in check_sop(probe, ctx.defaults, strict=True) if item["level"] == "error"]
|
|
1123
|
+
if blockers:
|
|
1124
|
+
lines = [_("{0} 还不能启用:").format(sop['id'])] + [f" - {item['message']}" for item in blockers]
|
|
1125
|
+
return 1, {"id": sop["id"], "applied": False, "blockers": blockers}, lines
|
|
1126
|
+
plan = set_status_plan(ctx, sop, "active", vocab.text("note_activated", vocab.detect(sop["body"], ctx.language)))
|
|
1127
|
+
result = plan_result(ctx.project, plan["writes"], ctx.args.apply, {"id": sop["id"], "status": "active"})
|
|
1128
|
+
return 0, result, [f"{_('已启用') if result['applied'] else _('将启用')} {sop['id']}@{sop['meta']['version']}"] + \
|
|
1129
|
+
([] if result["applied"] else [_("以上为预览,没有写入。确认后加 --apply。")])
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def cmd_retire(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1133
|
+
sop = ctx.one(ctx.args.sop)
|
|
1134
|
+
target = "retired" if ctx.args.command == "retire" else "active"
|
|
1135
|
+
if sop["meta"].get("status") == target:
|
|
1136
|
+
fail(_("已经是 {0}").format(target), "conflict")
|
|
1137
|
+
if target == "active":
|
|
1138
|
+
probe = dict(sop, meta={**sop["meta"], "status": "active"})
|
|
1139
|
+
blockers = [item for item in check_sop(probe, ctx.defaults, strict=True) if item["level"] == "error"]
|
|
1140
|
+
if blockers:
|
|
1141
|
+
return 1, {"id": sop["id"], "applied": False, "blockers": blockers}, \
|
|
1142
|
+
[_("{0} 不能恢复为 active:").format(sop['id'])] + [f" - {item['message']}" for item in blockers]
|
|
1143
|
+
language = vocab.detect(sop["body"], ctx.language)
|
|
1144
|
+
plan = set_status_plan(ctx, sop, target, ctx.args.note or vocab.text("note_retired" if target == "retired" else "note_restored", language))
|
|
1145
|
+
result = plan_result(ctx.project, plan["writes"], ctx.args.apply, {"id": sop["id"], "status": target})
|
|
1146
|
+
return 0, result, [(_("已退役 {0}") if result["applied"] else _("将退役 {0}")).format(sop["id"]) if target == "retired"
|
|
1147
|
+
else (_("已恢复 {0}") if result["applied"] else _("将恢复 {0}")).format(sop["id"])] + \
|
|
1148
|
+
([] if result["applied"] else [_("以上为预览,没有写入。确认后加 --apply。")])
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def cmd_revise(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1152
|
+
"""你改完 SOP.md 之后,用它登记新版本:升版本号、写修订记录、存当前内容的快照。"""
|
|
1153
|
+
sop = ctx.one(ctx.args.sop)
|
|
1154
|
+
if sop["error"]:
|
|
1155
|
+
fail(_("无法读取:{0}").format(sop['error']), "invalid")
|
|
1156
|
+
if not ctx.args.note:
|
|
1157
|
+
fail(_("revise 需要 --note 说明这次改了什么、为什么"))
|
|
1158
|
+
version = bump(str(sop["meta"]["version"]), ctx.args.bump)
|
|
1159
|
+
meta = {**sop["meta"], "version": version, "verified_at": today().isoformat()}
|
|
1160
|
+
body = append_revision(sop["body"], revision_line(sop["body"], version, ctx.args.note, ctx.language), ctx.language)
|
|
1161
|
+
text = render_sop(meta, body)
|
|
1162
|
+
writes = [{"path": sop["path"], "content": text, "expect": sha(sop["text"])}]
|
|
1163
|
+
snapshot = snapshot_write(sop["dir"], version, text)
|
|
1164
|
+
if snapshot:
|
|
1165
|
+
writes.append(snapshot)
|
|
1166
|
+
old_snapshot = sop["dir"] / SNAPSHOTS / f"{sop['meta']['version']}.md"
|
|
1167
|
+
if not old_snapshot.exists() and sop["meta"].get("status") == "active":
|
|
1168
|
+
writes.append({"path": old_snapshot, "content": sop["text"], "expect": None})
|
|
1169
|
+
result = plan_result(ctx.project, writes, ctx.args.apply, {"id": sop["id"], "from": sop["meta"]["version"], "to": version})
|
|
1170
|
+
return 0, result, [_("{0} {1} → {2}({3}):{4}").format(sop['id'], sop['meta']['version'], version, ctx.args.bump, ctx.args.note)] + \
|
|
1171
|
+
([result["verify"]] if result["applied"] else [_("以上为预览,没有写入。确认后加 --apply。")])
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def cmd_decide(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1175
|
+
"""记录用户已经确认的事项:--confirm trigger / requires 表示该部分内容用户看过并认可;--set 键=值 同时视为确认对应事项。"""
|
|
1176
|
+
sop = ctx.one(ctx.args.sop)
|
|
1177
|
+
if sop["error"]:
|
|
1178
|
+
fail(_("无法读取:{0}").format(sop['error']), "invalid")
|
|
1179
|
+
meta = dict(sop["meta"])
|
|
1180
|
+
decided = list(meta.get("decided") or [])
|
|
1181
|
+
valid = {key for key, _unused, _unused in DECISIONS}
|
|
1182
|
+
for key in ctx.args.confirm or []:
|
|
1183
|
+
if key not in valid:
|
|
1184
|
+
fail(_("--confirm 只能是:{0}").format(_('、').join(sorted(valid))))
|
|
1185
|
+
decided.append(key)
|
|
1186
|
+
for pair in ctx.args.set or []:
|
|
1187
|
+
key, separator, value = pair.partition("=")
|
|
1188
|
+
if not separator:
|
|
1189
|
+
fail(_("--set 需要 键=值:{0}").format(pair))
|
|
1190
|
+
check_choice(key.strip(), value.strip())
|
|
1191
|
+
meta[key.strip()] = value.strip()
|
|
1192
|
+
if key.strip() in FIELD_DECISION:
|
|
1193
|
+
decided.append(FIELD_DECISION[key.strip()])
|
|
1194
|
+
meta["decided"] = sorted(set(decided), key=[key for key, _unused, _unused in DECISIONS].index)
|
|
1195
|
+
text = render_sop(meta, sop["body"])
|
|
1196
|
+
remaining = [gap["key"] for gap in gaps(meta, ctx.defaults)]
|
|
1197
|
+
result = plan_result(ctx.project, [{"path": sop["path"], "content": text, "expect": sha(sop["text"])}], ctx.args.apply,
|
|
1198
|
+
{"id": sop["id"], "decided": meta["decided"], "remaining": remaining})
|
|
1199
|
+
return 0, result, [_("{0} 已确认:{1};还差:{2}").format(sop['id'], _('、').join(meta['decided']) or _('(无)'), _('、').join(remaining) or _('无'))] + \
|
|
1200
|
+
([result["verify"]] if result["applied"] else [_("以上为预览,没有写入。确认后加 --apply。")])
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def cmd_match(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1204
|
+
found = match_sops(ctx.sops(), " ".join(ctx.args.text))
|
|
1205
|
+
lines = [_("{0}@{1}({2})").format(item['id'], item['version'], _(';').join(item['reasons']))
|
|
1206
|
+
+ (_(" 注意不触发条件:{0}").format(item['exclude']) if item["exclude"] else "") for item in found]
|
|
1207
|
+
return (0 if found else 1), {"matches": found, "note": _("仅为关键词预筛;是否真的触发由 Agent 对照 trigger / exclude 判断,默认先问用户。")}, \
|
|
1208
|
+
lines or [_("没有关键词命中的 SOP(语义上是否相关仍由 Agent 判断)。")]
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def cmd_due(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1212
|
+
runs, problem = last_runs(ctx.project)
|
|
1213
|
+
now = today()
|
|
1214
|
+
rows = [info for sop in ctx.sops() if not sop["shadowed"] and (info := due_info(sop, runs, now))]
|
|
1215
|
+
due = [row for row in rows if row["due"]]
|
|
1216
|
+
lines = [_("{0} {1}({2},{3}):上次 {4},下次 {5}").format(_('已到期') if row['due'] else _('未到期'), row['id'], row['title'], row['schedule'], row['last_run'] or _('从未执行'), row['next']) + (_(",已超 {0} 天").format(row['overdue_days']) if row["overdue_days"] else "")
|
|
1217
|
+
for row in rows]
|
|
1218
|
+
if problem:
|
|
1219
|
+
lines.append(_("注意:读不到执行记录({0}),按 SOP 创建日期计算。").format(problem))
|
|
1220
|
+
return 0, {"due": due, "schedules": rows, "today": now.isoformat(), "problem": problem}, lines or [_("没有设置周期的 active SOP。")]
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def cmd_events(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1224
|
+
if ctx.args.event not in EVENTS:
|
|
1225
|
+
fail(_("事件只支持:{0}").format(_('、').join(EVENTS)))
|
|
1226
|
+
if ctx.args.case:
|
|
1227
|
+
data = read_event_case(ctx, ctx.args.case)
|
|
1228
|
+
hints = event_hints(ctx.project, ctx.args.event, data, ctx.sops())
|
|
1229
|
+
note = _("这个 Case 本身是 SOP 的一次执行,不再触发事件。") if data.get("sop") else ""
|
|
1230
|
+
lines = [_("{0}:{1}(运行:{2})").format(hint['id'], hint['trigger'], hint['command']) for hint in hints]
|
|
1231
|
+
return 0, {"event": ctx.args.event, "case": data.get("id"), "sops": hints, "note": note}, \
|
|
1232
|
+
lines or [note or _("没有 SOP 对这个 Case 的该事件感兴趣。")]
|
|
1233
|
+
found = [sop for sop in ctx.sops() if sop["meta"].get("status") == "active" and not sop["shadowed"]
|
|
1234
|
+
and ctx.args.event in (sop["meta"].get("on_events") or [])]
|
|
1235
|
+
rows = [{"id": sop["id"], "title": sop["meta"].get("title", ""), "trigger": sop["meta"].get("trigger", ""),
|
|
1236
|
+
"event_tags": sop["meta"].get("event_tags") or []} for sop in found]
|
|
1237
|
+
return 0, {"event": ctx.args.event, "sops": rows}, \
|
|
1238
|
+
[_("{0}:{1}").format(row['id'], row['trigger']) + (_("(仅标签 {0})").format(_('、').join(row['event_tags'])) if row["event_tags"] else "")
|
|
1239
|
+
for row in rows] or [_("没有 SOP 监听这个事件。")]
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def read_event_case(ctx: Context, token: str) -> dict:
|
|
1243
|
+
try:
|
|
1244
|
+
cases = case_tool.Project(ctx.project)
|
|
1245
|
+
data, _unused = case_tool.read_case(cases.find(token) / "CASE.md")
|
|
1246
|
+
except case_tool.CaseError as exc:
|
|
1247
|
+
raise SopError(_("读不了 Case:{0}").format(exc), "not-found") from exc
|
|
1248
|
+
return data
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
# ---------------------------------------------------------------- 执行:一次执行 = 一个 Case
|
|
1252
|
+
|
|
1253
|
+
def checklist_text(sop: dict, info: dict, habit: dict, params: list[str], runtime_note: str,
|
|
1254
|
+
private_hits: list[str] | None = None, language: str = "zh") -> str:
|
|
1255
|
+
def t(key: str, **fields: object) -> str:
|
|
1256
|
+
return vocab.text(key, language, **fields)
|
|
1257
|
+
|
|
1258
|
+
version = sop["meta"]["version"]
|
|
1259
|
+
keep = habit["artifact_keep"]["value"]
|
|
1260
|
+
place = habit["artifact_dir"]["value"] or t("cl_data_dir")
|
|
1261
|
+
joiner = t("list_join")
|
|
1262
|
+
lines = ["## " + vocab.name(vocab.CASE_SECTIONS, "checklist", language), "", t("cl_source", id=sop["id"], version=version, note=runtime_note), "",
|
|
1263
|
+
t("cl_conventions"),
|
|
1264
|
+
t("cl_on_error") + habit["on_error"]["value"] + (t("cl_retries", n=habit["max_retries"]["value"]) if habit["on_error"]["value"] == "retry" else ""),
|
|
1265
|
+
t("cl_on_missing") + habit["on_missing_tool"]["value"], t("cl_artifacts_label") + t("cl_artifacts", place=place, keep=keep),
|
|
1266
|
+
t("cl_resume") + habit["resume"]["value"], t("cl_costly") + habit["confirm_costly"]["value"]]
|
|
1267
|
+
if private_hits:
|
|
1268
|
+
lines.append(t("cl_private", dirs=joiner.join(private_hits)))
|
|
1269
|
+
lines.append("")
|
|
1270
|
+
if params:
|
|
1271
|
+
lines += [t("cl_params")] + [f"- {item}" for item in params] + [""]
|
|
1272
|
+
lines += [t("cl_steps")] + [f"- [ ] {step['n']}. {step['title']}" + (t("cl_tools", tools=joiner.join(step["tools"])) if step["tools"] else "")
|
|
1273
|
+
for step in info["steps"]]
|
|
1274
|
+
return "\n".join(lines)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def insert_before(body: str, heading: str, text: str) -> str:
|
|
1278
|
+
"""Insert ``text`` before the section whose logical key (or heading) is ``heading``, in either language."""
|
|
1279
|
+
try:
|
|
1280
|
+
key = case_structure.key_of(heading)
|
|
1281
|
+
except KeyError:
|
|
1282
|
+
key = None
|
|
1283
|
+
found = case_structure.existing_heading(body, key) if key else None
|
|
1284
|
+
marker = f"## {found or heading}\n"
|
|
1285
|
+
position = body.find(marker)
|
|
1286
|
+
block = text.rstrip("\n") + "\n\n"
|
|
1287
|
+
return body.rstrip("\n") + "\n\n" + block if position < 0 else body[:position] + block + body[position:]
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def cmd_run(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1291
|
+
sop = ctx.one(ctx.args.sop)
|
|
1292
|
+
status = sop["meta"].get("status")
|
|
1293
|
+
if sop["error"]:
|
|
1294
|
+
fail(_("无法读取:{0}").format(sop['error']), "invalid")
|
|
1295
|
+
if status != "active" and not ctx.args.trial:
|
|
1296
|
+
fail(_("{0} 是 {1},不能正式执行").format(sop['id'], status), "conflict", [_("草稿试跑请加 --trial(Case 会带 sop-trial 标签)"), _("正式使用先 sop.py activate")])
|
|
1297
|
+
if sop["meta"].get("creates_case") == "never":
|
|
1298
|
+
fail(_("这个 SOP 声明了 creates_case=never,没有执行记录可建"), "conflict")
|
|
1299
|
+
info = parsed(sop, ctx.language)
|
|
1300
|
+
if not info["steps"]:
|
|
1301
|
+
fail(_("SOP 没有可执行的步骤"), "invalid")
|
|
1302
|
+
language = ctx.language # 执行 Case 用项目语言写;SOP 文件自己的语言只影响怎么解析它
|
|
1303
|
+
habit = effective(sop["meta"], ctx.defaults)
|
|
1304
|
+
report = preflight(sop, ctx.project, ctx.defaults)
|
|
1305
|
+
private_dirs = private_paths.declared(ctx.project)
|
|
1306
|
+
private_hits = private_paths.mentions(sop["text"], private_dirs)
|
|
1307
|
+
private_warnings = [_("{0} 尚未被 git 忽略:先运行 sop private --apply").format(item)
|
|
1308
|
+
for item in private_hits if private_paths.git_state(ctx.project, item)["ignored"] is False]
|
|
1309
|
+
version = str(sop["meta"]["version"])
|
|
1310
|
+
existing = sop["dir"] / SNAPSHOTS / f"{version}.md"
|
|
1311
|
+
if status == "active" and existing.is_file() and existing.read_text(encoding="utf-8") != sop["text"]:
|
|
1312
|
+
fail(_("{0}@{1} 的内容比版本快照新,但版本号没升").format(sop['id'], version), "conflict",
|
|
1313
|
+
[_("先 sop.py revise {0} --bump patch --note …,再执行").format(sop['id'])])
|
|
1314
|
+
snapshot = snapshot_write(sop["dir"], version, sop["text"])
|
|
1315
|
+
note = vocab.text("snapshot_note", ctx.language, path=display(sop["dir"] / SNAPSHOTS / (version + ".md"), ctx.project))
|
|
1316
|
+
event_note = ""
|
|
1317
|
+
params = list(ctx.args.param or [])
|
|
1318
|
+
if ctx.args.event:
|
|
1319
|
+
if ctx.args.event not in (sop["meta"].get("on_events") or []):
|
|
1320
|
+
fail(_("{0} 没有声明监听 {1}").format(sop['id'], ctx.args.event), "conflict", [_("在 SOP 的 on_events 里声明后用 sop.py revise 登记")])
|
|
1321
|
+
if not ctx.args.event_case:
|
|
1322
|
+
fail(_("--event 需要同时给出 --event-case(触发它的 Case)"))
|
|
1323
|
+
trigger = read_event_case(ctx, ctx.args.event_case)
|
|
1324
|
+
if trigger.get("sop"):
|
|
1325
|
+
fail(_("SOP 自己的执行 Case 不能再触发事件"), "conflict")
|
|
1326
|
+
event_note = vocab.text("event_note", language, event=ctx.args.event, id=trigger.get("id"))
|
|
1327
|
+
params.insert(0, vocab.text("cl_event_param", language, event=ctx.args.event, id=trigger.get("id"), title=trigger.get("title", "")))
|
|
1328
|
+
goal = (vocab.section_text(info["sections"], "purpose").strip().splitlines() or [sop["meta"].get("title", sop["id"])])[0]
|
|
1329
|
+
done_when = vocab.text("list_join", language).replace("、", ";").replace(", ", "; ").join(
|
|
1330
|
+
line.strip("- ").strip() for line in vocab.section_text(info["sections"], "done").splitlines() if line.strip())[:300]
|
|
1331
|
+
title = ctx.args.title or f"{sop['meta'].get('title', sop['id'])} {today().isoformat()}"
|
|
1332
|
+
case_args = argparse.Namespace(title=title, slug=sop["id"], goal=goal, next_action=vocab.text("next_first", language, title=info["steps"][0]["title"]),
|
|
1333
|
+
status="active", done_when=done_when,
|
|
1334
|
+
source=f"SOP {sop['id']}@{version}" + (vocab.text("source_event", language, note=event_note) if event_note else ""),
|
|
1335
|
+
current_state=vocab.text("progress0", language, total=len(info["steps"])),
|
|
1336
|
+
tag=["sop", *(["sop-trial"] if status != "active" else [])], sop=f"{sop['id']}@{version}",
|
|
1337
|
+
sensitivity="private" if private_hits else "shared")
|
|
1338
|
+
try:
|
|
1339
|
+
cases = case_tool.Project(ctx.project)
|
|
1340
|
+
case_id, case_text = case_tool.plan_create(cases, case_args)
|
|
1341
|
+
except case_tool.CaseError as exc:
|
|
1342
|
+
raise SopError(_("不能建立执行记录:{0}").format(exc), "unavailable", [_("执行记录需要项目启用 case-workspace(Profile work.mode)")]) from exc
|
|
1343
|
+
section = checklist_text(sop, info, habit, params, note, private_hits, language)
|
|
1344
|
+
preview = {"case_id": case_id, "title": title, "steps": [step["title"] for step in info["steps"]],
|
|
1345
|
+
"preflight": {"ready": report["ready"], "counts": report["counts"]}, "snapshot": bool(snapshot),
|
|
1346
|
+
"checklist": section, "private": private_hits, "private_warnings": private_warnings}
|
|
1347
|
+
lines = [_("将建立 Case:{0}({1})").format(case_id, title), "", section, "", _("中间产物:执行中用 sop.py step … --artifact 路径:说明 登记。"),
|
|
1348
|
+
_("执行前检查:{0} 项就绪,{1} 项缺失,{2} 项待核对").format(report['counts']['ok'], report['counts']['missing'], report['counts']['unknown'])
|
|
1349
|
+
+ ("" if report["ready"] else _("(先处理:sop.py preflight ") + sop["id"] + _(")"))]
|
|
1350
|
+
if private_hits:
|
|
1351
|
+
lines.append(_("这个流程会用到私有目录 {0}:执行 Case 将标为 private,读取前须你逐次确认。").format(_('、').join(private_hits)))
|
|
1352
|
+
lines += [_("注意:{0}").format(item) for item in private_warnings]
|
|
1353
|
+
if not ctx.args.apply:
|
|
1354
|
+
return 0, {"applied": False, **preview}, lines + [_("以上为预览,没有写入。确认后加 --apply。")]
|
|
1355
|
+
if snapshot:
|
|
1356
|
+
apply_writes([snapshot])
|
|
1357
|
+
created = case_tool.create(cases, case_args)
|
|
1358
|
+
path = ctx.project / created["path"]
|
|
1359
|
+
data, body = case_tool.read_case(path)
|
|
1360
|
+
intermediate = vocab.name(vocab.CASE_SECTIONS, "intermediate", language)
|
|
1361
|
+
body = insert_before(body, "blockers", section + f"\n\n## {intermediate}\n\n" + vocab.text("empty_intermediates", language))
|
|
1362
|
+
body = case_tool.append_section(body, "work_log", vocab.text(
|
|
1363
|
+
"log_started", language, stamp=case_tool.now(), id=sop["id"], version=version,
|
|
1364
|
+
event=vocab.text("log_started_event", language, note=event_note) if event_note else "",
|
|
1365
|
+
ok=report["counts"]["ok"], missing=report["counts"]["missing"], unknown=report["counts"]["unknown"]), language)
|
|
1366
|
+
case_tool.write_atomic(path, case_tool.render_frontmatter(data) + body)
|
|
1367
|
+
return 0, {"applied": True, **preview, "case_id": created["id"], "path": created["path"]}, \
|
|
1368
|
+
lines + [_("已建立:{0}").format(created['path'])]
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def read_run(ctx: Context, token: str) -> tuple[Path, dict, str, list[dict]]:
|
|
1372
|
+
try:
|
|
1373
|
+
cases = case_tool.Project(ctx.project)
|
|
1374
|
+
path = cases.find(token) / "CASE.md"
|
|
1375
|
+
data, body = case_tool.read_case(path)
|
|
1376
|
+
except case_tool.CaseError as exc:
|
|
1377
|
+
raise SopError(str(exc), "not-found") from exc
|
|
1378
|
+
sections = split_sections(body)
|
|
1379
|
+
section = next((sections[name] for name in vocab.checklist_headings() if name in sections), "")
|
|
1380
|
+
steps = []
|
|
1381
|
+
for line in section.splitlines():
|
|
1382
|
+
match = CHECK_RE.match(line)
|
|
1383
|
+
if match:
|
|
1384
|
+
steps.append({"n": int(match.group(2)), "mark": match.group(1), "state": MARKS[match.group(1)], "text": match.group(3)})
|
|
1385
|
+
if not steps:
|
|
1386
|
+
fail(_("{0} 不是 SOP 执行记录(没有「SOP 执行清单」)").format(data.get('id', token)), "not-found")
|
|
1387
|
+
return path, data, body, steps
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def case_artifacts_heading(body: str, language: str) -> str:
|
|
1391
|
+
"""The heading of the intermediate-artifacts section in a run Case, as the Case itself spells it."""
|
|
1392
|
+
for name in vocab.CASE_SECTIONS["intermediate"]:
|
|
1393
|
+
if f"## {name}\n" in body or body.rstrip().endswith(f"## {name}"):
|
|
1394
|
+
return name
|
|
1395
|
+
return vocab.name(vocab.CASE_SECTIONS, "intermediate", language)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def section_value(body: str, text_key: str) -> str:
|
|
1399
|
+
"""The value of a ``- label: value`` line in the run checklist, whichever language the Case is written in."""
|
|
1400
|
+
sections = split_sections(body)
|
|
1401
|
+
section = next((sections[name] for name in vocab.checklist_headings() if name in sections), "")
|
|
1402
|
+
for line in section.splitlines():
|
|
1403
|
+
for prefix in vocab.prefixes(text_key):
|
|
1404
|
+
if line.startswith(prefix):
|
|
1405
|
+
return line[len(prefix):].strip()
|
|
1406
|
+
return ""
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def cmd_step(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1410
|
+
args = ctx.args
|
|
1411
|
+
path, data, body, steps = read_run(ctx, args.case)
|
|
1412
|
+
language = vocab.detect_case(body, ctx.language)
|
|
1413
|
+
current = path.read_text(encoding="utf-8")
|
|
1414
|
+
if args.expect_sha256 and sha(current) != args.expect_sha256:
|
|
1415
|
+
fail(_("Case 在预览之后被修改过,请重新读取"), "conflict")
|
|
1416
|
+
wanted = next((step for step in steps if step["n"] == args.n), None)
|
|
1417
|
+
if wanted is None:
|
|
1418
|
+
fail(_("没有步骤 {0}(共 {1} 步)").format(args.n, len(steps)), "not-found")
|
|
1419
|
+
flags = [name for name in ("done", "skip", "fail", "pending") if getattr(args, name)]
|
|
1420
|
+
if len(flags) != 1:
|
|
1421
|
+
fail(_("--done / --skip / --fail / --pending 必须且只能给一个"))
|
|
1422
|
+
state = flags[0]
|
|
1423
|
+
if state in ("skip", "fail") and not (args.note or "").strip():
|
|
1424
|
+
fail(_("--skip 和 --fail 必须用 --note 写明原因"))
|
|
1425
|
+
mark = {"done": "x", "skip": "-", "fail": "!", "pending": " "}[state]
|
|
1426
|
+
label = vocab.text("state_" + state, language)
|
|
1427
|
+
shown = {"done": _("完成"), "skip": _("跳过"), "fail": _("失败"), "pending": _("重置为待办")}[state]
|
|
1428
|
+
body = body.replace(f"- [{wanted['mark']}] {wanted['n']}. {wanted['text']}", f"- [{mark}] {wanted['n']}. {wanted['text']}", 1)
|
|
1429
|
+
stamp = case_tool.now()
|
|
1430
|
+
note = vocab.text("note_join", language, note=args.note.strip()) if args.note else ""
|
|
1431
|
+
body = case_tool.append_section(body, "work_log", vocab.text(
|
|
1432
|
+
"log_step", language, stamp=stamp, n=args.n, title=vocab.strip_tools(wanted["text"]), label=label, note=note), language)
|
|
1433
|
+
intermediate = case_artifacts_heading(body, language)
|
|
1434
|
+
for item in args.artifact or []:
|
|
1435
|
+
location, _unused, what = item.partition(":")
|
|
1436
|
+
body = case_tool.append_section(body, intermediate, vocab.text("artifact_line", language, n=args.n, location=location.strip())
|
|
1437
|
+
+ (vocab.text("artifact_what", language, what=what.strip()) if what.strip() else ""), language)
|
|
1438
|
+
states = {step["n"]: step["mark"] for step in steps}
|
|
1439
|
+
states[args.n] = mark
|
|
1440
|
+
pending = [step for step in steps if states[step["n"]] == " "]
|
|
1441
|
+
failed = [step for step in steps if states[step["n"]] == "!"]
|
|
1442
|
+
habit = section_value(body, "cl_on_error") or "ask"
|
|
1443
|
+
if failed:
|
|
1444
|
+
next_action = vocab.text("next_failed", language, n=failed[0]["n"], habit=habit) \
|
|
1445
|
+
+ (vocab.text("note_join", language, note=args.note.strip()) if args.note and state == "fail" else "")
|
|
1446
|
+
elif pending:
|
|
1447
|
+
next_action = vocab.text("next_step", language, n=pending[0]["n"], title=vocab.strip_tools(pending[0]["text"]))
|
|
1448
|
+
else:
|
|
1449
|
+
next_action = vocab.text("next_done", language)
|
|
1450
|
+
finished = sum(1 for key in states.values() if key in ("x", "-"))
|
|
1451
|
+
data.update({"next_action": next_action, "current_state": vocab.text("state_progress", language, done=finished, total=len(steps))
|
|
1452
|
+
+ (vocab.text("state_failed_suffix", language, n=failed[0]["n"]) if failed else ""), "updated_at": stamp})
|
|
1453
|
+
body = case_tool._replace_section(body, "current_state", data["current_state"], language) # noqa: SLF001
|
|
1454
|
+
body = case_tool._replace_section(body, "next_action", next_action, language) # noqa: SLF001
|
|
1455
|
+
errors = case_tool.validate_schema(data, case_tool.Project(ctx.project).schema)
|
|
1456
|
+
if errors:
|
|
1457
|
+
fail(_("更新后 Case 不符合 Schema:") + _(";").join(errors), "invalid")
|
|
1458
|
+
text = case_tool.render_frontmatter(data) + body
|
|
1459
|
+
result = {"case": data["id"], "step": args.n, "state": state, "next_action": next_action, "progress": f"{finished}/{len(steps)}",
|
|
1460
|
+
"applied": False, "sha256_before": sha(current)}
|
|
1461
|
+
if args.apply:
|
|
1462
|
+
if path.read_text(encoding="utf-8") != current:
|
|
1463
|
+
fail(_("Case 刚被其他人修改,请重新读取"), "conflict")
|
|
1464
|
+
case_tool.write_atomic(path, text)
|
|
1465
|
+
result["applied"] = True
|
|
1466
|
+
return 0, result, [_("{0} 步骤 {1} {2};进度 {3}/{4};下一步:{5}").format(data['id'], args.n, shown, finished, len(steps), next_action)] + \
|
|
1467
|
+
([] if args.apply else [_("以上为预览,没有写入。确认后加 --apply(用户已确认执行该 SOP 时,步骤记录属于这次授权的范围)。")])
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def cmd_status(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1471
|
+
path, data, body, steps = read_run(ctx, ctx.args.case)
|
|
1472
|
+
sections = split_sections(body)
|
|
1473
|
+
intermediate = next((sections[name] for name in vocab.CASE_SECTIONS["intermediate"] if name in sections), "")
|
|
1474
|
+
artifacts = [line[2:] for line in intermediate.splitlines() if line.startswith("- ")]
|
|
1475
|
+
pending = [step for step in steps if step["mark"] == " "]
|
|
1476
|
+
failed = [step for step in steps if step["mark"] == "!"]
|
|
1477
|
+
skipped = [step for step in steps if step["mark"] == "-"]
|
|
1478
|
+
complete = not pending and not failed
|
|
1479
|
+
lines = [_("{0}({1})进度:{2}/{3}").format(data['id'], data.get('sop', ''), len(steps) - len(pending) - len(failed), len(steps))]
|
|
1480
|
+
lines += [_(" [{0}] {1}. {2}({3})").format(step['mark'], step['n'], step['text'], _(step['state'])) for step in steps]
|
|
1481
|
+
lines += [_("中间产物:") + (_(";").join(artifacts) if artifacts else _("无"))]
|
|
1482
|
+
lines += [_("可以核对完成标准并收尾。") if complete else _("还有未完成或失败的步骤。")]
|
|
1483
|
+
return (0 if complete else 1), {"case": data["id"], "sop": data.get("sop", ""), "steps": steps, "complete": complete,
|
|
1484
|
+
"skipped": [step["n"] for step in skipped], "failed": [step["n"] for step in failed],
|
|
1485
|
+
"artifacts": artifacts,
|
|
1486
|
+
"keep_policy": section_value(body, "cl_artifacts_label")}, lines
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def cmd_confirm_read(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1490
|
+
"""记录“用户已确认读取这些私有路径”:写进 Case 的决策记录,并把 Case 标为 private。确认本身必须先问过用户。"""
|
|
1491
|
+
dirs = private_paths.declared(ctx.project)
|
|
1492
|
+
outside = [item for item in ctx.args.paths if not private_paths.is_private(ctx.project, item, dirs)]
|
|
1493
|
+
if outside:
|
|
1494
|
+
fail(_("这些路径不在私有目录里,无需确认:{0}").format(_('、').join(outside)), "usage",
|
|
1495
|
+
[_("私有目录:") + (_("、").join(dirs) or _("(无;在 Profile 的 privacy.forbidden_default_reads 声明,或使用项目根的 private/)"))])
|
|
1496
|
+
try:
|
|
1497
|
+
cases = case_tool.Project(ctx.project)
|
|
1498
|
+
path = cases.find(ctx.args.case) / "CASE.md"
|
|
1499
|
+
data, body = case_tool.read_case(path)
|
|
1500
|
+
except case_tool.CaseError as exc:
|
|
1501
|
+
raise SopError(str(exc), "not-found") from exc
|
|
1502
|
+
current = path.read_text(encoding="utf-8")
|
|
1503
|
+
language = vocab.detect_case(body, ctx.language)
|
|
1504
|
+
stamp = case_tool.now()
|
|
1505
|
+
note = vocab.text("confirm_note", language, note=ctx.args.note.strip()) if ctx.args.note else ""
|
|
1506
|
+
body = case_tool.append_section(body, "decisions", vocab.text(
|
|
1507
|
+
"log_confirm_read", language, stamp=stamp, paths=vocab.text("list_join", language).join(ctx.args.paths), note=note), language)
|
|
1508
|
+
marked = data.get("sensitivity") != "private"
|
|
1509
|
+
data.update({"sensitivity": "private", "updated_at": stamp})
|
|
1510
|
+
errors = case_tool.validate_schema(data, cases.schema)
|
|
1511
|
+
if errors:
|
|
1512
|
+
fail(_("更新后 Case 不符合 Schema:") + _(";").join(errors), "invalid")
|
|
1513
|
+
result = plan_result(ctx.project, [{"path": path, "content": case_tool.render_frontmatter(data) + body, "expect": sha(current)}],
|
|
1514
|
+
ctx.args.apply, {"case": data["id"], "paths": ctx.args.paths, "marked_private": marked})
|
|
1515
|
+
return 0, result, [_("{0}:用户确认读取 {1}").format(data['id'], _('、').join(ctx.args.paths)) + (_(";Case 已标为 private") if marked else "")] + \
|
|
1516
|
+
([result["verify"]] if result["applied"] else [_("以上为预览,没有写入。确认后加 --apply(只有用户确实确认过才写)。")])
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def cmd_private(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1520
|
+
"""私有目录现状:哪些路径、是否被 git 忽略、是否已有文件被跟踪;--apply 补上缺失的 .gitignore。"""
|
|
1521
|
+
dirs = private_paths.declared(ctx.project)
|
|
1522
|
+
if not dirs and ctx.args.create:
|
|
1523
|
+
dirs = [private_paths.CONVENTION]
|
|
1524
|
+
rows, writes, lines = [], [], []
|
|
1525
|
+
for item in dirs:
|
|
1526
|
+
state = private_paths.git_state(ctx.project, item)
|
|
1527
|
+
plan = private_paths.plan_ignore(ctx.project, item) if (ctx.project / item).is_dir() or ctx.args.create else []
|
|
1528
|
+
writes += plan
|
|
1529
|
+
rows.append({**state, "needs_gitignore": bool(plan)})
|
|
1530
|
+
status = _("不在 git 仓库里或没有 git") if state["ignored"] is None else _("已被 git 忽略") if state["ignored"] else _("未被 git 忽略")
|
|
1531
|
+
lines.append(_("{0}/:{1};{2}").format(item, _('存在') if state['exists'] else _('不存在'), status)
|
|
1532
|
+
+ (_(";已有 {0} 个文件被 git 跟踪").format(len(state['tracked'])) if state["tracked"] else "")
|
|
1533
|
+
+ (_(";将创建 .gitignore") if plan else ""))
|
|
1534
|
+
if state["tracked"]:
|
|
1535
|
+
lines.append(_(" 已被跟踪的文件不会因为 .gitignore 而消失:确认后用 git rm -r --cached {0} 取消跟踪(历史里的内容仍在)").format(item))
|
|
1536
|
+
if not dirs:
|
|
1537
|
+
lines.append(_("没有私有目录。把私有内容放进项目根的 private/,或在 Profile 的 privacy.forbidden_default_reads 声明路径;"
|
|
1538
|
+
"运行 sop private --create --apply 创建 private/。"))
|
|
1539
|
+
result = plan_result(ctx.project, writes, ctx.args.apply, {"dirs": rows})
|
|
1540
|
+
return (1 if any(row["ignored"] is False or row["tracked"] for row in rows) and not result["applied"] else 0), result, \
|
|
1541
|
+
lines + ([] if result["applied"] or not writes else [_("以上为预览,没有写入。确认后加 --apply。")])
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def cmd_init(ctx: Context) -> tuple[int, dict, list[str]]:
|
|
1545
|
+
root = project_root(ctx.project)
|
|
1546
|
+
writes = [] if root.is_dir() else [{"path": root / ".gitkeep", "content": "", "expect": None}]
|
|
1547
|
+
result = plan_result(ctx.project, writes, ctx.args.apply, {"root": display(root, ctx.project)})
|
|
1548
|
+
return 0, result, [_("SOP 目录:{0}").format(display(root, ctx.project)) + (_("(已存在)") if not writes else ""),
|
|
1549
|
+
_("可在 Profile 加 \"sop\": {\"root\": \"sops\"},让 check 把它列入脚手架(可选,不加也能用)。")] + \
|
|
1550
|
+
([] if result["applied"] or not writes else [_("以上为预览,没有写入。确认后加 --apply。")])
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
COMMANDS = {"list": cmd_list, "index": cmd_index, "show": cmd_show, "validate": cmd_validate, "interview": cmd_interview,
|
|
1554
|
+
"defaults": cmd_defaults, "preflight": cmd_preflight, "new": cmd_new, "activate": cmd_activate,
|
|
1555
|
+
"retire": cmd_retire, "restore": cmd_retire, "decide": cmd_decide, "revise": cmd_revise, "match": cmd_match, "due": cmd_due,
|
|
1556
|
+
"events": cmd_events, "run": cmd_run, "step": cmd_step, "status": cmd_status, "init": cmd_init,
|
|
1557
|
+
"confirm-read": cmd_confirm_read, "private": cmd_private}
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def parser() -> argparse.ArgumentParser:
|
|
1561
|
+
result = argparse.ArgumentParser(prog="sop.py", description=__doc__.split("\n\n")[0],
|
|
1562
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
|
|
1563
|
+
result.add_argument("--project", type=Path, default=Path.cwd())
|
|
1564
|
+
result.add_argument("--output", choices=("text", "json"), default="text")
|
|
1565
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
1566
|
+
shared.add_argument("--project", type=Path, default=argparse.SUPPRESS)
|
|
1567
|
+
shared.add_argument("--output", choices=("text", "json"), default=argparse.SUPPRESS)
|
|
1568
|
+
sub = result.add_subparsers(dest="command", required=True)
|
|
1569
|
+
|
|
1570
|
+
def add(name: str, help_text: str, *, write: bool = False) -> argparse.ArgumentParser:
|
|
1571
|
+
item = sub.add_parser(name, parents=[shared], help=help_text)
|
|
1572
|
+
if write:
|
|
1573
|
+
item.add_argument("--apply", action="store_true", help=_("执行写入(缺省只预览)"))
|
|
1574
|
+
return item
|
|
1575
|
+
|
|
1576
|
+
listing = add("list", _("列出 SOP"))
|
|
1577
|
+
listing.add_argument("--all", action="store_true", help=_("含已退役"))
|
|
1578
|
+
listing.add_argument("--scope", choices=("project", "user"))
|
|
1579
|
+
add("index", _("active SOP 的索引(每个一行,Agent 读它判断是否触发)"))
|
|
1580
|
+
add("show", _("查看一个 SOP")).add_argument("sop")
|
|
1581
|
+
add("validate", _("校验结构、前置条件、触发与版本")).add_argument("sop", nargs="?")
|
|
1582
|
+
add("interview", _("创建与配置时要谈清的事")).add_argument("sop", nargs="?")
|
|
1583
|
+
defaults = add("defaults", _("查看或设置本项目的使用习惯"), write=True)
|
|
1584
|
+
defaults.add_argument("--set", action="append", metavar=_("键=值"))
|
|
1585
|
+
add("preflight", _("执行前检查所需程序与环境")).add_argument("sop")
|
|
1586
|
+
new = add("new", _("创建草稿"), write=True)
|
|
1587
|
+
new.add_argument("--slug", required=True)
|
|
1588
|
+
new.add_argument("--title")
|
|
1589
|
+
new.add_argument("--template", default="blank", help=_("样例:blank / video-to-transcript / weekly-review"))
|
|
1590
|
+
new.add_argument("--language", choices=vocab.LANGUAGES, help=_("新 SOP 的文件语言(缺省取项目 Profile 的 language,再缺省为 zh)"))
|
|
1591
|
+
new.add_argument("--from-file", help=_("用现成的 SOP.md 作为草稿"))
|
|
1592
|
+
new.add_argument("--from-case", help=_("从一个已完成的 Case 的工作记录提炼草稿"))
|
|
1593
|
+
new.add_argument("--scope", choices=("project", "user"), default="project")
|
|
1594
|
+
add("activate", _("启用草稿(要求内容与约定都已确认)"), write=True).add_argument("sop")
|
|
1595
|
+
for name, text in (("retire", _("退役")), ("restore", _("恢复为 active"))):
|
|
1596
|
+
item = add(name, text, write=True)
|
|
1597
|
+
item.add_argument("sop")
|
|
1598
|
+
item.add_argument("--note")
|
|
1599
|
+
decide = add("decide", _("记录用户已确认的事项(触发、前置条件、出错/缺工具/中间产物等约定)"), write=True)
|
|
1600
|
+
decide.add_argument("sop")
|
|
1601
|
+
decide.add_argument("--confirm", action="append", metavar="trigger|requires")
|
|
1602
|
+
decide.add_argument("--set", action="append", metavar=_("键=值"))
|
|
1603
|
+
revise = add("revise", _("登记新版本:升版本号、写修订记录、存快照"), write=True)
|
|
1604
|
+
revise.add_argument("sop")
|
|
1605
|
+
revise.add_argument("--bump", choices=("patch", "minor", "major"), required=True)
|
|
1606
|
+
revise.add_argument("--note")
|
|
1607
|
+
add("match", _("关键词预筛")).add_argument("text", nargs="+")
|
|
1608
|
+
add("due", _("哪些有周期的 SOP 到期了"))
|
|
1609
|
+
events = add("events", _("监听某事件的 SOP;加 --case 只看对这个 Case 会触发的"))
|
|
1610
|
+
events.add_argument("event")
|
|
1611
|
+
events.add_argument("--case", help=_("发生事件的 Case:按它的标签过滤,SOP 自己的执行 Case 不触发"))
|
|
1612
|
+
run = add("run", _("按 SOP 建立一次执行(Case)"), write=True)
|
|
1613
|
+
run.add_argument("sop")
|
|
1614
|
+
run.add_argument("--title")
|
|
1615
|
+
run.add_argument("--param", action="append", metavar=_("名称=值"))
|
|
1616
|
+
run.add_argument("--trial", action="store_true", help=_("试跑草稿"))
|
|
1617
|
+
run.add_argument("--event", choices=EVENTS, help=_("由哪个事件触发(需同时给 --event-case)"))
|
|
1618
|
+
run.add_argument("--event-case", help=_("触发事件的 Case"))
|
|
1619
|
+
step = add("step", _("记录某一步的结果"), write=True)
|
|
1620
|
+
step.add_argument("case")
|
|
1621
|
+
step.add_argument("n", type=int)
|
|
1622
|
+
for name in ("done", "skip", "fail", "pending"):
|
|
1623
|
+
step.add_argument(f"--{name}", action="store_true")
|
|
1624
|
+
step.add_argument("--note")
|
|
1625
|
+
step.add_argument("--artifact", action="append", metavar=_("路径[:说明]"),
|
|
1626
|
+
help=_("登记中间产物;路径相对本 Case 目录(目录外的用绝对路径或 ../)"))
|
|
1627
|
+
step.add_argument("--expect-sha256")
|
|
1628
|
+
add("status", _("一次执行的进度与中间产物")).add_argument("case")
|
|
1629
|
+
add("init", _("创建 SOP 目录"), write=True)
|
|
1630
|
+
confirm = add("confirm-read", _("记录用户已确认读取某些私有路径(写入 Case 的决策记录,Case 标为 private)"), write=True)
|
|
1631
|
+
confirm.add_argument("case")
|
|
1632
|
+
confirm.add_argument("paths", nargs="+")
|
|
1633
|
+
confirm.add_argument("--note")
|
|
1634
|
+
private = add("private", _("私有目录现状:是否被 git 忽略;--apply 补上 .gitignore"), write=True)
|
|
1635
|
+
private.add_argument("--create", action="store_true", help=_("没有私有目录时创建项目根的 private/"))
|
|
1636
|
+
return result
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def execute(argv: list[str]) -> tuple[int, dict, list[str]]:
|
|
1640
|
+
args = parser().parse_args(argv)
|
|
1641
|
+
return COMMANDS[args.command](Context(args))
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
EXIT_BY_CODE = {"usage": 2, "invalid": 2, "not-found": 1, "ambiguous": 1, "conflict": 5, "unavailable": 4, "failed": 6}
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1648
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1649
|
+
output = "json" if "--output" in argv and argv[argv.index("--output") + 1:][:1] == ["json"] else "text"
|
|
1650
|
+
try:
|
|
1651
|
+
code, data, lines = execute(argv)
|
|
1652
|
+
except SopError as exc:
|
|
1653
|
+
if output == "json":
|
|
1654
|
+
print(json.dumps({"ok": False, "code": exc.code, "error": str(exc), "next": exc.next_steps}, ensure_ascii=False))
|
|
1655
|
+
else:
|
|
1656
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1657
|
+
for step in exc.next_steps:
|
|
1658
|
+
print(f" - {step}", file=sys.stderr)
|
|
1659
|
+
return EXIT_BY_CODE.get(exc.code, 2)
|
|
1660
|
+
except SystemExit as exc:
|
|
1661
|
+
return int(exc.code or 0) if isinstance(exc.code, int) else 2
|
|
1662
|
+
if output == "json":
|
|
1663
|
+
print(json.dumps({"ok": code == 0, "exit_code": code, **data}, ensure_ascii=False, indent=2))
|
|
1664
|
+
else:
|
|
1665
|
+
print("\n".join(lines))
|
|
1666
|
+
return code
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
if __name__ == "__main__":
|
|
1670
|
+
raise SystemExit(main())
|