@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.
Files changed (35) hide show
  1. package/README.md +68 -0
  2. package/README.zh-CN.md +28 -0
  3. package/package.json +2 -2
  4. package/runtime/index.json +15 -15
  5. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +11 -4
  6. package/runtime/plugins/agent-pack/plugin.json +1 -1
  7. package/runtime/plugins/agent-pack/references/capability-config.md +29 -5
  8. package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_config.py +178 -22
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
  12. package/runtime/plugins/agent-pack/runtime/mcp_server.py +30 -8
  13. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +2 -2
  14. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +5 -5
  15. package/runtime/plugins/agents-init/content.json +15 -0
  16. package/runtime/plugins/agents-init/plugin.json +2 -2
  17. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +14 -1
  18. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
  19. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
  20. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
  21. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
  23. package/runtime/plugins/agents-init/skills/agents-init/skill.json +3 -3
  24. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +5 -5
  25. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  26. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +1 -1
  27. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +2 -2
  28. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +10 -4
  29. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  30. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
  31. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
  32. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
  33. package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
  34. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +1 -1
  35. 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"