@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1

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