@sakanano-moe/agenthelm 6.5.3 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/README.zh-CN.md +28 -0
- package/bin/agenthelm.js +1 -1
- package/package.json +2 -2
- package/runtime/index.json +80 -0
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +26 -0
- package/runtime/plugins/agent-pack/.mcp.json +12 -0
- package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
- package/runtime/plugins/agent-pack/mcp.json +14 -0
- package/runtime/plugins/agent-pack/plugin.json +20 -0
- package/runtime/plugins/agent-pack/references/capability-config.md +148 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
- package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +178 -22
- package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +42 -1
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
- package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +30 -8
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
- package/runtime/plugins/agents-init/content.json +31 -0
- package/runtime/plugins/agents-init/plugin.json +19 -0
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
- package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
- package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
- package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
- package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/content.json +63 -0
- package/runtime/plugins/opinion-manager/plugin.json +18 -0
- package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
- package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
- package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
- package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
- package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
- package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
- package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
- package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
- package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
- package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +24 -0
- package/runtime/plugins/project-orchestrator/content.json +37 -0
- package/runtime/plugins/project-orchestrator/plugin.json +19 -0
- package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
- package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
- package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
- /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from constants import KNOWN_PLACEHOLDERS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PUBLIC_CATALOG = Path(__file__).resolve().parents[1] / "catalog"
|
|
17
|
+
IDENTIFIER = re.compile(r"[a-z0-9]+(?:[.-][a-z0-9]+)*\Z")
|
|
18
|
+
VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z")
|
|
19
|
+
EDITABLE = {"title", "level", "scopes", "text"}
|
|
20
|
+
RULE_FIELDS = EDITABLE | {"id", "version", "slot"}
|
|
21
|
+
TEMPLATE_FIELDS = {"family", "variant", "version", "name", "description", "scopes", "rules"}
|
|
22
|
+
PROFILE_FIELDS = {"schema_version", "id", "version", "templates", "rules", "sources", "overrides", "custom", "derived_from"}
|
|
23
|
+
PROFILE_OPTIONAL_FIELDS = {"loading"}
|
|
24
|
+
LOADING_FIELDS = {"core", "signals", "bundles"}
|
|
25
|
+
BUNDLE_FIELDS = {"family", "trigger", "exclude", "routes"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpinionVersionError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fail(message: str) -> None:
|
|
33
|
+
raise OpinionVersionError(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def canonical(value: Any) -> str:
|
|
37
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def digest(value: Any) -> str:
|
|
41
|
+
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def text_digest(value: str) -> str:
|
|
45
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def pretty(value: Any) -> str:
|
|
49
|
+
return json.dumps(value, ensure_ascii=False, indent=2) + "\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def safe_path(path: Path) -> Path:
|
|
53
|
+
path = Path(os.path.abspath(path.expanduser()))
|
|
54
|
+
for component in (path, *path.parents):
|
|
55
|
+
if component.is_symlink():
|
|
56
|
+
fail(f"拒绝使用符号链接:{component}")
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_text(path: Path) -> str:
|
|
61
|
+
path = safe_path(path)
|
|
62
|
+
if not stat.S_ISREG(path.lstat().st_mode):
|
|
63
|
+
fail(f"必须使用普通文件:{path}")
|
|
64
|
+
return path.read_text(encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_object(path: Path) -> dict[str, Any]:
|
|
68
|
+
value = json.loads(read_text(path))
|
|
69
|
+
if not isinstance(value, dict):
|
|
70
|
+
fail(f"JSON 根节点必须是对象:{path}")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fields(value: Any, expected: set[str], label: str) -> None:
|
|
75
|
+
if not isinstance(value, dict) or set(value) != expected:
|
|
76
|
+
fail(f"{label} 字段必须为:{sorted(expected)}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def string(value: Any, label: str) -> None:
|
|
80
|
+
if not isinstance(value, str) or not value.strip():
|
|
81
|
+
fail(f"{label} 必须是非空字符串")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def identifier(value: Any) -> None:
|
|
85
|
+
if not isinstance(value, str) or not IDENTIFIER.fullmatch(value):
|
|
86
|
+
fail(f"编号无效:{value!r}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def version(value: Any) -> tuple[int, int, int]:
|
|
90
|
+
if not isinstance(value, str) or not VERSION.fullmatch(value):
|
|
91
|
+
fail(f"版本必须是无前导零的三段数字:{value!r}")
|
|
92
|
+
return tuple(int(part) for part in value.split("."))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def strings(value: Any, label: str, *, empty: bool = False) -> None:
|
|
96
|
+
if not isinstance(value, list) or (not empty and not value):
|
|
97
|
+
fail(f"{label} 必须是字符串数组")
|
|
98
|
+
for item in value:
|
|
99
|
+
string(item, label)
|
|
100
|
+
if len(value) != len(set(value)):
|
|
101
|
+
fail(f"{label} 包含重复项")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_ref(ref: str, kind: str) -> tuple[str, str]:
|
|
105
|
+
if not isinstance(ref, str) or ref.count("@") != 1:
|
|
106
|
+
fail(f"{kind} 必须精确指定 @VERSION:{ref!r}")
|
|
107
|
+
identity, release = ref.split("@")
|
|
108
|
+
version(release)
|
|
109
|
+
parts = identity.split("/")
|
|
110
|
+
if len(parts) != (2 if kind == "templates" else 1):
|
|
111
|
+
fail(f"{kind} 引用格式无效:{ref}")
|
|
112
|
+
for part in parts:
|
|
113
|
+
identifier(part)
|
|
114
|
+
return identity, release
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_edit(value: Any) -> None:
|
|
118
|
+
fields(value, EDITABLE, "规则内容")
|
|
119
|
+
string(value["title"], "title")
|
|
120
|
+
string(value["text"], "text")
|
|
121
|
+
strings(value["scopes"], "scopes")
|
|
122
|
+
if value["level"] not in ("required", "preferred"):
|
|
123
|
+
fail("level 必须为 required 或 preferred")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def release_ref(value: dict[str, Any], kind: str) -> str:
|
|
127
|
+
identity = value["id"] if kind == "rules" else f"{value['family']}/{value['variant']}"
|
|
128
|
+
return f"{identity}@{value['version']}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_release(value: Any) -> str:
|
|
132
|
+
if not isinstance(value, dict):
|
|
133
|
+
fail("发布内容必须是对象")
|
|
134
|
+
kind = "rules" if "id" in value else "templates"
|
|
135
|
+
fields(value, RULE_FIELDS if kind == "rules" else TEMPLATE_FIELDS, "发布内容")
|
|
136
|
+
version(value["version"])
|
|
137
|
+
if kind == "rules":
|
|
138
|
+
identifier(value["id"])
|
|
139
|
+
identifier(value["slot"])
|
|
140
|
+
validate_edit({key: value[key] for key in EDITABLE})
|
|
141
|
+
else:
|
|
142
|
+
identifier(value["family"])
|
|
143
|
+
identifier(value["variant"])
|
|
144
|
+
string(value["name"], "name")
|
|
145
|
+
string(value["description"], "description")
|
|
146
|
+
strings(value["scopes"], "scopes")
|
|
147
|
+
strings(value["rules"], "rules")
|
|
148
|
+
for ref in value["rules"]:
|
|
149
|
+
parse_ref(ref, "rules")
|
|
150
|
+
return kind
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def release_path(root: Path, value: dict[str, Any], kind: str) -> Path:
|
|
154
|
+
identity, release = parse_ref(release_ref(value, kind), kind)
|
|
155
|
+
return root / kind / identity / "versions" / f"{release}.json"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_catalog(root: Path) -> dict[str, dict[str, Any]]:
|
|
159
|
+
root = safe_path(root)
|
|
160
|
+
if not root.is_dir():
|
|
161
|
+
fail(f"目录不存在:{root}")
|
|
162
|
+
catalog: dict[str, dict[str, Any]] = {"templates": {}, "rules": {}}
|
|
163
|
+
for path in sorted(root.rglob("*")):
|
|
164
|
+
safe_path(path)
|
|
165
|
+
if path.suffix != ".json":
|
|
166
|
+
continue
|
|
167
|
+
value = read_object(path)
|
|
168
|
+
kind = validate_release(value)
|
|
169
|
+
if path != release_path(root, value, kind):
|
|
170
|
+
fail(f"发布文件路径与身份不一致:{path}")
|
|
171
|
+
ref = release_ref(value, kind)
|
|
172
|
+
catalog[kind][ref] = value
|
|
173
|
+
for template in catalog["templates"].values():
|
|
174
|
+
sources = resolve(catalog, [release_ref(template, "templates")], [])
|
|
175
|
+
effective_rules({"sources": sources, "overrides": {}})
|
|
176
|
+
return catalog
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve(catalog: dict[str, dict[str, Any]], templates: list[str], rules: list[str]) -> dict[str, Any]:
|
|
180
|
+
families: set[str] = set()
|
|
181
|
+
sources: dict[str, list[Any]] = {"templates": [], "rules": []}
|
|
182
|
+
references = list(rules)
|
|
183
|
+
for ref in templates:
|
|
184
|
+
identity, _ = parse_ref(ref, "templates")
|
|
185
|
+
family = identity.split("/")[0]
|
|
186
|
+
if family in families:
|
|
187
|
+
fail(f"同一模板系列只能选择一个变体和版本:{family}")
|
|
188
|
+
families.add(family)
|
|
189
|
+
if ref not in catalog["templates"]:
|
|
190
|
+
fail(f"未知模板:{ref}")
|
|
191
|
+
value = catalog["templates"][ref]
|
|
192
|
+
sources["templates"].append({"ref": ref, "sha256": digest(value), "value": value})
|
|
193
|
+
references.extend(value["rules"])
|
|
194
|
+
selected: dict[str, Any] = {}
|
|
195
|
+
for ref in references:
|
|
196
|
+
identity, _ = parse_ref(ref, "rules")
|
|
197
|
+
if ref not in catalog["rules"]:
|
|
198
|
+
fail(f"未知规则:{ref}")
|
|
199
|
+
value = catalog["rules"][ref]
|
|
200
|
+
if identity in selected and selected[identity]["ref"] != ref:
|
|
201
|
+
fail(f"规则选择了不同版本:{identity}")
|
|
202
|
+
selected.setdefault(identity, {"ref": ref, "sha256": digest(value), "value": value})
|
|
203
|
+
sources["rules"] = list(selected.values())
|
|
204
|
+
return sources
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def base_rules(profile: dict[str, Any]) -> dict[str, Any]:
|
|
208
|
+
return {source["value"]["id"]: source["value"] for source in profile["sources"]["rules"]}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def effective_rules(profile: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
result = dict(base_rules(profile))
|
|
213
|
+
for identity, override in profile["overrides"].items():
|
|
214
|
+
if override is None:
|
|
215
|
+
result.pop(identity, None)
|
|
216
|
+
else:
|
|
217
|
+
result[identity] = override
|
|
218
|
+
slots: dict[str, str] = {}
|
|
219
|
+
for identity, rule in result.items():
|
|
220
|
+
slot = rule["slot"]
|
|
221
|
+
if slot in slots:
|
|
222
|
+
fail(f"规则 slot 冲突:{slot}({slots[slot]}、{identity})")
|
|
223
|
+
slots[slot] = identity
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def selected_families(profile: dict[str, Any]) -> list[str]:
|
|
228
|
+
return [parse_ref(ref, "templates")[0].split("/")[0] for ref in profile["templates"]]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def validate_loading(profile: dict[str, Any]) -> None:
|
|
232
|
+
loading = profile["loading"]
|
|
233
|
+
fields(loading, LOADING_FIELDS, "loading")
|
|
234
|
+
strings(loading["core"], "loading.core", empty=True)
|
|
235
|
+
if not isinstance(loading["signals"], dict) or not loading["signals"]:
|
|
236
|
+
fail("loading.signals 必须是非空对象")
|
|
237
|
+
for key, values in loading["signals"].items():
|
|
238
|
+
identifier(key)
|
|
239
|
+
strings(values, f"loading.signals.{key}")
|
|
240
|
+
for item in values:
|
|
241
|
+
identifier(item)
|
|
242
|
+
if not isinstance(loading["bundles"], list) or not loading["bundles"]:
|
|
243
|
+
fail("loading.bundles 至少包含一个按需规则束")
|
|
244
|
+
families = set(selected_families(profile))
|
|
245
|
+
seen = set(loading["core"])
|
|
246
|
+
for family in loading["core"]:
|
|
247
|
+
if family not in families:
|
|
248
|
+
fail(f"loading.core 引用了未选择的模板系列:{family}")
|
|
249
|
+
for bundle in loading["bundles"]:
|
|
250
|
+
fields(bundle, BUNDLE_FIELDS, "loading.bundles 项")
|
|
251
|
+
family = bundle["family"]
|
|
252
|
+
if family not in families:
|
|
253
|
+
fail(f"loading.bundles 引用了未选择的模板系列:{family!r}")
|
|
254
|
+
if family in seen:
|
|
255
|
+
fail(f"模板系列在 loading 中重复出现:{family}")
|
|
256
|
+
seen.add(family)
|
|
257
|
+
string(bundle["trigger"], "trigger")
|
|
258
|
+
string(bundle["exclude"], "exclude")
|
|
259
|
+
if not isinstance(bundle["routes"], list) or not bundle["routes"]:
|
|
260
|
+
fail(f"规则束至少需要一条路由:{family}")
|
|
261
|
+
for route in bundle["routes"]:
|
|
262
|
+
if not isinstance(route, dict) or not route:
|
|
263
|
+
fail(f"路由必须是非空对象:{family}")
|
|
264
|
+
for key, values in route.items():
|
|
265
|
+
strings(values, f"routes.{key}")
|
|
266
|
+
if key not in loading["signals"] or not set(values) <= set(loading["signals"][key]):
|
|
267
|
+
fail(f"路由使用了未声明的信号:{family}.{key}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def validate_profile(value: Any) -> dict[str, Any]:
|
|
271
|
+
tiered = isinstance(value, dict) and value.get("schema_version") == "2.1"
|
|
272
|
+
fields(value, PROFILE_FIELDS | PROFILE_OPTIONAL_FIELDS if tiered else PROFILE_FIELDS, "Profile")
|
|
273
|
+
if value["schema_version"] not in ("2.0", "2.1"):
|
|
274
|
+
fail("Profile schema_version 必须为 2.0 或 2.1")
|
|
275
|
+
identifier(value["id"])
|
|
276
|
+
version(value["version"])
|
|
277
|
+
strings(value["templates"], "templates", empty=True)
|
|
278
|
+
strings(value["rules"], "rules", empty=True)
|
|
279
|
+
fields(value["sources"], {"templates", "rules"}, "sources")
|
|
280
|
+
catalog: dict[str, dict[str, Any]] = {"templates": {}, "rules": {}}
|
|
281
|
+
for kind, sources in value["sources"].items():
|
|
282
|
+
if not isinstance(sources, list):
|
|
283
|
+
fail("sources 项必须是数组")
|
|
284
|
+
for source in sources:
|
|
285
|
+
fields(source, {"ref", "sha256", "value"}, "source")
|
|
286
|
+
if validate_release(source["value"]) != kind:
|
|
287
|
+
fail("source 类型不一致")
|
|
288
|
+
if source["ref"] != release_ref(source["value"], kind) or source["sha256"] != digest(source["value"]):
|
|
289
|
+
fail(f"source 指纹或引用不一致:{source['ref']}")
|
|
290
|
+
if source["ref"] in catalog[kind]:
|
|
291
|
+
fail(f"重复 source:{source['ref']}")
|
|
292
|
+
catalog[kind][source["ref"]] = source["value"]
|
|
293
|
+
if resolve(catalog, value["templates"], value["rules"]) != value["sources"]:
|
|
294
|
+
fail("Profile 引用与 sources 不一致")
|
|
295
|
+
if not isinstance(value["overrides"], dict):
|
|
296
|
+
fail("overrides 必须是对象")
|
|
297
|
+
for identity, override in value["overrides"].items():
|
|
298
|
+
identifier(identity)
|
|
299
|
+
if override is not None:
|
|
300
|
+
if validate_release(override) != "rules" or override["id"] != identity:
|
|
301
|
+
fail("override 必须使用匹配编号的完整规则")
|
|
302
|
+
elif identity not in base_rules(value):
|
|
303
|
+
fail(f"停用规则不存在:{identity}")
|
|
304
|
+
if value["custom"] is not None:
|
|
305
|
+
string(value["custom"], "custom")
|
|
306
|
+
if value["derived_from"] is not None:
|
|
307
|
+
fields(value["derived_from"], {"id", "version", "sha256"}, "derived_from")
|
|
308
|
+
identifier(value["derived_from"]["id"])
|
|
309
|
+
version(value["derived_from"]["version"])
|
|
310
|
+
if not isinstance(value["derived_from"]["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", value["derived_from"]["sha256"]):
|
|
311
|
+
fail("derived_from 指纹无效")
|
|
312
|
+
if not effective_rules(value) and not value["custom"]:
|
|
313
|
+
fail("Profile 至少需要一条有效规则或自定义内容")
|
|
314
|
+
if tiered:
|
|
315
|
+
validate_loading(value)
|
|
316
|
+
return value
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def load_profile(path: Path) -> dict[str, Any]:
|
|
320
|
+
return validate_profile(read_object(path))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def replace_refs(previous: list[str], incoming: list[str], kind: str) -> list[str]:
|
|
324
|
+
result = list(previous)
|
|
325
|
+
keys: set[str] = set()
|
|
326
|
+
for ref in incoming:
|
|
327
|
+
identity, _ = parse_ref(ref, kind)
|
|
328
|
+
key = identity.split("/")[0] if kind == "templates" else identity
|
|
329
|
+
if key in keys:
|
|
330
|
+
fail(f"同一次选择包含重复身份:{key}")
|
|
331
|
+
keys.add(key)
|
|
332
|
+
result = [item for item in result if (parse_ref(item, kind)[0].split("/")[0] if kind == "templates" else parse_ref(item, kind)[0]) != key]
|
|
333
|
+
result.append(ref)
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def behavior(rule: dict[str, Any] | None) -> Any:
|
|
338
|
+
return None if rule is None else {key: rule[key] for key in RULE_FIELDS - {"version", "title"}}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def comparison(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
|
|
342
|
+
before, after = effective_rules(old), effective_rules(new)
|
|
343
|
+
base, upstream = base_rules(old), base_rules(new)
|
|
344
|
+
conflicts = []
|
|
345
|
+
for identity in sorted(set(base) | set(upstream)):
|
|
346
|
+
if identity in old["overrides"] and base.get(identity) != upstream.get(identity):
|
|
347
|
+
local = before.get(identity)
|
|
348
|
+
if behavior(local) != behavior(upstream.get(identity)) and behavior(base.get(identity)) != behavior(upstream.get(identity)):
|
|
349
|
+
conflicts.append({"id": identity, "base": base.get(identity), "local": local, "upstream": upstream.get(identity)})
|
|
350
|
+
return {
|
|
351
|
+
"added": sorted(set(after) - set(before)),
|
|
352
|
+
"removed": sorted(set(before) - set(after)),
|
|
353
|
+
"changed": [identity for identity in sorted(set(before) & set(after)) if before[identity] != after[identity]],
|
|
354
|
+
"custom_changed": old["custom"] != new["custom"],
|
|
355
|
+
"templates": {"before": old["templates"], "after": new["templates"]},
|
|
356
|
+
"direct_rules": {"before": old["rules"], "after": new["rules"]},
|
|
357
|
+
"rule_changes": [{"id": identity, "before": before.get(identity), "after": after.get(identity)} for identity in sorted(set(before) | set(after)) if before.get(identity) != after.get(identity)],
|
|
358
|
+
"custom": {"before": old["custom"], "after": new["custom"]},
|
|
359
|
+
"loading_changed": old.get("loading") != new.get("loading"),
|
|
360
|
+
"loading": {"before": old.get("loading"), "after": new.get("loading")},
|
|
361
|
+
"conflicts": conflicts,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def immutable_write(path: Path, value: dict[str, Any]) -> str:
|
|
366
|
+
path = safe_path(path)
|
|
367
|
+
if path.exists():
|
|
368
|
+
if read_object(path) == value:
|
|
369
|
+
return "unchanged"
|
|
370
|
+
fail(f"已经发布的版本不可修改:{path}")
|
|
371
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
373
|
+
temporary = Path(name)
|
|
374
|
+
try:
|
|
375
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
376
|
+
handle.write(pretty(value))
|
|
377
|
+
handle.flush()
|
|
378
|
+
os.fsync(handle.fileno())
|
|
379
|
+
# 硬链接只创建不存在的目标,避免并发发布覆盖已经存在的版本。
|
|
380
|
+
os.link(temporary, path)
|
|
381
|
+
finally:
|
|
382
|
+
temporary.unlink()
|
|
383
|
+
if read_object(path) != value:
|
|
384
|
+
fail(f"写入后核对失败:{path}")
|
|
385
|
+
return "written"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def confirm(args: argparse.Namespace, fingerprint: str) -> None:
|
|
389
|
+
if args.confirm != fingerprint:
|
|
390
|
+
fail(f"请先审阅预览,再使用 --confirm {fingerprint} --apply")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def profile_command(args: argparse.Namespace) -> dict[str, Any]:
|
|
394
|
+
root = safe_path(args.project)
|
|
395
|
+
if not root.is_dir():
|
|
396
|
+
fail(f"项目目录不存在:{root}")
|
|
397
|
+
identifier(args.id)
|
|
398
|
+
version(args.version)
|
|
399
|
+
old = load_profile(args.from_profile) if args.from_profile else None
|
|
400
|
+
if old and args.id == old["id"] and version(args.version) <= version(old["version"]):
|
|
401
|
+
fail("修订 Profile 必须增加版本;私有派生必须使用独立编号")
|
|
402
|
+
templates = replace_refs(old["templates"] if old else [], args.template, "templates")
|
|
403
|
+
rules = replace_refs(old["rules"] if old else [], args.rule, "rules")
|
|
404
|
+
for family in args.remove_template:
|
|
405
|
+
identifier(family)
|
|
406
|
+
if not any(parse_ref(ref, "templates")[0].split("/")[0] == family for ref in templates):
|
|
407
|
+
fail(f"无法移除未选择的模板系列:{family}")
|
|
408
|
+
templates = [ref for ref in templates if parse_ref(ref, "templates")[0].split("/")[0] != family]
|
|
409
|
+
for identity in args.remove_rule:
|
|
410
|
+
identifier(identity)
|
|
411
|
+
if not any(parse_ref(ref, "rules")[0] == identity for ref in rules):
|
|
412
|
+
fail(f"无法移除未直接选择的规则:{identity}")
|
|
413
|
+
rules = [ref for ref in rules if parse_ref(ref, "rules")[0] != identity]
|
|
414
|
+
catalog = load_catalog(args.catalog)
|
|
415
|
+
if old:
|
|
416
|
+
for kind, sources in old["sources"].items():
|
|
417
|
+
for source in sources:
|
|
418
|
+
existing = catalog[kind].get(source["ref"])
|
|
419
|
+
if existing is not None and digest(existing) != source["sha256"]:
|
|
420
|
+
fail(f"已经发布的来源内容发生变化:{source['ref']}")
|
|
421
|
+
catalog[kind].setdefault(source["ref"], source["value"])
|
|
422
|
+
custom = read_text(args.custom_file).strip() if args.custom_file else (old["custom"] if old else None)
|
|
423
|
+
if args.loading_file and args.remove_loading:
|
|
424
|
+
fail("--loading-file 与 --remove-loading 不能同时使用")
|
|
425
|
+
# 加载声明整体替换;未提供时沿用原 Profile,保证未变更的声明不会在修订中丢失。
|
|
426
|
+
loading = read_object(args.loading_file) if args.loading_file else (None if args.remove_loading or not old else old.get("loading"))
|
|
427
|
+
value = {
|
|
428
|
+
"schema_version": "2.0" if loading is None else "2.1", "id": args.id, "version": args.version,
|
|
429
|
+
"templates": templates, "rules": rules, "sources": resolve(catalog, templates, rules),
|
|
430
|
+
"overrides": dict(old["overrides"]) if old else {}, "custom": custom,
|
|
431
|
+
"derived_from": {"id": old["id"], "version": old["version"], "sha256": digest(old)} if old else None,
|
|
432
|
+
}
|
|
433
|
+
if loading is not None:
|
|
434
|
+
value["loading"] = loading
|
|
435
|
+
changes = comparison(old, value) if old else {"conflicts": []}
|
|
436
|
+
resolutions = read_object(args.resolutions_file) if args.resolutions_file else {}
|
|
437
|
+
conflict_ids = {item["id"] for item in changes["conflicts"]}
|
|
438
|
+
if set(resolutions) - conflict_ids:
|
|
439
|
+
fail("resolutions 包含没有冲突的编号")
|
|
440
|
+
for identity, choice in resolutions.items():
|
|
441
|
+
if choice == "use-upstream":
|
|
442
|
+
value["overrides"].pop(identity, None)
|
|
443
|
+
elif choice != "keep-local":
|
|
444
|
+
fail("冲突处理必须为 keep-local 或 use-upstream")
|
|
445
|
+
overrides = read_object(args.overrides_file) if args.overrides_file else {}
|
|
446
|
+
for identity, override in overrides.items():
|
|
447
|
+
identifier(identity)
|
|
448
|
+
if identity not in base_rules(value):
|
|
449
|
+
fail(f"override 引用未知规则:{identity}")
|
|
450
|
+
if override is None:
|
|
451
|
+
value["overrides"][identity] = None
|
|
452
|
+
else:
|
|
453
|
+
validate_edit(override)
|
|
454
|
+
value["overrides"][identity] = {**base_rules(value)[identity], **override}
|
|
455
|
+
unresolved = conflict_ids - set(resolutions) - set(overrides)
|
|
456
|
+
# 上游删除的停用记录没有实际作用,可以直接移除。
|
|
457
|
+
for identity in list(value["overrides"]):
|
|
458
|
+
if value["overrides"][identity] is None and identity not in base_rules(value):
|
|
459
|
+
value["overrides"].pop(identity)
|
|
460
|
+
validate_profile(value)
|
|
461
|
+
if old:
|
|
462
|
+
updated_changes = comparison(old, value)
|
|
463
|
+
updated_changes["conflicts"] = changes["conflicts"]
|
|
464
|
+
changes = updated_changes
|
|
465
|
+
path = root / ".opinion" / "profiles" / args.id / "versions" / f"{args.version}.json"
|
|
466
|
+
fingerprint = digest(value)
|
|
467
|
+
result = {"ok": True, "status": "preview", "path": str(path), "profile": value, "content": render(value), "effective_rules": list(effective_rules(value).values()), "changes": changes, "unresolved": sorted(unresolved), "confirmation_sha256": fingerprint}
|
|
468
|
+
if args.apply:
|
|
469
|
+
if unresolved:
|
|
470
|
+
fail(f"升级存在未确认的冲突:{sorted(unresolved)}")
|
|
471
|
+
confirm(args, fingerprint)
|
|
472
|
+
result["status"] = immutable_write(path, value)
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def publish_command(args: argparse.Namespace) -> dict[str, Any]:
|
|
477
|
+
value = read_object(args.file)
|
|
478
|
+
kind = validate_release(value)
|
|
479
|
+
catalog = load_catalog(args.catalog)
|
|
480
|
+
ref = release_ref(value, kind)
|
|
481
|
+
existing = catalog[kind].get(ref)
|
|
482
|
+
if existing is not None and existing != value:
|
|
483
|
+
fail(f"已经发布的版本不可修改:{ref}")
|
|
484
|
+
identity, release = parse_ref(ref, kind)
|
|
485
|
+
patch_peers = [item for key, item in catalog[kind].items() if parse_ref(key, kind)[0] == identity and version(item["version"])[:2] == version(release)[:2] and item["version"] != release]
|
|
486
|
+
for latest in patch_peers:
|
|
487
|
+
before, after = version(latest["version"]), version(release)
|
|
488
|
+
if before[:2] == after[:2]:
|
|
489
|
+
if kind == "rules" and behavior(latest) != behavior(value):
|
|
490
|
+
fail("patch 版本只允许不改变行为的修改")
|
|
491
|
+
if kind == "templates":
|
|
492
|
+
if latest["scopes"] != value["scopes"]:
|
|
493
|
+
fail("patch 版本不能改变适用范围")
|
|
494
|
+
a = resolve(catalog, [release_ref(latest, kind)], [])
|
|
495
|
+
prospective = {**catalog, "templates": {**catalog["templates"], ref: value}}
|
|
496
|
+
b = resolve(prospective, [ref], [])
|
|
497
|
+
if {s["value"]["id"]: behavior(s["value"]) for s in a["rules"]} != {s["value"]["id"]: behavior(s["value"]) for s in b["rules"]}:
|
|
498
|
+
fail("patch 版本不能改变有效规则")
|
|
499
|
+
catalog[kind][ref] = value
|
|
500
|
+
if kind == "templates":
|
|
501
|
+
sources = resolve(catalog, [ref], [])
|
|
502
|
+
effective_rules({"sources": sources, "overrides": {}})
|
|
503
|
+
root = safe_path(args.catalog)
|
|
504
|
+
path = release_path(root, value, kind)
|
|
505
|
+
fingerprint = digest(value)
|
|
506
|
+
result = {"ok": True, "status": "preview", "kind": kind, "ref": ref, "path": str(path), "release": value, "confirmation_sha256": fingerprint}
|
|
507
|
+
if args.apply:
|
|
508
|
+
confirm(args, fingerprint)
|
|
509
|
+
if (root == PUBLIC_CATALOG or PUBLIC_CATALOG in root.parents) and not args.public_approved:
|
|
510
|
+
fail("公开内容需要用户批准完整文件,并使用 --public-approved")
|
|
511
|
+
result["status"] = immutable_write(path, value)
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def render_rule(profile: dict[str, Any], rule: dict[str, Any]) -> str:
|
|
516
|
+
level = "强制规则" if rule["level"] == "required" else "偏好"
|
|
517
|
+
origin = f"个人修订:`{profile['id']}@{profile['version']}`" if rule["id"] in profile["overrides"] else f"规则:`{rule['id']}@{rule['version']}`"
|
|
518
|
+
return "\n".join((f"## {rule['title']}", "", f"{origin};{level};适用:{'、'.join(rule['scopes'])}。", "", rule["text"]))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def render(profile: dict[str, Any]) -> str:
|
|
522
|
+
metadata = {"schema_version": "2.0", "profile": f"{profile['id']}@{profile['version']}", "sha256": digest(profile), "templates": profile["templates"], "rules": profile["rules"]}
|
|
523
|
+
lines = ["<!-- opinion-manager:metadata", pretty(metadata).rstrip(), "-->", "", "# Agent Opinion", "", "本文件包含当前环境中用户确认的 Agent 行为、表达、判断与交付要求。"]
|
|
524
|
+
for rule in effective_rules(profile).values():
|
|
525
|
+
lines.extend(("", render_rule(profile, rule)))
|
|
526
|
+
if profile["custom"]:
|
|
527
|
+
lines.extend(("", "## 用户自定义规则", "", profile["custom"]))
|
|
528
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def file_state(path: Path) -> str | None:
|
|
532
|
+
safe_path(path)
|
|
533
|
+
return read_text(path) if path.exists() else None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def lock_value(profile: dict[str, Any], content: str) -> dict[str, Any]:
|
|
537
|
+
return {"schema_version": "2.0", "renderer_version": "2.0", "profile": profile, "profile_sha256": digest(profile), "opinion_sha256": text_digest(content)}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def validate_lock(lock: Any) -> dict[str, Any]:
|
|
541
|
+
fields(lock, {"schema_version", "renderer_version", "profile", "profile_sha256", "opinion_sha256"}, "lock")
|
|
542
|
+
if lock["schema_version"] != "2.0" or lock["renderer_version"] != "2.0":
|
|
543
|
+
fail("锁定文件版本不受支持")
|
|
544
|
+
profile = validate_profile(lock["profile"])
|
|
545
|
+
if lock != lock_value(profile, render(profile)):
|
|
546
|
+
fail("锁定文件指纹与内容不一致")
|
|
547
|
+
return lock
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
551
|
+
safe_path(path)
|
|
552
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
553
|
+
temporary = Path(name)
|
|
554
|
+
try:
|
|
555
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
556
|
+
handle.write(content)
|
|
557
|
+
handle.flush()
|
|
558
|
+
os.fsync(handle.fileno())
|
|
559
|
+
os.chmod(temporary, 0o644)
|
|
560
|
+
os.replace(temporary, path)
|
|
561
|
+
finally:
|
|
562
|
+
if temporary.exists():
|
|
563
|
+
temporary.unlink()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def compose_command(args: argparse.Namespace) -> dict[str, Any]:
|
|
567
|
+
if args.template or args.rule or args.custom_file:
|
|
568
|
+
fail("--profile 必须独立使用;通过 profile 命令修改选择和自定义内容")
|
|
569
|
+
profile = load_profile(args.profile)
|
|
570
|
+
root = safe_path(args.project)
|
|
571
|
+
if not root.is_dir():
|
|
572
|
+
fail(f"项目目录不存在:{root}")
|
|
573
|
+
content = render(profile)
|
|
574
|
+
lock = lock_value(profile, content)
|
|
575
|
+
destination, lock_path = root / "OPINION.md", root / "opinion.lock.json"
|
|
576
|
+
current, current_lock = file_state(destination), file_state(lock_path)
|
|
577
|
+
blocked = None
|
|
578
|
+
if current_lock is not None:
|
|
579
|
+
previous = validate_lock(json.loads(current_lock))
|
|
580
|
+
previous_profile = previous["profile"]
|
|
581
|
+
if (previous_profile["id"], previous_profile["version"]) == (profile["id"], profile["version"]) and digest(previous_profile) != digest(profile):
|
|
582
|
+
fail("已经锁定的 Profile 版本不可改变内容;请发布新的 Profile 版本")
|
|
583
|
+
if current is None:
|
|
584
|
+
if previous != lock:
|
|
585
|
+
blocked = "正文缺失;请先使用原锁定 Profile 重建正文,再进行升级"
|
|
586
|
+
elif text_digest(current) != previous["opinion_sha256"]:
|
|
587
|
+
if args.accept_current != text_digest(current):
|
|
588
|
+
blocked = "OPINION.md 已被人工修改;请读取并纳入新的 Profile,审阅后用 --accept-current 当前文件SHA256 确认"
|
|
589
|
+
elif current is not None and current != content and not args.replace:
|
|
590
|
+
# 完整匹配初始化占位内容,任何追加内容均需要审阅。
|
|
591
|
+
if current not in KNOWN_PLACEHOLDERS:
|
|
592
|
+
blocked = "已有 OPINION.md 需要审阅并使用 --replace"
|
|
593
|
+
fingerprint = digest({"lock": lock, "before": {"opinion": current, "lock": current_lock}, "replace": args.replace, "accept_current": args.accept_current})
|
|
594
|
+
result = {"ok": True, "status": "preview", "content": content, "lock": lock, "blocked": blocked, "confirmation_sha256": fingerprint, "path": str(destination)}
|
|
595
|
+
if args.apply:
|
|
596
|
+
if blocked:
|
|
597
|
+
fail(blocked)
|
|
598
|
+
confirm(args, fingerprint)
|
|
599
|
+
if current == content and current_lock is not None and json.loads(current_lock) == lock:
|
|
600
|
+
result["status"] = "unchanged"
|
|
601
|
+
else:
|
|
602
|
+
# 两个文件分别原子写入;中断后的不一致由 verify 明确报告。
|
|
603
|
+
atomic_write(destination, content)
|
|
604
|
+
atomic_write(lock_path, pretty(lock))
|
|
605
|
+
verify_project(root)
|
|
606
|
+
result["status"] = "written"
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def verify_project(project: Path) -> dict[str, Any]:
|
|
611
|
+
root = safe_path(project)
|
|
612
|
+
lock = validate_lock(read_object(root / "opinion.lock.json"))
|
|
613
|
+
if text_digest(read_text(root / "OPINION.md")) != lock["opinion_sha256"]:
|
|
614
|
+
fail("OPINION.md 与锁定文件不一致;请读取人工修改或检查中断的写入")
|
|
615
|
+
return {"ok": True, "status": "verified", "profile": f"{lock['profile']['id']}@{lock['profile']['version']}", "profile_sha256": lock["profile_sha256"], "opinion_sha256": lock["opinion_sha256"]}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def updates_command(args: argparse.Namespace) -> dict[str, Any]:
|
|
619
|
+
profile = load_profile(args.profile)
|
|
620
|
+
catalog = load_catalog(args.catalog)
|
|
621
|
+
for kind, sources in profile["sources"].items():
|
|
622
|
+
for source in sources:
|
|
623
|
+
item = catalog[kind].get(source["ref"])
|
|
624
|
+
if item is not None and digest(item) != source["sha256"]:
|
|
625
|
+
fail(f"已经发布的来源内容发生变化:{source['ref']}")
|
|
626
|
+
updates = []
|
|
627
|
+
for kind in ("templates", "rules"):
|
|
628
|
+
for ref in profile[kind]:
|
|
629
|
+
identity, current = parse_ref(ref, kind)
|
|
630
|
+
candidates = [candidate for candidate in catalog[kind] if parse_ref(candidate, kind)[0] == identity and version(parse_ref(candidate, kind)[1]) > version(current)]
|
|
631
|
+
if candidates:
|
|
632
|
+
updates.append({"kind": kind, "current": ref, "available": sorted(candidates, key=lambda candidate: version(parse_ref(candidate, kind)[1]))})
|
|
633
|
+
return {"ok": True, "updates": updates, "applied": False}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def options(parser: argparse.ArgumentParser, *, catalog: bool = False, mutation: bool = False) -> None:
|
|
637
|
+
parser.add_argument("--output", choices=("json", "markdown"), default="json")
|
|
638
|
+
if catalog:
|
|
639
|
+
parser.add_argument("--catalog", type=Path, default=PUBLIC_CATALOG)
|
|
640
|
+
if mutation:
|
|
641
|
+
parser.add_argument("--apply", action="store_true")
|
|
642
|
+
parser.add_argument("--confirm", help="预览返回的完整内容 SHA-256")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def add_commands(subparsers: Any, compose: argparse.ArgumentParser, catalog: argparse.ArgumentParser) -> None:
|
|
646
|
+
compose.add_argument("--profile", type=Path)
|
|
647
|
+
compose.add_argument("--confirm")
|
|
648
|
+
compose.add_argument("--accept-current", help="人工修改或写入中断后的当前 OPINION.md SHA-256;读取并保留内容后使用")
|
|
649
|
+
catalog.add_argument("--catalog", type=Path)
|
|
650
|
+
profile = subparsers.add_parser("profile", help="预览、派生和发布不可变的个人 Profile")
|
|
651
|
+
options(profile, catalog=True, mutation=True)
|
|
652
|
+
profile.add_argument("--project", required=True, type=Path)
|
|
653
|
+
profile.add_argument("--id", required=True)
|
|
654
|
+
profile.add_argument("--version", required=True)
|
|
655
|
+
profile.add_argument("--template", action="append", default=[])
|
|
656
|
+
profile.add_argument("--rule", action="append", default=[])
|
|
657
|
+
profile.add_argument("--remove-template", action="append", default=[])
|
|
658
|
+
profile.add_argument("--remove-rule", action="append", default=[])
|
|
659
|
+
profile.add_argument("--custom-file", type=Path)
|
|
660
|
+
profile.add_argument("--from-profile", type=Path)
|
|
661
|
+
profile.add_argument("--overrides-file", type=Path)
|
|
662
|
+
profile.add_argument("--resolutions-file", type=Path)
|
|
663
|
+
profile.add_argument("--loading-file", type=Path, help="完整的分层加载声明 JSON;整体替换原声明")
|
|
664
|
+
profile.add_argument("--remove-loading", action="store_true", help="移除分层加载声明,恢复为完整读取")
|
|
665
|
+
publish = subparsers.add_parser("publish", help="预览或发布规则、模板版本")
|
|
666
|
+
options(publish, catalog=True, mutation=True)
|
|
667
|
+
publish.add_argument("--file", required=True, type=Path)
|
|
668
|
+
publish.add_argument("--public-approved", action="store_true")
|
|
669
|
+
updates = subparsers.add_parser("updates", help="只读检查同一变体的新版本")
|
|
670
|
+
options(updates, catalog=True)
|
|
671
|
+
updates.add_argument("--profile", required=True, type=Path)
|
|
672
|
+
compare = subparsers.add_parser("compare", help="查看两份 Profile 的差异和三方冲突")
|
|
673
|
+
options(compare)
|
|
674
|
+
compare.add_argument("--from-profile", required=True, type=Path)
|
|
675
|
+
compare.add_argument("--to-profile", required=True, type=Path)
|
|
676
|
+
verify = subparsers.add_parser("verify", help="核对正文、Profile 快照和锁定指纹")
|
|
677
|
+
options(verify)
|
|
678
|
+
verify.add_argument("--project", required=True, type=Path)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def handles(args: argparse.Namespace) -> bool:
|
|
682
|
+
return args.command in {"profile", "publish", "updates", "compare", "verify"} or (args.command == "compose" and args.profile is not None) or (args.command == "catalog" and args.catalog is not None)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def execute(args: argparse.Namespace) -> tuple[int, str]:
|
|
686
|
+
if args.command == "profile":
|
|
687
|
+
result = profile_command(args)
|
|
688
|
+
elif args.command == "publish":
|
|
689
|
+
result = publish_command(args)
|
|
690
|
+
elif args.command == "compose":
|
|
691
|
+
result = compose_command(args)
|
|
692
|
+
elif args.command == "verify":
|
|
693
|
+
result = verify_project(args.project)
|
|
694
|
+
elif args.command == "updates":
|
|
695
|
+
result = updates_command(args)
|
|
696
|
+
elif args.command == "compare":
|
|
697
|
+
result = {"ok": True, **comparison(load_profile(args.from_profile), load_profile(args.to_profile))}
|
|
698
|
+
else:
|
|
699
|
+
catalog = load_catalog(args.catalog)
|
|
700
|
+
result = {"ok": True, "templates": list(catalog["templates"].values()), "rules": list(catalog["rules"].values())}
|
|
701
|
+
if args.output == "markdown":
|
|
702
|
+
if args.command == "compose":
|
|
703
|
+
return 0, result["content"]
|
|
704
|
+
return 0, "```json\n" + pretty(result) + "```\n"
|
|
705
|
+
return 0, pretty(result)
|