@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,596 @@
1
+ #!/usr/bin/env python3
2
+ """接管已有内容的目录:只读扫描冲突,用户选择后只新增、不动历史。
3
+
4
+ - ``scan``:只读。列出骨架文件与已有路径的碰撞、目录含义是否相同(语义碰撞)、`AGENTS.md` / `CLAUDE.md`
5
+ 的状况,每项给出选项和推荐;推荐只是建议,工具不替用户选择。
6
+ - ``adopt``:默认预览,``--apply`` 才写入。已有文件默认原样保留,补上缺失的骨架;改动已有文件
7
+ (追加受管区块、拆分、并排)必须由用户显式选择,并先备份到项目外的 ``--recovery-dir``。
8
+ 保留下来的、与模板不同的文件记入决定文件,``check`` 不再追问。
9
+
10
+ 历史内容不需要逐项处理:与框架骨架冲突的旧文件可以长期并存,新增内容按标准结构落位,
11
+ 历史迁移由用户在合适的时候选择。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import regions
23
+ from i18n_init import _ # noqa: E402
24
+
25
+ init: Any = None # init_project 模块;由 bind() 设置
26
+
27
+ AGENTS = Path("AGENTS.md")
28
+ CLAUDE = Path("CLAUDE.md")
29
+ USER_FILE = Path("AGENT.USER.md")
30
+ RULES_FILE = Path("AGENT.RULES.md")
31
+ AGENTS_POLICIES = ("keep", "append", "split", "side-by-side")
32
+ CLAUDE_POLICIES = ("keep", "add-import")
33
+ ROUTE_DESTINATIONS = ("agents", "rules", "user", "opinion", "sop")
34
+ # 名字像“工作容器”的顶层目录:登记为 work.assets.roots 后可被查找,内容不动。
35
+ CONTAINER_NAMES = {"cases", "case", "tasks", "task", "projects", "project", "work", "jobs", "案例", "项目", "事项", "任务", "工作"}
36
+ OTHER_AGENT_FILES = (".cursorrules", ".cursor/rules", ".github/copilot-instructions.md", "GEMINI.md", ".windsurfrules")
37
+ IMPORT_RE = re.compile(r"^\s*@(?:\./)?AGENTS\.md\s*$", re.MULTILINE)
38
+ HEADING_RE = re.compile(r"^(#{1,2})\s+(.*\S)\s*$")
39
+ FENCE_RE = re.compile(r"^\s*(```|~~~)")
40
+ # (目的地, 关键词);按顺序匹配,先匹配到的生效。关键词只是启发式,最终由用户或 session 里的判断决定。
41
+ SECTION_HINTS = (
42
+ ("sop", ("流程", "步骤", "sop", "workflow", "checklist", "发布", "release", "部署", "deploy", "runbook", "手册")),
43
+ ("rules", ("规则", "约束", "规范", "禁止", "必须", "不得", "不要", "never", "must", "do not", "don't", "安全", "隐私",
44
+ "security", "privacy", "rules", "constraints", "conventions", "约定", "代码风格", "code style", "lint", "格式", "format")),
45
+ ("opinion", ("偏好", "语气", "口吻", "回复", "表达", "tone", "persona", "沟通", "prefer", "语言")),
46
+ ("agents", ("命令", "构建", "测试", "运行", "安装", "目录", "结构", "架构", "概览", "简介", "overview", "command",
47
+ "build", "test", "run", "install", "architecture", "structure", "setup", "开发", "环境")),
48
+ )
49
+
50
+
51
+ # 写进项目文件的文字跟项目文件语言(Profile 的 language),不随界面语言变化。
52
+ FILE_TEXT = {
53
+ "zh": {"user_header": "# 接管前的用户内容\n\n本文件由 agenthelm 在接管项目时从 AGENTS.md 移出,原文保留。`AGENTS.md` 要求与本文件一并遵循。\n\n",
54
+ "rules_heading": "\n## 接管前的用户规则(来自原 AGENTS.md)\n\n",
55
+ "opinion": "待交给 opinion-manager 处理(偏好与表达类)", "sop": "待并入 SOP 机制(流程类)"},
56
+ "en": {"user_header": "# User content from before adoption\n\nagenthelm moved this out of AGENTS.md when it adopted the project; the original text is kept. "
57
+ "`AGENTS.md` asks agents to follow this file as well.\n\n",
58
+ "rules_heading": "\n## User rules from before adoption (from the original AGENTS.md)\n\n",
59
+ "opinion": "To hand over to opinion-manager (preferences and style)", "sop": "To merge into the SOP mechanism (procedures)"},
60
+ }
61
+
62
+
63
+ def file_text(language: str, key: str) -> str:
64
+ return FILE_TEXT.get(language, FILE_TEXT["zh"])[key]
65
+
66
+
67
+ def bind(module: Any) -> None:
68
+ global init
69
+ init = module
70
+
71
+
72
+ def _fail(message: str, code: str = "invalid-request") -> Exception:
73
+ return init.InitError(message, code=code)
74
+
75
+
76
+ def _lstat(path: Path) -> os.stat_result | None:
77
+ try:
78
+ return path.lstat()
79
+ except FileNotFoundError:
80
+ return None
81
+
82
+
83
+ def _is_dir(path: Path) -> bool:
84
+ return path.is_dir() and not path.is_symlink()
85
+
86
+
87
+ def _read(path: Path) -> str | None:
88
+ try:
89
+ if path.is_symlink() or not path.is_file():
90
+ return None
91
+ return path.read_text(encoding="utf-8")
92
+ except (OSError, UnicodeDecodeError):
93
+ return None
94
+
95
+
96
+ # ---------------------------------------------------------------- AGENTS.md sections
97
+
98
+ @dataclass(frozen=True)
99
+ class Section:
100
+ index: int
101
+ heading: str
102
+ first: int # 起始行(含)
103
+ last: int # 结束行(不含)
104
+ suggestion: str
105
+ matched: str | None
106
+
107
+
108
+ def classify(heading: str) -> tuple[str, str | None]:
109
+ lowered = heading.lower()
110
+ for destination, words in SECTION_HINTS:
111
+ for word in words:
112
+ if word in lowered:
113
+ return destination, word
114
+ return "agents", None
115
+
116
+
117
+ def sections(text: str) -> list[Section]:
118
+ """Split by level 1-2 headings outside code fences; the text before the first heading is section 0."""
119
+ lines = text.splitlines(keepends=True)
120
+ starts: list[tuple[int, str]] = []
121
+ fenced = False
122
+ for number, line in enumerate(lines):
123
+ if FENCE_RE.match(line):
124
+ fenced = not fenced
125
+ continue
126
+ match = None if fenced else HEADING_RE.match(line)
127
+ if match:
128
+ starts.append((number, match.group(2)))
129
+ result: list[Section] = []
130
+ if not starts or starts[0][0] > 0:
131
+ end = starts[0][0] if starts else len(lines)
132
+ if end > 0 and "".join(lines[:end]).strip():
133
+ result.append(Section(0, _("(开头)"), 0, end, "agents", None))
134
+ for position, (number, heading) in enumerate(starts):
135
+ end = starts[position + 1][0] if position + 1 < len(starts) else len(lines)
136
+ destination, matched = classify(heading)
137
+ result.append(Section(0, heading, number, end, destination, matched))
138
+ return [Section(i + 1, s.heading, s.first, s.last, s.suggestion, s.matched) for i, s in enumerate(result)]
139
+
140
+
141
+ def parse_routes(values: list[str], found: list[Section]) -> dict[int, str]:
142
+ routes: dict[int, str] = {}
143
+ valid = {section.index for section in found}
144
+ for value in values:
145
+ number, _sep, destination = value.partition("=")
146
+ if not number.strip().isdigit() or destination not in ROUTE_DESTINATIONS:
147
+ raise _fail(_("--route 格式为 <序号>=<{0}>:{1}").format("|".join(ROUTE_DESTINATIONS), value))
148
+ if int(number) not in valid:
149
+ raise _fail(_("--route 序号不存在:{0}(先用 scan 查看章节序号)").format(number))
150
+ routes[int(number)] = destination
151
+ return routes
152
+
153
+
154
+ # ---------------------------------------------------------------- scan
155
+
156
+ def _option(option_id: str, label: str, flag: str | None = None) -> dict:
157
+ return {"id": option_id, "label": label, "flag": flag}
158
+
159
+
160
+ def _finding(identifier: str, kind: str, path: str, state: str, summary: str, *, options: list[dict] | None = None,
161
+ recommended: str | None = None, detail: dict | None = None) -> dict:
162
+ return {"id": identifier, "kind": kind, "path": path, "state": state, "summary": summary,
163
+ "options": options or [], "recommended": recommended, "detail": detail or {}}
164
+
165
+
166
+ def _git_dirty(root: Path) -> int | None:
167
+ if not (root / ".git").exists():
168
+ return None
169
+ try:
170
+ done = subprocess.run(["git", "-C", str(root), "status", "--porcelain"], capture_output=True, text=True,
171
+ check=False, timeout=15)
172
+ except (OSError, subprocess.SubprocessError):
173
+ return None
174
+ return len([line for line in done.stdout.splitlines() if line.strip()]) if done.returncode == 0 else None
175
+
176
+
177
+ def _contract_findings(root: Path, expected: dict[Path, str]) -> list[dict]:
178
+ found: list[dict] = []
179
+ text = _read(root / AGENTS)
180
+ if _lstat(root / AGENTS) is None:
181
+ found.append(_finding("adopt:contract:AGENTS.md", "contract", "AGENTS.md", "absent", _("没有 AGENTS.md,将按模板新建")))
182
+ elif text is None:
183
+ found.append(_finding("adopt:contract:AGENTS.md", "contract", "AGENTS.md", "unreadable",
184
+ _("AGENTS.md 是符号链接或无法按 UTF-8 读取,保持原样")))
185
+ elif text == expected[AGENTS]:
186
+ found.append(_finding("adopt:contract:AGENTS.md", "contract", "AGENTS.md", "same", _("AGENTS.md 与模板一致")))
187
+ else:
188
+ found_sections = sections(text)
189
+ region = regions.get(text, "agents-contract")
190
+ found.append(_finding(
191
+ "adopt:contract:AGENTS.md", "contract", "AGENTS.md", "user-content",
192
+ _("AGENTS.md 已有用户内容({0} 字节,{1} 个章节)").format(len(text.encode('utf-8')), len(found_sections)),
193
+ options=[
194
+ _option("keep", _("保持原样;之后项目契约不会出现在 AGENTS.md 里"), "--agents-policy keep"),
195
+ _option("append", _("原文不动,末尾追加带标记的项目契约区块(可随时撤回)"), "--agents-policy append"),
196
+ _option("split", _("按章节决定去向:留在 AGENTS.md,或移到 AGENT.RULES.md / AGENT.USER.md;并追加契约区块"),
197
+ _("--agents-policy split --route <序号>=<agents|rules|user|opinion|sop>")),
198
+ _option("side-by-side", _("原文整体移到 AGENT.USER.md,AGENTS.md 换成模板(AGENTS.md 里会指向 AGENT.USER.md)"),
199
+ "--agents-policy side-by-side"),
200
+ ],
201
+ recommended="append",
202
+ detail={"has_contract_region": region is not None,
203
+ "sections": [{"index": s.index, "heading": s.heading, "lines": s.last - s.first,
204
+ "suggested": s.suggestion, "matched": s.matched,
205
+ "confidence": "keyword" if s.matched else "default"} for s in found_sections],
206
+ "note": _("章节去向只是关键词启发式的建议;opinion / sop 暂由 opinion-manager 与后续 SOP 机制处理,"
207
+ "工具会先把它们放进 AGENT.USER.md 并标注待交接。")},
208
+ ))
209
+ claude = _lstat(root / CLAUDE)
210
+ if claude is None:
211
+ found.append(_finding("adopt:contract:CLAUDE.md", "contract", "CLAUDE.md", "absent",
212
+ _("没有 CLAUDE.md,将新建一行 @AGENTS.md 的导入桩")))
213
+ elif (root / CLAUDE).is_symlink():
214
+ target = os.path.realpath(root / CLAUDE)
215
+ same = target == os.path.realpath(root / AGENTS)
216
+ found.append(_finding("adopt:contract:CLAUDE.md", "contract", "CLAUDE.md", "symlink-agents" if same else "symlink-other",
217
+ _("CLAUDE.md 是指向 AGENTS.md 的符号链接,Claude 读到的就是 AGENTS.md") if same
218
+ else _("CLAUDE.md 是指向其他位置的符号链接,保持原样")))
219
+ else:
220
+ body = _read(root / CLAUDE)
221
+ if body is None:
222
+ found.append(_finding("adopt:contract:CLAUDE.md", "contract", "CLAUDE.md", "unreadable", _("CLAUDE.md 无法读取,保持原样")))
223
+ elif body == expected[CLAUDE]:
224
+ found.append(_finding("adopt:contract:CLAUDE.md", "contract", "CLAUDE.md", "same", _("CLAUDE.md 与模板一致")))
225
+ elif IMPORT_RE.search(body):
226
+ found.append(_finding("adopt:contract:CLAUDE.md", "contract", "CLAUDE.md", "imports-agents",
227
+ _("CLAUDE.md 已导入 AGENTS.md,Claude 能读到项目契约")))
228
+ else:
229
+ found.append(_finding(
230
+ "adopt:contract:CLAUDE.md", "contract", "CLAUDE.md", "shadows-agents",
231
+ _("CLAUDE.md 已有内容但没有导入 AGENTS.md:Claude Code 两者并存时只读 CLAUDE.md,AGENTS.md 里的项目契约不会加载"),
232
+ options=[_option("keep", _("保持原样"), "--claude-policy keep"),
233
+ _option("add-import", _("原文不动,末尾追加带标记的 @AGENTS.md 导入区块(可随时撤回)"), "--claude-policy add-import")],
234
+ recommended="add-import"))
235
+ return found
236
+
237
+
238
+ def _top_entries(path: Path) -> list[Path]:
239
+ try:
240
+ return [p for p in sorted(path.iterdir()) if p.name != ".gitkeep"]
241
+ except OSError:
242
+ return []
243
+
244
+
245
+ def _semantic_findings(root: Path, case_root: str, memory_root: str, work_mode: str) -> list[dict]:
246
+ found: list[dict] = []
247
+ cases = root / case_root
248
+ if _is_dir(cases) and work_mode == "case-workspace":
249
+ entries = _top_entries(cases)
250
+ ours = [p.name for p in entries if _is_dir(p) and (p / "CASE.md").is_file()]
251
+ foreign = [p.name for p in entries if p.name not in ours]
252
+ if not foreign:
253
+ found.append(_finding(f"adopt:semantic:{case_root}", "semantic", case_root, "ours" if ours else "empty",
254
+ _("{0}/ 已存在,里面的 {1} 个目录都有 CASE.md").format(case_root, len(ours)) if ours
255
+ else _("{0}/ 已存在且为空").format(case_root)))
256
+ else:
257
+ suggestion = _unused_name(root, "cases-managed")
258
+ found.append(_finding(
259
+ f"adopt:semantic:{case_root}", "semantic", case_root, "mixed" if ours else "foreign",
260
+ _("{0}/ 已有 {1} 个没有 CASE.md 的条目({2}),它们是历史内容:Case 工具会忽略它们,新 Case 与它们并存").format(
261
+ case_root, len(foreign), ", ".join(foreign[:3]) + ("…" if len(foreign) > 3 else "")),
262
+ options=[_option("coexist", _("并存:新 Case 放进同一目录,历史条目不动"), None),
263
+ _option("alt-case-root", _("新 Case 改放到另一个目录(如 {0}/),{1}/ 完全不碰").format(suggestion, case_root),
264
+ f"--case-root {suggestion}")],
265
+ recommended="coexist", detail={"foreign": foreign[:50], "ours": ours[:50]}))
266
+ for entry in sorted(root.iterdir()) if root.is_dir() else []:
267
+ name = entry.name
268
+ if name in (case_root, memory_root) or name.lower() not in CONTAINER_NAMES or not _is_dir(entry):
269
+ continue
270
+ children = [p for p in _top_entries(entry) if _is_dir(p)]
271
+ if children:
272
+ found.append(_finding(
273
+ f"adopt:semantic:container:{name}", "semantic", name, "container",
274
+ _("{0}/ 看起来是已有的工作容器({1} 个子目录),内容不动").format(name, len(children)),
275
+ options=[_option("register-root", _("登记为额外资料根(work.assets.roots),之后可被查找,不改任何文件"),
276
+ f"--register-root {name}"),
277
+ _option("ignore", _("不登记"), None)],
278
+ recommended="register-root"))
279
+ memory = root / memory_root
280
+ if _is_dir(memory):
281
+ entries = _top_entries(memory)
282
+ if entries:
283
+ suggestion = _unused_name(root, "agent-memory")
284
+ found.append(_finding(
285
+ f"adopt:semantic:{memory_root}", "semantic", memory_root, "foreign",
286
+ _("{0}/ 已有 {1} 个条目,格式未知:随包记忆工具会在这个目录里写自己的条目,建议分开").format(memory_root, len(entries)),
287
+ options=[_option("coexist", _("共用同一目录(记忆工具的索引可能把历史文件当作异常)"), None),
288
+ _option("alt-memory-root", _("记忆条目改放到 {0}/,{1}/ 不碰").format(suggestion, memory_root), f"--memory-root {suggestion}")],
289
+ recommended="alt-memory-root", detail={"entries": [p.name for p in entries[:50]]}))
290
+ docs = root / "docs"
291
+ if _is_dir(docs):
292
+ notes = [_("docs/{0}/ 已存在").format(name) for name in ("refs", "drafts") if _is_dir(docs / name)]
293
+ found.append(_finding("adopt:semantic:docs", "semantic", "docs", "shared",
294
+ _("docs/ 已存在:骨架只在里面新增 refs/README.md 与 drafts/,已有文件不动")
295
+ + ("; " + ", ".join(notes) if notes else "")))
296
+ agent_doc = root / ".agent-doc"
297
+ if _is_dir(agent_doc):
298
+ found.append(_finding("adopt:semantic:.agent-doc", "semantic", ".agent-doc", "shared",
299
+ _(".agent-doc/ 已存在:只补上缺少的 plan.md / progress.md / chat-summary.md")))
300
+ return found
301
+
302
+
303
+ def _unused_name(root: Path, base: str) -> str:
304
+ name, number = base, 1
305
+ while (root / name).exists():
306
+ number += 1
307
+ name = f"{base}-{number}"
308
+ return name
309
+
310
+
311
+ def scan(root: Path, inputs: Any, *, case_root: str = "cases", memory_root: str = "memory") -> dict:
312
+ """Read-only report of how this directory's existing content meets the skeleton."""
313
+ blockers: list[dict] = []
314
+ if (root / init.LEGACY_PROFILE_PATH).exists() and not (root / init.PROFILE_PATH).exists():
315
+ blockers.append({"code": "migrate-required", "message": _("检测到 v5 Project Profile,请改用 --mode migrate")})
316
+ if (root / init.PROFILE_PATH).exists():
317
+ blockers.append({"code": "already-initialized", "message": _("已有 v6 Project Profile,不是接管场景:用 check / refresh")})
318
+ files = init.files_for(inputs)
319
+ files.pop(Path("cases/.gitkeep"), None)
320
+ files.pop(Path("memory/.gitkeep"), None)
321
+ expected = files
322
+ create, collisions, symlinked = init.plan_writes(root, files, skip_symlinked_parents=True)
323
+ findings = _contract_findings(root, expected)
324
+ findings += _semantic_findings(root, case_root, memory_root, inputs.work_mode)
325
+ launcher = inputs.entry_launcher
326
+ if launcher and _lstat(root / launcher) is not None:
327
+ findings.append(_finding(
328
+ f"adopt:launcher:{launcher}", "launcher", launcher, "name-taken",
329
+ _("根目录已有 {0},是你的文件:不创建启动器,也不在 Profile 里声明 entry").format(launcher),
330
+ options=[_option("other-name", _("换一个启动器名称"), _("--entry-name <名称>")), _option("no-entry", _("不要启动器"), "--no-entry")],
331
+ recommended="other-name"))
332
+ other = [p for p in collisions if str(p) not in ("AGENTS.md", "CLAUDE.md") and str(p) != launcher]
333
+ if other:
334
+ tracked = {x.as_posix() for x in init.tracked_paths(init.contract_pack(), launcher)}
335
+ differing = [str(p) for p in other if str(p) in tracked and _read(root / p) != expected.get(p)]
336
+ findings.append(_finding(
337
+ "adopt:exists", "exists", "", "preserved",
338
+ _("{0} 个骨架路径已存在,原样保留(不覆盖)").format(len(other)),
339
+ detail={"paths": [str(p) for p in other], "differs_from_template": differing}))
340
+ if symlinked:
341
+ findings.append(_finding("adopt:symlink", "symlink", "", "skipped",
342
+ _("{0} 个骨架路径的父目录是符号链接,跳过").format(len(symlinked)),
343
+ detail={"paths": [str(p) for p in symlinked]}))
344
+ others = [name for name in OTHER_AGENT_FILES if _lstat(root / name) is not None]
345
+ if others:
346
+ findings.append(_finding("adopt:other-agent-files", "info", "", "present",
347
+ _("其他客户端的指令文件不会被触碰:") + ", ".join(others), detail={"paths": others}))
348
+ recommended = [option["flag"] for item in findings if item["recommended"]
349
+ for option in item["options"] if option["id"] == item["recommended"] and option["flag"]]
350
+ return {"blockers": blockers, "findings": findings, "recommended_flags": recommended,
351
+ "git_dirty_files": _git_dirty(root), "would_create": [str(p) for p in create],
352
+ "would_preserve": [str(p) for p in collisions]}
353
+
354
+
355
+ # ---------------------------------------------------------------- adopt
356
+
357
+ def _validate_root(value: str, label: str, root: Path, *, must_exist: bool = False) -> str:
358
+ path = Path(value)
359
+ reserved = {".git", ".agents", ".codex", ".cursor", ".claude", "local-ai", "confidential"}
360
+ if (not value or value == "." or path.is_absolute() or ".." in path.parts or "\\" in value
361
+ or re.match(r"^[A-Za-z]:", value) or set(path.parts) & reserved):
362
+ raise _fail(_("{0} 必须是项目内明确的相对目录:{1}").format(label, value), "profile-invalid")
363
+ target = root / path
364
+ if must_exist and not _is_dir(target):
365
+ raise _fail(_("{0} 不是项目里已有的目录:{1}").format(label, value))
366
+ if not must_exist and _lstat(target) is not None and not _is_dir(target):
367
+ raise _fail(_("{0} 已被同名文件占用:{1}").format(label, value))
368
+ for parent in [target, *target.parents]:
369
+ if parent == root:
370
+ break
371
+ if parent.is_symlink():
372
+ raise _fail(_("{0} 经过符号链接:{1}").format(label, value))
373
+ return path.as_posix()
374
+
375
+
376
+ def _moved_block(found: list[Section], lines: list[str], note: str | None = None) -> str:
377
+ pieces = []
378
+ for section in found:
379
+ body = "".join(lines[section.first:section.last]).rstrip("\n")
380
+ pieces.append((f"> {note}\n\n" if note else "") + body)
381
+ return "\n\n".join(pieces)
382
+
383
+
384
+ def _plan_agents(text: str, policy: str, routes: dict[int, str], expected: str, body: str, language: str = "zh") -> dict:
385
+ """What a policy does to a user's AGENTS.md: the new text, files that receive moved sections, region records."""
386
+ plan: dict[str, Any] = {"policy": policy, "new_text": None, "replace": False, "user_text": None, "rules_block": None,
387
+ "region": None, "pending_handoff": [], "moved": {}}
388
+ if policy == "keep":
389
+ return plan
390
+ if policy == "side-by-side":
391
+ plan.update(new_text=expected, replace=True, user_text=text, moved={"user": [_("(整个文件)")]})
392
+ return plan
393
+ lines = text.splitlines(keepends=True)
394
+ found = sections(text)
395
+ stay, rules, user, handoff = [], [], [], []
396
+ if policy == "split":
397
+ for section in found:
398
+ destination = routes.get(section.index, "agents")
399
+ if destination == "agents":
400
+ stay.append(section)
401
+ elif destination == "rules":
402
+ rules.append(section)
403
+ else:
404
+ user.append(section)
405
+ if destination in ("opinion", "sop"):
406
+ handoff.append({"section": section.index, "heading": section.heading, "destination": destination})
407
+ moved = {section.first for section in [*rules, *user]}
408
+ kept_lines = [line for number, line in enumerate(lines)
409
+ if not any(s.first <= number < s.last for s in [*rules, *user])] if moved else lines
410
+ text = "".join(kept_lines)
411
+ if rules:
412
+ plan["rules_block"] = file_text(language, "rules_heading") + _moved_block(rules, lines) + "\n"
413
+ plan["moved"]["rules"] = [s.heading for s in rules]
414
+ if user:
415
+ blocks = []
416
+ for section in user:
417
+ destination = routes[section.index]
418
+ note = file_text(language, destination) if destination in ("opinion", "sop") else None
419
+ blocks.append(_moved_block([section], lines, note))
420
+ plan["user_text"] = "\n\n".join(blocks)
421
+ plan["moved"]["user"] = [s.heading for s in user]
422
+ plan["pending_handoff"] = handoff
423
+ new_text, state = regions.upsert(text, "agents-contract", body)
424
+ plan.update(new_text=new_text, region=("agents-contract", regions.digest(body), state))
425
+ return plan
426
+
427
+
428
+ def run(args: Any, root: Path, inputs: Any) -> tuple[int, dict]:
429
+ """``--mode adopt``: preview, or with ``--apply`` add the skeleton around existing content."""
430
+ from dataclasses import replace as dc_replace
431
+ import json
432
+
433
+ scaffold = init._scaffold()
434
+ templates = init.load_templates()
435
+ policy = getattr(args, "agents_policy", None) or "keep"
436
+ claude_policy = getattr(args, "claude_policy", None) or "keep"
437
+ case_root = _validate_root(getattr(args, "case_root", None) or "cases", "--case-root", root)
438
+ memory_root = _validate_root(getattr(args, "memory_root", None) or "memory", "--memory-root", root)
439
+ if case_root == memory_root:
440
+ raise _fail(_("--case-root 与 --memory-root 不能相同"))
441
+ registered = sorted({_validate_root(value, "--register-root", root, must_exist=True)
442
+ for value in getattr(args, "register_root", None) or []})
443
+ taken = None
444
+ if inputs.entry_launcher and _lstat(root / inputs.entry_launcher) is not None:
445
+ taken = inputs.entry_launcher
446
+ inputs = dc_replace(inputs, entry_launcher=None)
447
+ report = scan(root, inputs, case_root=case_root, memory_root=memory_root)
448
+ if report["blockers"]:
449
+ raise _fail(report["blockers"][0]["message"], report["blockers"][0]["code"])
450
+
451
+ pristine = init.files_for(inputs)
452
+ files = dict(pristine)
453
+ profile = json.loads(files[init.PROFILE_PATH])
454
+ files.pop(Path("cases/.gitkeep"), None)
455
+ files.pop(Path("memory/.gitkeep"), None)
456
+ if inputs.work_mode == "case-workspace":
457
+ profile["work"]["case"]["root"] = case_root
458
+ files[Path(case_root) / ".gitkeep"] = ""
459
+ profile["memory"]["root"] = memory_root
460
+ files[Path(memory_root) / ".gitkeep"] = ""
461
+ if registered:
462
+ profile["work"]["assets"] = {"roots": registered}
463
+ files[init.PROFILE_PATH] = init.json_text(profile)
464
+ if not (root / "README.md").exists():
465
+ files[Path("README.md")] = f"# {inputs.name}\n\n{init.readme_block(inputs.entry_launcher, inputs.language)}"
466
+ readme = init.readme_plan(root, inputs.name, inputs.entry_launcher, inputs.language) if getattr(args, "readme_entry", False) else None
467
+
468
+ modifications: list[dict] = [] # 改动已有文件:先备份再写入
469
+ adopted: list[str] = []
470
+ notes: list[str] = []
471
+ region_records: dict[str, dict[str, str]] = {}
472
+ pending_handoff: list[dict] = []
473
+ extras: dict[Path, str] = {}
474
+ agents_text = _read(root / AGENTS)
475
+ agents_plan = None
476
+ if policy != "keep" and (agents_text is None or agents_text == pristine[AGENTS]):
477
+ notes.append(_("--agents-policy {0} 没有生效:AGENTS.md 不存在、不可读,或已与模板一致").format(policy))
478
+ elif policy != "keep":
479
+ agents_plan = _plan_agents(agents_text, policy, init_routes(args, agents_text), pristine[AGENTS],
480
+ templates["region/agents-contract"], inputs.language)
481
+ if agents_plan["replace"]:
482
+ modifications.append({"path": AGENTS, "kind": "replace", "new_text": agents_plan["new_text"]})
483
+ else:
484
+ modifications.append({"path": AGENTS, "kind": "modify", "new_text": agents_plan["new_text"],
485
+ "region": "agents-contract"})
486
+ region_records.setdefault("AGENTS.md", {})["agents-contract"] = agents_plan["region"][1]
487
+ if agents_plan["user_text"] is not None:
488
+ extras[USER_FILE] = file_text(inputs.language, "user_header") + agents_plan["user_text"].rstrip("\n") + "\n"
489
+ pending_handoff = agents_plan["pending_handoff"]
490
+ elif agents_text is not None and agents_text != pristine[AGENTS]:
491
+ adopted.append("AGENTS.md")
492
+ claude_text = _read(root / CLAUDE)
493
+ claude_state = next((f["state"] for f in report["findings"] if f["id"] == "adopt:contract:CLAUDE.md"), None)
494
+ if claude_policy == "add-import":
495
+ if claude_state != "shadows-agents":
496
+ notes.append(_("--claude-policy add-import 没有生效:CLAUDE.md 不存在,或已能读到 AGENTS.md,或是符号链接"))
497
+ else:
498
+ new_text, _state = regions.upsert(claude_text, "claude-import", templates["region/claude-import"])
499
+ modifications.append({"path": CLAUDE, "kind": "modify", "new_text": new_text, "region": "claude-import"})
500
+ region_records.setdefault("CLAUDE.md", {})["claude-import"] = regions.digest(templates["region/claude-import"])
501
+ elif claude_state == "shadows-agents":
502
+ adopted.append("CLAUDE.md")
503
+ if agents_plan and agents_plan["rules_block"]:
504
+ rules_path = root / RULES_FILE
505
+ if _lstat(rules_path) is None:
506
+ files[RULES_FILE] = files[RULES_FILE].rstrip("\n") + "\n" + agents_plan["rules_block"]
507
+ adopted.append(str(RULES_FILE))
508
+ elif _read(rules_path) is None:
509
+ raise _fail(_("AGENT.RULES.md 不可读,无法移入规则章节"))
510
+ else:
511
+ modifications.append({"path": RULES_FILE, "kind": "modify",
512
+ "new_text": _read(rules_path).rstrip("\n") + "\n" + agents_plan["rules_block"]})
513
+ for path, text in extras.items():
514
+ if _lstat(root / path) is not None:
515
+ raise _fail(_("{0} 已存在,不会被覆盖:先改名或删除后再选择拆分").format(path), "collision")
516
+ files[path] = text
517
+ if taken:
518
+ notes.append(_("{0} 已存在:没有创建启动器。之后可用 --mode entry --entry-name <其他名称> 补上").format(taken))
519
+
520
+ create, collisions, symlinked = init.plan_writes(root, files, skip_symlinked_parents=True)
521
+ moving = {m["path"] for m in modifications}
522
+ preserved = [p for p in collisions if p not in moving]
523
+ for item in modifications:
524
+ if init.plan_writes(root, {item["path"]: ""}, skip_symlinked_parents=False)[1] == []:
525
+ raise _fail(_("{0} 不存在或不是普通文件").format(item['path']), "collision")
526
+ needs_recovery = bool(modifications)
527
+ result: dict[str, Any] = {
528
+ "ok": True, "mode": "adopt", "applied": False, "scan": report,
529
+ "policy": {"agents": policy, "claude": claude_policy, "case_root": case_root, "memory_root": memory_root,
530
+ "register_root": registered},
531
+ "create": [str(p) for p in sorted(create, key=str)],
532
+ "modify": [{"path": str(m["path"]), "action": m["kind"], "region": m.get("region")} for m in modifications],
533
+ "preserve": [str(p) for p in sorted(preserved, key=str)],
534
+ "skipped_symlinked_parents": [str(p) for p in symlinked],
535
+ "needs_recovery_dir": needs_recovery, "pending_handoff": pending_handoff, "notes": notes,
536
+ "readme": readme["action"] if readme else ("create" if Path("README.md") in create else "untouched"),
537
+ }
538
+ if not getattr(args, "apply", False):
539
+ return 0, result
540
+ recovery_dir = getattr(args, "recovery_dir", None)
541
+ if needs_recovery and recovery_dir is None:
542
+ raise _fail(_("改动已有文件前需要 --recovery-dir(项目外的备份目录)"))
543
+ if recovery_dir is not None and needs_recovery:
544
+ resolved = recovery_dir.expanduser().resolve()
545
+ if resolved == root or root in resolved.parents:
546
+ raise _fail("--recovery-dir must be outside the project root")
547
+
548
+ originals: dict[Path, tuple[str, int]] = {}
549
+ try:
550
+ for item in modifications:
551
+ path = root / item["path"]
552
+ if path.is_symlink() or not path.is_file():
553
+ raise _fail(_("{0} 不是普通文件,拒绝改动").format(item['path']))
554
+ original = path.read_text(encoding="utf-8")
555
+ originals[item["path"]] = (original, os.stat(path).st_mode & 0o7777)
556
+ for item in modifications:
557
+ scaffold._backup(root, recovery_dir, item["path"])
558
+ for item in modifications:
559
+ init._atomic_write(root / item["path"], item["new_text"], originals[item["path"]][1])
560
+ init.apply_writes(root, files, create)
561
+ except Exception:
562
+ for relative, (original, mode) in originals.items():
563
+ try:
564
+ init._atomic_write(root / relative, original, mode)
565
+ except OSError:
566
+ pass
567
+ raise
568
+ launcher = inputs.entry_launcher
569
+ if launcher and Path(launcher) in create:
570
+ os.chmod(root / launcher, 0o755)
571
+ if readme:
572
+ init._atomic_write(root / readme["path"], readme["text"])
573
+ pack = init.contract_pack()
574
+ replaced = [m["path"] for m in modifications if m["kind"] == "replace"]
575
+ baseline = init.content_baseline(pack, files, [*create, *replaced], launcher)
576
+ for name in adopted:
577
+ baseline.pop(name, None)
578
+ if pack is not None:
579
+ init.write_content_lock(root, pack, baseline, inputs=inputs, regions=region_records or None)
580
+ decided = []
581
+ after = scaffold.check(root, state_dir=getattr(args, "state_dir", None), reprompt=True)
582
+ for finding in after["findings"]:
583
+ if finding["kind"] == "content-differs":
584
+ scaffold.decide(root, finding["id"], "keep", reason="adopt: kept the file that existed before adoption",
585
+ fingerprint=finding["fingerprint"], scope="project", apply=True,
586
+ state_dir=getattr(args, "state_dir", None))
587
+ decided.append(finding["id"])
588
+ final = scaffold.check(root, state_dir=getattr(args, "state_dir", None))
589
+ result.update(applied=True, recovery=str(recovery_dir.expanduser().resolve()) if needs_recovery else None,
590
+ decisions_recorded=decided,
591
+ check_after={"ok": final["ok"], "findings": [f["id"] for f in final["findings"]]})
592
+ return 0, result
593
+
594
+
595
+ def init_routes(args: Any, text: str) -> dict[int, str]:
596
+ return parse_routes(list(getattr(args, "route", None) or []), sections(text))