@sakanano-moe/agenthelm 6.6.0 → 6.8.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/package.json +2 -2
- package/runtime/index.json +15 -15
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +11 -4
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/capability-config.md +29 -5
- package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_config.py +178 -22
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +30 -8
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +2 -2
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +5 -5
- package/runtime/plugins/agents-init/content.json +15 -0
- package/runtime/plugins/agents-init/plugin.json +2 -2
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +14 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +3 -3
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +5 -5
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +1 -1
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +2 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +10 -4
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +1 -1
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +2 -2
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""脚手架一致性:把项目和 agents-init 发布的期望骨架清单对比,并保存用户的决定。
|
|
3
|
+
|
|
4
|
+
- ``check``:只读。骨架文件缺失、被内容锁跟踪的模板文件与当前模板不同、Profile 受检字段缺失或不对、
|
|
5
|
+
内容锁版本未知;被用户跳过的项单独列出。
|
|
6
|
+
- ``add_skeleton`` / ``fix_profile`` / ``repair_lock``:默认预览,``apply`` 才写入;替换前备份。
|
|
7
|
+
- ``decide`` / ``undecide``:把“跳过 / 保留 / 稍后”记入决定文件。项目级决定随项目同步
|
|
8
|
+
(``.agents/moe.sakanano.agent-pack/check-decisions.json``);只和这台机器有关的发现(id 以 ``local:`` 开头)
|
|
9
|
+
存在用户状态目录,不进项目。
|
|
10
|
+
|
|
11
|
+
比较的对象只有脚手架。Opinion、Profile 里的项目自有字段与审计字段不参与比较,也不进入指纹。
|
|
12
|
+
本模块由 ``init_project.py``(命令行)和 agent-pack(``agenthelm check``)共同使用。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib.util
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
HERE = Path(__file__).resolve().parent
|
|
29
|
+
MANIFEST_PATH = HERE.parent / "skeleton.json"
|
|
30
|
+
SCHEMA_PATH = HERE.parent / "project.schema.json"
|
|
31
|
+
SKELETON_PACK = "agents-init/project-skeleton"
|
|
32
|
+
DECISIONS_PATH = Path(".agents/moe.sakanano.agent-pack/check-decisions.json")
|
|
33
|
+
DECISION_VALUES = ("skip", "keep", "later")
|
|
34
|
+
LOCAL_PREFIX = "local:"
|
|
35
|
+
# macOS iCloud 未下载文件:不读内容,避免触发下载。
|
|
36
|
+
SF_DATALESS = 0x40000000
|
|
37
|
+
|
|
38
|
+
init: Any = None # init_project 模块;由 bind() 或首次使用时加载
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_default() -> Any:
|
|
42
|
+
name = "agenthelm_init_project"
|
|
43
|
+
path = HERE / "init_project.py"
|
|
44
|
+
loaded = sys.modules.get(name)
|
|
45
|
+
if loaded is not None and Path(getattr(loaded, "__file__", "")).resolve() == path:
|
|
46
|
+
return loaded
|
|
47
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
48
|
+
module = importlib.util.module_from_spec(spec)
|
|
49
|
+
sys.modules[name] = module
|
|
50
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
51
|
+
return module
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bind(module: Any) -> None:
|
|
55
|
+
"""Use the init_project module that is already running, so its exception classes are the ones callers catch."""
|
|
56
|
+
global init
|
|
57
|
+
init = module
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _init() -> Any:
|
|
61
|
+
global init
|
|
62
|
+
if init is None:
|
|
63
|
+
init = _load_default()
|
|
64
|
+
return init
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sha(value: str | bytes) -> str:
|
|
68
|
+
return hashlib.sha256(value.encode("utf-8") if isinstance(value, str) else value).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def now_iso() -> str:
|
|
72
|
+
return datetime.now().astimezone().replace(microsecond=0).isoformat()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fail(message: str, code: str = "invalid-request") -> Exception:
|
|
76
|
+
return _init().InitError(message, code=code)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- manifest
|
|
80
|
+
|
|
81
|
+
def load_manifest() -> dict:
|
|
82
|
+
try:
|
|
83
|
+
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
84
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
85
|
+
raise _fail(f"无法读取期望骨架清单:{MANIFEST_PATH}", "template-invalid") from exc
|
|
86
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("files"), list):
|
|
87
|
+
raise _fail("期望骨架清单格式不对", "template-invalid")
|
|
88
|
+
return manifest
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def pack_versions() -> dict[str, str]:
|
|
92
|
+
"""Versions of the content packs the expectation comes from (part of the fingerprint)."""
|
|
93
|
+
module = _init()
|
|
94
|
+
versions = {}
|
|
95
|
+
try:
|
|
96
|
+
content = json.loads((module.PLUGIN_ROOT / "content.json").read_text(encoding="utf-8"))
|
|
97
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
98
|
+
return versions
|
|
99
|
+
for pack in content.get("packs", []):
|
|
100
|
+
if pack.get("id") in (SKELETON_PACK, "agents-init/project-contract"):
|
|
101
|
+
versions[pack["id"]] = str(pack.get("version", ""))
|
|
102
|
+
return versions
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _relative_ok(value: Any) -> bool:
|
|
106
|
+
if not isinstance(value, str) or not value or value == ".":
|
|
107
|
+
return False
|
|
108
|
+
path = Path(value)
|
|
109
|
+
return not (path.is_absolute() or ".." in path.parts or "\\" in value or re.match(r"^[A-Za-z]:", value)
|
|
110
|
+
or set(path.parts) & {".git", ".agents", ".codex", ".cursor", ".claude"})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get(data: Any, dotted: str) -> tuple[bool, Any]:
|
|
114
|
+
current = data
|
|
115
|
+
for part in dotted.split("."):
|
|
116
|
+
if not isinstance(current, dict) or part not in current:
|
|
117
|
+
return False, None
|
|
118
|
+
current = current[part]
|
|
119
|
+
return True, current
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _set(data: dict, dotted: str, value: Any) -> None:
|
|
123
|
+
current = data
|
|
124
|
+
parts = dotted.split(".")
|
|
125
|
+
for part in parts[:-1]:
|
|
126
|
+
child = current.get(part)
|
|
127
|
+
if not isinstance(child, dict):
|
|
128
|
+
child = {}
|
|
129
|
+
current[part] = child
|
|
130
|
+
current = child
|
|
131
|
+
current[parts[-1]] = copy.deepcopy(value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Context:
|
|
135
|
+
"""Everything derived from one project: its Profile, the inputs it was rendered with, the expected files."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, root: Path) -> None:
|
|
138
|
+
module = _init()
|
|
139
|
+
self.root = root
|
|
140
|
+
self.profile = module._read_project_json(root, module.PROFILE_PATH, "Project Profile")
|
|
141
|
+
self.inputs = self._inputs()
|
|
142
|
+
work = self.profile.get("work") if isinstance(self.profile.get("work"), dict) else {}
|
|
143
|
+
case = work.get("case") if isinstance(work.get("case"), dict) else {}
|
|
144
|
+
memory = self.profile.get("memory") if isinstance(self.profile.get("memory"), dict) else {}
|
|
145
|
+
self.case_root = case.get("root") if _relative_ok(case.get("root")) else "cases"
|
|
146
|
+
self.memory_root = memory.get("root") if _relative_ok(memory.get("root")) else "memory"
|
|
147
|
+
self.rendered = self._rendered()
|
|
148
|
+
self.fresh = json.loads(self.rendered[module.PROFILE_PATH])
|
|
149
|
+
self.pack = module.contract_pack()
|
|
150
|
+
self.tracked = {path.as_posix() for path in module.tracked_paths(self.pack, self.inputs.entry_launcher)}
|
|
151
|
+
self.manifest = load_manifest()
|
|
152
|
+
|
|
153
|
+
def _inputs(self) -> Any:
|
|
154
|
+
module = _init()
|
|
155
|
+
try:
|
|
156
|
+
return module.inputs_from_profile(self.root)
|
|
157
|
+
except module.InitError:
|
|
158
|
+
# 中文项目名没有可推导的 slug;检查只需要渲染出可比较的模板,不影响 Profile。
|
|
159
|
+
details = self.profile.get("profile") or {}
|
|
160
|
+
work = self.profile.get("work") if isinstance(self.profile.get("work"), dict) else {}
|
|
161
|
+
return module.Inputs(
|
|
162
|
+
self.root, str(self.profile.get("name") or self.root.name), "project",
|
|
163
|
+
tuple(details.get("project_type") or ()), str(details.get("vcs") or ""),
|
|
164
|
+
tuple(details.get("stack") or ()), str(details.get("runtime") or ""),
|
|
165
|
+
tuple(details.get("agent_cli") or ()), str(work.get("mode") or "legacy"),
|
|
166
|
+
module._profile_launcher(self.profile),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _rendered(self) -> dict[Path, str]:
|
|
170
|
+
files = _init().files_for(self.inputs)
|
|
171
|
+
files.pop(Path("cases/.gitkeep"), None)
|
|
172
|
+
files.pop(Path("memory/.gitkeep"), None)
|
|
173
|
+
if self.inputs.work_mode == "case-workspace":
|
|
174
|
+
files[Path(self.case_root) / ".gitkeep"] = ""
|
|
175
|
+
files[Path(self.memory_root) / ".gitkeep"] = ""
|
|
176
|
+
return files
|
|
177
|
+
|
|
178
|
+
def entries(self) -> list[dict]:
|
|
179
|
+
"""The manifest's file entries resolved for this project (tokens filled, inapplicable ones dropped)."""
|
|
180
|
+
resolved = []
|
|
181
|
+
for entry in self.manifest["files"]:
|
|
182
|
+
when = entry.get("when")
|
|
183
|
+
if when == "case-workspace" and self.inputs.work_mode != "case-workspace":
|
|
184
|
+
continue
|
|
185
|
+
if when == "entry" and not self.inputs.entry_launcher:
|
|
186
|
+
continue
|
|
187
|
+
path = (entry["path"].replace("{case_root}", self.case_root).replace("{memory_root}", self.memory_root)
|
|
188
|
+
.replace("{launcher}", self.inputs.entry_launcher or ""))
|
|
189
|
+
text = self.rendered.get(Path(path))
|
|
190
|
+
resolved.append({**entry, "path": path, "fingerprint": sha(text if text is not None else path)})
|
|
191
|
+
return resolved
|
|
192
|
+
|
|
193
|
+
def expected_text(self, path: str) -> str | None:
|
|
194
|
+
return self.rendered.get(Path(path))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------- reading project state
|
|
198
|
+
|
|
199
|
+
def _lstat(path: Path) -> os.stat_result | None:
|
|
200
|
+
try:
|
|
201
|
+
return path.lstat()
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _file_digest(path: Path) -> str | None:
|
|
207
|
+
"""sha256 of a regular file; None when it is not a readable regular file or is still in iCloud."""
|
|
208
|
+
metadata = _lstat(path)
|
|
209
|
+
if metadata is None or not stat.S_ISREG(metadata.st_mode):
|
|
210
|
+
return None
|
|
211
|
+
if sys.platform == "darwin" and getattr(metadata, "st_flags", 0) & SF_DATALESS:
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return sha(path.read_bytes())
|
|
215
|
+
except OSError:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def read_lock(root: Path) -> tuple[dict | None, str | None]:
|
|
220
|
+
module = _init()
|
|
221
|
+
try:
|
|
222
|
+
return module._read_project_json(root, module.CONTENT_LOCK_PATH, "content lock"), None
|
|
223
|
+
except module.InitError as exc:
|
|
224
|
+
return None, None if exc.code == "refresh-unavailable" else str(exc)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _skeleton_state(ctx: Context, entry: dict, baseline: dict[str, str]) -> str:
|
|
228
|
+
target = ctx.root / entry["path"]
|
|
229
|
+
compare = entry["compare"]
|
|
230
|
+
if compare == "opinion":
|
|
231
|
+
return "ignored"
|
|
232
|
+
if compare == "dir":
|
|
233
|
+
if _lstat(target) is not None or (ctx.root / Path(entry["path"]).parent).is_dir():
|
|
234
|
+
return "ok"
|
|
235
|
+
return "missing"
|
|
236
|
+
if _lstat(target) is None:
|
|
237
|
+
return "missing"
|
|
238
|
+
if compare == "lock" and entry["path"] in ctx.tracked and entry["path"] not in baseline:
|
|
239
|
+
current = _file_digest(target)
|
|
240
|
+
expected = ctx.expected_text(entry["path"])
|
|
241
|
+
if current is not None and expected is not None and current != sha(expected):
|
|
242
|
+
return "differs"
|
|
243
|
+
return "ok"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _check_profile(ctx: Context) -> list[dict]:
|
|
247
|
+
work_mode = ctx.inputs.work_mode
|
|
248
|
+
rows = []
|
|
249
|
+
for rule in ctx.manifest.get("profile", []):
|
|
250
|
+
if rule.get("when") == "case-workspace" and work_mode != "case-workspace":
|
|
251
|
+
continue
|
|
252
|
+
present, value = _get(ctx.profile, rule["path"])
|
|
253
|
+
_, default = _get(ctx.fresh, rule["path"])
|
|
254
|
+
mode = rule["mode"]
|
|
255
|
+
if mode == "default" and default is None:
|
|
256
|
+
mode = "manual"
|
|
257
|
+
expected = default if mode in ("default", "equal") else None
|
|
258
|
+
kinds = {"string": str, "boolean": bool, "array": list}
|
|
259
|
+
kind = kinds[rule["type"]]
|
|
260
|
+
if not present:
|
|
261
|
+
state = "missing"
|
|
262
|
+
elif value is None and mode == "default":
|
|
263
|
+
state = "ok"
|
|
264
|
+
elif not isinstance(value, kind) or (kind is str and rule.get("enum") and value not in rule["enum"]) \
|
|
265
|
+
or (mode == "equal" and value != expected):
|
|
266
|
+
state = "invalid"
|
|
267
|
+
else:
|
|
268
|
+
state = "ok"
|
|
269
|
+
rows.append({"field": rule["path"], "state": state, "mode": mode, "expected": expected,
|
|
270
|
+
"value": value if state == "invalid" else None,
|
|
271
|
+
"fingerprint": sha(json.dumps({"field": rule["path"], "expected": expected,
|
|
272
|
+
"enum": rule.get("enum")}, sort_keys=True, ensure_ascii=False))})
|
|
273
|
+
return rows
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _project_fields(ctx: Context) -> list[str]:
|
|
277
|
+
"""Profile fields the scaffold does not know: the project's own, never compared."""
|
|
278
|
+
try:
|
|
279
|
+
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
280
|
+
except (OSError, ValueError):
|
|
281
|
+
return []
|
|
282
|
+
known = set(ctx.manifest.get("audit_fields", [])) | set(ctx.manifest.get("opinion_fields", [])) \
|
|
283
|
+
| {rule["path"] for rule in ctx.manifest.get("profile", [])}
|
|
284
|
+
extras: list[str] = []
|
|
285
|
+
|
|
286
|
+
def walk(value: dict, props: dict, prefix: str) -> None:
|
|
287
|
+
for key, child in value.items():
|
|
288
|
+
dotted = f"{prefix}{key}"
|
|
289
|
+
declared = props.get(key)
|
|
290
|
+
if declared is None:
|
|
291
|
+
if not any(item == dotted or item.startswith(dotted + ".") for item in known):
|
|
292
|
+
extras.append(dotted)
|
|
293
|
+
elif isinstance(child, dict) and isinstance(declared.get("properties"), dict):
|
|
294
|
+
walk(child, declared["properties"], dotted + ".")
|
|
295
|
+
|
|
296
|
+
walk(ctx.profile, schema.get("properties") or {}, "")
|
|
297
|
+
return sorted(extras)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------- decisions
|
|
301
|
+
|
|
302
|
+
def local_decisions_file(state_dir: Path, root: Path) -> Path:
|
|
303
|
+
return state_dir / "agenthelm" / "decisions" / f"{sha(str(root))[:16]}.json"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def default_state_dir() -> Path:
|
|
307
|
+
base = os.environ.get("XDG_STATE_HOME")
|
|
308
|
+
return Path(base) if base else Path.home() / ".local" / "state"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def read_decisions(path: Path) -> tuple[dict[str, dict], str | None, str | None]:
|
|
312
|
+
"""(decisions, sha256 of the file text or None, error)."""
|
|
313
|
+
if path.is_symlink():
|
|
314
|
+
return {}, None, f"拒绝符号链接:{path.name}"
|
|
315
|
+
try:
|
|
316
|
+
text = path.read_text(encoding="utf-8")
|
|
317
|
+
except FileNotFoundError:
|
|
318
|
+
return {}, None, None
|
|
319
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
320
|
+
return {}, None, f"决定文件无法读取:{exc}"
|
|
321
|
+
try:
|
|
322
|
+
data = json.loads(text)
|
|
323
|
+
except ValueError as exc:
|
|
324
|
+
return {}, sha(text), f"决定文件不是合法 JSON:{exc}"
|
|
325
|
+
table = data.get("decisions") if isinstance(data, dict) else None
|
|
326
|
+
if not isinstance(table, dict) or not all(isinstance(item, dict) and item.get("decision") in DECISION_VALUES
|
|
327
|
+
for item in table.values()):
|
|
328
|
+
return {}, sha(text), "决定文件格式不对(需要 decisions 对象,decision 为 skip / keep / later)"
|
|
329
|
+
return table, sha(text), None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def conflict_copies(root: Path) -> list[str]:
|
|
333
|
+
"""Copies a sync service leaves when two machines wrote the decisions file at once."""
|
|
334
|
+
directory = (root / DECISIONS_PATH).parent
|
|
335
|
+
stem = DECISIONS_PATH.stem
|
|
336
|
+
if not directory.is_dir():
|
|
337
|
+
return []
|
|
338
|
+
return sorted(p.name for p in directory.iterdir()
|
|
339
|
+
if p.name != DECISIONS_PATH.name and p.name.startswith(stem) and p.suffix == ".json")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def load_decisions(root: Path, state_dir: Path | None) -> dict:
|
|
343
|
+
project, project_sha, project_error = read_decisions(root / DECISIONS_PATH)
|
|
344
|
+
local_path = local_decisions_file(state_dir or default_state_dir(), root)
|
|
345
|
+
local, local_sha, local_error = read_decisions(local_path)
|
|
346
|
+
return {"project": project, "project_sha": project_sha, "local": local, "local_sha": local_sha,
|
|
347
|
+
"local_path": str(local_path), "errors": [e for e in (project_error, local_error) if e],
|
|
348
|
+
"conflict_copies": conflict_copies(root)}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def partition(findings: list[dict], decisions: dict, *, reprompt: bool = False) -> tuple[list[dict], list[dict]]:
|
|
352
|
+
"""Split findings into those still to show and those the user decided on (and whose expectation is unchanged)."""
|
|
353
|
+
active, skipped = [], []
|
|
354
|
+
for finding in findings:
|
|
355
|
+
table = decisions["local"] if finding.get("scope") == "machine" else decisions["project"]
|
|
356
|
+
decision = table.get(finding["id"])
|
|
357
|
+
item = dict(finding)
|
|
358
|
+
if decision is not None:
|
|
359
|
+
if reprompt:
|
|
360
|
+
item["previous_decision"] = decision
|
|
361
|
+
elif decision["decision"] in ("skip", "keep") and decision.get("fingerprint") == finding["fingerprint"]:
|
|
362
|
+
skipped.append({**item, "decision": decision})
|
|
363
|
+
continue
|
|
364
|
+
elif decision["decision"] == "later":
|
|
365
|
+
item["deferred"] = decision
|
|
366
|
+
else:
|
|
367
|
+
item["changed_since_decision"] = decision
|
|
368
|
+
active.append(item)
|
|
369
|
+
return active, skipped
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def keep_fingerprints(root: Path) -> dict[str, str]:
|
|
373
|
+
"""Files the user chose to keep, with the expected-content fingerprint of that moment (refresh honours them)."""
|
|
374
|
+
table, _, error = read_decisions(root / DECISIONS_PATH)
|
|
375
|
+
if error:
|
|
376
|
+
return {}
|
|
377
|
+
return {key[len("content:"):]: item["fingerprint"] for key, item in table.items()
|
|
378
|
+
if key.startswith("content:") and item["decision"] == "keep" and item.get("fingerprint")}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------- case directory naming
|
|
382
|
+
|
|
383
|
+
CASE_STYLES = {
|
|
384
|
+
"case-id": r"CASE-[0-9]{8}-[0-9]{3}-[a-z0-9]+(?:-[a-z0-9]+)*",
|
|
385
|
+
"date-topic": r"[0-9]{8}-.+",
|
|
386
|
+
}
|
|
387
|
+
TEXT_SUFFIXES = {".md", ".json", ".txt", ".yaml", ".yml", ".csv", ".html", ".toml"}
|
|
388
|
+
SKIPPED_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".cache", "dist", "build", "target"}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def case_naming_spec(profile: dict) -> dict:
|
|
392
|
+
"""The effective naming rule: ``work.case.directory_naming`` if declared, else the default Case id."""
|
|
393
|
+
work = profile.get("work") if isinstance(profile.get("work"), dict) else {}
|
|
394
|
+
case = work.get("case") if isinstance(work.get("case"), dict) else {}
|
|
395
|
+
declared = case.get("directory_naming")
|
|
396
|
+
if declared is None:
|
|
397
|
+
return {"style": "case-id", "pattern": CASE_STYLES["case-id"], "declared": False, "error": None}
|
|
398
|
+
if not isinstance(declared, dict):
|
|
399
|
+
return {"style": None, "pattern": None, "declared": True, "error": "directory_naming 必须是对象"}
|
|
400
|
+
pattern = declared.get("pattern")
|
|
401
|
+
style = declared.get("style") or ("custom" if pattern else "case-id")
|
|
402
|
+
if style not in (*CASE_STYLES, "custom"):
|
|
403
|
+
return {"style": style, "pattern": None, "declared": True,
|
|
404
|
+
"error": "style 只能是 case-id / date-topic / custom"}
|
|
405
|
+
if pattern is not None and not isinstance(pattern, str):
|
|
406
|
+
return {"style": style, "pattern": None, "declared": True, "error": "pattern 必须是字符串(正则,匹配整个目录名)"}
|
|
407
|
+
if style == "custom" and not pattern:
|
|
408
|
+
return {"style": style, "pattern": None, "declared": True, "error": "style 为 custom 时需要 pattern"}
|
|
409
|
+
pattern = pattern or CASE_STYLES[style]
|
|
410
|
+
try:
|
|
411
|
+
re.compile(pattern)
|
|
412
|
+
except re.error as exc:
|
|
413
|
+
return {"style": style, "pattern": pattern, "declared": True, "error": f"pattern 不是合法正则:{exc}"}
|
|
414
|
+
return {"style": style, "pattern": pattern, "declared": True, "error": None}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _case_directories(ctx: Context) -> list[str]:
|
|
418
|
+
base = ctx.root / ctx.case_root
|
|
419
|
+
if not base.is_dir() or base.is_symlink():
|
|
420
|
+
return []
|
|
421
|
+
return sorted(p.name for p in base.iterdir()
|
|
422
|
+
if p.is_dir() and not p.is_symlink() and not p.name.startswith((".", "_")) and (p / "CASE.md").is_file())
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def check_case_naming(ctx: Context) -> dict:
|
|
426
|
+
spec = case_naming_spec(ctx.profile)
|
|
427
|
+
names = _case_directories(ctx)
|
|
428
|
+
mismatched = []
|
|
429
|
+
if spec["error"] is None and ctx.inputs.work_mode == "case-workspace":
|
|
430
|
+
compiled = re.compile(spec["pattern"])
|
|
431
|
+
mismatched = [name for name in names if not compiled.fullmatch(name)]
|
|
432
|
+
return {**spec, "root": ctx.case_root, "directories": len(names), "mismatched": mismatched[:200],
|
|
433
|
+
"mismatched_count": len(mismatched)}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _suggest_name(name: str, style: str, taken: set[str]) -> str | None:
|
|
437
|
+
"""A rename that fits ``case-id`` or ``date-topic`` when the old name carries a date; otherwise None (you decide)."""
|
|
438
|
+
match = re.match(r"^(?:CASE-)?([0-9]{8})-(?:[0-9]{3}-)?(.+)$", name)
|
|
439
|
+
if not match or style not in CASE_STYLES:
|
|
440
|
+
return None
|
|
441
|
+
date, topic = match.groups()
|
|
442
|
+
slug = re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-")
|
|
443
|
+
if not slug:
|
|
444
|
+
return None
|
|
445
|
+
if style == "date-topic":
|
|
446
|
+
return f"{date}-{slug}"
|
|
447
|
+
sequence = 1
|
|
448
|
+
while f"CASE-{date}-{sequence:03d}-{slug}" in taken:
|
|
449
|
+
sequence += 1
|
|
450
|
+
return f"CASE-{date}-{sequence:03d}-{slug}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _references(root: Path, needles: dict[str, str], limit_files: int = 3000) -> dict[str, list[str]]:
|
|
454
|
+
"""Text files that mention a Case directory name; read-only and never touching iCloud placeholders."""
|
|
455
|
+
found: dict[str, list[str]] = {name: [] for name in needles}
|
|
456
|
+
seen = 0
|
|
457
|
+
for current, directories, files in os.walk(root):
|
|
458
|
+
directories[:] = [d for d in directories if d not in SKIPPED_DIRS and not (Path(current) / d).is_symlink()]
|
|
459
|
+
for filename in files:
|
|
460
|
+
path = Path(current) / filename
|
|
461
|
+
if path.suffix.lower() not in TEXT_SUFFIXES or path.is_symlink():
|
|
462
|
+
continue
|
|
463
|
+
seen += 1
|
|
464
|
+
if seen > limit_files:
|
|
465
|
+
return found
|
|
466
|
+
try:
|
|
467
|
+
metadata = path.lstat()
|
|
468
|
+
if metadata.st_size > 512 * 1024 or (sys.platform == "darwin" and getattr(metadata, "st_flags", 0) & SF_DATALESS):
|
|
469
|
+
continue
|
|
470
|
+
text = path.read_text(encoding="utf-8")
|
|
471
|
+
except (OSError, UnicodeDecodeError):
|
|
472
|
+
continue
|
|
473
|
+
for name in needles:
|
|
474
|
+
if name in text and len(found[name]) < 20:
|
|
475
|
+
found[name].append(path.relative_to(root).as_posix())
|
|
476
|
+
return found
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def case_naming_plan(root: Path) -> dict:
|
|
480
|
+
"""What would have to change for the case directories to follow the declared naming. Moves nothing."""
|
|
481
|
+
ctx = Context(root)
|
|
482
|
+
naming = check_case_naming(ctx)
|
|
483
|
+
result = {"ok": True, "mode": "case-naming", "applied": False, "spec": {k: naming[k] for k in ("style", "pattern", "declared")},
|
|
484
|
+
"error": naming["error"], "renames": [], "declaration_hint": None, "moves_directories": False}
|
|
485
|
+
if naming["error"]:
|
|
486
|
+
return result
|
|
487
|
+
names = _case_directories(ctx)
|
|
488
|
+
taken = set(names)
|
|
489
|
+
mismatched = naming["mismatched"]
|
|
490
|
+
refs = _references(root, {name: name for name in mismatched})
|
|
491
|
+
for name in mismatched:
|
|
492
|
+
suggestion = _suggest_name(name, naming["style"], taken)
|
|
493
|
+
if suggestion:
|
|
494
|
+
taken.add(suggestion)
|
|
495
|
+
result["renames"].append({"from": f"{ctx.case_root}/{name}", "to": f"{ctx.case_root}/{suggestion}" if suggestion else None,
|
|
496
|
+
"references": refs.get(name, []),
|
|
497
|
+
"note": "" if suggestion else "名称里没有日期或主题,需要你自己决定新名字",
|
|
498
|
+
})
|
|
499
|
+
if not naming["declared"] and mismatched:
|
|
500
|
+
names_style = "date-topic" if all(re.fullmatch(CASE_STYLES["date-topic"], n) for n in mismatched) else "custom"
|
|
501
|
+
result["declaration_hint"] = {
|
|
502
|
+
"if_this_is_your_convention": {"work": {"case": {"directory_naming": (
|
|
503
|
+
{"style": "date-topic"} if names_style == "date-topic" else {"style": "custom", "pattern": "<匹配全部目录名的正则>"})}}},
|
|
504
|
+
"note": "目录名是你的约定的话,在 Profile 里声明它(fix profile 不会替你写这一项),之后 check 按声明检查"}
|
|
505
|
+
return result
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------- fingerprint
|
|
509
|
+
|
|
510
|
+
def fingerprint(report: dict) -> dict:
|
|
511
|
+
"""One hash over the scaffold state: expected-list versions, skeleton state, checked Profile fields, lock.
|
|
512
|
+
|
|
513
|
+
Skipped items, Opinion, audit fields and the project's own fields are left out, so two machines that agree on
|
|
514
|
+
the scaffold get the same value no matter what the project adds on top.
|
|
515
|
+
"""
|
|
516
|
+
skipped = {item["id"] for item in report["skipped"]}
|
|
517
|
+
detail: dict[str, str] = {}
|
|
518
|
+
for pack, version in sorted(report["packs"].items()):
|
|
519
|
+
detail[f"expected:{pack}"] = version
|
|
520
|
+
for row in report["skeleton"]:
|
|
521
|
+
if row["state"] == "ignored" or f"skel:{row['path']}" in skipped or f"content:{row['path']}" in skipped:
|
|
522
|
+
continue
|
|
523
|
+
detail[f"skeleton:{row['path']}"] = row["state"]
|
|
524
|
+
for row in report["profile"]:
|
|
525
|
+
if f"profile:{row['field']}" not in skipped:
|
|
526
|
+
detail[f"profile:{row['field']}"] = row["state"]
|
|
527
|
+
lock = report["lock"]
|
|
528
|
+
if lock["pack"] and f"lock:{lock['pack']}" not in skipped:
|
|
529
|
+
detail["lock:version"] = f"{lock['status']}@{lock['version']}"
|
|
530
|
+
naming = report["case_naming"]
|
|
531
|
+
if "case-naming" not in skipped:
|
|
532
|
+
detail["case-naming"] = f"{naming['style']}:{naming['pattern']}:" + ("error" if naming["error"] else
|
|
533
|
+
"mismatch" if naming["mismatched_count"] else "ok")
|
|
534
|
+
digest = sha(json.dumps(detail, sort_keys=True, ensure_ascii=False))
|
|
535
|
+
return {"fingerprint": f"sha256:{digest}", "detail": detail}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def explain(mine: dict[str, str], theirs: dict[str, str]) -> list[str]:
|
|
539
|
+
"""Human-readable differences between two fingerprint details (this machine vs another)."""
|
|
540
|
+
lines = []
|
|
541
|
+
for key in sorted(set(mine) | set(theirs)):
|
|
542
|
+
if mine.get(key) == theirs.get(key):
|
|
543
|
+
continue
|
|
544
|
+
if key not in theirs:
|
|
545
|
+
lines.append(f"{key}:只有这台有({mine[key]})")
|
|
546
|
+
elif key not in mine:
|
|
547
|
+
lines.append(f"{key}:只有对方有({theirs[key]})")
|
|
548
|
+
elif key.startswith("expected:"):
|
|
549
|
+
lines.append(f"{key}:期望清单版本不同——这台 {mine[key]},对方 {theirs[key]}(先把两边的 agents-init 更新到同一版本)")
|
|
550
|
+
else:
|
|
551
|
+
lines.append(f"{key}:这台 {mine[key]},对方 {theirs[key]}")
|
|
552
|
+
return lines
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------- check
|
|
556
|
+
|
|
557
|
+
def _finding(identifier: str, kind: str, title: str, reason: str, fingerprint: str, fix: str | None,
|
|
558
|
+
**extra: Any) -> dict:
|
|
559
|
+
return {"id": identifier, "kind": kind, "level": "suggest", "scope": "project", "title": title,
|
|
560
|
+
"reason": reason, "fingerprint": fingerprint, "fix": fix, **extra}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def check(root: Path, *, state_dir: Path | None = None, reprompt: bool = False) -> dict:
|
|
564
|
+
"""Read-only comparison of the project with the expected scaffold."""
|
|
565
|
+
module = _init()
|
|
566
|
+
ctx = Context(root)
|
|
567
|
+
lock, lock_error = read_lock(root)
|
|
568
|
+
pack_entry = ((lock or {}).get("packs") or {}).get(ctx.pack["id"]) if ctx.pack else None
|
|
569
|
+
baseline = dict((pack_entry or {}).get("files") or {})
|
|
570
|
+
versions = pack_versions()
|
|
571
|
+
findings: list[dict] = []
|
|
572
|
+
|
|
573
|
+
skeleton = []
|
|
574
|
+
for entry in ctx.entries():
|
|
575
|
+
state = _skeleton_state(ctx, entry, baseline)
|
|
576
|
+
skeleton.append({"path": entry["path"], "compare": entry["compare"], "required": entry["required"],
|
|
577
|
+
"state": state, "fingerprint": entry["fingerprint"]})
|
|
578
|
+
if state == "missing" and entry["required"]:
|
|
579
|
+
findings.append(_finding(f"skel:{entry['path']}", "skeleton-missing", f"缺少脚手架文件 {entry['path']}",
|
|
580
|
+
"期望骨架清单里有,项目里没有", entry["fingerprint"], "add-skeleton",
|
|
581
|
+
path=entry["path"]))
|
|
582
|
+
elif state == "differs":
|
|
583
|
+
findings.append(_finding(f"content:{entry['path']}", "content-differs", f"{entry['path']} 与当前模板不同",
|
|
584
|
+
"内容锁里没有它的基线,无法判断是你改过还是模板变了",
|
|
585
|
+
entry["fingerprint"], "update-content", path=entry["path"]))
|
|
586
|
+
|
|
587
|
+
profile_rows = _check_profile(ctx)
|
|
588
|
+
for row in profile_rows:
|
|
589
|
+
if row["state"] == "ok":
|
|
590
|
+
continue
|
|
591
|
+
fixable = row["mode"] in ("default", "equal")
|
|
592
|
+
what = "缺少" if row["state"] == "missing" else "不符合期望"
|
|
593
|
+
findings.append(_finding(f"profile:{row['field']}", f"profile-{row['state']}",
|
|
594
|
+
f"Profile {what}:{row['field']}",
|
|
595
|
+
f"期望 {json.dumps(row['expected'], ensure_ascii=False)}" if fixable else "需要你自己填写",
|
|
596
|
+
row["fingerprint"], "profile" if fixable else None, field=row["field"]))
|
|
597
|
+
|
|
598
|
+
lock_state = {"pack": ctx.pack["id"] if ctx.pack else None, "version": (pack_entry or {}).get("version"),
|
|
599
|
+
"latest": ctx.pack["version"] if ctx.pack else None, "error": lock_error, "status": "ok"}
|
|
600
|
+
if pack_entry is not None and pack_entry.get("version") == module.UNKNOWN_PACK_VERSION:
|
|
601
|
+
repairable, _ = _lock_repair_plan(ctx, lock)
|
|
602
|
+
lock_state["status"] = "unknown-version"
|
|
603
|
+
findings.append(_finding(
|
|
604
|
+
f"lock:{ctx.pack['id']}", "lock-version", "内容锁的模板版本未知(0.0.0)",
|
|
605
|
+
"旧版本写入的占位版本" + (";基线与当前模板一致,可自动修复" if repairable else ";基线与当前模板不同,需要逐文件选择"),
|
|
606
|
+
sha(f"{ctx.pack['id']}@{module.UNKNOWN_PACK_VERSION}"), "repair-lock" if repairable else "update-content"))
|
|
607
|
+
if lock_error:
|
|
608
|
+
lock_state["status"] = "error"
|
|
609
|
+
|
|
610
|
+
naming = check_case_naming(ctx)
|
|
611
|
+
if naming["error"]:
|
|
612
|
+
findings.append(_finding("profile:work.case.directory_naming", "profile-invalid",
|
|
613
|
+
"Profile 的 work.case.directory_naming 无法使用", naming["error"],
|
|
614
|
+
sha(json.dumps({"field": "work.case.directory_naming", "error": naming["error"]})), None,
|
|
615
|
+
field="work.case.directory_naming"))
|
|
616
|
+
elif naming["mismatched_count"]:
|
|
617
|
+
shown = "、".join(naming["mismatched"][:3]) + ("……" if naming["mismatched_count"] > 3 else "")
|
|
618
|
+
source = "Profile 声明的" if naming["declared"] else "默认的 CASE-日期-序号-主题"
|
|
619
|
+
findings.append(_finding("case-naming", "case-naming",
|
|
620
|
+
f"{naming['mismatched_count']} 个 Case 目录的命名与{source}规则不符",
|
|
621
|
+
f"例如 {shown};规则 {naming['pattern']}",
|
|
622
|
+
sha(json.dumps({"style": naming["style"], "pattern": naming["pattern"]})), "case-naming"))
|
|
623
|
+
extras = [{"path": p, "kind": "legacy"} for p in ctx.manifest.get("legacy_paths", []) if _lstat(root / p)]
|
|
624
|
+
extras += [{"path": f, "kind": "project-field"} for f in _project_fields(ctx)]
|
|
625
|
+
|
|
626
|
+
decisions = load_decisions(root, state_dir)
|
|
627
|
+
active, skipped = partition(findings, decisions, reprompt=reprompt)
|
|
628
|
+
return {"ok": not active, "mode": "check", "packs": versions, "skeleton": skeleton, "profile": profile_rows,
|
|
629
|
+
"lock": lock_state, "case_naming": naming, "extras": extras, "findings": active, "skipped": skipped,
|
|
630
|
+
"decisions": {"path": DECISIONS_PATH.as_posix(), "errors": decisions["errors"],
|
|
631
|
+
"conflict_copies": decisions["conflict_copies"], "local_path": decisions["local_path"]}}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------- shared write helpers
|
|
635
|
+
|
|
636
|
+
def _backup(root: Path, recovery_dir: Path | None, relative: Path) -> str:
|
|
637
|
+
if recovery_dir is None:
|
|
638
|
+
raise _fail("替换文件前需要 --recovery-dir")
|
|
639
|
+
recovery = recovery_dir.expanduser().resolve()
|
|
640
|
+
if recovery == root or root in recovery.parents:
|
|
641
|
+
raise _fail("--recovery-dir must be outside the project root")
|
|
642
|
+
archived = recovery / relative
|
|
643
|
+
if archived.exists():
|
|
644
|
+
raise _fail(f"recovery collision: {relative}")
|
|
645
|
+
archived.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
646
|
+
archived.write_bytes((root / relative).read_bytes())
|
|
647
|
+
return str(recovery)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _record_baseline(root: Path, pack: dict, additions: dict[str, str]) -> bool:
|
|
651
|
+
"""Add baselines for files this tool just wrote, but only where the lock's version is the current one."""
|
|
652
|
+
module = _init()
|
|
653
|
+
if not additions:
|
|
654
|
+
return False
|
|
655
|
+
path = root / module.CONTENT_LOCK_PATH
|
|
656
|
+
lock = module._read_project_json(root, module.CONTENT_LOCK_PATH, "content lock") if path.exists() \
|
|
657
|
+
else {"schema_version": 1, "packs": {}}
|
|
658
|
+
entry = lock.setdefault("packs", {}).get(pack["id"])
|
|
659
|
+
if entry is None:
|
|
660
|
+
entry = {"plugin": "agents-init", "version": pack["version"], "files": {}}
|
|
661
|
+
elif entry.get("version") != pack["version"]:
|
|
662
|
+
return False
|
|
663
|
+
entry["files"] = dict(sorted({**entry.get("files", {}), **additions}.items()))
|
|
664
|
+
lock["packs"][pack["id"]] = entry
|
|
665
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
666
|
+
module._atomic_write(path, module.json_text(lock))
|
|
667
|
+
return True
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# ---------------------------------------------------------------- add-skeleton
|
|
671
|
+
|
|
672
|
+
def add_skeleton(root: Path, targets: list[str] | None, *, apply: bool = False, state_dir: Path | None = None) -> dict:
|
|
673
|
+
"""Create missing skeleton files from the templates; existing files are never touched."""
|
|
674
|
+
module = _init()
|
|
675
|
+
ctx = Context(root)
|
|
676
|
+
report = check(root, state_dir=state_dir, reprompt=True)
|
|
677
|
+
known = {item["path"]: item for item in report["skeleton"] if item["state"] != "ignored"}
|
|
678
|
+
if targets:
|
|
679
|
+
unknown = [t for t in targets if t not in known]
|
|
680
|
+
if unknown:
|
|
681
|
+
raise _fail("不在期望骨架清单里:" + "、".join(unknown))
|
|
682
|
+
wanted = list(dict.fromkeys(targets))
|
|
683
|
+
else:
|
|
684
|
+
wanted = [path for path, item in known.items() if item["state"] == "missing" and item["required"]]
|
|
685
|
+
missing = [p for p in wanted if known[p]["state"] == "missing"]
|
|
686
|
+
unchanged = [p for p in wanted if known[p]["state"] != "missing"]
|
|
687
|
+
files = {Path(p): ctx.rendered.get(Path(p), "") for p in missing}
|
|
688
|
+
create: list[Path] = []
|
|
689
|
+
if files:
|
|
690
|
+
create, collisions, _ = module.plan_writes(root, files)
|
|
691
|
+
unchanged += [str(p) for p in collisions]
|
|
692
|
+
result = {"ok": True, "mode": "add-skeleton", "applied": False,
|
|
693
|
+
"create": [{"path": str(p), "bytes": len(files[p].encode("utf-8"))} for p in create],
|
|
694
|
+
"unchanged": sorted(unchanged)}
|
|
695
|
+
if not apply or not create:
|
|
696
|
+
return result
|
|
697
|
+
module.apply_writes(root, files, create)
|
|
698
|
+
launcher = ctx.inputs.entry_launcher
|
|
699
|
+
if launcher and Path(launcher) in create:
|
|
700
|
+
os.chmod(root / launcher, 0o755)
|
|
701
|
+
if ctx.pack:
|
|
702
|
+
_record_baseline(root, ctx.pack, {str(p): sha(files[p]) for p in create if str(p) in ctx.tracked})
|
|
703
|
+
result["applied"] = True
|
|
704
|
+
return result
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ---------------------------------------------------------------- profile
|
|
708
|
+
|
|
709
|
+
def fix_profile(root: Path, fields: list[str] | None, *, apply: bool = False, recovery_dir: Path | None = None,
|
|
710
|
+
state_dir: Path | None = None) -> dict:
|
|
711
|
+
"""Set the selected missing/invalid Profile fields to the template value; nothing else changes."""
|
|
712
|
+
module = _init()
|
|
713
|
+
report = check(root, state_dir=state_dir, reprompt=True)
|
|
714
|
+
rows = {row["field"]: row for row in report["profile"] if row["state"] != "ok" and row["mode"] in ("default", "equal")}
|
|
715
|
+
if fields:
|
|
716
|
+
unknown = [f for f in fields if f not in rows]
|
|
717
|
+
if unknown:
|
|
718
|
+
raise _fail("这些字段不需要或不能自动修复:" + "、".join(unknown))
|
|
719
|
+
selected = list(dict.fromkeys(fields))
|
|
720
|
+
else:
|
|
721
|
+
selected = list(rows)
|
|
722
|
+
profile = module._read_project_json(root, module.PROFILE_PATH, "Project Profile")
|
|
723
|
+
updated = copy.deepcopy(profile)
|
|
724
|
+
for field in selected:
|
|
725
|
+
_set(updated, field, rows[field]["expected"])
|
|
726
|
+
changed = updated != profile
|
|
727
|
+
result = {"ok": True, "mode": "profile", "applied": False, "fields": selected if changed else [],
|
|
728
|
+
"diff": module._unified_diff(module.PROFILE_PATH.as_posix(), module.json_text(profile),
|
|
729
|
+
module.json_text(updated)) if changed else ""}
|
|
730
|
+
if not apply or not changed:
|
|
731
|
+
return result
|
|
732
|
+
if module._read_project_json(root, module.PROFILE_PATH, "Project Profile") != profile:
|
|
733
|
+
raise _fail("Profile 刚被改过,请重新预览", "conflict")
|
|
734
|
+
result["recovery"] = _backup(root, recovery_dir, module.PROFILE_PATH)
|
|
735
|
+
module._atomic_write(root / module.PROFILE_PATH, module.json_text(updated))
|
|
736
|
+
result["applied"] = True
|
|
737
|
+
return result
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ---------------------------------------------------------------- content lock
|
|
741
|
+
|
|
742
|
+
def _lock_repair_plan(ctx: Context, lock: dict | None) -> tuple[bool, dict]:
|
|
743
|
+
"""(can the 0.0.0 version be replaced truthfully, the lock entry as it would become)."""
|
|
744
|
+
module = _init()
|
|
745
|
+
if ctx.pack is None or lock is None:
|
|
746
|
+
return False, {}
|
|
747
|
+
entry = copy.deepcopy((lock.get("packs") or {}).get(ctx.pack["id"]) or {})
|
|
748
|
+
files = dict(entry.get("files") or {})
|
|
749
|
+
comparable = True
|
|
750
|
+
for path, digest in files.items():
|
|
751
|
+
text = ctx.expected_text(path)
|
|
752
|
+
if text is None or sha(text) != digest:
|
|
753
|
+
comparable = False
|
|
754
|
+
if entry.get("version") == module.UNKNOWN_PACK_VERSION and comparable:
|
|
755
|
+
entry["version"] = ctx.pack["version"]
|
|
756
|
+
return comparable, entry
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def repair_lock(root: Path, *, apply: bool = False) -> dict:
|
|
760
|
+
"""Replace a 0.0.0 lock version by the real one and record baselines for tracked files equal to the template.
|
|
761
|
+
|
|
762
|
+
The version is only replaced when every recorded baseline equals what the current template renders: then the
|
|
763
|
+
baselines really are this template version. Otherwise nothing is guessed; ``update-content`` decides per file.
|
|
764
|
+
"""
|
|
765
|
+
module = _init()
|
|
766
|
+
ctx = Context(root)
|
|
767
|
+
lock, error = read_lock(root)
|
|
768
|
+
result = {"ok": True, "mode": "repair-lock", "applied": False, "changes": [], "blocked": "", "diff": ""}
|
|
769
|
+
if ctx.pack is None or lock is None:
|
|
770
|
+
result["blocked"] = error or "项目没有内容锁"
|
|
771
|
+
return result
|
|
772
|
+
entry = (lock.get("packs") or {}).get(ctx.pack["id"])
|
|
773
|
+
if entry is None:
|
|
774
|
+
result["blocked"] = "内容锁里没有项目契约模板包"
|
|
775
|
+
return result
|
|
776
|
+
comparable, new_entry = _lock_repair_plan(ctx, lock)
|
|
777
|
+
if entry.get("version") == module.UNKNOWN_PACK_VERSION and not comparable:
|
|
778
|
+
result["blocked"] = "记录的基线与当前模板不同,无法确定真实版本:用 update-content 逐文件选择保留或采用"
|
|
779
|
+
return result
|
|
780
|
+
if new_entry["version"] != entry.get("version"):
|
|
781
|
+
result["changes"].append(f"模板版本 {entry.get('version')} → {new_entry['version']}")
|
|
782
|
+
if new_entry.get("version") == ctx.pack["version"]:
|
|
783
|
+
files = dict(new_entry.get("files") or {})
|
|
784
|
+
for path in sorted(ctx.tracked):
|
|
785
|
+
text = ctx.expected_text(path)
|
|
786
|
+
current = _file_digest(root / path)
|
|
787
|
+
if path not in files and text is not None and current == sha(text):
|
|
788
|
+
files[path] = current
|
|
789
|
+
result["changes"].append(f"记录基线:{path}")
|
|
790
|
+
new_entry["files"] = dict(sorted(files.items()))
|
|
791
|
+
if not result["changes"]:
|
|
792
|
+
return result
|
|
793
|
+
new_lock = copy.deepcopy(lock)
|
|
794
|
+
new_lock["packs"][ctx.pack["id"]] = new_entry
|
|
795
|
+
result["diff"] = module._unified_diff(module.CONTENT_LOCK_PATH.as_posix(), module.json_text(lock),
|
|
796
|
+
module.json_text(new_lock))
|
|
797
|
+
if not apply:
|
|
798
|
+
return result
|
|
799
|
+
fresh, _ = read_lock(root)
|
|
800
|
+
if fresh != lock:
|
|
801
|
+
raise _fail("内容锁刚被改过,请重新预览", "conflict")
|
|
802
|
+
module._atomic_write(root / module.CONTENT_LOCK_PATH, module.json_text(new_lock))
|
|
803
|
+
result["applied"] = True
|
|
804
|
+
return result
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ---------------------------------------------------------------- recording decisions
|
|
808
|
+
|
|
809
|
+
def _decision_file(root: Path, state_dir: Path | None, scope: str) -> Path:
|
|
810
|
+
return local_decisions_file(state_dir or default_state_dir(), root) if scope == "machine" else root / DECISIONS_PATH
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _store(path: Path, table: dict, expect_sha: str | None, root: Path, scope: str) -> None:
|
|
814
|
+
module = _init()
|
|
815
|
+
if scope == "project":
|
|
816
|
+
for parent in [path.parent, *path.parents]:
|
|
817
|
+
if parent == root:
|
|
818
|
+
break
|
|
819
|
+
if parent.is_symlink():
|
|
820
|
+
raise _fail(f"拒绝符号链接:{parent.relative_to(root)}")
|
|
821
|
+
current_sha = None
|
|
822
|
+
try:
|
|
823
|
+
current_sha = sha(path.read_text(encoding="utf-8"))
|
|
824
|
+
except FileNotFoundError:
|
|
825
|
+
pass
|
|
826
|
+
except (OSError, UnicodeDecodeError):
|
|
827
|
+
pass
|
|
828
|
+
if current_sha != expect_sha:
|
|
829
|
+
raise _fail("决定文件刚被别处改过(可能是另一台机器同步过来),请重新预览", "conflict")
|
|
830
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700 if scope == "machine" else 0o755)
|
|
831
|
+
text = module.json_text({"schema_version": 1, "decisions": dict(sorted(table.items()))})
|
|
832
|
+
module._atomic_write(path, text)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def decide(root: Path, finding_id: str, decision: str, *, reason: str = "", fingerprint: str | None = None,
|
|
836
|
+
scope: str | None = None, apply: bool = False, state_dir: Path | None = None) -> dict:
|
|
837
|
+
"""Record skip / keep / later for a finding. ``fingerprint`` defaults to the finding's current expectation."""
|
|
838
|
+
if decision not in DECISION_VALUES:
|
|
839
|
+
raise _fail("决定只能是 skip / keep / later")
|
|
840
|
+
scope = scope or ("machine" if finding_id.startswith(LOCAL_PREFIX) else "project")
|
|
841
|
+
if fingerprint is None:
|
|
842
|
+
report = check(root, state_dir=state_dir, reprompt=True)
|
|
843
|
+
match = next((f for f in [*report["findings"], *report["skipped"]] if f["id"] == finding_id), None)
|
|
844
|
+
if match is None:
|
|
845
|
+
raise _fail(f"现在没有这个发现:{finding_id}(用 check 查看当前的发现 ID)", "not-found")
|
|
846
|
+
fingerprint = match["fingerprint"]
|
|
847
|
+
path = _decision_file(root, state_dir, scope)
|
|
848
|
+
table, current_sha, error = read_decisions(path)
|
|
849
|
+
if error:
|
|
850
|
+
raise _fail(error, "profile-invalid")
|
|
851
|
+
entry = {"decision": decision, "fingerprint": fingerprint, "decided_at": now_iso()}
|
|
852
|
+
if reason:
|
|
853
|
+
entry["reason"] = reason
|
|
854
|
+
table = {**table, finding_id: entry}
|
|
855
|
+
result = {"ok": True, "mode": "decide", "applied": False, "id": finding_id, "scope": scope, "entry": entry,
|
|
856
|
+
"file": DECISIONS_PATH.as_posix() if scope == "project" else str(path)}
|
|
857
|
+
if apply:
|
|
858
|
+
_store(path, table, current_sha, root, scope)
|
|
859
|
+
result["applied"] = True
|
|
860
|
+
return result
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def undecide(root: Path, finding_id: str, *, apply: bool = False, state_dir: Path | None = None) -> dict:
|
|
864
|
+
scopes = ["machine"] if finding_id.startswith(LOCAL_PREFIX) else ["project", "machine"]
|
|
865
|
+
for scope in scopes:
|
|
866
|
+
path = _decision_file(root, state_dir, scope)
|
|
867
|
+
table, current_sha, error = read_decisions(path)
|
|
868
|
+
if error:
|
|
869
|
+
raise _fail(error, "profile-invalid")
|
|
870
|
+
if finding_id in table:
|
|
871
|
+
remaining = {k: v for k, v in table.items() if k != finding_id}
|
|
872
|
+
result = {"ok": True, "mode": "undecide", "applied": False, "id": finding_id, "scope": scope,
|
|
873
|
+
"removed": table[finding_id],
|
|
874
|
+
"file": DECISIONS_PATH.as_posix() if scope == "project" else str(path)}
|
|
875
|
+
if apply:
|
|
876
|
+
_store(path, remaining, current_sha, root, scope)
|
|
877
|
+
result["applied"] = True
|
|
878
|
+
return result
|
|
879
|
+
raise _fail(f"没有关于 {finding_id} 的决定", "not-found")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# ---------------------------------------------------------------- command line (called by init_project)
|
|
883
|
+
|
|
884
|
+
def run_mode(args: Any, root: Path) -> tuple[int, dict]:
|
|
885
|
+
"""Handle ``--mode check | add-skeleton | profile | repair-lock | decide | case-naming`` of init_project.py."""
|
|
886
|
+
state_dir = args.state_dir
|
|
887
|
+
targets = list(args.target) or None
|
|
888
|
+
if args.mode == "check":
|
|
889
|
+
report = check(root, state_dir=state_dir, reprompt=args.reprompt)
|
|
890
|
+
report["fingerprint"] = fingerprint(report)
|
|
891
|
+
return (0 if report["ok"] else 1), report
|
|
892
|
+
if args.mode == "case-naming":
|
|
893
|
+
return 0, case_naming_plan(root)
|
|
894
|
+
if args.mode == "add-skeleton":
|
|
895
|
+
return 0, add_skeleton(root, targets, apply=args.apply, state_dir=state_dir)
|
|
896
|
+
if args.mode == "profile":
|
|
897
|
+
return 0, fix_profile(root, targets, apply=args.apply, recovery_dir=args.recovery_dir, state_dir=state_dir)
|
|
898
|
+
if args.mode == "repair-lock":
|
|
899
|
+
result = repair_lock(root, apply=args.apply)
|
|
900
|
+
return (1 if result["blocked"] else 0), result
|
|
901
|
+
if not args.target or len(args.target) != 1:
|
|
902
|
+
raise _fail("--mode decide 需要且只需要一个 --target <发现 ID>")
|
|
903
|
+
if args.decision == "unskip":
|
|
904
|
+
return 0, undecide(root, args.target[0], apply=args.apply, state_dir=state_dir)
|
|
905
|
+
return 0, decide(root, args.target[0], args.decision or "skip", reason=args.reason or "",
|
|
906
|
+
fingerprint=args.fingerprint, apply=args.apply, state_dir=state_dir)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def render_text(result: dict) -> str:
|
|
910
|
+
lines = []
|
|
911
|
+
mode = result.get("mode")
|
|
912
|
+
if mode == "check":
|
|
913
|
+
for item in result["findings"]:
|
|
914
|
+
lines.append(f" [{item['id']}] {item['title']}:{item['reason']}" + (f"(修复:{item['fix']})" if item["fix"] else ""))
|
|
915
|
+
for item in result["skipped"]:
|
|
916
|
+
reason = item["decision"].get("reason") or "(未写理由)"
|
|
917
|
+
lines.append(f" [已跳过 {item['id']}] {reason}")
|
|
918
|
+
lines.append("脚手架与期望一致。" if result["ok"] else f"{len(result['findings'])} 项差异。")
|
|
919
|
+
else:
|
|
920
|
+
label = "已执行" if result.get("applied") else "预览"
|
|
921
|
+
lines.append(f"{label}:{mode}")
|
|
922
|
+
for item in result.get("renames", []):
|
|
923
|
+
lines.append(f" {item['from']} → {item['to'] or '(需要你决定)'}"
|
|
924
|
+
+ (f";被 {len(item['references'])} 个文件提到" if item["references"] else ""))
|
|
925
|
+
for item in result.get("create", []):
|
|
926
|
+
lines.append(f" create {item['path']}")
|
|
927
|
+
for change in result.get("changes", []):
|
|
928
|
+
lines.append(f" {change}")
|
|
929
|
+
if result.get("diff"):
|
|
930
|
+
lines.append(result["diff"].rstrip())
|
|
931
|
+
if result.get("blocked"):
|
|
932
|
+
lines.append(result["blocked"])
|
|
933
|
+
return "\n".join(lines) + "\n"
|