@sakanano-moe/agenthelm 6.5.2 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agenthelm.js +1 -1
- package/package.json +2 -2
- package/runtime/index.json +80 -0
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
- package/runtime/plugins/agent-pack/.mcp.json +12 -0
- package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
- package/runtime/plugins/agent-pack/mcp.json +14 -0
- package/runtime/plugins/agent-pack/plugin.json +20 -0
- package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
- package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +111 -28
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
- package/runtime/plugins/agents-init/content.json +31 -0
- package/runtime/plugins/agents-init/plugin.json +19 -0
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
- package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
- package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
- package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
- package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/content.json +63 -0
- package/runtime/plugins/opinion-manager/plugin.json +18 -0
- package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
- package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
- package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
- package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
- package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
- package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
- package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
- package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
- package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
- package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
- package/runtime/plugins/project-orchestrator/content.json +37 -0
- package/runtime/plugins/project-orchestrator/plugin.json +19 -0
- package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
- package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
- package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
- /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
- /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
- /package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +0 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""项目私有的候选、灰度与使用记录;只保存状态和证据快照,从不发布规则或修改 OPINION.md。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import versioned
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_ROOTS = ("tasks", "cases", "reviews", "projects", "journal")
|
|
20
|
+
RESULTS = ("passed", "not-applicable", "violated")
|
|
21
|
+
DATE = re.compile(r"\d{4}-\d{2}-\d{2}\Z")
|
|
22
|
+
EMPTY_STATE = {"schema_version": 1, "candidates": {}, "rollouts": {}, "events": []}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LifecycleError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fail(message: str) -> None:
|
|
30
|
+
raise LifecycleError(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def confined(root: Path, relative: str | Path) -> Path:
|
|
34
|
+
path = root / relative
|
|
35
|
+
if Path(relative).is_absolute() or ".." in Path(relative).parts:
|
|
36
|
+
fail("路径必须是项目内的相对路径")
|
|
37
|
+
if any(item.is_symlink() for item in (path, *path.parents) if item != root.parent):
|
|
38
|
+
fail("拒绝使用符号链接")
|
|
39
|
+
path.resolve().relative_to(root.resolve())
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def current_lock(root: Path) -> dict[str, Any]:
|
|
44
|
+
try:
|
|
45
|
+
versioned.verify_project(root)
|
|
46
|
+
except (versioned.OpinionVersionError, OSError, json.JSONDecodeError) as exc:
|
|
47
|
+
raise LifecycleError(f"当前 Opinion 必须先通过 verify:{exc}") from exc
|
|
48
|
+
return versioned.read_object(root / "opinion.lock.json")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def occurrence(path: str, roots: tuple[str, ...]) -> str:
|
|
52
|
+
parts = Path(path).parts
|
|
53
|
+
if len(parts) < 2 or parts[0] not in roots:
|
|
54
|
+
fail(f"occurrence 必须指向项目内的工作记录,根目录为:{'、'.join(roots)}")
|
|
55
|
+
return "/".join(parts[:2])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def profile_ref(profile: dict[str, Any]) -> str:
|
|
59
|
+
return f"{profile['id']}@{profile['version']}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rule_fingerprints(profile: dict[str, Any]) -> dict[str, str]:
|
|
63
|
+
"""有效规则的内容指纹;个人修订后的规则与原规则引用相同但指纹不同。"""
|
|
64
|
+
return {f"{rule['id']}@{rule['version']}": versioned.digest(rule) for rule in versioned.effective_rules(profile).values()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rule_families(profile: dict[str, Any]) -> dict[str, str]:
|
|
68
|
+
families: dict[str, str] = {}
|
|
69
|
+
for source in profile["sources"]["templates"]:
|
|
70
|
+
for ref in source["value"]["rules"]:
|
|
71
|
+
families.setdefault(ref, source["ref"].split("/")[0])
|
|
72
|
+
return families
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def candidate_view(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
usable = [item for item in candidate["evidence"] if not item.get("fixture")]
|
|
77
|
+
groups = {item["occurrence"] for item in usable}
|
|
78
|
+
failed = any(item["validation"] == "failed" for item in usable)
|
|
79
|
+
passed = any(item["validation"] == "passed" for item in usable)
|
|
80
|
+
status = candidate["decision"] or ("needs-resolution" if failed else "ready-for-review" if passed else "waiting-verification")
|
|
81
|
+
return {"id": candidate["id"], "content": candidate["content"], "content_sha256": versioned.digest(candidate["content"]),
|
|
82
|
+
"status": status, "distinct_occurrences": len(groups),
|
|
83
|
+
"recommendation_due": candidate["credit"] >= 1 and not candidate["decision"],
|
|
84
|
+
"priority": len(groups), "evidence": candidate["evidence"], "writes_stable_rules": False}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def record_use(event: dict[str, Any], lock: dict[str, Any], roots: tuple[str, ...]) -> dict[str, Any]:
|
|
88
|
+
profile = lock["profile"]
|
|
89
|
+
known = {source["ref"] for source in profile["sources"]["rules"]}
|
|
90
|
+
if not event.get("rules") or not set(event["rules"]) <= known:
|
|
91
|
+
fail("use 必须记录适用规则的精确引用")
|
|
92
|
+
use = {key: event[key] for key in ("evidence", "evidence_sha256", "rules", "passed", "user_changed")}
|
|
93
|
+
if type(use["passed"]) is not bool or type(use["user_changed"]) is not bool:
|
|
94
|
+
fail("passed 与 user_changed 必须是明确的布尔值")
|
|
95
|
+
results = event.get("results")
|
|
96
|
+
if results is not None:
|
|
97
|
+
if not isinstance(results, dict) or set(results) != set(event["rules"]) or not set(results.values()) <= set(RESULTS):
|
|
98
|
+
fail(f"results 必须为每条规则给出结果:{'、'.join(RESULTS)}")
|
|
99
|
+
if use["passed"] and "violated" in results.values():
|
|
100
|
+
fail("存在违反的规则时 passed 不能为 true")
|
|
101
|
+
use["results"] = results
|
|
102
|
+
bundles = {bundle["family"] for bundle in profile["loading"]["bundles"]} if "loading" in profile else None
|
|
103
|
+
for key in ("loaded_bundles", "routing_miss"):
|
|
104
|
+
if key in event:
|
|
105
|
+
if bundles is None:
|
|
106
|
+
fail(f"当前 Profile 未声明分层加载,不能记录 {key}")
|
|
107
|
+
if not isinstance(event[key], list) or not set(event[key]) <= bundles or len(event[key]) != len(set(event[key])):
|
|
108
|
+
fail(f"{key} 必须是已声明规则束的不重复列表")
|
|
109
|
+
use[key] = event[key]
|
|
110
|
+
if "date" in event:
|
|
111
|
+
if not isinstance(event["date"], str) or not DATE.fullmatch(event["date"]):
|
|
112
|
+
fail("date 必须为 YYYY-MM-DD")
|
|
113
|
+
use["date"] = event["date"]
|
|
114
|
+
use.update(occurrence=occurrence(event["occurrence"], roots), fixture=bool(event.get("fixture")))
|
|
115
|
+
return use
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def reduce(state: dict[str, Any], event: dict[str, Any], lock: dict[str, Any] | None = None,
|
|
119
|
+
roots: tuple[str, ...] = DEFAULT_ROOTS) -> dict[str, Any]:
|
|
120
|
+
result = deepcopy(state)
|
|
121
|
+
event_hash = versioned.digest(event)
|
|
122
|
+
if event_hash in result["events"]:
|
|
123
|
+
return result
|
|
124
|
+
kind = event["kind"]
|
|
125
|
+
receipt = {key: event[key] for key in ("evidence", "evidence_sha256", "evidence_origin") if key in event}
|
|
126
|
+
if "fixture" in event and type(event["fixture"]) is not bool:
|
|
127
|
+
fail("fixture 必须是明确的布尔值")
|
|
128
|
+
if kind == "capture":
|
|
129
|
+
identity, content = event["id"], event["content"]
|
|
130
|
+
if not all(isinstance(content.get(key), str) and content[key].strip() for key in ("title", "text")):
|
|
131
|
+
fail("候选需要完整的 title 与 text")
|
|
132
|
+
if not content.get("scopes") or not all(isinstance(item, str) and item.strip() for item in content["scopes"]):
|
|
133
|
+
fail("候选需要明确的 scopes")
|
|
134
|
+
if identity in result["candidates"]:
|
|
135
|
+
if result["candidates"][identity]["content"] != content:
|
|
136
|
+
fail("候选内容变化时必须使用新的候选编号")
|
|
137
|
+
else:
|
|
138
|
+
result["candidates"][identity] = {"id": identity, "content": content, "capture_evidence": receipt,
|
|
139
|
+
"evidence": [], "presentations": [], "credit": 0.0, "decision": None}
|
|
140
|
+
elif kind == "observe":
|
|
141
|
+
candidate = result["candidates"][event["id"]]
|
|
142
|
+
group = occurrence(event["occurrence"], roots)
|
|
143
|
+
before = {item["occurrence"] for item in candidate["evidence"] if not item.get("fixture")}
|
|
144
|
+
evidence = {key: event[key] for key in ("evidence", "evidence_sha256", "validation")}
|
|
145
|
+
if evidence["validation"] not in ("pending", "passed", "failed"):
|
|
146
|
+
fail("validation 必须为 pending、passed 或 failed")
|
|
147
|
+
evidence.update(occurrence=group, fixture=bool(event.get("fixture")))
|
|
148
|
+
candidate["evidence"].append(evidence)
|
|
149
|
+
if not evidence["fixture"] and group not in before:
|
|
150
|
+
count = len(before) + 1
|
|
151
|
+
candidate["credit"] += count / (count + 1)
|
|
152
|
+
elif kind == "presented":
|
|
153
|
+
candidate = result["candidates"][event["id"]]
|
|
154
|
+
if event["content_sha256"] != versioned.digest(candidate["content"]):
|
|
155
|
+
fail("展示的内容与候选不一致")
|
|
156
|
+
candidate["credit"] = max(0.0, candidate["credit"] - 1)
|
|
157
|
+
candidate.setdefault("presentations", []).append(receipt)
|
|
158
|
+
elif kind == "decision":
|
|
159
|
+
candidate = result["candidates"][event["id"]]
|
|
160
|
+
if event["content_sha256"] != versioned.digest(candidate["content"]):
|
|
161
|
+
fail("人工裁决指向的候选内容不一致")
|
|
162
|
+
if event["decision"] not in ("accepted", "rejected", "held") or event.get("human_confirmed") is not True:
|
|
163
|
+
fail("需要明确的人工裁决")
|
|
164
|
+
candidate["decision"] = event["decision"]
|
|
165
|
+
candidate["decision_evidence"] = event["evidence"]
|
|
166
|
+
candidate["decision_receipt"] = receipt
|
|
167
|
+
# accepted 只允许据此准备 Profile,从不代表可以自动发布。
|
|
168
|
+
elif kind == "adopt":
|
|
169
|
+
ref = profile_ref(lock["profile"])
|
|
170
|
+
if event.get("human_confirmed") is not True or event["profile_sha256"] != lock["profile_sha256"]:
|
|
171
|
+
fail("采用需要人工批准当前精确的 Profile")
|
|
172
|
+
if ref in result["rollouts"] and result["rollouts"][ref]["profile_sha256"] != lock["profile_sha256"]:
|
|
173
|
+
fail("不可变的 Profile 身份发生了变化")
|
|
174
|
+
result["rollouts"].setdefault(ref, {"state": "gray", "profile_sha256": lock["profile_sha256"],
|
|
175
|
+
"opinion_sha256": lock["opinion_sha256"], "approval_evidence": event["evidence"],
|
|
176
|
+
"approval_receipt": receipt, "uses": [], "blockers": []})
|
|
177
|
+
elif kind == "use":
|
|
178
|
+
rollout = result["rollouts"][profile_ref(lock["profile"])]
|
|
179
|
+
if rollout["profile_sha256"] != lock["profile_sha256"] or rollout["opinion_sha256"] != lock["opinion_sha256"]:
|
|
180
|
+
fail("use 指向的 Opinion 内容已经变化")
|
|
181
|
+
use = record_use(event, lock, roots)
|
|
182
|
+
rollout["uses"].append(use)
|
|
183
|
+
if not use["fixture"] and (not use["passed"] or use["user_changed"] or use.get("routing_miss")):
|
|
184
|
+
rollout["blockers"].append(event_hash)
|
|
185
|
+
elif kind == "formalize":
|
|
186
|
+
rollout = result["rollouts"][profile_ref(lock["profile"])]
|
|
187
|
+
if event.get("human_confirmed") is not True or event["profile_sha256"] != lock["profile_sha256"]:
|
|
188
|
+
fail("复盘必须确认未变化的当前 Profile")
|
|
189
|
+
if rollout["profile_sha256"] != lock["profile_sha256"] or rollout["opinion_sha256"] != lock["opinion_sha256"]:
|
|
190
|
+
fail("灰度内容已经变化;请采用新的已核验版本")
|
|
191
|
+
if rollout["blockers"]:
|
|
192
|
+
fail("转正式前先在新版本中解决灰度失败")
|
|
193
|
+
rollout["state"] = "formal"
|
|
194
|
+
rollout["review_evidence"] = event["evidence"]
|
|
195
|
+
rollout["review_receipt"] = receipt
|
|
196
|
+
else:
|
|
197
|
+
fail("未知的生命周期事件")
|
|
198
|
+
result["events"].append(event_hash)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def evidence_valid(root: Path, state: dict[str, Any]) -> None:
|
|
203
|
+
receipts: list[dict[str, Any]] = []
|
|
204
|
+
for candidate in state["candidates"].values():
|
|
205
|
+
receipts.extend(candidate["evidence"])
|
|
206
|
+
receipts.extend(candidate.get("presentations", []))
|
|
207
|
+
receipts.extend(candidate[key] for key in ("capture_evidence", "decision_receipt") if candidate.get(key))
|
|
208
|
+
for rollout in state["rollouts"].values():
|
|
209
|
+
receipts.extend(rollout["uses"])
|
|
210
|
+
receipts.extend(rollout[key] for key in ("approval_receipt", "review_receipt") if rollout.get(key))
|
|
211
|
+
for item in receipts:
|
|
212
|
+
if hashlib.sha256(confined(root, item["evidence"]).read_bytes()).hexdigest() != item["evidence_sha256"]:
|
|
213
|
+
fail("不可变的生命周期证据发生变化;修改状态前先重新核验")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def successful(use: dict[str, Any]) -> bool:
|
|
217
|
+
return not use["fixture"] and use["passed"] and not use["user_changed"] and not use.get("routing_miss")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def carried_uses(state: dict[str, Any], profile: dict[str, Any], profiles_root: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
|
221
|
+
"""同一 Profile 编号的旧版本中,所记录规则在当前版本指纹全部未变的使用记录。"""
|
|
222
|
+
current, fingerprints = profile_ref(profile), rule_fingerprints(profile)
|
|
223
|
+
carried: list[dict[str, Any]] = []
|
|
224
|
+
skipped: list[dict[str, str]] = []
|
|
225
|
+
for ref, rollout in state["rollouts"].items():
|
|
226
|
+
identity, release = ref.split("@")
|
|
227
|
+
if ref == current or identity != profile["id"]:
|
|
228
|
+
continue
|
|
229
|
+
path = profiles_root / identity / "versions" / f"{release}.json"
|
|
230
|
+
try:
|
|
231
|
+
previous = versioned.load_profile(path)
|
|
232
|
+
except (versioned.OpinionVersionError, OSError, json.JSONDecodeError):
|
|
233
|
+
skipped.append({"profile": ref, "reason": "找不到可核验的旧 Profile"})
|
|
234
|
+
continue
|
|
235
|
+
if versioned.digest(previous) != rollout["profile_sha256"]:
|
|
236
|
+
skipped.append({"profile": ref, "reason": "旧 Profile 与灰度记录的指纹不一致"})
|
|
237
|
+
continue
|
|
238
|
+
before = rule_fingerprints(previous)
|
|
239
|
+
for use in rollout["uses"]:
|
|
240
|
+
if all(before.get(rule) is not None and before.get(rule) == fingerprints.get(rule) for rule in use["rules"]):
|
|
241
|
+
carried.append({**use, "carried_from": ref})
|
|
242
|
+
return carried, skipped
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def coverage_view(state: dict[str, Any], profile: dict[str, Any], profiles_root: Path) -> dict[str, Any]:
|
|
246
|
+
ref = profile_ref(profile)
|
|
247
|
+
own = state["rollouts"].get(ref, {}).get("uses", [])
|
|
248
|
+
carried, skipped = carried_uses(state, profile, profiles_root)
|
|
249
|
+
uses = [*own, *carried]
|
|
250
|
+
families = rule_families(profile)
|
|
251
|
+
rules = []
|
|
252
|
+
for rule in rule_fingerprints(profile):
|
|
253
|
+
relevant = [use for use in uses if rule in use["rules"] and not use["fixture"]]
|
|
254
|
+
passed = [use for use in relevant if successful(use) and use.get("results", {}).get(rule, "passed") == "passed"]
|
|
255
|
+
dates = sorted(use["date"] for use in passed if use.get("date"))
|
|
256
|
+
rules.append({"ref": rule, "family": families.get(rule), "distinct_successful_uses": len({use["occurrence"] for use in passed}),
|
|
257
|
+
"violations": sum(1 for use in relevant if use.get("results", {}).get(rule) == "violated"),
|
|
258
|
+
"last_used": dates[-1] if dates else None})
|
|
259
|
+
result = {"profile": ref, "own_uses": len([use for use in own if not use["fixture"]]),
|
|
260
|
+
"carried_uses": len([use for use in carried if not use["fixture"]]), "not_carried": skipped,
|
|
261
|
+
"distinct_successful_uses": len({use["occurrence"] for use in uses if successful(use)}),
|
|
262
|
+
"rules": rules, "unused_rules": [item["ref"] for item in rules if not item["distinct_successful_uses"]]}
|
|
263
|
+
if "loading" in profile:
|
|
264
|
+
bundles = []
|
|
265
|
+
for bundle in profile["loading"]["bundles"]:
|
|
266
|
+
family = bundle["family"]
|
|
267
|
+
members = {rule for rule, owner in families.items() if owner == family}
|
|
268
|
+
good = [use for use in uses if successful(use)]
|
|
269
|
+
bundles.append({"family": family,
|
|
270
|
+
"loaded_in": len({use["occurrence"] for use in good if family in use.get("loaded_bundles", [])}),
|
|
271
|
+
"exercised_in": len({use["occurrence"] for use in good if members & set(use["rules"])}),
|
|
272
|
+
"routing_misses": sum(1 for use in uses if not use["fixture"] and family in use.get("routing_miss", []))})
|
|
273
|
+
result["bundles"] = bundles
|
|
274
|
+
result["unused_bundles"] = [item["family"] for item in bundles if not item["exercised_in"]]
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def status_view(state: dict[str, Any], root: Path, profiles_root: Path) -> dict[str, Any]:
|
|
279
|
+
value = deepcopy(state["rollouts"])
|
|
280
|
+
for rollout in value.values():
|
|
281
|
+
actual = [use for use in rollout["uses"] if not use["fixture"]]
|
|
282
|
+
rollout["distinct_successful_uses"] = len({use["occurrence"] for use in actual if successful(use)})
|
|
283
|
+
rollout["ready_for_review"] = not rollout["blockers"] and rollout["distinct_successful_uses"] > 1
|
|
284
|
+
rollout["automatic_formalization"] = False
|
|
285
|
+
payload: dict[str, Any] = {"ok": True, "status": "read-only", "value": value}
|
|
286
|
+
try:
|
|
287
|
+
profile = current_lock(root)["profile"]
|
|
288
|
+
except LifecycleError as exc:
|
|
289
|
+
payload["coverage"] = None
|
|
290
|
+
payload["coverage_unavailable"] = str(exc)
|
|
291
|
+
return payload
|
|
292
|
+
coverage = coverage_view(state, profile, profiles_root)
|
|
293
|
+
payload["coverage"] = coverage
|
|
294
|
+
ref = profile_ref(profile)
|
|
295
|
+
if ref in value:
|
|
296
|
+
# 指纹未变的规则沿用旧版本的使用证据;失败记录不延续,但在覆盖视图中保留可见。
|
|
297
|
+
value[ref]["distinct_successful_uses"] = coverage["distinct_successful_uses"]
|
|
298
|
+
value[ref]["carried_uses"] = coverage["carried_uses"]
|
|
299
|
+
value[ref]["ready_for_review"] = not value[ref]["blockers"] and coverage["distinct_successful_uses"] > 1
|
|
300
|
+
return payload
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def add_command(subparsers: Any) -> None:
|
|
304
|
+
parser = subparsers.add_parser("lifecycle", help="项目私有的候选、灰度与使用记录;不发布规则")
|
|
305
|
+
parser.add_argument("action", choices=("queue", "status", "event"))
|
|
306
|
+
parser.add_argument("--project", type=Path, required=True)
|
|
307
|
+
parser.add_argument("--store", required=True, help="项目内保存状态与证据快照的相对目录")
|
|
308
|
+
parser.add_argument("--file", type=Path, help="event 的事件 JSON,必须位于项目内")
|
|
309
|
+
parser.add_argument("--profiles-root", help="保存历史 Profile 的项目内相对目录,默认 .opinion/profiles")
|
|
310
|
+
parser.add_argument("--occurrence-root", action="append", default=[], help="允许作为独立发生场景的顶层目录,可重复")
|
|
311
|
+
parser.add_argument("--titles", action="store_true", help="queue 只输出未裁决候选的标题与适用范围")
|
|
312
|
+
parser.add_argument("--apply", action="store_true")
|
|
313
|
+
parser.add_argument("--confirm", help="预览返回的 confirmation_sha256")
|
|
314
|
+
parser.add_argument("--output", choices=("json",), default="json")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def run(args: argparse.Namespace) -> dict[str, Any]:
|
|
318
|
+
root = args.project.resolve()
|
|
319
|
+
roots = tuple(args.occurrence_root) or DEFAULT_ROOTS
|
|
320
|
+
store = confined(root, args.store)
|
|
321
|
+
profiles_root = confined(root, args.profiles_root or ".opinion/profiles")
|
|
322
|
+
state_path = store / "lifecycle.json"
|
|
323
|
+
raw = state_path.read_text(encoding="utf-8") if state_path.exists() else None
|
|
324
|
+
state = json.loads(raw) if raw else deepcopy(EMPTY_STATE)
|
|
325
|
+
evidence_valid(root, state)
|
|
326
|
+
if args.action in ("queue", "status"):
|
|
327
|
+
if args.apply:
|
|
328
|
+
fail("只读命令不能使用 --apply")
|
|
329
|
+
if args.action == "status":
|
|
330
|
+
return status_view(state, root, profiles_root)
|
|
331
|
+
views = sorted((candidate_view(item) for item in state["candidates"].values()), key=lambda item: (-item["priority"], item["id"]))
|
|
332
|
+
if args.titles:
|
|
333
|
+
views = [{"id": item["id"], "title": item["content"]["title"], "scopes": item["content"]["scopes"],
|
|
334
|
+
"status": item["status"], "distinct_occurrences": item["distinct_occurrences"]}
|
|
335
|
+
for item in views if item["status"] not in ("accepted", "rejected", "held")]
|
|
336
|
+
return {"ok": True, "status": "read-only", "value": views}
|
|
337
|
+
if not args.file:
|
|
338
|
+
fail("event 需要 --file")
|
|
339
|
+
event_path = confined(root, os.path.relpath(args.file.resolve(), root))
|
|
340
|
+
event = json.loads(event_path.read_text(encoding="utf-8"))
|
|
341
|
+
source = confined(root, event["evidence"])
|
|
342
|
+
source_bytes = source.read_bytes()
|
|
343
|
+
source_hash = hashlib.sha256(source_bytes).hexdigest()
|
|
344
|
+
if event.get("evidence_sha256") and event["evidence_sha256"] != source_hash:
|
|
345
|
+
fail("事件证据已经变化")
|
|
346
|
+
event["evidence_sha256"] = source_hash
|
|
347
|
+
event["evidence_origin"] = event["evidence"]
|
|
348
|
+
snapshot = store / "evidence" / f"{source_hash}.blob"
|
|
349
|
+
event["evidence"] = snapshot.relative_to(root).as_posix()
|
|
350
|
+
lock = current_lock(root) if event["kind"] in ("adopt", "use", "formalize") else None
|
|
351
|
+
after = reduce(state, event, lock, roots)
|
|
352
|
+
fingerprint = versioned.digest({"before": state, "after": after, "event": event})
|
|
353
|
+
if args.apply:
|
|
354
|
+
if args.confirm != fingerprint:
|
|
355
|
+
fail("确认指纹不一致")
|
|
356
|
+
store.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
with (store / ".lifecycle.lock").open("a") as guard:
|
|
358
|
+
fcntl.flock(guard, fcntl.LOCK_EX)
|
|
359
|
+
live = state_path.read_text(encoding="utf-8") if state_path.exists() else None
|
|
360
|
+
if live != raw:
|
|
361
|
+
fail("状态已经变化;请重新生成预览")
|
|
362
|
+
evidence_valid(root, state)
|
|
363
|
+
if lock is not None and current_lock(root) != lock:
|
|
364
|
+
fail("Opinion 在写入期间发生变化;请重新生成预览")
|
|
365
|
+
if hashlib.sha256(source.read_bytes()).hexdigest() != source_hash:
|
|
366
|
+
fail("证据在预览后发生变化")
|
|
367
|
+
snapshot.parent.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
if snapshot.exists():
|
|
369
|
+
if snapshot.read_bytes() != source_bytes:
|
|
370
|
+
fail("不可变的证据快照被改动")
|
|
371
|
+
else:
|
|
372
|
+
with snapshot.open("xb") as handle:
|
|
373
|
+
handle.write(source_bytes)
|
|
374
|
+
handle.flush()
|
|
375
|
+
os.fsync(handle.fileno())
|
|
376
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".lifecycle-", dir=store)
|
|
377
|
+
try:
|
|
378
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
379
|
+
json.dump(after, handle, ensure_ascii=False, indent=2)
|
|
380
|
+
handle.write("\n")
|
|
381
|
+
handle.flush()
|
|
382
|
+
os.fsync(handle.fileno())
|
|
383
|
+
os.replace(temporary, state_path)
|
|
384
|
+
finally:
|
|
385
|
+
if Path(temporary).exists():
|
|
386
|
+
Path(temporary).unlink()
|
|
387
|
+
return {"ok": True, "status": "written" if args.apply else "preview", "confirmation_sha256": fingerprint,
|
|
388
|
+
"event": event, "state": after, "writes_stable_rules": False}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def execute(args: argparse.Namespace) -> tuple[int, str]:
|
|
392
|
+
try:
|
|
393
|
+
return 0, versioned.pretty(run(args))
|
|
394
|
+
except (LifecycleError, versioned.OpinionVersionError, ValueError, KeyError, OSError, json.JSONDecodeError) as exc:
|
|
395
|
+
return 2, versioned.pretty({"ok": False, "error": str(exc)})
|